mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Merge pull request #20 from emptyname-org/feature/files-thumbnails-statusbar
feat(gui): add file thumbnails and a browser status bar
This commit is contained in:
25 files changed
+2584
-176
No files matched your search
@@ -122,7 +122,7 @@ jobs:
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Maintainer: Nils Pukropp <contact@narl.io>
|
||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4
|
||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl
|
||||
Installed-Size: ${INSTALLED_SIZE}
|
||||
Homepage: https://github.com/narrrl/proton-drive-linux
|
||||
Description: Unofficial Proton Drive client for Linux
|
||||
|
||||
Generated
+1
@@ -3578,6 +3578,7 @@ dependencies = [
|
||||
name = "pdfs-fuse"
|
||||
version = "1.9.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"fuser",
|
||||
"futures",
|
||||
"image",
|
||||
|
||||
@@ -25,6 +25,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||
|
||||
@@ -64,6 +64,7 @@ pdfs-app # the desktop app
|
||||
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
||||
- **File Thumbnails**: Browse locally cached image previews in Files, Shared, and Trash, including embedded previews from camera RAW files. Build a whole folder tree from the Files toolbar and resize the grid from the status bar.
|
||||
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
||||
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
||||
@@ -327,19 +328,21 @@ sudo apt-get install -y \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
libdbus-1-dev \
|
||||
libimage-exiftool-perl
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
```bash
|
||||
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0
|
||||
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 perl-image-exiftool
|
||||
```
|
||||
|
||||
### Fedora (44+)
|
||||
```bash
|
||||
sudo dnf install -y \
|
||||
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
||||
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel cargo rust
|
||||
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel \
|
||||
perl-Image-ExifTool cargo rust
|
||||
```
|
||||
|
||||
Runtime extras (pick your desktop):
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -415,6 +416,10 @@ pub struct ContentCache {
|
||||
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
||||
/// of `content_dir` so the budget scan never sees thumbnail files.
|
||||
thumb_dir: PathBuf,
|
||||
/// Owner-protected scratch space for decrypted camera RAWs passed to
|
||||
/// exiftool. Files are removed on drop and swept on the next cache open after
|
||||
/// an unclean shutdown.
|
||||
raw_thumb_dir: PathBuf,
|
||||
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
|
||||
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
|
||||
/// sees them; blocks carry their own LRU budget.
|
||||
@@ -507,6 +512,19 @@ impl ContentCache {
|
||||
std::fs::create_dir_all(&content_dir)?;
|
||||
let thumb_dir = content_dir.join("thumbs");
|
||||
std::fs::create_dir_all(&thumb_dir)?;
|
||||
let raw_thumb_dir = content_dir.join("raw-thumbnails");
|
||||
std::fs::create_dir_all(&raw_thumb_dir)?;
|
||||
std::fs::set_permissions(&raw_thumb_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
if let Ok(entries) = std::fs::read_dir(&raw_thumb_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().starts_with("pdfs-raw-")
|
||||
&& entry.file_type().is_ok_and(|kind| kind.is_file())
|
||||
{
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
let block_dir = content_dir.join("blocks");
|
||||
std::fs::create_dir_all(&block_dir)?;
|
||||
// Scratch holds disk-backed write buffers. A previous run's leftovers are
|
||||
@@ -530,6 +548,7 @@ impl ContentCache {
|
||||
let cache = Self {
|
||||
content_dir,
|
||||
thumb_dir,
|
||||
raw_thumb_dir,
|
||||
block_dir,
|
||||
scratch_dir,
|
||||
staging_dir,
|
||||
@@ -1666,6 +1685,12 @@ impl ContentCache {
|
||||
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
|
||||
}
|
||||
|
||||
/// Owner-protected transient directory used to stage decrypted camera RAWs
|
||||
/// for metadata helpers. Callers still remove individual files promptly.
|
||||
pub fn raw_thumbnail_staging_dir(&self) -> &Path {
|
||||
&self.raw_thumb_dir
|
||||
}
|
||||
|
||||
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
|
||||
/// Validated by node `mtime` alone: a new revision (which is what changes the
|
||||
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
|
||||
|
||||
@@ -93,6 +93,32 @@ pub enum Request {
|
||||
/// 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.
|
||||
PhotoThumbs { uids: Vec<String> },
|
||||
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
|
||||
/// timeline. The modification time is the cache validity tag, so replacing
|
||||
/// an image can never reuse its previous revision's thumbnail.
|
||||
FileThumbs {
|
||||
items: Vec<FileThumbRequest>,
|
||||
/// Identifies the front-end's current listing. Moving to another page or
|
||||
/// folder advances it, allowing the daemon to stop work for rows that
|
||||
/// are no longer visible.
|
||||
#[serde(default)]
|
||||
generation: u64,
|
||||
},
|
||||
/// Reserve a daemon-issued generation for ordinary-file thumbnail work.
|
||||
/// The daemon is the ordering authority, so GUI restarts and wall-clock
|
||||
/// corrections cannot make a new listing look older than an earlier one.
|
||||
ReserveFileThumbGeneration,
|
||||
/// Stop ordinary-file thumbnail work belonging to older listings. Photos
|
||||
/// timeline work and an explicit recursive build are deliberately separate.
|
||||
CancelFileThumbs { generation: u64 },
|
||||
/// Start building local thumbnails for every supported image below `path`.
|
||||
/// The request returns immediately; progress is read with
|
||||
/// [`Request::ThumbnailBuildStatus`].
|
||||
StartThumbnailBuild { path: String },
|
||||
/// Stop the current recursive ordinary-file thumbnail build, if any.
|
||||
CancelThumbnailBuild,
|
||||
/// Read progress for the recursive ordinary-file thumbnail build.
|
||||
ThumbnailBuildStatus,
|
||||
/// Download a photo's full content into the cache; replies with its path.
|
||||
OpenPhoto { uid: String },
|
||||
/// Add or remove Proton's `Favorite` tag on a photo. Replies with
|
||||
@@ -679,6 +705,10 @@ pub struct SharedItem {
|
||||
/// The shared node's decrypted name.
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
/// Modification time used to validate an image thumbnail. Older daemons
|
||||
/// omit it, in which case clients use `0` as a conservative fallback tag.
|
||||
#[serde(default)]
|
||||
pub modified: i64,
|
||||
/// Mountpoint-relative path, when the daemon can resolve it (the node lives in
|
||||
/// my own tree). Empty when the path is unknown.
|
||||
#[serde(default)]
|
||||
@@ -1187,6 +1217,76 @@ pub struct PhotoThumb {
|
||||
pub pending: bool,
|
||||
}
|
||||
|
||||
/// One ordinary Drive file whose thumbnail a front-end wants to paint.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct FileThumbRequest {
|
||||
/// Node uid in `volume~link` form.
|
||||
pub uid: String,
|
||||
/// File modification time, used as the thumbnail cache validity tag.
|
||||
pub modified: i64,
|
||||
/// Original Drive name. Local RAW-preview extraction uses the extension as a
|
||||
/// format hint; defaulted for wire compatibility with older front-ends.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Whether a Drive name is a raster image that the built-in decoder can read,
|
||||
/// or a concrete camera RAW format whose embedded preview exiftool can extract.
|
||||
/// Keep this list shared by the daemon and every GUI surface so a visible tile
|
||||
/// is never advertised without a matching generation path.
|
||||
pub fn is_thumbnail_image_name(name: &str) -> bool {
|
||||
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
let extension = extension.to_ascii_lowercase();
|
||||
STANDARD_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
||||
|| RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
||||
}
|
||||
|
||||
/// Whether `name` is one of the concrete RAW formats handled by the exiftool
|
||||
/// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose
|
||||
/// preview availability is not dependable.
|
||||
pub fn is_raw_image_name(name: &str) -> bool {
|
||||
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
let extension = extension.to_ascii_lowercase();
|
||||
RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
||||
}
|
||||
|
||||
const STANDARD_THUMBNAIL_EXTENSIONS: &[&str] =
|
||||
&["bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "webp"];
|
||||
|
||||
/// Concrete camera RAW formats with embedded previews handled by exiftool.
|
||||
/// This is the single source used by both RAW classification and the broader
|
||||
/// thumbnail capability check.
|
||||
const RAW_THUMBNAIL_EXTENSIONS: &[&str] = &[
|
||||
"arw", "cr2", "cr3", "crw", "dng", "k25", "kdc", "mrw", "nef", "nrw", "orf", "pef", "raf",
|
||||
"raw", "rw2", "sr2", "srf", "x3f",
|
||||
];
|
||||
|
||||
/// Progress of the one recursive ordinary-file thumbnail build the daemon may
|
||||
/// run at a time.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ThumbnailBuildStatus {
|
||||
pub running: bool,
|
||||
/// True while folders are still being discovered, before the final image
|
||||
/// count is known and the progress bar can become determinate.
|
||||
pub scanning: bool,
|
||||
/// Mountpoint-relative root selected when the build started.
|
||||
pub path: String,
|
||||
pub folders_scanned: u64,
|
||||
pub images_found: u64,
|
||||
/// Images already cached or processed during this build. Includes failures,
|
||||
/// so `completed == images_found` still means the job has finished.
|
||||
pub completed: u64,
|
||||
/// Images that could not be downloaded or decoded.
|
||||
pub failed: u64,
|
||||
/// A traversal-level problem, such as a folder that could not be listed.
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// A daemon too old to report connectivity is assumed online — it could not
|
||||
/// have mounted at all otherwise.
|
||||
fn default_online() -> bool {
|
||||
@@ -1339,6 +1439,15 @@ pub enum Response {
|
||||
PhotoMonths { months: Vec<PhotoMonth> },
|
||||
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
||||
Thumbs { items: Vec<PhotoThumb> },
|
||||
/// 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.
|
||||
FileThumbsStale,
|
||||
/// Daemon-issued generation reserved by
|
||||
/// [`Request::ReserveFileThumbGeneration`].
|
||||
FileThumbGeneration { generation: u64 },
|
||||
/// Current recursive thumbnail-build progress.
|
||||
ThumbnailBuild { status: ThumbnailBuildStatus },
|
||||
/// An on-disk path the front-end can open (e.g. a downloaded photo).
|
||||
FilePath { path: String },
|
||||
/// Full-text search results (reply to [`Request::Search`]).
|
||||
@@ -1473,7 +1582,7 @@ impl ErrorKind {
|
||||
}
|
||||
|
||||
/// Send one [`Request`] to the daemon listening on `socket` and read its
|
||||
/// [`Response`]. Errors (e.g. [`Error::Io`]) if no daemon is listening.
|
||||
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening.
|
||||
///
|
||||
/// Shared by the CLI and GUI so both speak the wire format identically.
|
||||
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
||||
@@ -1632,6 +1741,86 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_thumbnail_requests_keep_their_revision_tags() {
|
||||
let request = Request::FileThumbs {
|
||||
items: vec![
|
||||
FileThumbRequest {
|
||||
uid: "volume~first".into(),
|
||||
modified: 1_700_000_001,
|
||||
name: "first.jpg".into(),
|
||||
},
|
||||
FileThumbRequest {
|
||||
uid: "volume~second".into(),
|
||||
modified: 1_700_000_002,
|
||||
name: "second.nef".into(),
|
||||
},
|
||||
],
|
||||
generation: 42,
|
||||
};
|
||||
let line = serde_json::to_string(&request).unwrap();
|
||||
let decoded: Request = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_file_thumbnail_requests_default_the_name() {
|
||||
let decoded: Request = serde_json::from_str(
|
||||
r#"{"FileThumbs":{"items":[{"uid":"volume~first","modified":1700000001}],"generation":42}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let Request::FileThumbs { items, generation } = decoded else {
|
||||
panic!("decoded the wrong request variant");
|
||||
};
|
||||
assert_eq!(generation, 42);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert!(items[0].name.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_build_requests_and_progress_roundtrip() {
|
||||
let requests = [
|
||||
Request::ReserveFileThumbGeneration,
|
||||
Request::CancelFileThumbs { generation: 43 },
|
||||
Request::StartThumbnailBuild {
|
||||
path: "pictures/events".into(),
|
||||
},
|
||||
Request::CancelThumbnailBuild,
|
||||
Request::ThumbnailBuildStatus,
|
||||
];
|
||||
for request in requests {
|
||||
let line = serde_json::to_string(&request).unwrap();
|
||||
let decoded: Request = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||
}
|
||||
|
||||
let response = Response::ThumbnailBuild {
|
||||
status: ThumbnailBuildStatus {
|
||||
running: true,
|
||||
scanning: false,
|
||||
path: "pictures/events".into(),
|
||||
folders_scanned: 12,
|
||||
images_found: 80,
|
||||
completed: 25,
|
||||
failed: 1,
|
||||
message: None,
|
||||
},
|
||||
};
|
||||
let line = serde_json::to_string(&response).unwrap();
|
||||
let decoded: Response = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||
|
||||
let response = Response::FileThumbGeneration { generation: 44 };
|
||||
let line = serde_json::to_string(&response).unwrap();
|
||||
let decoded: Response = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||
|
||||
let response = Response::FileThumbsStale;
|
||||
let line = serde_json::to_string(&response).unwrap();
|
||||
let decoded: Response = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_search_request_and_response_roundtrip() {
|
||||
let request = Request::SearchV2 {
|
||||
|
||||
@@ -20,6 +20,7 @@ tracing.workspace = true
|
||||
fuser.workspace = true
|
||||
libc.workspace = true
|
||||
serde_json.workspace = true
|
||||
base64.workspace = true
|
||||
image.workspace = true
|
||||
notify = "8"
|
||||
parking_lot.workspace = true
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -134,12 +134,21 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
||||
/// the mount root.
|
||||
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() {
|
||||
let relative = if p.is_absolute() {
|
||||
p.strip_prefix(mountpoint)
|
||||
.map(Path::to_path_buf)
|
||||
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
||||
} else {
|
||||
Ok(p.to_path_buf())
|
||||
}?;
|
||||
|
||||
if relative
|
||||
.components()
|
||||
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||
{
|
||||
Ok(relative)
|
||||
} else {
|
||||
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +329,34 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
items: core.photo_thumbs(&parsed),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::FileThumbs { items, generation }) => {
|
||||
match core.file_thumbs(&items, generation) {
|
||||
Some(items) => CtlResponse::Thumbs { items },
|
||||
None => CtlResponse::FileThumbsStale,
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ReserveFileThumbGeneration) => CtlResponse::FileThumbGeneration {
|
||||
generation: core.reserve_file_thumb_generation(),
|
||||
},
|
||||
Ok(CtlRequest::CancelFileThumbs { generation }) => {
|
||||
core.cancel_file_thumbs(generation);
|
||||
CtlResponse::Ok {
|
||||
message: "thumbnail requests cancelled".to_string(),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::StartThumbnailBuild { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.start_thumbnail_build(rel) {
|
||||
Ok(status) => CtlResponse::ThumbnailBuild { status },
|
||||
Err(error) => CtlResponse::error(error),
|
||||
},
|
||||
Err(error) => CtlResponse::error(error),
|
||||
},
|
||||
Ok(CtlRequest::CancelThumbnailBuild) => CtlResponse::ThumbnailBuild {
|
||||
status: core.cancel_thumbnail_build(),
|
||||
},
|
||||
Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild {
|
||||
status: core.thumbnail_build_status(),
|
||||
},
|
||||
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
||||
Some(u) => match core.open_photo(&u) {
|
||||
Ok(p) => CtlResponse::FilePath {
|
||||
@@ -1221,4 +1258,29 @@ mod request_limit_tests {
|
||||
std::io::ErrorKind::InvalidData
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_paths_inside_the_mountpoint() {
|
||||
let mountpoint = Path::new("/mnt/proton");
|
||||
assert_eq!(
|
||||
rel_to_mount(mountpoint, "Photos/2026").unwrap(),
|
||||
Path::new("Photos/2026")
|
||||
);
|
||||
assert_eq!(
|
||||
rel_to_mount(mountpoint, "/mnt/proton/Photos/2026").unwrap(),
|
||||
Path::new("Photos/2026")
|
||||
);
|
||||
assert_eq!(
|
||||
rel_to_mount(mountpoint, "/mnt/proton").unwrap(),
|
||||
Path::new("")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_paths_that_escape_the_mountpoint() {
|
||||
let mountpoint = Path::new("/mnt/proton");
|
||||
assert!(rel_to_mount(mountpoint, "../escape").is_err());
|
||||
assert!(rel_to_mount(mountpoint, "Photos/../../escape").is_err());
|
||||
assert!(rel_to_mount(mountpoint, "/mnt/elsewhere").is_err());
|
||||
}
|
||||
}
|
||||
+91
-14
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
||||
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||
TransferDirection,
|
||||
ThumbnailBuildStatus, TransferDirection,
|
||||
};
|
||||
use pdfs_core::db::{
|
||||
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
||||
@@ -374,6 +374,45 @@ impl SearchRoots {
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative thumbnail knowledge has two different authorities. A remote miss
|
||||
/// only says Proton has no thumbnail to download; it must never suppress local
|
||||
/// generation. A local miss says this exact file revision was successfully
|
||||
/// inspected and could not be decoded. Both maps are bounded independently.
|
||||
#[derive(Default)]
|
||||
struct ThumbnailMissCaches {
|
||||
remote: HashMap<(NodeUid, i32), i64>,
|
||||
local: HashMap<(NodeUid, i32), i64>,
|
||||
}
|
||||
|
||||
impl ThumbnailMissCaches {
|
||||
fn remote_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
|
||||
self.remote.get(key) == Some(&tag)
|
||||
}
|
||||
|
||||
fn local_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
|
||||
self.local.get(key) == Some(&tag)
|
||||
}
|
||||
|
||||
fn remember_remote(&mut self, key: (NodeUid, i32), tag: i64) {
|
||||
Self::remember(&mut self.remote, key, tag);
|
||||
}
|
||||
|
||||
fn remember_local(&mut self, key: (NodeUid, i32), tag: i64) {
|
||||
Self::remember(&mut self.local, key, tag);
|
||||
}
|
||||
|
||||
fn forget_local(&mut self, key: &(NodeUid, i32)) {
|
||||
self.local.remove(key);
|
||||
}
|
||||
|
||||
fn remember(map: &mut HashMap<(NodeUid, i32), i64>, key: (NodeUid, i32), tag: i64) {
|
||||
if map.len() >= MAX_THUMBNAIL_MISSES && !map.contains_key(&key) {
|
||||
map.clear();
|
||||
}
|
||||
map.insert(key, tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` expressed relative to `root`, or `None` if it is not below it. The
|
||||
/// root itself resolves to the empty path; an empty root is the mount itself
|
||||
/// and covers everything.
|
||||
@@ -511,8 +550,22 @@ struct Core {
|
||||
/// those downloads is a full-size photo — so an in-flight uid is never started
|
||||
/// twice.
|
||||
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
|
||||
/// Nodes the remote has told us have *no* thumbnail of a given type, keyed by
|
||||
/// `(uid, thumbnail type)` and holding the mtime the answer was learned at.
|
||||
/// Global permit pool for locally generated thumbnails. The old per-request
|
||||
/// limit multiplied when the GUI sent several batches, allowing dozens of
|
||||
/// full-size images to download at once.
|
||||
thumb_gen_budget: Arc<tokio::sync::Semaphore>,
|
||||
/// Current ordinary-file listing generation and a wake-up for tasks waiting
|
||||
/// on a permit or a download when that listing is abandoned.
|
||||
file_thumb_generation: Arc<AtomicU64>,
|
||||
file_thumb_cancel: Arc<tokio::sync::Notify>,
|
||||
/// Progress of the explicit recursive build started from the Files toolbar.
|
||||
thumbnail_build: Arc<Mutex<ThumbnailBuildStatus>>,
|
||||
/// Cancellation for the explicit recursive build. Separate from ordinary
|
||||
/// listing cancellation because the two jobs have independent lifetimes.
|
||||
thumbnail_build_cancelled: Arc<AtomicBool>,
|
||||
thumbnail_build_cancel: Arc<tokio::sync::Notify>,
|
||||
/// Negative thumbnail knowledge, separated by whether the remote lacked a
|
||||
/// thumbnail or local decoding proved this revision unsupported.
|
||||
///
|
||||
/// Absence has to be cached or it costs a round trip every time it is asked
|
||||
/// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister
|
||||
@@ -520,7 +573,7 @@ struct Core {
|
||||
/// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime
|
||||
/// is the validity tag — a new revision may well have a thumbnail — matching
|
||||
/// how [`ContentCache::read_thumbnail`] validates the positive side.
|
||||
no_thumbnail: Arc<Mutex<HashMap<(NodeUid, i32), i64>>>,
|
||||
thumbnail_misses: Arc<Mutex<ThumbnailMissCaches>>,
|
||||
/// Last account quota answer and when it was learned, for `statfs(2)`.
|
||||
///
|
||||
/// `df` and the free-space preflight of every file manager and installer ask
|
||||
@@ -3580,7 +3633,7 @@ impl Core {
|
||||
// "This file has no thumbnail" is an answer worth remembering: without
|
||||
// it every listing pays a round trip per file to be told nothing (B5).
|
||||
let key = (uid.clone(), ttype.as_i32());
|
||||
if self.no_thumbnail.lock().get(&key) == Some(&mtime) {
|
||||
if self.thumbnail_misses.lock().remote_contains(&key, mtime) {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = self
|
||||
@@ -3597,15 +3650,7 @@ impl Core {
|
||||
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
|
||||
}
|
||||
None => {
|
||||
let mut misses = self.no_thumbnail.lock();
|
||||
// Bounded by clearing rather than by LRU: the entries are two
|
||||
// words each, the cap is far above any plausible working set,
|
||||
// and re-learning a miss costs one round trip. Not worth a
|
||||
// second data structure to order them.
|
||||
if misses.len() >= MAX_THUMBNAIL_MISSES {
|
||||
misses.clear();
|
||||
}
|
||||
misses.insert(key, mtime);
|
||||
self.thumbnail_misses.lock().remember_remote(key, mtime);
|
||||
}
|
||||
}
|
||||
Ok(bytes)
|
||||
@@ -7546,6 +7591,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod thumbnail_miss_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_misses_never_suppress_local_generation() {
|
||||
let uid = parse_uid("volume~node").expect("uid");
|
||||
let key = (uid, ThumbnailType::Thumbnail.as_i32());
|
||||
let mut misses = ThumbnailMissCaches::default();
|
||||
|
||||
misses.remember_remote(key.clone(), 42);
|
||||
assert!(misses.remote_contains(&key, 42));
|
||||
assert!(!misses.local_contains(&key, 42));
|
||||
|
||||
misses.remember_local(key.clone(), 42);
|
||||
assert!(misses.local_contains(&key, 42));
|
||||
misses.forget_local(&key);
|
||||
assert!(!misses.local_contains(&key, 42));
|
||||
assert!(misses.remote_contains(&key, 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_miss_cache_is_bounded_on_gui_only_paths() {
|
||||
let mut misses = ThumbnailMissCaches::default();
|
||||
for index in 0..=MAX_THUMBNAIL_MISSES {
|
||||
let uid = parse_uid(&format!("volume~node-{index}")).expect("uid");
|
||||
misses.remember_local((uid, ThumbnailType::Thumbnail.as_i32()), 42);
|
||||
}
|
||||
assert_eq!(misses.local.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod search_root_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -389,7 +389,15 @@ pub fn mount(
|
||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
||||
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||
super::photos::THUMB_GEN_CONCURRENCY,
|
||||
)),
|
||||
file_thumb_generation: Arc::new(AtomicU64::new(0)),
|
||||
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
thumbnail_build: Arc::new(Mutex::new(Default::default())),
|
||||
thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
|
||||
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||
quota: Arc::new(Mutex::new(None)),
|
||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||
size_waiters: Arc::new(Default::default()),
|
||||
|
||||
+983
-39
File diff suppressed because it is too large.
Load diff
@@ -609,6 +609,7 @@ impl Core {
|
||||
items.push(SharedItem {
|
||||
uid: uid.to_string(),
|
||||
is_dir: n.is_folder(),
|
||||
modified: n.modification_time,
|
||||
name: n.name,
|
||||
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
|
||||
member_count: members,
|
||||
|
||||
@@ -22,6 +22,9 @@ 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"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Generate-thumbnails action: a picture frame with a sparkle. A single
|
||||
symbolic fill lets GTK recolour it with the surrounding control. -->
|
||||
<path fill="#222222" d="M 2,3 C 1.45,3 1,3.45 1,4 V 14 C 1,14.55 1.45,15 2,15 H 14 C 14.55,15 15,14.55 15,14 V 7 H 13 V 13 H 3 V 5 H 9 V 3 Z M 5,6 C 4.45,6 4,6.45 4,7 C 4,7.55 4.45,8 5,8 C 5.55,8 6,7.55 6,7 C 6,6.45 5.55,6 5,6 Z M 10,7 L 7.5,10 L 6,9 L 4,11.5 V 12 H 12 V 9 Z M 13,0 L 12.4,2.4 L 10,3 L 12.4,3.6 L 13,6 L 13.6,3.6 L 16,3 L 13.6,2.4 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 598 B |
@@ -4,6 +4,7 @@
|
||||
icon-theme resource path at startup, so they resolve by name (and recolour
|
||||
with the theme) exactly like a stock symbolic icon. -->
|
||||
<gresource prefix="/de/nils/protondrivelinux/icons">
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@@ -19,6 +19,7 @@ use pages::trash::*;
|
||||
use pages::verify::*;
|
||||
use widgets::details::*;
|
||||
use widgets::share_dialog::*;
|
||||
use widgets::thumbnails::*;
|
||||
use widgets::versions_dialog::*;
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
@@ -49,8 +50,8 @@ use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
|
||||
ImportSummary, InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope,
|
||||
Request, Response, RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind,
|
||||
SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem,
|
||||
pending_summary, send,
|
||||
SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection,
|
||||
TransferItem, pending_summary, send,
|
||||
};
|
||||
|
||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
@@ -107,6 +108,10 @@ struct Ui {
|
||||
/// The sidebar/content split. Collapsed while signed out, so the login page
|
||||
/// owns the whole window and no destination is reachable without a session.
|
||||
nav: adw::NavigationSplitView,
|
||||
/// Shared thumbnails for ordinary image files outside the Photos gallery.
|
||||
/// One cache and request queue serves Files, search, Shared and Trash, so
|
||||
/// the same image is downloaded and decoded only once.
|
||||
pub(crate) file_thumbs: FileThumbnailState,
|
||||
|
||||
// Per-page state. Each page module owns its own struct; `Ui` keeps only
|
||||
// what more than one page genuinely shares.
|
||||
@@ -207,10 +212,15 @@ fn load_proton_theme() {
|
||||
.file-grid {{ padding: 6px; }}\n\
|
||||
.file-tile {{ padding: 8px; border-radius: 10px; }}\n\
|
||||
.file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\
|
||||
.file-thumbnail {{ border-radius: 7px; }}\n\
|
||||
.file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\
|
||||
.badge-pinned {{ color: #f5c211; }}\n\
|
||||
.badge-cached {{ color: #2ec27e; }}\n\
|
||||
.badge-cloud {{ color: #9aa0a6; }}\n\
|
||||
.browser-statusbar {{ background-color: alpha(currentColor, 0.025); }}\n\
|
||||
scale.browser-status-meter trough, progressbar.browser-status-meter trough {{ min-width: 104px; }}\n\
|
||||
scale.browser-status-meter trough {{ min-height: 6px; }}\n\
|
||||
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress {{ min-height: 6px; }}\n\
|
||||
.photo-viewer-window {{ background-color: #111014; }}\n\
|
||||
.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; }}\n\
|
||||
.viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\
|
||||
@@ -345,6 +355,7 @@ fn build_window(app: &adw::Application) {
|
||||
mounted: RefCell::new(false),
|
||||
sidebar: sidebar_list.clone(),
|
||||
nav: split.clone(),
|
||||
file_thumbs: FileThumbnailState::new(),
|
||||
login: LoginState {
|
||||
email: login_widgets.0,
|
||||
password: login_widgets.1,
|
||||
@@ -390,7 +401,15 @@ fn build_window(app: &adw::Application) {
|
||||
new_folder: browser_widgets.new_folder.clone(),
|
||||
upload: browser_widgets.upload.clone(),
|
||||
upload_folder: browser_widgets.upload_folder.clone(),
|
||||
build_thumbnails: browser_widgets.build_thumbnails.clone(),
|
||||
thumbnail_build_row: browser_widgets.thumbnail_build_row.clone(),
|
||||
thumbnail_progress: browser_widgets.thumbnail_progress.clone(),
|
||||
thumbnail_status: browser_widgets.thumbnail_status.clone(),
|
||||
thumbnail_poll: RefCell::new(None),
|
||||
thumbnail_build_running: Cell::new(false),
|
||||
thumbnail_cancel_pending: Cell::new(false),
|
||||
search_source: RefCell::new(None),
|
||||
load_generation: Cell::new(0),
|
||||
views: browser_widgets.views.clone(),
|
||||
bulk: browser_widgets.bulk.clone(),
|
||||
bulk_label: browser_widgets.bulk_label.clone(),
|
||||
@@ -398,6 +417,13 @@ fn build_window(app: &adw::Application) {
|
||||
bulk_pin: browser_widgets.bulk_pin.clone(),
|
||||
bulk_unpin: browser_widgets.bulk_unpin.clone(),
|
||||
empty_actions: browser_widgets.empty_actions.clone(),
|
||||
summary: browser_widgets.summary.clone(),
|
||||
zoom: browser_widgets.zoom.clone(),
|
||||
grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT),
|
||||
grid_tiles: RefCell::new(Vec::new()),
|
||||
quota_box: browser_widgets.quota_box.clone(),
|
||||
quota: browser_widgets.quota.clone(),
|
||||
quota_text: browser_widgets.quota_text.clone(),
|
||||
},
|
||||
details: DetailsState {
|
||||
details: browser_widgets.details,
|
||||
@@ -552,6 +578,7 @@ fn build_window(app: &adw::Application) {
|
||||
&browser_widgets.new_folder,
|
||||
&browser_widgets.upload,
|
||||
&browser_widgets.upload_folder,
|
||||
&browser_widgets.build_thumbnails,
|
||||
);
|
||||
wire_details(&ui);
|
||||
wire_search(&ui);
|
||||
@@ -588,6 +615,10 @@ fn build_window(app: &adw::Application) {
|
||||
// network round-trip only happens on demand rather than on every refresh.
|
||||
let ui_nav = ui.clone();
|
||||
stack.connect_visible_child_name_notify(move |st| {
|
||||
// Rows from the page being left must not keep full-size image downloads
|
||||
// alive in the daemon. The page being entered establishes a fresh
|
||||
// thumbnail generation as it paints.
|
||||
cancel_file_thumbnails(&ui_nav);
|
||||
sync_sidebar(&ui_nav);
|
||||
match st.visible_child_name().as_deref() {
|
||||
Some("browser") => load_browser(&ui_nav),
|
||||
|
||||
@@ -28,9 +28,21 @@ pub(crate) struct BrowserState {
|
||||
pub(crate) new_folder: gtk4::Button,
|
||||
pub(crate) upload: gtk4::Button,
|
||||
pub(crate) upload_folder: gtk4::Button,
|
||||
/// Starts a daemon-side recursive local-thumbnail build for [`Self::path`].
|
||||
pub(crate) build_thumbnails: gtk4::Button,
|
||||
pub(crate) thumbnail_build_row: gtk4::Box,
|
||||
pub(crate) thumbnail_progress: gtk4::ProgressBar,
|
||||
pub(crate) thumbnail_status: gtk4::Label,
|
||||
pub(crate) thumbnail_poll: RefCell<Option<glib::SourceId>>,
|
||||
pub(crate) thumbnail_build_running: Cell<bool>,
|
||||
pub(crate) thumbnail_cancel_pending: Cell<bool>,
|
||||
/// Pending debounce timer for the search box; replaced on every keystroke so
|
||||
/// only the last pause actually fires a [`Request::Search`].
|
||||
pub(crate) search_source: RefCell<Option<glib::SourceId>>,
|
||||
/// Identity of the newest folder/search request. Paths and queries can be
|
||||
/// requested repeatedly, so comparing their text alone cannot reject an
|
||||
/// older response that finishes after a manual refresh.
|
||||
pub(crate) load_generation: Cell<u64>,
|
||||
/// The grid/list view stack, read to find out which of the two selections is
|
||||
/// the one the user is actually working in.
|
||||
pub(crate) views: gtk4::Stack,
|
||||
@@ -46,6 +58,17 @@ pub(crate) struct BrowserState {
|
||||
/// state is a place to act rather than a dead end. Hidden on every other
|
||||
/// status (a load error is not the moment to offer an upload).
|
||||
pub(crate) empty_actions: gtk4::Box,
|
||||
/// Dolphin-style bottom status bar: current listing counts, the icon-grid
|
||||
/// zoom value, and Proton account storage usage.
|
||||
pub(crate) summary: gtk4::Label,
|
||||
pub(crate) zoom: gtk4::Scale,
|
||||
pub(crate) grid_thumbnail_size: Cell<i32>,
|
||||
/// Weak references to realised grid cells. Zoom resizes only these visible,
|
||||
/// recycled surfaces instead of invalidating the whole list model.
|
||||
pub(crate) grid_tiles: RefCell<Vec<(glib::WeakRef<gtk4::Overlay>, glib::WeakRef<gtk4::Label>)>>,
|
||||
pub(crate) quota_box: gtk4::Box,
|
||||
pub(crate) quota: gtk4::ProgressBar,
|
||||
pub(crate) quota_text: gtk4::Label,
|
||||
}
|
||||
|
||||
/// Idle pause after the last keystroke before a search query is sent, so typing
|
||||
@@ -55,6 +78,13 @@ pub(crate) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250);
|
||||
/// Cap on search hits requested from the daemon.
|
||||
pub(crate) const SEARCH_LIMIT: usize = 200;
|
||||
|
||||
/// Browser grid thumbnail size controlled by the bottom zoom slider. The
|
||||
/// default preserves the former fixed 72 px presentation.
|
||||
pub(crate) const GRID_THUMB_MIN: i32 = 48;
|
||||
pub(crate) const GRID_THUMB_MAX: i32 = 144;
|
||||
pub(crate) const GRID_THUMB_DEFAULT: i32 = 72;
|
||||
pub(crate) const GRID_THUMB_STEP: i32 = 8;
|
||||
|
||||
/// The Files page: a Nautilus-style file manager. A back/breadcrumb header with
|
||||
/// a grid/list view toggle sits over a [`gtk4::Stack`] that swaps between an
|
||||
/// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`]
|
||||
@@ -86,6 +116,15 @@ pub(crate) struct BrowserWidgets {
|
||||
pub(crate) new_folder: gtk4::Button,
|
||||
pub(crate) upload: gtk4::Button,
|
||||
pub(crate) upload_folder: gtk4::Button,
|
||||
pub(crate) build_thumbnails: gtk4::Button,
|
||||
pub(crate) thumbnail_build_row: gtk4::Box,
|
||||
pub(crate) thumbnail_progress: gtk4::ProgressBar,
|
||||
pub(crate) thumbnail_status: gtk4::Label,
|
||||
pub(crate) summary: gtk4::Label,
|
||||
pub(crate) zoom: gtk4::Scale,
|
||||
pub(crate) quota_box: gtk4::Box,
|
||||
pub(crate) quota: gtk4::ProgressBar,
|
||||
pub(crate) quota_text: gtk4::Label,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
/// Wraps the views + the details pane; the pane slides in on selection.
|
||||
pub(crate) split: adw::OverlaySplitView,
|
||||
@@ -149,6 +188,12 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
upload_folder.add_css_class("flat");
|
||||
let build_thumbnails = gtk4::Button::builder()
|
||||
.icon_name("pdfs-build-thumbnails-symbolic")
|
||||
.tooltip_text("Build thumbnails in this folder and its subfolders")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
build_thumbnails.add_css_class("flat");
|
||||
|
||||
// Linked grid/list toggle, top-right, Nautilus-style.
|
||||
let grid_toggle = gtk4::ToggleButton::builder()
|
||||
@@ -178,12 +223,24 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||||
header.append(&back);
|
||||
header.append(&crumb_scroll);
|
||||
header.append(&refresh);
|
||||
header.append(&build_thumbnails);
|
||||
header.append(&new_folder);
|
||||
header.append(&upload);
|
||||
header.append(&upload_folder);
|
||||
header.append(&search);
|
||||
header.append(&toggles);
|
||||
|
||||
let thumbnail_status = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
thumbnail_status.add_css_class("caption");
|
||||
let thumbnail_progress = gtk4::ProgressBar::builder().hexpand(true).build();
|
||||
let thumbnail_build_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
thumbnail_build_row.append(&thumbnail_status);
|
||||
thumbnail_build_row.append(&thumbnail_progress);
|
||||
thumbnail_build_row.set_visible(false);
|
||||
|
||||
// Empty / loading / error surface, shown in place of the views.
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
@@ -314,17 +371,94 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||||
.child(&bulk_box)
|
||||
.build();
|
||||
|
||||
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(&bulk);
|
||||
inner.append(&split);
|
||||
// Mirror DolphinStatusBar's full-width order: contextual text (stretch 1),
|
||||
// "Zoom:", its slider, then capacity information. Both visible troughs use
|
||||
// the same CSS dimensions.
|
||||
let summary = gtk4::Label::builder()
|
||||
.label("Loading…")
|
||||
.halign(gtk4::Align::Start)
|
||||
.valign(gtk4::Align::Center)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
let zoom_label = gtk4::Label::new(Some("Zoom:"));
|
||||
zoom_label.set_valign(gtk4::Align::Center);
|
||||
let zoom = gtk4::Scale::with_range(
|
||||
gtk4::Orientation::Horizontal,
|
||||
f64::from(GRID_THUMB_MIN),
|
||||
f64::from(GRID_THUMB_MAX),
|
||||
f64::from(GRID_THUMB_STEP),
|
||||
);
|
||||
zoom.set_value(f64::from(GRID_THUMB_DEFAULT));
|
||||
zoom.set_draw_value(false);
|
||||
zoom.set_valign(gtk4::Align::Center);
|
||||
zoom.set_tooltip_text(Some("Size: 72 pixels"));
|
||||
zoom.add_css_class("browser-status-meter");
|
||||
|
||||
let quota = gtk4::ProgressBar::new();
|
||||
quota.set_hexpand(false);
|
||||
quota.set_valign(gtk4::Align::Center);
|
||||
quota.set_tooltip_text(Some("Proton account storage"));
|
||||
quota.add_css_class("browser-status-meter");
|
||||
let quota_text = gtk4::Label::builder()
|
||||
.label("Loading…")
|
||||
.halign(gtk4::Align::Start)
|
||||
.valign(gtk4::Align::Center)
|
||||
.margin_end(6)
|
||||
.tooltip_text("Proton account storage")
|
||||
.build();
|
||||
let quota_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
|
||||
quota_box.set_valign(gtk4::Align::Center);
|
||||
quota_box.append("a);
|
||||
quota_box.append("a_text);
|
||||
quota_box.set_visible(false);
|
||||
|
||||
let status_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
|
||||
status_bar.add_css_class("browser-statusbar");
|
||||
status_bar.set_margin_start(6);
|
||||
status_bar.set_margin_end(2);
|
||||
status_bar.append(&summary);
|
||||
status_bar.append(&zoom_label);
|
||||
status_bar.append(&zoom);
|
||||
status_bar.append("a_box);
|
||||
|
||||
let zoom_label_for_view = zoom_label.clone();
|
||||
let zoom_for_view = zoom.clone();
|
||||
let content_for_view = content.clone();
|
||||
view_stack.connect_visible_child_name_notify(move |stack| {
|
||||
let visible = content_for_view.visible_child_name().as_deref() == Some("views")
|
||||
&& stack.visible_child_name().as_deref() == Some("grid");
|
||||
zoom_label_for_view.set_visible(visible);
|
||||
zoom_for_view.set_visible(visible);
|
||||
});
|
||||
let zoom_label_for_content = zoom_label.clone();
|
||||
let zoom_for_content = zoom.clone();
|
||||
let view_for_content = view_stack.clone();
|
||||
content.connect_visible_child_name_notify(move |stack| {
|
||||
let visible = stack.visible_child_name().as_deref() == Some("views")
|
||||
&& view_for_content.visible_child_name().as_deref() == Some("grid");
|
||||
zoom_label_for_content.set_visible(visible);
|
||||
zoom_for_content.set_visible(visible);
|
||||
});
|
||||
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
body.set_vexpand(true);
|
||||
body.set_margin_top(12);
|
||||
body.set_margin_bottom(6);
|
||||
body.set_margin_start(12);
|
||||
body.set_margin_end(12);
|
||||
body.append(&header);
|
||||
body.append(&thumbnail_build_row);
|
||||
body.append(&bulk);
|
||||
body.append(&split);
|
||||
|
||||
let page = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
page.append(&body);
|
||||
page.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
|
||||
page.append(&status_bar);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
page.upcast(),
|
||||
BrowserWidgets {
|
||||
model,
|
||||
back,
|
||||
@@ -338,6 +472,15 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||||
new_folder,
|
||||
upload,
|
||||
upload_folder,
|
||||
build_thumbnails,
|
||||
thumbnail_build_row,
|
||||
thumbnail_progress,
|
||||
thumbnail_status,
|
||||
summary,
|
||||
zoom,
|
||||
quota_box,
|
||||
quota,
|
||||
quota_text,
|
||||
refresh,
|
||||
split,
|
||||
details,
|
||||
@@ -653,23 +796,51 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: >k4::GridView, column_view: >k
|
||||
load_browser(&ui_back);
|
||||
});
|
||||
|
||||
// Grid tiles: a big icon over an ellipsized name, with a right-click menu.
|
||||
// Resize only realised grid cells. Rebuilding the whole model for every
|
||||
// slider step would repeatedly tear down selection state while the pointer
|
||||
// is still moving.
|
||||
let zoom = ui.browser.zoom.clone();
|
||||
let ui_zoom = ui.clone();
|
||||
let grid_zoom = grid.clone();
|
||||
zoom.connect_value_changed(move |scale| {
|
||||
let size = (scale.value().round() as i32).clamp(GRID_THUMB_MIN, GRID_THUMB_MAX);
|
||||
scale.set_tooltip_text(Some(&format!("Size: {size} pixels")));
|
||||
if ui_zoom.browser.grid_thumbnail_size.replace(size) == size {
|
||||
return;
|
||||
}
|
||||
ui_zoom
|
||||
.browser
|
||||
.grid_tiles
|
||||
.borrow_mut()
|
||||
.retain(|(thumbnail_ref, label_ref)| {
|
||||
let (Some(thumbnail), Some(label)) = (thumbnail_ref.upgrade(), label_ref.upgrade())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
resize_grid_tile(&thumbnail, &label, size);
|
||||
true
|
||||
});
|
||||
grid_zoom.queue_resize();
|
||||
});
|
||||
|
||||
// Grid tiles: a thumbnail over an ellipsized name, with a right-click menu.
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_setup({
|
||||
let ui = ui.clone();
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let icon = gtk4::Image::builder().pixel_size(64).build();
|
||||
// Corner sync-state badge, overlaid on the big icon.
|
||||
let size = ui.browser.grid_thumbnail_size.get();
|
||||
let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size));
|
||||
// Keep the sync-state badge inside the thumbnail surface.
|
||||
let badge = gtk4::Image::builder()
|
||||
.pixel_size(18)
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Start)
|
||||
.margin_top(2)
|
||||
.margin_end(2)
|
||||
.build();
|
||||
badge.add_css_class("file-badge");
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_child(Some(&icon));
|
||||
overlay.add_overlay(&badge);
|
||||
thumbnail.add_overlay(&badge);
|
||||
// `WordChar` rather than the default `Word`: a name with no spaces
|
||||
// offers no word-break opportunity, so word wrapping cannot break it
|
||||
// at all and the label asks for its full natural width instead —
|
||||
@@ -686,9 +857,13 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: >k4::GridView, column_view: >k
|
||||
.wrap_mode(gtk4::pango::WrapMode::WordChar)
|
||||
.lines(2)
|
||||
.build();
|
||||
ui.browser
|
||||
.grid_tiles
|
||||
.borrow_mut()
|
||||
.push((thumbnail.downgrade(), label.downgrade()));
|
||||
let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
tile.add_css_class("file-tile");
|
||||
tile.append(&overlay);
|
||||
tile.append(&thumbnail);
|
||||
tile.append(&label);
|
||||
attach_context_menu(&ui, item, &tile);
|
||||
attach_drag(&ui, item, &tile);
|
||||
@@ -696,21 +871,29 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: >k4::GridView, column_view: >k
|
||||
item.set_child(Some(&tile));
|
||||
}
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let overlay = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
||||
let icon = overlay.first_child().and_downcast::<gtk4::Image>().unwrap();
|
||||
let badge = overlay.last_child().and_downcast::<gtk4::Image>().unwrap();
|
||||
let label = tile.last_child().and_downcast::<gtk4::Label>().unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let entry = obj.borrow::<DirEntry>();
|
||||
icon.set_icon_name(Some(icon_base_for(&entry)));
|
||||
label.set_label(&entry.name);
|
||||
// The tile shows at most two lines of it, so the full name has to be
|
||||
// reachable somehow.
|
||||
label.set_tooltip_text(Some(&entry.name));
|
||||
apply_badge(&badge, &entry);
|
||||
factory.connect_bind({
|
||||
let ui = ui.clone();
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let thumbnail = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
||||
let badge = thumbnail
|
||||
.last_child()
|
||||
.and_downcast::<gtk4::Image>()
|
||||
.unwrap();
|
||||
let label = thumbnail
|
||||
.next_sibling()
|
||||
.and_downcast::<gtk4::Label>()
|
||||
.unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let entry = obj.borrow::<DirEntry>();
|
||||
let size = ui.browser.grid_thumbnail_size.get();
|
||||
resize_grid_tile(&thumbnail, &label, size);
|
||||
bind_file_thumbnail(&ui, &thumbnail, &entry, false);
|
||||
label.set_label(&entry.name);
|
||||
label.set_tooltip_text(Some(&entry.name));
|
||||
apply_badge(&badge, &entry);
|
||||
}
|
||||
});
|
||||
grid.set_factory(Some(&factory));
|
||||
|
||||
@@ -740,15 +923,33 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: >k4::GridView, column_view: >k
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the Name column: a small icon plus the (star-prefixed when pinned) name,
|
||||
/// with the same right-click menu the grid tiles carry.
|
||||
fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) {
|
||||
resize_file_thumbnail(thumbnail, size, grid_fallback_size(size));
|
||||
let name_width = (size / 6 + 1).clamp(8, 24);
|
||||
label.set_width_chars(name_width);
|
||||
label.set_max_width_chars(name_width);
|
||||
}
|
||||
|
||||
fn grid_fallback_size(thumbnail_size: i32) -> i32 {
|
||||
(thumbnail_size * 8 / 9).clamp(24, thumbnail_size)
|
||||
}
|
||||
|
||||
/// Build the Name column: a small thumbnail with its local-state badge overlaid,
|
||||
/// followed by the name and the same right-click menu the grid tiles carry.
|
||||
pub(crate) fn name_column(ui: &Rc<Ui>) -> gtk4::ColumnViewColumn {
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_setup({
|
||||
let ui = ui.clone();
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let icon = gtk4::Image::builder().pixel_size(16).build();
|
||||
let thumbnail = file_thumbnail_widget(28, 16);
|
||||
let badge = gtk4::Image::builder()
|
||||
.pixel_size(14)
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Start)
|
||||
.build();
|
||||
badge.add_css_class("file-badge");
|
||||
thumbnail.add_overlay(&badge);
|
||||
// Ellipsized so the Name column can be *narrower* than its longest
|
||||
// name. Without it the label's minimum width is the whole string,
|
||||
// the column inherits that minimum, and one long name pushes Size
|
||||
@@ -757,30 +958,36 @@ pub(crate) fn name_column(ui: &Rc<Ui>) -> gtk4::ColumnViewColumn {
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
let badge = gtk4::Image::builder().pixel_size(14).build();
|
||||
badge.add_css_class("file-badge");
|
||||
let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
cell.append(&icon);
|
||||
cell.append(&thumbnail);
|
||||
cell.append(&label);
|
||||
cell.append(&badge);
|
||||
attach_context_menu(&ui, item, &cell);
|
||||
attach_drag(&ui, item, &cell);
|
||||
attach_drop(&ui, item, &cell);
|
||||
item.set_child(Some(&cell));
|
||||
}
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let icon = cell.first_child().and_downcast::<gtk4::Image>().unwrap();
|
||||
let badge = cell.last_child().and_downcast::<gtk4::Image>().unwrap();
|
||||
let label = icon.next_sibling().and_downcast::<gtk4::Label>().unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let entry = obj.borrow::<DirEntry>();
|
||||
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
|
||||
label.set_label(&entry.name);
|
||||
label.set_tooltip_text(Some(&entry.name));
|
||||
apply_badge(&badge, &entry);
|
||||
factory.connect_bind({
|
||||
let ui = ui.clone();
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let thumbnail = cell.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
||||
let badge = thumbnail
|
||||
.last_child()
|
||||
.and_downcast::<gtk4::Image>()
|
||||
.unwrap();
|
||||
let label = thumbnail
|
||||
.next_sibling()
|
||||
.and_downcast::<gtk4::Label>()
|
||||
.unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let entry = obj.borrow::<DirEntry>();
|
||||
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
||||
label.set_label(&entry.name);
|
||||
label.set_tooltip_text(Some(&entry.name));
|
||||
apply_badge(&badge, &entry);
|
||||
}
|
||||
});
|
||||
let column = gtk4::ColumnViewColumn::new(Some("Name"), Some(factory));
|
||||
column.set_expand(true);
|
||||
@@ -1254,6 +1461,7 @@ pub(crate) fn wire_browser_actions(
|
||||
new_folder: >k4::Button,
|
||||
upload: >k4::Button,
|
||||
upload_folder: >k4::Button,
|
||||
build_thumbnails: >k4::Button,
|
||||
) {
|
||||
let ui_nf = ui.clone();
|
||||
new_folder.connect_clicked(move |_| prompt_new_folder(&ui_nf));
|
||||
@@ -1261,6 +1469,229 @@ pub(crate) fn wire_browser_actions(
|
||||
upload.connect_clicked(move |_| prompt_upload(&ui_up));
|
||||
let ui_uf = ui.clone();
|
||||
upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf));
|
||||
let ui_thumbs = ui.clone();
|
||||
build_thumbnails.connect_clicked(move |_| {
|
||||
if ui_thumbs.browser.thumbnail_build_running.get() {
|
||||
cancel_thumbnail_build(&ui_thumbs);
|
||||
} else {
|
||||
start_thumbnail_build(&ui_thumbs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const THUMBNAIL_BUILD_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Start the daemon's deliberate recursive job. Opportunistic requests for
|
||||
/// visible rows are cancelled first; the explicit job has its own lifetime and
|
||||
/// continues if the user navigates while watching its progress.
|
||||
fn start_thumbnail_build(ui: &Rc<Ui>) {
|
||||
if !*ui.mounted.borrow() {
|
||||
toast_error(
|
||||
ui,
|
||||
"Couldn't build thumbnails",
|
||||
"Proton Drive isn't connected.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
cancel_file_thumbnails(ui);
|
||||
ui.browser.thumbnail_build_running.set(true);
|
||||
// Do not expose Cancel until the daemon has acknowledged Start; the two
|
||||
// requests use separate control connections and could otherwise reorder.
|
||||
ui.browser.thumbnail_cancel_pending.set(true);
|
||||
repaint_thumbnail_build_action(ui, true);
|
||||
ui.browser.thumbnail_build_row.set_visible(true);
|
||||
ui.browser.thumbnail_progress.set_fraction(0.0);
|
||||
ui.browser
|
||||
.thumbnail_status
|
||||
.set_label("Starting thumbnail build…");
|
||||
|
||||
let path = ui.browser.path.borrow().clone();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::StartThumbnailBuild { path },
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::ThumbnailBuild { status })) => {
|
||||
ui.browser.thumbnail_cancel_pending.set(false);
|
||||
repaint_thumbnail_build(&ui, &status);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
thumbnail_build_failed(&ui);
|
||||
toast_failure(&ui, "Couldn't build thumbnails", &message, kind);
|
||||
}
|
||||
_ => {
|
||||
let message = "The mount service didn't respond.";
|
||||
thumbnail_build_failed(&ui);
|
||||
toast_error(&ui, "Couldn't build thumbnails", message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn cancel_thumbnail_build(ui: &Rc<Ui>) {
|
||||
if ui.browser.thumbnail_cancel_pending.replace(true) {
|
||||
return;
|
||||
}
|
||||
ui.browser.build_thumbnails.set_sensitive(false);
|
||||
ui.browser
|
||||
.thumbnail_status
|
||||
.set_label("Stopping thumbnail build…");
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::CancelThumbnailBuild);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::ThumbnailBuild { status })) if !status.running => {
|
||||
repaint_thumbnail_build(&ui, &status)
|
||||
}
|
||||
Ok(Ok(Response::ThumbnailBuild { .. })) => schedule_thumbnail_build_poll(&ui),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
thumbnail_cancel_failed(&ui);
|
||||
toast_failure(&ui, "Couldn't cancel thumbnail build", &message, kind);
|
||||
}
|
||||
_ => {
|
||||
thumbnail_cancel_failed(&ui);
|
||||
toast_error(
|
||||
&ui,
|
||||
"Couldn't cancel thumbnail build",
|
||||
"The mount service didn't respond.",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn thumbnail_cancel_failed(ui: &Rc<Ui>) {
|
||||
ui.browser.thumbnail_cancel_pending.set(false);
|
||||
ui.browser.thumbnail_build_running.set(true);
|
||||
repaint_thumbnail_build_action(ui, true);
|
||||
schedule_thumbnail_build_poll(ui);
|
||||
}
|
||||
|
||||
fn schedule_thumbnail_build_poll(ui: &Rc<Ui>) {
|
||||
if let Some(source) = ui.browser.thumbnail_poll.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
let ui_poll = ui.clone();
|
||||
let source = glib::timeout_add_local_once(THUMBNAIL_BUILD_POLL, move || {
|
||||
ui_poll.browser.thumbnail_poll.borrow_mut().take();
|
||||
let rx = spawn_request(ui_poll.dirs.control_socket(), Request::ThumbnailBuildStatus);
|
||||
let ui_result = ui_poll.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::ThumbnailBuild { status })) => {
|
||||
repaint_thumbnail_build(&ui_result, &status)
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
thumbnail_build_failed(&ui_result);
|
||||
toast_error(&ui_result, "Thumbnail build stopped", &message);
|
||||
}
|
||||
_ => {
|
||||
thumbnail_build_failed(&ui_result);
|
||||
toast_error(
|
||||
&ui_result,
|
||||
"Thumbnail build stopped",
|
||||
"The mount service stopped reporting thumbnail progress.",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
*ui.browser.thumbnail_poll.borrow_mut() = Some(source);
|
||||
}
|
||||
|
||||
fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
|
||||
ui.browser
|
||||
.thumbnail_build_row
|
||||
.set_visible(show_thumbnail_build_progress(status));
|
||||
let root = if status.path.is_empty() {
|
||||
"Proton Drive"
|
||||
} else {
|
||||
&status.path
|
||||
};
|
||||
let text = if status.scanning {
|
||||
ui.browser.thumbnail_progress.pulse();
|
||||
format!(
|
||||
"Scanning {root}… {} folders, {} images",
|
||||
status.folders_scanned, status.images_found
|
||||
)
|
||||
} else if status.running {
|
||||
let fraction = if status.images_found == 0 {
|
||||
0.0
|
||||
} else {
|
||||
status.completed as f64 / status.images_found as f64
|
||||
};
|
||||
ui.browser
|
||||
.thumbnail_progress
|
||||
.set_fraction(fraction.clamp(0.0, 1.0));
|
||||
format!(
|
||||
"Building thumbnails in {root}… {} of {}",
|
||||
status.completed, status.images_found
|
||||
)
|
||||
} else {
|
||||
ui.browser.thumbnail_progress.set_fraction(1.0);
|
||||
let available = status.completed.saturating_sub(status.failed);
|
||||
match status.failed {
|
||||
0 => format!("Thumbnails ready for {available} images in {root}"),
|
||||
failed => {
|
||||
format!("Thumbnails ready for {available} images in {root}; {failed} unavailable")
|
||||
}
|
||||
}
|
||||
};
|
||||
let text = match status.message.as_deref() {
|
||||
Some(message) => format!("{text}. {message}"),
|
||||
None => text,
|
||||
};
|
||||
ui.browser.thumbnail_status.set_label(&text);
|
||||
ui.browser.thumbnail_status.set_tooltip_text(Some(&text));
|
||||
|
||||
if status.running {
|
||||
ui.browser.thumbnail_build_running.set(true);
|
||||
repaint_thumbnail_build_action(ui, true);
|
||||
schedule_thumbnail_build_poll(ui);
|
||||
} else {
|
||||
ui.browser.thumbnail_build_running.set(false);
|
||||
ui.browser.thumbnail_cancel_pending.set(false);
|
||||
repaint_thumbnail_build_action(ui, false);
|
||||
if ui.stack.visible_child_name().as_deref() == Some("browser") {
|
||||
reload_listing(ui);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The progress row is transient: once the daemon reports completion, both its
|
||||
/// label and bar leave the browser instead of retaining a stale success state.
|
||||
fn show_thumbnail_build_progress(status: &ThumbnailBuildStatus) -> bool {
|
||||
status.running
|
||||
}
|
||||
|
||||
fn repaint_thumbnail_build_action(ui: &Rc<Ui>, running: bool) {
|
||||
ui.browser.build_thumbnails.set_icon_name(if running {
|
||||
"process-stop-symbolic"
|
||||
} else {
|
||||
"pdfs-build-thumbnails-symbolic"
|
||||
});
|
||||
ui.browser
|
||||
.build_thumbnails
|
||||
.set_tooltip_text(Some(if running {
|
||||
"Cancel thumbnail build"
|
||||
} else {
|
||||
"Build thumbnails in this folder and its subfolders"
|
||||
}));
|
||||
ui.browser.build_thumbnails.set_sensitive(
|
||||
*ui.mounted.borrow() && (!running || !ui.browser.thumbnail_cancel_pending.get()),
|
||||
);
|
||||
}
|
||||
|
||||
fn thumbnail_build_failed(ui: &Rc<Ui>) {
|
||||
ui.browser.thumbnail_build_running.set(false);
|
||||
ui.browser.thumbnail_cancel_pending.set(false);
|
||||
repaint_thumbnail_build_action(ui, false);
|
||||
ui.browser.thumbnail_build_row.set_visible(false);
|
||||
ui.browser.thumbnail_progress.set_fraction(0.0);
|
||||
ui.browser.thumbnail_status.set_label("");
|
||||
ui.browser.thumbnail_status.set_tooltip_text(None);
|
||||
}
|
||||
|
||||
/// Send a mutating request (rename / move / delete / mkdir / upload, or a trash
|
||||
@@ -1666,9 +2097,13 @@ pub(crate) fn format_modified(secs: i64) -> String {
|
||||
|
||||
/// Request the current browser directory from the daemon and repaint both views.
|
||||
pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
||||
cancel_file_thumbnails(ui);
|
||||
let generation = ui.browser.load_generation.get().wrapping_add(1);
|
||||
ui.browser.load_generation.set(generation);
|
||||
let path = ui.browser.path.borrow().clone();
|
||||
repaint_crumb(ui, &path);
|
||||
ui.browser.back.set_sensitive(!path.is_empty());
|
||||
ui.browser.summary.set_label("Loading…");
|
||||
|
||||
// Drop the previous folder's rows up front: a slow reply must not leave stale
|
||||
// entries visible, where clicking one would open with a wrong relative path.
|
||||
@@ -1693,7 +2128,7 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
||||
// The user may have navigated on while this folder was loading. A stale
|
||||
// out-of-order reply must not repaint rows for a folder we've left, or
|
||||
// the breadcrumb and the grid would disagree.
|
||||
if *ui.browser.path.borrow() != path {
|
||||
if ui.browser.load_generation.get() != generation || *ui.browser.path.borrow() != path {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
@@ -1714,6 +2149,7 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
||||
/// (which restarts the service) wouldn't help and isn't offered.
|
||||
pub(crate) fn browser_failed(ui: &Rc<Ui>, message: &str, kind: ErrorKind) {
|
||||
ui.browser.model.remove_all();
|
||||
ui.browser.summary.set_label("Folder unavailable");
|
||||
browser_status(
|
||||
ui,
|
||||
"dialog-warning-symbolic",
|
||||
@@ -1730,6 +2166,7 @@ pub(crate) fn browser_failed(ui: &Rc<Ui>, message: &str, kind: ErrorKind) {
|
||||
/// once the systemd mount comes up but a real failure stays visible.
|
||||
pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
ui.browser.summary.set_label("Not connected");
|
||||
browser_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
@@ -1739,6 +2176,7 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.browser.summary.set_label("Connecting…");
|
||||
browser_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
@@ -1759,6 +2197,15 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
|
||||
/// which refreshes both the grid and the column list.
|
||||
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
ui.browser.model.remove_all();
|
||||
ui.browser.summary.set_label(&listing_summary(
|
||||
entries.iter().filter(|entry| !entry.is_dir).count(),
|
||||
entries.iter().filter(|entry| entry.is_dir).count(),
|
||||
entries
|
||||
.iter()
|
||||
.filter(|entry| !entry.is_dir)
|
||||
.map(|entry| entry.size)
|
||||
.sum(),
|
||||
));
|
||||
if entries.is_empty() {
|
||||
browser_status(
|
||||
ui,
|
||||
@@ -1810,7 +2257,11 @@ pub(crate) fn wire_search(ui: &Rc<Ui>) {
|
||||
/// views, reusing the same row model so click-to-open and pin work unchanged
|
||||
/// (each hit carries its full path; see [`entry_rel`]).
|
||||
pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
cancel_file_thumbnails(ui);
|
||||
let generation = ui.browser.load_generation.get().wrapping_add(1);
|
||||
ui.browser.load_generation.set(generation);
|
||||
ui.browser.model.remove_all();
|
||||
ui.browser.summary.set_label("Searching…");
|
||||
browser_status(
|
||||
ui,
|
||||
"system-search-symbolic",
|
||||
@@ -1835,7 +2286,9 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
// The box may have been cleared or typed past while the reply was in
|
||||
// flight; if the query no longer matches, a fresher load/search already
|
||||
// owns the model — drop this stale, possibly out-of-order result.
|
||||
if ui.browser.search.text().trim() != query {
|
||||
if ui.browser.load_generation.get() != generation
|
||||
|| ui.browser.search.text().trim() != query
|
||||
{
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
@@ -1856,6 +2309,17 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
/// handlers already understand.
|
||||
pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
ui.browser.model.remove_all();
|
||||
let counts = listing_summary(
|
||||
hits.iter().filter(|hit| !hit.is_dir).count(),
|
||||
hits.iter().filter(|hit| hit.is_dir).count(),
|
||||
hits.iter()
|
||||
.filter(|hit| !hit.is_dir)
|
||||
.map(|hit| hit.size)
|
||||
.sum(),
|
||||
);
|
||||
ui.browser
|
||||
.summary
|
||||
.set_label(&format!("{counts} — search results"));
|
||||
if hits.is_empty() {
|
||||
browser_status(
|
||||
ui,
|
||||
@@ -1892,3 +2356,47 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
ui.browser.model.append(&BoxedAnyObject::new(entry));
|
||||
}
|
||||
}
|
||||
|
||||
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
||||
let file_word = if files == 1 { "file" } else { "files" };
|
||||
let folder_word = if folders == 1 { "folder" } else { "folders" };
|
||||
match (folders, files) {
|
||||
(0, 0) => "0 folders, 0 files".to_string(),
|
||||
(_, 0) => format!("{folders} {folder_word}"),
|
||||
(0, _) => format!("{files} {file_word} ({})", human_bytes(file_bytes)),
|
||||
_ => format!(
|
||||
"{folders} {folder_word}, {files} {file_word} ({})",
|
||||
human_bytes(file_bytes)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{listing_summary, show_thumbnail_build_progress};
|
||||
use pdfs_core::control::ThumbnailBuildStatus;
|
||||
|
||||
#[test]
|
||||
fn listing_summary_matches_dolphin_order_and_wording() {
|
||||
assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files");
|
||||
assert_eq!(listing_summary(0, 2, 0), "2 folders");
|
||||
assert_eq!(listing_summary(1, 0, 1024), "1 file (1.0 KiB)");
|
||||
assert_eq!(listing_summary(3, 1, 3072), "1 folder, 3 files (3.0 KiB)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_progress_disappears_when_build_finishes() {
|
||||
let mut status = ThumbnailBuildStatus {
|
||||
running: true,
|
||||
scanning: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(show_thumbnail_build_progress(&status));
|
||||
|
||||
status.scanning = false;
|
||||
assert!(show_thumbnail_build_progress(&status));
|
||||
|
||||
status.running = false;
|
||||
assert!(!show_thumbnail_build_progress(&status));
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,7 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
||||
if ui.shared.inflight.get() {
|
||||
return;
|
||||
}
|
||||
cancel_file_thumbnails(ui);
|
||||
let current = ui.shared.nav.borrow().last().cloned();
|
||||
if let Some((uid, _)) = current {
|
||||
load_shared_folder(ui, uid);
|
||||
@@ -308,11 +309,7 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
if let Some(badge) = role_badge(&entry.role) {
|
||||
row.add_suffix(&badge);
|
||||
}
|
||||
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
|
||||
let ui_act = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
|
||||
@@ -113,6 +113,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||
if ui.shared_by_me.inflight.get() {
|
||||
return;
|
||||
}
|
||||
cancel_file_thumbnails(ui);
|
||||
ui.shared_by_me.inflight.set(true);
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
@@ -200,15 +201,12 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
ui.shared_by_me.content.set_visible_child_name("list");
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for item in items {
|
||||
let entry = shared_item_as_entry(item);
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item.name)
|
||||
.subtitle(shared_item_summary(item))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if item.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
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()) {
|
||||
@@ -245,7 +243,6 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
let ui_manage = ui.clone();
|
||||
let entry = shared_item_as_entry(item);
|
||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
||||
row.add_suffix(&manage);
|
||||
|
||||
@@ -289,7 +286,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
name: item.name.clone(),
|
||||
is_dir: item.is_dir,
|
||||
size: 0,
|
||||
modified: 0,
|
||||
modified: item.modified,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: item.uid.clone(),
|
||||
|
||||
@@ -572,7 +572,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
refresh_takeout(ui);
|
||||
}
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("main") => refresh_quota(ui),
|
||||
Some("main" | "browser") => refresh_quota(ui),
|
||||
Some("locations") => refresh_locations(ui),
|
||||
Some("activity") => refresh_activity(ui),
|
||||
_ => {}
|
||||
@@ -580,13 +580,11 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
/// How long a quota reading stays fresh. Account storage barely moves, so the
|
||||
/// Settings tick refetches it only this often rather than every 2s.
|
||||
/// active-page tick refetches it only this often rather than every 2s.
|
||||
const QUOTA_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Fetch the account quota (if the last reading is stale) and paint the Account
|
||||
/// storage group. Runs only while Settings is on screen. The group stays hidden
|
||||
/// until a reading lands, so an account the API can't report — or a still-starting
|
||||
/// daemon — shows nothing rather than an empty bar. A failed fetch leaves the last
|
||||
/// 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
|
||||
/// good reading in place.
|
||||
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
||||
if ui.status.quota_inflight.get() {
|
||||
@@ -609,28 +607,71 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
||||
})) = result
|
||||
{
|
||||
ui.status.quota_checked_at.set(Some(Instant::now()));
|
||||
let used = used_space.max(0) as u64;
|
||||
if max_space > 0 {
|
||||
let total = max_space as u64;
|
||||
let fraction = (used as f64 / total as f64).min(1.0);
|
||||
ui.status.quota_bar.set_fraction(fraction);
|
||||
let pct = (fraction * 100.0).round() as u64;
|
||||
ui.status.quota_label.set_text(&format!(
|
||||
"{} of {} used ({pct}%)",
|
||||
human_bytes(used),
|
||||
human_bytes(total)
|
||||
));
|
||||
} else {
|
||||
ui.status.quota_bar.set_fraction(0.0);
|
||||
ui.status
|
||||
.quota_label
|
||||
.set_text(&format!("{} used", human_bytes(used)));
|
||||
}
|
||||
paint_account_quota(&ui, max_space, used_space);
|
||||
ui.status.quota_group.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.
|
||||
ui.browser.quota_box.set_visible(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn paint_account_quota(ui: &Rc<Ui>, max_space: i64, used_space: i64) {
|
||||
let (fraction, text) = quota_display(max_space, used_space);
|
||||
ui.status.quota_bar.set_fraction(fraction);
|
||||
ui.status.quota_label.set_text(&text);
|
||||
if let Some((fraction, free_text, tooltip)) = quota_status_display(max_space, used_space) {
|
||||
ui.browser.quota.set_fraction(fraction);
|
||||
ui.browser.quota.set_tooltip_text(Some(&tooltip));
|
||||
ui.browser.quota_text.set_label(&free_text);
|
||||
ui.browser.quota_text.set_tooltip_text(Some(&tooltip));
|
||||
ui.browser.quota_box.set_visible(true);
|
||||
} else {
|
||||
ui.browser.quota_box.set_visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
||||
let used = used_space.max(0) as u64;
|
||||
if max_space <= 0 {
|
||||
return (0.0, format!("{} used", human_bytes(used)));
|
||||
}
|
||||
let total = max_space as u64;
|
||||
let fraction = (used as f64 / total as f64).clamp(0.0, 1.0);
|
||||
let pct = (fraction * 100.0).round() as u64;
|
||||
(
|
||||
fraction,
|
||||
format!(
|
||||
"{} of {} used ({pct}%)",
|
||||
human_bytes(used),
|
||||
human_bytes(total)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Dolphin's status bar shows a bare capacity bar followed by “X free”; the
|
||||
/// full free/total/percentage sentence is a tooltip rather than inline bar text.
|
||||
fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String, String)> {
|
||||
if max_space <= 0 {
|
||||
return None;
|
||||
}
|
||||
let total = max_space as u64;
|
||||
let used = (used_space.max(0) as u64).min(total);
|
||||
let free = total.saturating_sub(used);
|
||||
let fraction = used as f64 / total as f64;
|
||||
let pct = (fraction * 100.0).round() as u64;
|
||||
Some((
|
||||
fraction,
|
||||
format!("{} free", human_bytes(free)),
|
||||
format!(
|
||||
"{} free out of {} ({pct}% used)",
|
||||
human_bytes(free),
|
||||
human_bytes(total)
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Record the mount state seen by the last status poll: gate every control that
|
||||
/// needs a live daemon, and notify the desktop when the state actually flips.
|
||||
///
|
||||
@@ -642,6 +683,11 @@ pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
||||
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()),
|
||||
);
|
||||
ui.gallery.upload.set_sensitive(mounted);
|
||||
ui.details.details.pin_row.set_sensitive(mounted);
|
||||
ui.details.details.rename_button.set_sensitive(mounted);
|
||||
@@ -1010,3 +1056,36 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
.push(PinRow { row, unpin: None });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{quota_display, quota_status_display};
|
||||
|
||||
#[test]
|
||||
fn quota_display_reports_used_total_and_percentage() {
|
||||
let gib = 1024_i64.pow(3);
|
||||
let (fraction, text) = quota_display(4 * gib, gib);
|
||||
assert!((fraction - 0.25).abs() < f64::EPSILON);
|
||||
assert_eq!(text, "1.0 GiB of 4.0 GiB used (25%)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_display_clamps_bad_api_values() {
|
||||
assert_eq!(quota_display(0, -1), (0.0, "0 B used".to_string()));
|
||||
assert_eq!(quota_display(100, 150).0, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_status_display_matches_dolphin_wording() {
|
||||
let gib = 1024_i64.pow(3);
|
||||
assert_eq!(
|
||||
quota_status_display(4 * gib, gib),
|
||||
Some((
|
||||
0.25,
|
||||
"3.0 GiB free".to_string(),
|
||||
"3.0 GiB free out of 4.0 GiB (25% used)".to_string()
|
||||
))
|
||||
);
|
||||
assert_eq!(quota_status_display(0, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
||||
factory.connect_setup(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
|
||||
let icon = gtk4::Image::builder().pixel_size(32).build();
|
||||
let thumbnail = file_thumbnail_widget(40, 32);
|
||||
let name = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
@@ -169,35 +169,38 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
||||
row.set_margin_bottom(6);
|
||||
row.set_margin_start(6);
|
||||
row.set_margin_end(6);
|
||||
row.append(&icon);
|
||||
row.append(&thumbnail);
|
||||
row.append(&text);
|
||||
row.append(&restore);
|
||||
row.append(&purge);
|
||||
item.set_child(Some(&row));
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let Some(entry) = bound_entry(item) else {
|
||||
return;
|
||||
};
|
||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let Some(icon) = row.first_child().and_downcast::<gtk4::Image>() else {
|
||||
return;
|
||||
};
|
||||
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
|
||||
let Some(text) = icon.next_sibling().and_downcast::<gtk4::Box>() else {
|
||||
return;
|
||||
};
|
||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
||||
name.set_label(&entry.name);
|
||||
}
|
||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
||||
let kind = if entry.is_dir {
|
||||
"Folder".to_string()
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
factory.connect_bind({
|
||||
let ui = ui.clone();
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let Some(entry) = bound_entry(item) else {
|
||||
return;
|
||||
};
|
||||
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
|
||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let Some(thumbnail) = row.first_child().and_downcast::<gtk4::Overlay>() else {
|
||||
return;
|
||||
};
|
||||
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
||||
let Some(text) = thumbnail.next_sibling().and_downcast::<gtk4::Box>() else {
|
||||
return;
|
||||
};
|
||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
||||
name.set_label(&entry.name);
|
||||
}
|
||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
||||
let kind = if entry.is_dir {
|
||||
"Folder".to_string()
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
};
|
||||
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
|
||||
}
|
||||
}
|
||||
});
|
||||
list.set_factory(Some(&factory));
|
||||
@@ -216,6 +219,7 @@ pub(crate) fn bound_entry(item: >k4::ListItem) -> Option<DirEntry> {
|
||||
|
||||
/// Fetch the trash listing and repaint the page.
|
||||
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
cancel_file_thumbnails(ui);
|
||||
// Drop the old rows first: a stale row here would offer Restore on something
|
||||
// that may already be gone.
|
||||
ui.trash.model.remove_all();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod share_dialog;
|
||||
pub(crate) mod thumbnails;
|
||||
pub(crate) mod versions_dialog;
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Shared thumbnails for ordinary Drive image files.
|
||||
//!
|
||||
//! The Photos gallery has timeline-specific batching and aspect-ratio state.
|
||||
//! File listings need a smaller, reusable surface: one loader backs My files,
|
||||
//! search, Shared and Trash, with modification-time cache keys
|
||||
//! so a replaced image never flashes its previous contents.
|
||||
|
||||
use crate::*;
|
||||
use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name};
|
||||
|
||||
const THUMB_BATCH: usize = 32;
|
||||
const THUMB_CACHE_MAX: usize = 256;
|
||||
const THUMB_MISSING_MAX: usize = 2048;
|
||||
const THUMB_DEBOUNCE: Duration = Duration::from_millis(40);
|
||||
const THUMB_RETRY: Duration = Duration::from_secs(2);
|
||||
|
||||
pub(crate) struct FileThumbnailState {
|
||||
textures: RefCell<HashMap<String, gtk4::gdk::Texture>>,
|
||||
texture_order: RefCell<VecDeque<String>>,
|
||||
missing: RefCell<HashSet<String>>,
|
||||
missing_order: RefCell<VecDeque<String>>,
|
||||
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
||||
queue: RefCell<VecDeque<FileThumbRequest>>,
|
||||
inflight: Cell<bool>,
|
||||
source: RefCell<Option<glib::SourceId>>,
|
||||
generation: Cell<u64>,
|
||||
generation_loading: Cell<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ReplyDisposition {
|
||||
Ignore,
|
||||
Load,
|
||||
Retry,
|
||||
Missing,
|
||||
}
|
||||
|
||||
fn reply_disposition(wanted: bool, has_path: bool, pending: bool) -> ReplyDisposition {
|
||||
if !wanted {
|
||||
ReplyDisposition::Ignore
|
||||
} else if has_path {
|
||||
ReplyDisposition::Load
|
||||
} else if pending {
|
||||
ReplyDisposition::Retry
|
||||
} else {
|
||||
ReplyDisposition::Missing
|
||||
}
|
||||
}
|
||||
|
||||
impl FileThumbnailState {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
textures: RefCell::new(HashMap::new()),
|
||||
texture_order: RefCell::new(VecDeque::new()),
|
||||
missing: RefCell::new(HashSet::new()),
|
||||
missing_order: RefCell::new(VecDeque::new()),
|
||||
wanted: RefCell::new(HashMap::new()),
|
||||
queue: RefCell::new(VecDeque::new()),
|
||||
inflight: Cell::new(false),
|
||||
source: RefCell::new(None),
|
||||
generation: Cell::new(0),
|
||||
generation_loading: Cell::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_texture(&self, key: &str, texture: gtk4::gdk::Texture) {
|
||||
let mut textures = self.textures.borrow_mut();
|
||||
let mut order = self.texture_order.borrow_mut();
|
||||
if textures.insert(key.to_string(), texture).is_none() {
|
||||
order.push_back(key.to_string());
|
||||
}
|
||||
while order.len() > THUMB_CACHE_MAX {
|
||||
if let Some(old) = order.pop_front() {
|
||||
textures.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn store_missing(&self, key: String) {
|
||||
let mut missing = self.missing.borrow_mut();
|
||||
let mut order = self.missing_order.borrow_mut();
|
||||
if missing.insert(key.clone()) {
|
||||
order.push_back(key);
|
||||
}
|
||||
while order.len() > THUMB_MISSING_MAX {
|
||||
if let Some(old) = order.pop_front() {
|
||||
missing.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Abandon every opportunistic ordinary-file thumbnail associated with the
|
||||
/// listing being left. Advancing a wire generation lets the daemon cancel work
|
||||
/// even when an older request reaches it after this one.
|
||||
pub(crate) fn cancel_file_thumbnails(ui: &Rc<Ui>) {
|
||||
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
ui.file_thumbs.queue.borrow_mut().clear();
|
||||
ui.file_thumbs.wanted.borrow_mut().clear();
|
||||
let generation = ui.file_thumbs.generation.replace(0);
|
||||
// The request is useful even when its reply is not. `spawn_request` owns the
|
||||
// socket round-trip on a worker thread, so dropping the receiver is safe.
|
||||
if generation != 0 {
|
||||
drop(spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::CancelFileThumbs {
|
||||
generation: generation.saturating_add(1),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// A square thumbnail surface with a generic file icon underneath the picture.
|
||||
/// Call [`bind_file_thumbnail`] whenever a recycled row is bound to an entry.
|
||||
pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Overlay {
|
||||
let fallback = gtk4::Image::builder().pixel_size(fallback_size).build();
|
||||
let picture = gtk4::Picture::builder()
|
||||
.width_request(size)
|
||||
.height_request(size)
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.visible(false)
|
||||
.build();
|
||||
picture.add_css_class("file-thumbnail");
|
||||
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_width_request(size);
|
||||
overlay.set_height_request(size);
|
||||
overlay.set_halign(gtk4::Align::Center);
|
||||
overlay.set_valign(gtk4::Align::Center);
|
||||
overlay.set_overflow(gtk4::Overflow::Hidden);
|
||||
overlay.set_child(Some(&fallback));
|
||||
overlay.add_overlay(&picture);
|
||||
overlay
|
||||
}
|
||||
|
||||
/// Resize an existing thumbnail surface. Grid rows are recycled, so changing
|
||||
/// these requests in place and rebinding the model is enough to implement the
|
||||
/// status-bar zoom without downloading or decoding an image again.
|
||||
pub(crate) fn resize_file_thumbnail(widget: >k4::Overlay, size: i32, fallback_size: i32) {
|
||||
widget.set_width_request(size);
|
||||
widget.set_height_request(size);
|
||||
let Some(fallback) = widget.child().and_downcast::<gtk4::Image>() else {
|
||||
return;
|
||||
};
|
||||
fallback.set_pixel_size(fallback_size);
|
||||
if let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() {
|
||||
picture.set_width_request(size);
|
||||
picture.set_height_request(size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a thumbnail surface to one entry, painting a cached texture immediately
|
||||
/// or queueing an on-demand daemon request. Non-images retain the generic icon.
|
||||
pub(crate) fn bind_file_thumbnail(
|
||||
ui: &Rc<Ui>,
|
||||
widget: >k4::Overlay,
|
||||
entry: &DirEntry,
|
||||
symbolic_fallback: bool,
|
||||
) {
|
||||
let Some(fallback) = widget.child().and_downcast::<gtk4::Image>() else {
|
||||
return;
|
||||
};
|
||||
// The browser may add a status badge after the picture. Locate the picture
|
||||
// immediately after the base fallback instead of assuming it is the last
|
||||
// overlay child.
|
||||
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let fallback_name = if symbolic_fallback {
|
||||
format!("{}-symbolic", icon_base_for(entry))
|
||||
} else {
|
||||
icon_base_for(entry).to_string()
|
||||
};
|
||||
fallback.set_icon_name(Some(&fallback_name));
|
||||
fallback.set_visible(true);
|
||||
picture.set_visible(false);
|
||||
picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||
|
||||
// The widget name is a cheap binding token. List factories recycle their
|
||||
// children; a reply for the previous entry must not paint into the new one.
|
||||
let key = thumbnail_key(&entry.uid, entry.modified);
|
||||
picture.set_widget_name(&key);
|
||||
remove_waiter(ui, &picture);
|
||||
|
||||
if entry.is_dir || !is_image_name(&entry.name) || entry.uid.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) {
|
||||
paint(&picture, &key, texture);
|
||||
return;
|
||||
}
|
||||
if ui.file_thumbs.missing.borrow().contains(&key) {
|
||||
return;
|
||||
}
|
||||
|
||||
ui.file_thumbs
|
||||
.wanted
|
||||
.borrow_mut()
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.push(picture);
|
||||
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||
queue.retain(|item| item.uid != entry.uid);
|
||||
queue.push_back(FileThumbRequest {
|
||||
uid: entry.uid.clone(),
|
||||
modified: entry.modified,
|
||||
name: entry.name.clone(),
|
||||
});
|
||||
drop(queue);
|
||||
schedule_file_thumbs(ui);
|
||||
}
|
||||
|
||||
/// Create and immediately bind a thumbnail for a non-recycled row.
|
||||
pub(crate) fn file_thumbnail(
|
||||
ui: &Rc<Ui>,
|
||||
entry: &DirEntry,
|
||||
size: i32,
|
||||
fallback_size: i32,
|
||||
symbolic_fallback: bool,
|
||||
) -> gtk4::Overlay {
|
||||
let widget = file_thumbnail_widget(size, fallback_size);
|
||||
bind_file_thumbnail(ui, &widget, entry, symbolic_fallback);
|
||||
widget
|
||||
}
|
||||
|
||||
/// Whether the file name denotes an image format for which Proton or the local
|
||||
/// decoder can reasonably provide a thumbnail.
|
||||
pub(crate) fn is_image_name(name: &str) -> bool {
|
||||
is_thumbnail_image_name(name)
|
||||
}
|
||||
|
||||
fn thumbnail_key(uid: &str, modified: i64) -> String {
|
||||
format!("{uid}:{modified}")
|
||||
}
|
||||
|
||||
fn remove_waiter(ui: &Rc<Ui>, picture: >k4::Picture) {
|
||||
let mut wanted = ui.file_thumbs.wanted.borrow_mut();
|
||||
wanted.retain(|_, pictures| {
|
||||
pictures.retain(|candidate| candidate != picture);
|
||||
!pictures.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
fn paint(picture: >k4::Picture, key: &str, texture: >k4::gdk::Texture) {
|
||||
if picture.widget_name() != key {
|
||||
return;
|
||||
}
|
||||
picture.set_paintable(Some(texture));
|
||||
picture.set_visible(true);
|
||||
if let Some(overlay) = picture.parent().and_downcast::<gtk4::Overlay>()
|
||||
&& let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>()
|
||||
{
|
||||
fallback.set_visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_file_thumbs(ui: &Rc<Ui>) {
|
||||
if ui.file_thumbs.queue.borrow().is_empty() || ui.file_thumbs.inflight.get() {
|
||||
return;
|
||||
}
|
||||
if ui.file_thumbs.generation.get() == 0 {
|
||||
reserve_file_thumb_generation(ui);
|
||||
return;
|
||||
}
|
||||
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
let ui_flush = ui.clone();
|
||||
let source = glib::timeout_add_local_once(THUMB_DEBOUNCE, move || {
|
||||
ui_flush.file_thumbs.source.borrow_mut().take();
|
||||
flush_file_thumbs(&ui_flush);
|
||||
});
|
||||
*ui.file_thumbs.source.borrow_mut() = Some(source);
|
||||
}
|
||||
|
||||
fn reserve_file_thumb_generation(ui: &Rc<Ui>) {
|
||||
if ui.file_thumbs.generation_loading.replace(true) {
|
||||
return;
|
||||
}
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::ReserveFileThumbGeneration,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.file_thumbs.generation_loading.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::FileThumbGeneration { generation })) if generation != 0 => {
|
||||
ui.file_thumbs.generation.set(generation);
|
||||
schedule_file_thumbs(&ui);
|
||||
}
|
||||
_ => retry_file_thumb_generation(&ui),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn retry_file_thumb_generation(ui: &Rc<Ui>) {
|
||||
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
let ui_retry = ui.clone();
|
||||
let source = glib::timeout_add_local_once(THUMB_RETRY, move || {
|
||||
ui_retry.file_thumbs.source.borrow_mut().take();
|
||||
schedule_file_thumbs(&ui_retry);
|
||||
});
|
||||
*ui.file_thumbs.source.borrow_mut() = Some(source);
|
||||
}
|
||||
|
||||
fn flush_file_thumbs(ui: &Rc<Ui>) {
|
||||
if ui.file_thumbs.inflight.get() {
|
||||
return;
|
||||
}
|
||||
let items: Vec<FileThumbRequest> = {
|
||||
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||
let wanted = ui.file_thumbs.wanted.borrow();
|
||||
let mut batch = Vec::new();
|
||||
while batch.len() < THUMB_BATCH {
|
||||
let Some(item) = queue.pop_front() else {
|
||||
break;
|
||||
};
|
||||
if wanted.contains_key(&thumbnail_key(&item.uid, item.modified)) {
|
||||
batch.push(item);
|
||||
}
|
||||
}
|
||||
batch
|
||||
};
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
ui.file_thumbs.inflight.set(true);
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::FileThumbs {
|
||||
items: items.clone(),
|
||||
generation: ui.file_thumbs.generation.get(),
|
||||
},
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.file_thumbs.inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Thumbs { items: replies })) => {
|
||||
let mut requested: HashMap<String, FileThumbRequest> = items
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|item| (item.uid.clone(), item))
|
||||
.collect();
|
||||
let mut pending = Vec::new();
|
||||
for reply in replies {
|
||||
let Some(request) = requested.remove(&reply.uid) else {
|
||||
continue;
|
||||
};
|
||||
let key = thumbnail_key(&request.uid, request.modified);
|
||||
let wanted = ui.file_thumbs.wanted.borrow().contains_key(&key);
|
||||
match reply_disposition(wanted, reply.path.is_some(), reply.pending) {
|
||||
ReplyDisposition::Ignore => continue,
|
||||
ReplyDisposition::Load => {
|
||||
let Some(path) = reply.path else { continue };
|
||||
match gtk4::gdk::Texture::from_filename(&path) {
|
||||
Ok(texture) => {
|
||||
ui.file_thumbs.store_texture(&key, texture.clone());
|
||||
if let Some(pictures) =
|
||||
ui.file_thumbs.wanted.borrow_mut().remove(&key)
|
||||
{
|
||||
for picture in pictures {
|
||||
paint(&picture, &key, &texture);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("cannot decode file thumbnail {path}: {e}");
|
||||
ui.file_thumbs.store_missing(key.clone());
|
||||
ui.file_thumbs.wanted.borrow_mut().remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
ReplyDisposition::Retry => pending.push(request),
|
||||
ReplyDisposition::Missing => {
|
||||
ui.file_thumbs.store_missing(key.clone());
|
||||
ui.file_thumbs.wanted.borrow_mut().remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
pending.extend(requested.into_values());
|
||||
if !pending.is_empty() {
|
||||
retry_file_thumbs(&ui, pending);
|
||||
}
|
||||
}
|
||||
Ok(Ok(Response::FileThumbsStale)) => {
|
||||
ui.file_thumbs.generation.set(0);
|
||||
retry_file_thumbs(&ui, items);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
tracing::debug!("file thumbnails failed: {message}");
|
||||
retry_file_thumbs(&ui, items);
|
||||
}
|
||||
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => {
|
||||
tracing::debug!("file thumbnails: no reply");
|
||||
retry_file_thumbs(&ui, items);
|
||||
}
|
||||
}
|
||||
schedule_file_thumbs(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(THUMB_RETRY, move || {
|
||||
let wanted = ui.file_thumbs.wanted.borrow();
|
||||
let retry: Vec<FileThumbRequest> = items
|
||||
.into_iter()
|
||||
.filter(|item| wanted.contains_key(&thumbnail_key(&item.uid, item.modified)))
|
||||
.collect();
|
||||
drop(wanted);
|
||||
ui.file_thumbs.queue.borrow_mut().extend(retry);
|
||||
schedule_file_thumbs(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ReplyDisposition, is_image_name, reply_disposition};
|
||||
|
||||
#[test]
|
||||
fn image_extensions_are_case_insensitive_and_specific() {
|
||||
for name in [
|
||||
"photo.jpg",
|
||||
"SCAN.PNG",
|
||||
"animation.Gif",
|
||||
"archive.tiff",
|
||||
"camera.nef",
|
||||
"negative.CR3",
|
||||
"camera.RAW",
|
||||
] {
|
||||
assert!(is_image_name(name), "{name}");
|
||||
}
|
||||
for name in [
|
||||
"notes.txt",
|
||||
"movie.mp4",
|
||||
"image.jpg.zip",
|
||||
"jpg",
|
||||
"camera.heic",
|
||||
"frame.avif",
|
||||
"graphic.svg",
|
||||
"camera.rw",
|
||||
] {
|
||||
assert!(!is_image_name(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_listing_replies_cannot_become_permanent_misses() {
|
||||
assert_eq!(
|
||||
reply_disposition(false, false, false),
|
||||
ReplyDisposition::Ignore
|
||||
);
|
||||
assert_eq!(
|
||||
reply_disposition(true, false, false),
|
||||
ReplyDisposition::Missing
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, C
|
||||
arch=('x86_64')
|
||||
url="https://github.com/narrrl/proton-drive-linux"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool')
|
||||
makedepends=('cargo' 'git')
|
||||
provides=('pdfs')
|
||||
# Files-on-demand mount + keyring need these at runtime.
|
||||
|
||||
@@ -33,6 +33,7 @@ Requires: gtk4
|
||||
Requires: libadwaita
|
||||
Requires: libsecret
|
||||
Requires: webkitgtk6.0
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: xdg-utils
|
||||
|
||||
# DE-specific; do not Require a single desktop environment.
|
||||
|
||||
Reference in new issue
Block a user