Compare commits

...
8 Commits
Author SHA1 Message Date
nvrl 847ece17d7 version bumped to 1.9.1 2026-08-21 22:48:28 +02:00
nvrl 50d2acdaba Merge pull request #20 from emptyname-org/feature/files-thumbnails-statusbar
feat(gui): add file thumbnails and a browser status bar
2026-08-21 22:44:58 +02:00
emptyname 53caf63ad4 Fix thumbnail lifecycle failures 2026-08-21 12:32:31 +01:00
nvrl 5176dd91e1 fixed icon 2026-08-21 04:13:11 +02:00
nvrl e88903d5ff chore(branding) added branding, best practice to repo 2026-08-21 04:09:58 +02:00
emptyname b3e6af1ae5 docs: document thumbnail runtime support 2026-08-18 13:06:40 +01:00
emptyname b0f9513915 feat(gui): show file thumbnails and browser status bar 2026-08-18 13:06:36 +01:00
emptyname 91b0864fce feat(thumbnails): generate cached previews for Drive files 2026-08-18 13:06:27 +01:00
40 changed files with 3038 additions and 218 deletions

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
name: Bug report
description: Something in the client misbehaves
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Please check [`docs/BUGS.md`](../blob/main/docs/BUGS.md) first — the issue may already be
tracked there with context.
**Do not report a security vulnerability here.** See [SECURITY.md](../blob/main/SECURITY.md).
- type: textarea
id: what
attributes:
label: What happened
description: What you did, what you expected, and what happened instead.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
placeholder: |
1. …
2. …
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Output of `pdfs --version`, and how you installed it (deb / rpm / AUR / source).
validations:
required: true
- type: input
id: environment
attributes:
label: Distribution and desktop
placeholder: "Fedora 44, GNOME 48 (Wayland)"
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Where does it happen
multiple: true
options:
- FUSE mount / file operations
- Sync or backed-up folders (Computers)
- CLI (pdfs)
- Desktop app (pdfs-app)
- Tray (pdfs-tray)
- Search launcher (pdfs-prompt)
- Login / keyring
- Photos
- Packaging / installation
validations:
required: true
- type: textarea
id: diagnose
attributes:
label: "`pdfs diagnose` output"
description: Redact paths or the account address if you prefer.
render: console
- type: textarea
id: logs
attributes:
label: Daemon logs
description: "`journalctl --user -u proton-drive.service -n 200 --no-pager`"
render: console
- type: checkboxes
id: dataloss
attributes:
label: Data
options:
- label: This bug involved missing, truncated, or overwritten file content.
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
about: Report privately — never in a public issue.
- name: Known issues
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
about: The authoritative ledger of tracked bugs and their status.
- name: Proton Drive service problems
url: https://proton.me/support
about: This is an unofficial client. Account or service issues go to Proton AG.
@@ -0,0 +1,37 @@
name: Feature request
description: Suggest a capability or an improvement
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: The problem
description: What are you trying to do that the client makes hard or impossible today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What you'd like to see
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Which part of the client
multiple: true
options:
- FUSE mount
- Sync / Computers
- CLI (pdfs)
- Desktop app (pdfs-app)
- Tray (pdfs-tray)
- Search launcher (pdfs-prompt)
- Photos
- Packaging
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you considered
+26
View File
@@ -0,0 +1,26 @@
## What this changes
<!-- The user-visible effect, in a sentence or two. -->
**Crates touched:**
**Related `docs/BUGS.md` entries:**
## Verification
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --locked
```
<!-- Plus whatever else you ran: acceptance suite, a live mount, manual steps. -->
## Implications
- [ ] Adds a SQLite migration (new `MIGRATION_V*`, `SCHEMA_VERSION` bumped — no shipped migration edited)
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
- [ ] Changes packaging, the systemd unit, or desktop entries
- [ ] Changes the control protocol (daemon and front ends updated together)
- [ ] None of the above
<!-- Screenshots for GTK changes. -->
+7 -6
View File
@@ -122,13 +122,14 @@ jobs:
Priority: optional Priority: optional
Architecture: amd64 Architecture: amd64
Maintainer: Nils Pukropp <contact@narl.io> 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} Installed-Size: ${INSTALLED_SIZE}
Description: Proton Drive client for Linux Homepage: https://github.com/narrrl/proton-drive-linux
Proton Drive client for Linux featuring: Description: Unofficial Proton Drive client for Linux
- FUSE files-on-demand mount Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
- Command-line interface (CLI) - FUSE virtual mount with block-level caching
- GTK4 system tray, desktop application, and search launcher - Scriptable command-line interface (pdfs)
- GTK4 desktop application, system tray, and search launcher
EOF EOF
# Build Debian package # Build Debian package
+32
View File
@@ -0,0 +1,32 @@
# Code of Conduct
## Our pledge
We want participation in this project to be a harassment-free experience for everyone, regardless
of age, body size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socioeconomic status, nationality, personal appearance, race,
religion, or sexual identity and orientation.
## Standards
Examples of behavior that helps:
- being respectful of differing viewpoints and experience levels,
- giving and accepting constructive feedback gracefully,
- focusing on what is best for the project and its users.
Examples of unacceptable behavior:
- sexualized language or imagery, and unwelcome sexual attention,
- trolling, insulting or derogatory comments, and personal or political attacks,
- public or private harassment,
- publishing others' private information without explicit permission.
## Scope and enforcement
This applies in all project spaces — issues, pull requests, discussions — and when representing the
project in public. Report unacceptable behavior to <contact@narl.io>. Reports are handled
confidentially. Maintainers may edit, remove, or reject contributions that violate this document,
and may temporarily or permanently ban any contributor for behavior they deem inappropriate.
Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
+70
View File
@@ -0,0 +1,70 @@
# Contributing
Thanks for taking the time. This is an unofficial Proton Drive client that people trust with real
files, so the bar for changes that touch the filesystem, the sync engine, or the database is
deliberately high — everything else is ordinary Rust review.
## Before you start
- Read [`AGENTS.md`](AGENTS.md) for repository conventions and
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
together.
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
already be tracked there with context.
- For anything larger than a fix, open an issue first so the design can be discussed before you
spend the time.
## Development setup
```bash
git clone https://github.com/narrrl/proton-drive-linux.git
cd proton-drive-linux
cargo build --workspace
```
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
development packages listed in the [README](README.md#prerequisites). Minimum supported Rust
version is 1.96.
## Quality gates
CI runs exactly these, and they must pass with zero warnings:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --locked
scripts/fuse-acceptance.sh --offline-only
```
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
use a dedicated test account, never one holding data you cannot lose.
## Making changes
- **Keep shared logic in `pdfs-core`.** The CLI and GUI never touch the database or the network;
they speak the control protocol to the daemon.
- **The SQLite schema is forward-only.** Never edit a shipped migration — add a new one and bump
`SCHEMA_VERSION` in `crates/pdfs-core/src/db/migrations.rs`.
- **Typed errors and `?` over panics** on runtime paths. Blocking filesystem, keyring, or
control-socket work stays off GTK's main thread.
- **`staging/` and `recovery/` may hold the only copy of user data.** Nothing may clear or purge
them opportunistically.
## Commits and pull requests
Commits follow scoped [Conventional Commits](https://www.conventionalcommits.org), e.g.
`fix(fuse,core): keep truncate composable with queued revisions`.
A pull request should state:
- the user-visible effect and which crates it touches,
- the `docs/BUGS.md` entries it fixes or affects,
- the commands you ran to verify it,
- screenshots for GTK changes,
- and, explicitly, any migration, recovery, data-loss, or packaging implications.
## Security
Do not open a public issue for a vulnerability — see [SECURITY.md](SECURITY.md).
Generated
+5 -4
View File
@@ -3538,7 +3538,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "1.9.0" version = "1.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap 4.6.4", "clap 4.6.4",
@@ -3553,7 +3553,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "1.9.0" version = "1.9.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"directories", "directories",
@@ -3576,8 +3576,9 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "1.9.0" version = "1.9.1"
dependencies = [ dependencies = [
"base64",
"fuser", "fuser",
"futures", "futures",
"image", "image",
@@ -3595,7 +3596,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "1.9.0" version = "1.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
+7 -1
View File
@@ -3,11 +3,16 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "1.9.0" version = "1.9.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
rust-version = "1.96" rust-version = "1.96"
repository = "https://github.com/narrrl/proton-drive-linux"
homepage = "https://github.com/narrrl/proton-drive-linux"
readme = "README.md"
keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.6.1" } proton-sdk = { version = "0.6.1" }
@@ -20,6 +25,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22"
thiserror = "2" thiserror = "2"
anyhow = "1" anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not # Non-poisoning mutexes. A handler that panics while holding a lock must not
+76 -7
View File
@@ -1,12 +1,70 @@
# Proton Drive client for Linux (unofficial) <div align="center">
A fast, unofficial Proton Drive client for Linux. This client features an advanced files-on-demand FUSE virtual mount with block-level caching, a command-line interface (CLI), and a fully non-blocking GTK4 desktop application with system tray integration. <img src="images/logo.svg" alt="" width="112" height="112">
# Proton Drive for Linux
**Files-on-demand Proton Drive for the Linux desktop** — a FUSE virtual mount with block-level
caching, a scriptable CLI, and a native GTK4 app with tray and search launcher.
[![CI](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml/badge.svg)](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/narrrl/proton-drive-linux?sort=semver)](https://github.com/narrrl/proton-drive-linux/releases/latest)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust 1.96+](https://img.shields.io/badge/rust-1.96%2B-orange.svg)](https://www.rust-lang.org)
[![Platform: Linux](https://img.shields.io/badge/platform-linux-lightgrey.svg)](#prerequisites)
<img src="images/files.png" alt="The GTK4 file browser" width="820">
</div>
> [!IMPORTANT]
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
> to describe what the software talks to. Use a test account first, and read
> [`docs/RECOVERY.md`](docs/RECOVERY.md) before trusting it with data you cannot lose.
## Install
```bash
# Debian / Ubuntu
sudo apt install ./proton-drive-linux_*.deb
# Fedora
sudo dnf install ./proton-drive-linux-*.rpm
# Arch Linux
cd packaging && makepkg -fi
```
Packages for each release are attached to the [latest
release](https://github.com/narrrl/proton-drive-linux/releases/latest); see
[Installation & Packages](#installation--packages) for details and
[Building from Source](#building-from-source) to compile it yourself.
```bash
pdfs login # sign in (SRP + 2FA, credentials go to the system keyring)
pdfs status # mount state, queued writes, cache usage
pdfs-app # the desktop app
```
## Contents
- [Features](#features) · [Quick search](#quick-search-and-media-opening) · [Opening files](#choosing-how-files-open)
- [Selective sync](#selective-sync-pdfsignore) · [Diagnostics](#diagnostics--maintenance) · [Scripting](#scripting---json)
- [Performance](#performance--caching) · [Filesystem safety](#filesystem-safety) · [Screenshots](#screenshots)
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) ·
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
## Features ## Features
- **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. - **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. - **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. - **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. - **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. - **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. - **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.
@@ -270,19 +328,21 @@ sudo apt-get install -y \
libadwaita-1-dev \ libadwaita-1-dev \
libwebkitgtk-6.0-dev \ libwebkitgtk-6.0-dev \
libsecret-1-dev \ libsecret-1-dev \
libdbus-1-dev libdbus-1-dev \
libimage-exiftool-perl
``` ```
### Arch Linux ### Arch Linux
```bash ```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+) ### Fedora (44+)
```bash ```bash
sudo dnf install -y \ sudo dnf install -y \
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \ 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): Runtime extras (pick your desktop):
@@ -302,7 +362,7 @@ Ensure you have Rust and Cargo installed (minimum supported Rust version is 1.96
1. Clone the repository and navigate into the project directory: 1. Clone the repository and navigate into the project directory:
```bash ```bash
git clone https://github.com/narl/proton-drive-linux.git git clone https://github.com/narrrl/proton-drive-linux.git
cd proton-drive-linux cd proton-drive-linux
``` ```
2. Build the workspace in release mode: 2. Build the workspace in release mode:
@@ -372,6 +432,15 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
--- ---
## Contributing
Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
vulnerability privately.
## License ## License
This project is licensed under the [MIT License](LICENSE). Released under the [MIT License](LICENSE).
Proton Drive is a service of Proton AG. This project is an independent client and carries no
affiliation with or endorsement from Proton AG.
+34
View File
@@ -0,0 +1,34 @@
# Security Policy
## Supported versions
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
## Reporting a vulnerability
**Do not open a public issue.** Report privately through GitHub's
[private vulnerability reporting](https://github.com/narrrl/proton-drive-linux/security/advisories/new),
or by email to <contact@narl.io>.
Please include the version (`pdfs --version`), your distribution and desktop environment, what an
attacker gains, and a reproduction if you have one. You can expect an acknowledgement within a few
days; a fix ships in the next release, and you are credited in the advisory unless you ask
otherwise.
## Scope
This client holds Proton account credentials and decrypted file content on the local machine, so
the interesting boundaries are:
- **Credentials.** Sessions and the mailbox password live in the system Secret Service. Anything
that writes them elsewhere — logs, temp files, crash dumps — is a vulnerability.
- **The control socket.** Anything that can connect to `control.sock` has the daemon's
authenticated session. It is `0600`, and the state, cache, and config directories must be real,
current-user-owned `0700` directories; the daemon fails closed otherwise. Reports that defeat
those checks are in scope.
- **Local cache and staging.** Decrypted block content and staged writes on disk.
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
or the wrong key material to Proton.
Out of scope: vulnerabilities in Proton's own service (report those to Proton AG), and issues that
require an attacker who already has your user account on the machine.
+2
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "CLI for the Proton Drive Linux client" description = "CLI for the Proton Drive Linux client"
[[bin]] [[bin]]
+2
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "Core auth/session/config for the Proton Drive Linux client" description = "Core auth/session/config for the Proton Drive Linux client"
[dependencies] [dependencies]
+25
View File
@@ -19,6 +19,7 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt; use std::fmt;
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -415,6 +416,10 @@ pub struct ContentCache {
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out /// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
/// of `content_dir` so the budget scan never sees thumbnail files. /// of `content_dir` so the budget scan never sees thumbnail files.
thumb_dir: PathBuf, 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>` + /// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never /// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
/// sees them; blocks carry their own LRU budget. /// sees them; blocks carry their own LRU budget.
@@ -507,6 +512,19 @@ impl ContentCache {
std::fs::create_dir_all(&content_dir)?; std::fs::create_dir_all(&content_dir)?;
let thumb_dir = content_dir.join("thumbs"); let thumb_dir = content_dir.join("thumbs");
std::fs::create_dir_all(&thumb_dir)?; 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"); let block_dir = content_dir.join("blocks");
std::fs::create_dir_all(&block_dir)?; std::fs::create_dir_all(&block_dir)?;
// Scratch holds disk-backed write buffers. A previous run's leftovers are // Scratch holds disk-backed write buffers. A previous run's leftovers are
@@ -530,6 +548,7 @@ impl ContentCache {
let cache = Self { let cache = Self {
content_dir, content_dir,
thumb_dir, thumb_dir,
raw_thumb_dir,
block_dir, block_dir,
scratch_dir, scratch_dir,
staging_dir, staging_dir,
@@ -1666,6 +1685,12 @@ impl ContentCache {
.join(format!("{}.t{ttype}.meta", Self::key(uid))) .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. /// 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 /// Validated by node `mtime` alone: a new revision (which is what changes the
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails /// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
+190 -1
View File
@@ -93,6 +93,32 @@ pub enum Request {
/// already cached (one batched round-trip) and replying with their on-disk /// 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. /// paths. Keep the batch small — it is served on demand, as tiles scroll in.
PhotoThumbs { uids: Vec<String> }, 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. /// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String }, OpenPhoto { uid: String },
/// Add or remove Proton's `Favorite` tag on a photo. Replies with /// 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. /// The shared node's decrypted name.
pub name: String, pub name: String,
pub is_dir: bool, 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 /// Mountpoint-relative path, when the daemon can resolve it (the node lives in
/// my own tree). Empty when the path is unknown. /// my own tree). Empty when the path is unknown.
#[serde(default)] #[serde(default)]
@@ -1187,6 +1217,76 @@ pub struct PhotoThumb {
pub pending: bool, 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 /// A daemon too old to report connectivity is assumed online — it could not
/// have mounted at all otherwise. /// have mounted at all otherwise.
fn default_online() -> bool { fn default_online() -> bool {
@@ -1339,6 +1439,15 @@ pub enum Response {
PhotoMonths { months: Vec<PhotoMonth> }, PhotoMonths { months: Vec<PhotoMonth> },
/// Thumbnails for a [`Request::PhotoThumbs`] batch. /// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> }, 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). /// An on-disk path the front-end can open (e.g. a downloaded photo).
FilePath { path: String }, FilePath { path: String },
/// Full-text search results (reply to [`Request::Search`]). /// 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 /// 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. /// Shared by the CLI and GUI so both speak the wire format identically.
pub fn send(socket: &Path, req: &Request) -> Result<Response> { 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] #[test]
fn unified_search_request_and_response_roundtrip() { fn unified_search_request_and_response_roundtrip() {
let request = Request::SearchV2 { let request = Request::SearchV2 {
+3
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "FUSE on-demand filesystem for Proton Drive" description = "FUSE on-demand filesystem for Proton Drive"
[dependencies] [dependencies]
@@ -18,6 +20,7 @@ tracing.workspace = true
fuser.workspace = true fuser.workspace = true
libc.workspace = true libc.workspace = true
serde_json.workspace = true serde_json.workspace = true
base64.workspace = true
image.workspace = true image.workspace = true
notify = "8" notify = "8"
parking_lot.workspace = true parking_lot.workspace = true
+64 -2
View File
@@ -11,7 +11,7 @@
use std::io::{BufRead, BufReader, Write as _}; use std::io::{BufRead, BufReader, Write as _};
use std::os::unix::net::{UnixListener, UnixStream}; 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::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -134,12 +134,21 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
/// the mount root. /// the mount root.
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> { fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
let p = Path::new(path); let p = Path::new(path);
if p.is_absolute() { let relative = if p.is_absolute() {
p.strip_prefix(mountpoint) p.strip_prefix(mountpoint)
.map(Path::to_path_buf) .map(Path::to_path_buf)
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint"))) .map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
} else { } else {
Ok(p.to_path_buf()) 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), 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) { Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
Some(u) => match core.open_photo(&u) { Some(u) => match core.open_photo(&u) {
Ok(p) => CtlResponse::FilePath { Ok(p) => CtlResponse::FilePath {
@@ -1221,4 +1258,29 @@ mod request_limit_tests {
std::io::ErrorKind::InvalidData 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
View File
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
use pdfs_core::control::{ use pdfs_core::control::{
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo, ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress, SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
TransferDirection, ThumbnailBuildStatus, TransferDirection,
}; };
use pdfs_core::db::{ use pdfs_core::db::{
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp, 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 /// `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 /// root itself resolves to the empty path; an empty root is the mount itself
/// and covers everything. /// and covers everything.
@@ -511,8 +550,22 @@ struct Core {
/// those downloads is a full-size photo — so an in-flight uid is never started /// those downloads is a full-size photo — so an in-flight uid is never started
/// twice. /// twice.
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>, thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
/// Nodes the remote has told us have *no* thumbnail of a given type, keyed by /// Global permit pool for locally generated thumbnails. The old per-request
/// `(uid, thumbnail type)` and holding the mtime the answer was learned at. /// 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 /// 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 /// 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 /// 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 /// is the validity tag — a new revision may well have a thumbnail — matching
/// how [`ContentCache::read_thumbnail`] validates the positive side. /// 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)`. /// 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 /// `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 // "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). // it every listing pays a round trip per file to be told nothing (B5).
let key = (uid.clone(), ttype.as_i32()); 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); return Ok(None);
} }
let bytes = self let bytes = self
@@ -3597,15 +3650,7 @@ impl Core {
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes); .store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
} }
None => { None => {
let mut misses = self.no_thumbnail.lock(); self.thumbnail_misses.lock().remember_remote(key, mtime);
// 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);
} }
} }
Ok(bytes) 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)] #[cfg(test)]
mod search_root_tests { mod search_root_tests {
use super::*; use super::*;
+9 -1
View File
@@ -389,7 +389,15 @@ pub fn mount(
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())), own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
self_changes: Arc::new(Mutex::new(HashMap::new())), self_changes: Arc::new(Mutex::new(HashMap::new())),
thumb_gen: Arc::new(Mutex::new(HashSet::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)), quota: Arc::new(Mutex::new(None)),
size_upgrades: Arc::new(Mutex::new(HashMap::new())), size_upgrades: Arc::new(Mutex::new(HashMap::new())),
size_waiters: Arc::new(Default::default()), size_waiters: Arc::new(Default::default()),
File diff suppressed because it is too large. Load diff
+1
View File
@@ -609,6 +609,7 @@ impl Core {
items.push(SharedItem { items.push(SharedItem {
uid: uid.to_string(), uid: uid.to_string(),
is_dir: n.is_folder(), is_dir: n.is_folder(),
modified: n.modification_time,
name: n.name, name: n.name,
path: self.rel_path_for_uid(&uid).unwrap_or_default(), path: self.rel_path_for_uid(&uid).unwrap_or_default(),
member_count: members, member_count: members,
+3
View File
@@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "GTK4 desktop app, tray, and search launcher for the Proton Drive Linux client"
[[bin]] [[bin]]
name = "pdfs-tray" name = "pdfs-tray"
+3
View File
@@ -22,6 +22,9 @@ fn main() {
} }
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml"); println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
);
println!( println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg" "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 icon-theme resource path at startup, so they resolve by name (and recolour
with the theme) exactly like a stock symbolic icon. --> with the theme) exactly like a stock symbolic icon. -->
<gresource prefix="/de/nils/protondrivelinux/icons"> <gresource prefix="/de/nils/protondrivelinux/icons">
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
</gresource> </gresource>
</gresources> </gresources>
+44 -7
View File
@@ -19,6 +19,7 @@ use pages::trash::*;
use pages::verify::*; use pages::verify::*;
use widgets::details::*; use widgets::details::*;
use widgets::share_dialog::*; use widgets::share_dialog::*;
use widgets::thumbnails::*;
use widgets::versions_dialog::*; use widgets::versions_dialog::*;
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
@@ -49,8 +50,8 @@ use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
ImportSummary, InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, ImportSummary, InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope,
Request, Response, RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, Request, Response, RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind,
SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection,
pending_summary, send, TransferItem, pending_summary, send,
}; };
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; 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 /// The sidebar/content split. Collapsed while signed out, so the login page
/// owns the whole window and no destination is reachable without a session. /// owns the whole window and no destination is reachable without a session.
nav: adw::NavigationSplitView, 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 // Per-page state. Each page module owns its own struct; `Ui` keeps only
// what more than one page genuinely shares. // what more than one page genuinely shares.
@@ -207,10 +212,15 @@ fn load_proton_theme() {
.file-grid {{ padding: 6px; }}\n\ .file-grid {{ padding: 6px; }}\n\
.file-tile {{ padding: 8px; border-radius: 10px; }}\n\ .file-tile {{ padding: 8px; border-radius: 10px; }}\n\
.file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\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\ .file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\
.badge-pinned {{ color: #f5c211; }}\n\ .badge-pinned {{ color: #f5c211; }}\n\
.badge-cached {{ color: #2ec27e; }}\n\ .badge-cached {{ color: #2ec27e; }}\n\
.badge-cloud {{ color: #9aa0a6; }}\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\ .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-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\ .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), mounted: RefCell::new(false),
sidebar: sidebar_list.clone(), sidebar: sidebar_list.clone(),
nav: split.clone(), nav: split.clone(),
file_thumbs: FileThumbnailState::new(),
login: LoginState { login: LoginState {
email: login_widgets.0, email: login_widgets.0,
password: login_widgets.1, password: login_widgets.1,
@@ -390,7 +401,15 @@ fn build_window(app: &adw::Application) {
new_folder: browser_widgets.new_folder.clone(), new_folder: browser_widgets.new_folder.clone(),
upload: browser_widgets.upload.clone(), upload: browser_widgets.upload.clone(),
upload_folder: browser_widgets.upload_folder.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), search_source: RefCell::new(None),
load_generation: Cell::new(0),
views: browser_widgets.views.clone(), views: browser_widgets.views.clone(),
bulk: browser_widgets.bulk.clone(), bulk: browser_widgets.bulk.clone(),
bulk_label: browser_widgets.bulk_label.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_pin: browser_widgets.bulk_pin.clone(),
bulk_unpin: browser_widgets.bulk_unpin.clone(), bulk_unpin: browser_widgets.bulk_unpin.clone(),
empty_actions: browser_widgets.empty_actions.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: DetailsState {
details: browser_widgets.details, details: browser_widgets.details,
@@ -552,6 +578,7 @@ fn build_window(app: &adw::Application) {
&browser_widgets.new_folder, &browser_widgets.new_folder,
&browser_widgets.upload, &browser_widgets.upload,
&browser_widgets.upload_folder, &browser_widgets.upload_folder,
&browser_widgets.build_thumbnails,
); );
wire_details(&ui); wire_details(&ui);
wire_search(&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. // network round-trip only happens on demand rather than on every refresh.
let ui_nav = ui.clone(); let ui_nav = ui.clone();
stack.connect_visible_child_name_notify(move |st| { 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); sync_sidebar(&ui_nav);
match st.visible_child_name().as_deref() { match st.visible_child_name().as_deref() {
Some("browser") => load_browser(&ui_nav), Some("browser") => load_browser(&ui_nav),
@@ -914,7 +945,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
fn build_primary_menu() -> gtk4::MenuButton { fn build_primary_menu() -> gtk4::MenuButton {
let menu = gio::Menu::new(); let menu = gio::Menu::new();
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
menu.append(Some("About Proton Drive"), Some("win.about")); menu.append(Some("About Proton Drive for Linux"), Some("win.about"));
gtk4::MenuButton::builder() gtk4::MenuButton::builder()
.icon_name("open-menu-symbolic") .icon_name("open-menu-symbolic")
.tooltip_text("Main menu") .tooltip_text("Main menu")
@@ -933,11 +964,17 @@ fn install_window_actions(window: &adw::ApplicationWindow) {
let win = window.clone(); let win = window.clone();
about.connect_activate(move |_, _| { about.connect_activate(move |_, _| {
let dialog = adw::AboutDialog::builder() let dialog = adw::AboutDialog::builder()
.application_name("Proton Drive") .application_name("Proton Drive for Linux")
.application_icon("folder-remote-symbolic") .application_icon("io.narl.proton-drive-linux")
.version(pdfs_core::config::APP_VERSION) .version(pdfs_core::config::APP_VERSION)
.developer_name("proton-drive-linux") .developer_name("Nils Pukropp")
.comments("On-demand Proton Drive sync for Linux.") .website("https://github.com/narrrl/proton-drive-linux")
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
.license_type(gtk4::License::MitX11)
.comments(
"Files-on-demand Proton Drive for the Linux desktop.\n\n\
Unofficial client — not affiliated with, endorsed by, or supported by Proton AG.",
)
.build(); .build();
dialog.present(Some(&win)); dialog.present(Some(&win));
}); });
+561 -53
View File
@@ -28,9 +28,21 @@ pub(crate) struct BrowserState {
pub(crate) new_folder: gtk4::Button, pub(crate) new_folder: gtk4::Button,
pub(crate) upload: gtk4::Button, pub(crate) upload: gtk4::Button,
pub(crate) upload_folder: 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 /// Pending debounce timer for the search box; replaced on every keystroke so
/// only the last pause actually fires a [`Request::Search`]. /// only the last pause actually fires a [`Request::Search`].
pub(crate) search_source: RefCell<Option<glib::SourceId>>, 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 grid/list view stack, read to find out which of the two selections is
/// the one the user is actually working in. /// the one the user is actually working in.
pub(crate) views: gtk4::Stack, 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 /// 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). /// status (a load error is not the moment to offer an upload).
pub(crate) empty_actions: gtk4::Box, 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 /// 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. /// Cap on search hits requested from the daemon.
pub(crate) const SEARCH_LIMIT: usize = 200; 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 /// 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 /// a grid/list view toggle sits over a [`gtk4::Stack`] that swaps between an
/// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`] /// **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) new_folder: gtk4::Button,
pub(crate) upload: gtk4::Button, pub(crate) upload: gtk4::Button,
pub(crate) upload_folder: 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, pub(crate) refresh: gtk4::Button,
/// Wraps the views + the details pane; the pane slides in on selection. /// Wraps the views + the details pane; the pane slides in on selection.
pub(crate) split: adw::OverlaySplitView, pub(crate) split: adw::OverlaySplitView,
@@ -149,6 +188,12 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
upload_folder.add_css_class("flat"); 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. // Linked grid/list toggle, top-right, Nautilus-style.
let grid_toggle = gtk4::ToggleButton::builder() let grid_toggle = gtk4::ToggleButton::builder()
@@ -178,12 +223,24 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
header.append(&back); header.append(&back);
header.append(&crumb_scroll); header.append(&crumb_scroll);
header.append(&refresh); header.append(&refresh);
header.append(&build_thumbnails);
header.append(&new_folder); header.append(&new_folder);
header.append(&upload); header.append(&upload);
header.append(&upload_folder); header.append(&upload_folder);
header.append(&search); header.append(&search);
header.append(&toggles); 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. // Empty / loading / error surface, shown in place of the views.
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.label("Retry") .label("Retry")
@@ -314,17 +371,94 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.child(&bulk_box) .child(&bulk_box)
.build(); .build();
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); // Mirror DolphinStatusBar's full-width order: contextual text (stretch 1),
inner.set_margin_top(12); // "Zoom:", its slider, then capacity information. Both visible troughs use
inner.set_margin_bottom(12); // the same CSS dimensions.
inner.set_margin_start(12); let summary = gtk4::Label::builder()
inner.set_margin_end(12); .label("Loading…")
inner.append(&header); .halign(gtk4::Align::Start)
inner.append(&bulk); .valign(gtk4::Align::Center)
inner.append(&split); .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(&quota);
quota_box.append(&quota_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(&quota_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(&gtk4::Separator::new(gtk4::Orientation::Horizontal));
page.append(&status_bar);
( (
inner.upcast(), page.upcast(),
BrowserWidgets { BrowserWidgets {
model, model,
back, back,
@@ -338,6 +472,15 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
new_folder, new_folder,
upload, upload,
upload_folder, upload_folder,
build_thumbnails,
thumbnail_build_row,
thumbnail_progress,
thumbnail_status,
summary,
zoom,
quota_box,
quota,
quota_text,
refresh, refresh,
split, split,
details, details,
@@ -653,23 +796,51 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: &gtk4::GridView, column_view: &gtk
load_browser(&ui_back); 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(); let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup({ factory.connect_setup({
let ui = ui.clone(); let ui = ui.clone();
move |_, item| { move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let icon = gtk4::Image::builder().pixel_size(64).build(); let size = ui.browser.grid_thumbnail_size.get();
// Corner sync-state badge, overlaid on the big icon. let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size));
// Keep the sync-state badge inside the thumbnail surface.
let badge = gtk4::Image::builder() let badge = gtk4::Image::builder()
.pixel_size(18) .pixel_size(18)
.halign(gtk4::Align::End) .halign(gtk4::Align::End)
.valign(gtk4::Align::End) .valign(gtk4::Align::Start)
.margin_top(2)
.margin_end(2)
.build(); .build();
badge.add_css_class("file-badge"); badge.add_css_class("file-badge");
let overlay = gtk4::Overlay::new(); thumbnail.add_overlay(&badge);
overlay.set_child(Some(&icon));
overlay.add_overlay(&badge);
// `WordChar` rather than the default `Word`: a name with no spaces // `WordChar` rather than the default `Word`: a name with no spaces
// offers no word-break opportunity, so word wrapping cannot break it // offers no word-break opportunity, so word wrapping cannot break it
// at all and the label asks for its full natural width instead — // 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: &gtk4::GridView, column_view: &gtk
.wrap_mode(gtk4::pango::WrapMode::WordChar) .wrap_mode(gtk4::pango::WrapMode::WordChar)
.lines(2) .lines(2)
.build(); .build();
ui.browser
.grid_tiles
.borrow_mut()
.push((thumbnail.downgrade(), label.downgrade()));
let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
tile.add_css_class("file-tile"); tile.add_css_class("file-tile");
tile.append(&overlay); tile.append(&thumbnail);
tile.append(&label); tile.append(&label);
attach_context_menu(&ui, item, &tile); attach_context_menu(&ui, item, &tile);
attach_drag(&ui, item, &tile); attach_drag(&ui, item, &tile);
@@ -696,21 +871,29 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: &gtk4::GridView, column_view: &gtk
item.set_child(Some(&tile)); item.set_child(Some(&tile));
} }
}); });
factory.connect_bind(|_, item| { factory.connect_bind({
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); let ui = ui.clone();
let tile = item.child().and_downcast::<gtk4::Box>().unwrap(); move |_, item| {
let overlay = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap(); let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let icon = overlay.first_child().and_downcast::<gtk4::Image>().unwrap(); let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
let badge = overlay.last_child().and_downcast::<gtk4::Image>().unwrap(); let thumbnail = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
let label = tile.last_child().and_downcast::<gtk4::Label>().unwrap(); let badge = thumbnail
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap(); .last_child()
let entry = obj.borrow::<DirEntry>(); .and_downcast::<gtk4::Image>()
icon.set_icon_name(Some(icon_base_for(&entry))); .unwrap();
label.set_label(&entry.name); let label = thumbnail
// The tile shows at most two lines of it, so the full name has to be .next_sibling()
// reachable somehow. .and_downcast::<gtk4::Label>()
label.set_tooltip_text(Some(&entry.name)); .unwrap();
apply_badge(&badge, &entry); 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)); grid.set_factory(Some(&factory));
@@ -740,15 +923,33 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: &gtk4::GridView, column_view: &gtk
}); });
} }
/// Build the Name column: a small icon plus the (star-prefixed when pinned) name, fn resize_grid_tile(thumbnail: &gtk4::Overlay, label: &gtk4::Label, size: i32) {
/// with the same right-click menu the grid tiles carry. 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 { pub(crate) fn name_column(ui: &Rc<Ui>) -> gtk4::ColumnViewColumn {
let factory = gtk4::SignalListItemFactory::new(); let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup({ factory.connect_setup({
let ui = ui.clone(); let ui = ui.clone();
move |_, item| { move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); 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 // Ellipsized so the Name column can be *narrower* than its longest
// name. Without it the label's minimum width is the whole string, // name. Without it the label's minimum width is the whole string,
// the column inherits that minimum, and one long name pushes Size // 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) .halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End) .ellipsize(gtk4::pango::EllipsizeMode::End)
.build(); .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); let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
cell.append(&icon); cell.append(&thumbnail);
cell.append(&label); cell.append(&label);
cell.append(&badge);
attach_context_menu(&ui, item, &cell); attach_context_menu(&ui, item, &cell);
attach_drag(&ui, item, &cell); attach_drag(&ui, item, &cell);
attach_drop(&ui, item, &cell); attach_drop(&ui, item, &cell);
item.set_child(Some(&cell)); item.set_child(Some(&cell));
} }
}); });
factory.connect_bind(|_, item| { factory.connect_bind({
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); let ui = ui.clone();
let cell = item.child().and_downcast::<gtk4::Box>().unwrap(); move |_, item| {
let icon = cell.first_child().and_downcast::<gtk4::Image>().unwrap(); let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let badge = cell.last_child().and_downcast::<gtk4::Image>().unwrap(); let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
let label = icon.next_sibling().and_downcast::<gtk4::Label>().unwrap(); let thumbnail = cell.first_child().and_downcast::<gtk4::Overlay>().unwrap();
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap(); let badge = thumbnail
let entry = obj.borrow::<DirEntry>(); .last_child()
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); .and_downcast::<gtk4::Image>()
label.set_label(&entry.name); .unwrap();
label.set_tooltip_text(Some(&entry.name)); let label = thumbnail
apply_badge(&badge, &entry); .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)); let column = gtk4::ColumnViewColumn::new(Some("Name"), Some(factory));
column.set_expand(true); column.set_expand(true);
@@ -1254,6 +1461,7 @@ pub(crate) fn wire_browser_actions(
new_folder: &gtk4::Button, new_folder: &gtk4::Button,
upload: &gtk4::Button, upload: &gtk4::Button,
upload_folder: &gtk4::Button, upload_folder: &gtk4::Button,
build_thumbnails: &gtk4::Button,
) { ) {
let ui_nf = ui.clone(); let ui_nf = ui.clone();
new_folder.connect_clicked(move |_| prompt_new_folder(&ui_nf)); 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)); upload.connect_clicked(move |_| prompt_upload(&ui_up));
let ui_uf = ui.clone(); let ui_uf = ui.clone();
upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf)); 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 /// 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. /// Request the current browser directory from the daemon and repaint both views.
pub(crate) fn load_browser(ui: &Rc<Ui>) { 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(); let path = ui.browser.path.borrow().clone();
repaint_crumb(ui, &path); repaint_crumb(ui, &path);
ui.browser.back.set_sensitive(!path.is_empty()); 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 // 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. // 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 // 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 // out-of-order reply must not repaint rows for a folder we've left, or
// the breadcrumb and the grid would disagree. // 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; return;
} }
match result { 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. /// (which restarts the service) wouldn't help and isn't offered.
pub(crate) fn browser_failed(ui: &Rc<Ui>, message: &str, kind: ErrorKind) { pub(crate) fn browser_failed(ui: &Rc<Ui>, message: &str, kind: ErrorKind) {
ui.browser.model.remove_all(); ui.browser.model.remove_all();
ui.browser.summary.set_label("Folder unavailable");
browser_status( browser_status(
ui, ui,
"dialog-warning-symbolic", "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. /// once the systemd mount comes up but a real failure stays visible.
pub(crate) fn browser_unreachable(ui: &Rc<Ui>) { pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() { if service::is_failed() || !service::is_active() {
ui.browser.summary.set_label("Not connected");
browser_status( browser_status(
ui, ui,
"network-offline-symbolic", "network-offline-symbolic",
@@ -1739,6 +2176,7 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
); );
return; return;
} }
ui.browser.summary.set_label("Connecting…");
browser_status( browser_status(
ui, ui,
"folder-remote-symbolic", "folder-remote-symbolic",
@@ -1759,6 +2197,15 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
/// which refreshes both the grid and the column list. /// which refreshes both the grid and the column list.
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) { pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
ui.browser.model.remove_all(); 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() { if entries.is_empty() {
browser_status( browser_status(
ui, 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 /// views, reusing the same row model so click-to-open and pin work unchanged
/// (each hit carries its full path; see [`entry_rel`]). /// (each hit carries its full path; see [`entry_rel`]).
pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) { 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.model.remove_all();
ui.browser.summary.set_label("Searching…");
browser_status( browser_status(
ui, ui,
"system-search-symbolic", "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 // 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 // flight; if the query no longer matches, a fresher load/search already
// owns the model — drop this stale, possibly out-of-order result. // 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; return;
} }
match result { match result {
@@ -1856,6 +2309,17 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
/// handlers already understand. /// handlers already understand.
pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) { pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
ui.browser.model.remove_all(); 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() { if hits.is_empty() {
browser_status( browser_status(
ui, ui,
@@ -1892,3 +2356,47 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
ui.browser.model.append(&BoxedAnyObject::new(entry)); 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));
}
}
+2 -5
View File
@@ -151,6 +151,7 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
if ui.shared.inflight.get() { if ui.shared.inflight.get() {
return; return;
} }
cancel_file_thumbnails(ui);
let current = ui.shared.nav.borrow().last().cloned(); let current = ui.shared.nav.borrow().last().cloned();
if let Some((uid, _)) = current { if let Some((uid, _)) = current {
load_shared_folder(ui, uid); 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) { if let Some(badge) = role_badge(&entry.role) {
row.add_suffix(&badge); row.add_suffix(&badge);
} }
row.add_prefix(&gtk4::Image::from_icon_name(if entry.is_dir { row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
let ui_act = ui.clone(); let ui_act = ui.clone();
let uid = entry.uid.clone(); let uid = entry.uid.clone();
let name = entry.name.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() { if ui.shared_by_me.inflight.get() {
return; return;
} }
cancel_file_thumbnails(ui);
ui.shared_by_me.inflight.set(true); ui.shared_by_me.inflight.set(true);
shared_by_me_status( shared_by_me_status(
ui, 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"); ui.shared_by_me.content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = Vec::new(); let mut rows: Vec<gtk4::Widget> = Vec::new();
for item in items { for item in items {
let entry = shared_item_as_entry(item);
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&item.name) .title(&item.name)
.subtitle(shared_item_summary(item)) .subtitle(shared_item_summary(item))
.build(); .build();
row.add_prefix(&gtk4::Image::from_icon_name(if item.is_dir { row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
// Copy / open the public link, when there is one with a recovered URL. // 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()) { 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(); .build();
manage.add_css_class("flat"); manage.add_css_class("flat");
let ui_manage = ui.clone(); let ui_manage = ui.clone();
let entry = shared_item_as_entry(item);
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry)); manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
row.add_suffix(&manage); row.add_suffix(&manage);
@@ -289,7 +286,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
name: item.name.clone(), name: item.name.clone(),
is_dir: item.is_dir, is_dir: item.is_dir,
size: 0, size: 0,
modified: 0, modified: item.modified,
pinned: false, pinned: false,
cached: false, cached: false,
uid: item.uid.clone(), uid: item.uid.clone(),
+102 -23
View File
@@ -572,7 +572,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
refresh_takeout(ui); refresh_takeout(ui);
} }
match ui.stack.visible_child_name().as_deref() { 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("locations") => refresh_locations(ui),
Some("activity") => refresh_activity(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 /// 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); const QUOTA_TTL: Duration = Duration::from_secs(60);
/// Fetch the account quota (if the last reading is stale) and paint the Account /// Fetch the account quota (if the last reading is stale) and paint both the
/// storage group. Runs only while Settings is on screen. The group stays hidden /// Settings storage group and Files status bar. A failed fetch leaves the last
/// 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
/// good reading in place. /// good reading in place.
pub(crate) fn refresh_quota(ui: &Rc<Ui>) { pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
if ui.status.quota_inflight.get() { if ui.status.quota_inflight.get() {
@@ -609,28 +607,71 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
})) = result })) = result
{ {
ui.status.quota_checked_at.set(Some(Instant::now())); ui.status.quota_checked_at.set(Some(Instant::now()));
let used = used_space.max(0) as u64; paint_account_quota(&ui, max_space, used_space);
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)));
}
ui.status.quota_group.set_visible(true); 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 /// 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. /// 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.new_folder.set_sensitive(mounted);
ui.browser.upload.set_sensitive(mounted); ui.browser.upload.set_sensitive(mounted);
ui.browser.upload_folder.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.gallery.upload.set_sensitive(mounted);
ui.details.details.pin_row.set_sensitive(mounted); ui.details.details.pin_row.set_sensitive(mounted);
ui.details.details.rename_button.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 }); .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);
}
}
+28 -24
View File
@@ -122,7 +122,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: &gtk4::ListView, empty: &gtk4::Butto
factory.connect_setup(move |_, item| { factory.connect_setup(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); 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() let name = gtk4::Label::builder()
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::Middle) .ellipsize(gtk4::pango::EllipsizeMode::Middle)
@@ -169,35 +169,38 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: &gtk4::ListView, empty: &gtk4::Butto
row.set_margin_bottom(6); row.set_margin_bottom(6);
row.set_margin_start(6); row.set_margin_start(6);
row.set_margin_end(6); row.set_margin_end(6);
row.append(&icon); row.append(&thumbnail);
row.append(&text); row.append(&text);
row.append(&restore); row.append(&restore);
row.append(&purge); row.append(&purge);
item.set_child(Some(&row)); item.set_child(Some(&row));
}); });
factory.connect_bind(|_, item| { factory.connect_bind({
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); let ui = ui.clone();
let Some(entry) = bound_entry(item) else { move |_, item| {
return; let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
}; let Some(entry) = bound_entry(item) else {
let row = item.child().and_downcast::<gtk4::Box>().unwrap(); return;
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)
}; };
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)); list.set_factory(Some(&factory));
@@ -216,6 +219,7 @@ pub(crate) fn bound_entry(item: &gtk4::ListItem) -> Option<DirEntry> {
/// Fetch the trash listing and repaint the page. /// Fetch the trash listing and repaint the page.
pub(crate) fn load_trash(ui: &Rc<Ui>) { 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 // Drop the old rows first: a stale row here would offer Restore on something
// that may already be gone. // that may already be gone.
ui.trash.model.remove_all(); ui.trash.model.remove_all();
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod details; pub(crate) mod details;
pub(crate) mod share_dialog; pub(crate) mod share_dialog;
pub(crate) mod thumbnails;
pub(crate) mod versions_dialog; 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: &gtk4::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: &gtk4::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: &gtk4::Picture) {
let mut wanted = ui.file_thumbs.wanted.borrow_mut();
wanted.retain(|_, pictures| {
pictures.retain(|candidate| candidate != picture);
!pictures.is_empty()
});
}
fn paint(picture: &gtk4::Picture, key: &str, texture: &gtk4::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
);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Proton Drive for Linux">
<defs>
<linearGradient id="pdl-bg" x1="0" y1="0" x2="0" y2="128" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8B72FF"/>
<stop offset="1" stop-color="#5A38E8"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="29" fill="url(#pdl-bg)"/>
<!-- cloud: the remote drive (symmetric about x=64) -->
<g fill="#FFFFFF">
<circle cx="64" cy="42" r="20"/>
<circle cx="44" cy="52" r="14"/>
<circle cx="84" cy="52" r="14"/>
<rect x="32" y="50" width="64" height="16" rx="8"/>
</g>
<!-- pull: files arrive only when opened -->
<g stroke="#FFFFFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
<path d="M64 75v17"/>
<path d="M53 84l11 11 11-11"/>
<path d="M45 108h38" stroke-opacity="0.55"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 990 B

+4 -4
View File
@@ -9,13 +9,13 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=1.9.0 pkgver=1.9.1
pkgrel=1 pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
url="https://github.com/narl/proton-drive-linux" url="https://github.com/narrrl/proton-drive-linux"
license=('MIT') license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0') depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool')
makedepends=('cargo' 'git') makedepends=('cargo' 'git')
provides=('pdfs') provides=('pdfs')
# Files-on-demand mount + keyring need these at runtime. # Files-on-demand mount + keyring need these at runtime.
@@ -1,11 +1,11 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Proton Drive (Tray) Name=Proton Drive for Linux (Tray)
GenericName=Cloud Drive Tray GenericName=Cloud Drive Tray
Comment=Proton Drive system tray icon Comment=Proton Drive status and quick actions (unofficial client)
Exec=pdfs-tray Exec=pdfs-tray
Icon=io.narl.proton-drive-linux Icon=io.narl.proton-drive-linux
Terminal=false Terminal=false
Categories=Network;FileTransfer;GTK; Categories=Network;FileTransfer;GTK;
Keywords=proton;drive;cloud;sync;storage; Keywords=proton;drive;cloud;sync;storage;files;backup;
NoDisplay=true NoDisplay=true
+3 -3
View File
@@ -1,12 +1,12 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Proton Drive Name=Proton Drive for Linux
GenericName=Cloud Drive GenericName=Cloud Drive
Comment=Proton Drive files-on-demand for Linux Comment=Files-on-demand Proton Drive (unofficial client)
Exec=pdfs-app Exec=pdfs-app
Icon=io.narl.proton-drive-linux Icon=io.narl.proton-drive-linux
Terminal=false Terminal=false
Categories=Network;FileTransfer;GTK; Categories=Network;FileTransfer;GTK;
Keywords=proton;drive;cloud;sync;storage; Keywords=proton;drive;cloud;sync;storage;files;backup;
StartupNotify=true StartupNotify=true
SingleMainWindow=true SingleMainWindow=true
+21 -9
View File
@@ -1,14 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Proton Drive for Linux">
<defs> <defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="pdl-bg" x1="0" y1="0" x2="0" y2="128" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8b6dff"/> <stop offset="0" stop-color="#8B72FF"/>
<stop offset="1" stop-color="#6d4aff"/> <stop offset="1" stop-color="#5A38E8"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#g)"/>
<!-- cloud --> <rect width="128" height="128" rx="29" fill="url(#pdl-bg)"/>
<path fill="#ffffff" d="M44 84c-11 0-20-9-20-20 0-10 7-18 17-20 3-11 13-19 25-19 13 0 24 10 26 23 9 1 16 9 16 18 0 10-8 18-18 18z"/>
<!-- down arrow (on-demand pull) --> <!-- cloud: the remote drive (symmetric about x=64) -->
<path d="M64 50v22m0 0l-9-9m9 9l9-9" stroke="#6d4aff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none"/> <g fill="#FFFFFF">
<circle cx="64" cy="42" r="20"/>
<circle cx="44" cy="52" r="14"/>
<circle cx="84" cy="52" r="14"/>
<rect x="32" y="50" width="64" height="16" rx="8"/>
</g>
<!-- pull: files arrive only when opened -->
<g stroke="#FFFFFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
<path d="M64 75v17"/>
<path d="M53 84l11 11 11-11"/>
<path d="M45 108h38" stroke-opacity="0.55"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 718 B

After

Width:  |  Height:  |  Size: 990 B

+6 -4
View File
@@ -12,9 +12,9 @@
Name: proton-drive-linux Name: proton-drive-linux
Version: %{version} Version: %{version}
Release: 1%{?dist} Release: 1%{?dist}
Summary: Proton Drive client for Linux (FUSE, CLI, GTK4 app + tray) Summary: Unofficial Proton Drive client for Linux (FUSE, CLI, GTK4 app + tray)
License: MIT License: MIT
URL: https://github.com/narl/proton-drive-linux URL: https://github.com/narrrl/proton-drive-linux
ExclusiveArch: x86_64 ExclusiveArch: x86_64
BuildRequires: cargo BuildRequires: cargo
@@ -33,6 +33,7 @@ Requires: gtk4
Requires: libadwaita Requires: libadwaita
Requires: libsecret Requires: libsecret
Requires: webkitgtk6.0 Requires: webkitgtk6.0
Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
# DE-specific; do not Require a single desktop environment. # DE-specific; do not Require a single desktop environment.
@@ -43,8 +44,9 @@ Recommends: kwallet
Provides: pdfs = %{version}-%{release} Provides: pdfs = %{version}-%{release}
%description %description
Unofficial Proton Drive client featuring a FUSE files-on-demand mount, Files-on-demand Proton Drive for the Linux desktop: a FUSE virtual mount with
CLI, GTK4/Libadwaita GUI, system tray, and search launcher. block-level caching, a scriptable CLI, and a GTK4/Libadwaita desktop app with
system tray and search launcher. Unofficial and not affiliated with Proton AG.
%prep %prep
# In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout". # In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout".
+1 -1
View File
@@ -1,6 +1,6 @@
[Unit] [Unit]
Description=Proton Drive Files-On-Demand mount Description=Proton Drive Files-On-Demand mount
Documentation=https://github.com/narl/proton-drive-linux Documentation=https://github.com/narrrl/proton-drive-linux
# Tie the mount to the desktop session: start after it is up and stop with it. # Tie the mount to the desktop session: start after it is up and stop with it.
After=graphical-session.target After=graphical-session.target
PartOf=graphical-session.target PartOf=graphical-session.target