Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c67226ab0 | ||
|
|
6f7269b9b6 | ||
|
|
dacacf51bb | ||
|
|
8f28f5fd22 | ||
|
|
924e07e85f | ||
|
|
e77ab0ffa9 |
No files matched your search
@@ -62,6 +62,7 @@ jobs:
|
||||
cp target/release/pdfs dist/binaries/
|
||||
cp target/release/pdfs-tray dist/binaries/
|
||||
cp target/release/pdfs-app dist/binaries/
|
||||
cp target/release/pdfs-prompt dist/binaries/
|
||||
|
||||
# Create tarball
|
||||
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
||||
@@ -78,6 +79,7 @@ jobs:
|
||||
cp target/release/pdfs deb/usr/bin/
|
||||
cp target/release/pdfs-tray deb/usr/bin/
|
||||
cp target/release/pdfs-app deb/usr/bin/
|
||||
cp target/release/pdfs-prompt deb/usr/bin/
|
||||
|
||||
# Copy desktop integration files
|
||||
cp packaging/io.narl.proton-drive-linux.desktop deb/usr/share/applications/
|
||||
@@ -101,7 +103,7 @@ jobs:
|
||||
Proton Drive client for Linux featuring:
|
||||
- FUSE files-on-demand mount
|
||||
- Command-line interface (CLI)
|
||||
- GTK4 system tray and desktop application
|
||||
- GTK4 system tray, desktop application, and search launcher
|
||||
EOF
|
||||
|
||||
# Build Debian package
|
||||
|
||||
@@ -468,6 +468,16 @@ dependencies = [
|
||||
"cipher 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "buffer-redux"
|
||||
version = "1.1.0"
|
||||
@@ -804,6 +814,25 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
@@ -1689,6 +1718,19 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.20.10"
|
||||
@@ -2152,6 +2194,22 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2adf14691c72bcfc1058740436a35bdd3ae9c07d1a941ef00b749e9ea16aefa7"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
"log",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"same-file",
|
||||
"walkdir",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -2278,6 +2336,15 @@ dependencies = [
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kamadak-exif"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
|
||||
dependencies = [
|
||||
"mutate_once",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
@@ -2502,6 +2569,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mutate_once"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -2825,7 +2898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap 4.6.1",
|
||||
@@ -2840,9 +2913,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"ignore",
|
||||
"keyring",
|
||||
"proton-drive-rs",
|
||||
"proton-sdk",
|
||||
@@ -2857,7 +2931,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"fuser",
|
||||
"libc",
|
||||
@@ -2870,11 +2944,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"gtk4",
|
||||
"kamadak-exif",
|
||||
"ksni",
|
||||
"libadwaita",
|
||||
"pdfs-core",
|
||||
@@ -3085,9 +3160,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76e4399b4d9b076c5886c7db87ae7f70322dd7127e6c5a4e7eff8719434d2434"
|
||||
version = "0.1.4"
|
||||
source = "git+https://github.com/narrrl/proton-sdk-rs?branch=main#2e3c23803eecbdcb9f442c1e49229347f272596b"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
@@ -3110,9 +3184,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "099f6f20709452f2cdb0cee4db0a82e61a29bec261295812908bf956ffcdaa00"
|
||||
version = "0.1.4"
|
||||
source = "git+https://github.com/narrrl/proton-sdk-rs?branch=main#2e3c23803eecbdcb9f442c1e49229347f272596b"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -3,16 +3,15 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
rust-version = "1.96"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Local Proton SDK crates (path deps until published; switch to versioned later).
|
||||
proton-sdk = "0.1.3"
|
||||
proton-drive-rs = "0.1.3"
|
||||
proton-sdk = "0.1.4"
|
||||
proton-drive-rs = "0.1.4"
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -26,6 +25,7 @@ anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
directories = "5"
|
||||
ignore = "0.4"
|
||||
sha2 = "0.10"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
@@ -34,3 +34,9 @@ rpassword = "7"
|
||||
fuser = "0.17"
|
||||
libc = "0.2"
|
||||
|
||||
[patch.crates-io]
|
||||
# Track the SDK's `main` on GitHub rather than a sibling checkout, so a clean
|
||||
# clone (and CI, and the PKGBUILD) builds without the working tree next door.
|
||||
# `cargo update -p proton-sdk -p proton-drive-rs` pulls newer pushes.
|
||||
proton-sdk = { git = "https://github.com/narrrl/proton-sdk-rs", branch = "main" }
|
||||
proton-drive-rs = { git = "https://github.com/narrrl/proton-sdk-rs", branch = "main" }
|
||||
@@ -8,6 +8,7 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc
|
||||
- **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, and configure options through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||
- **Fuzzy Search Launcher Prompt (HUD)**: A Google Drive-style search launcher (`pdfs-prompt`) for finding and opening files or folders instantly, ideal for binding to a system-wide hotkey.
|
||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||
|
||||
@@ -23,15 +24,15 @@ The client includes several optimizations designed for high efficiency, a low me
|
||||
|
||||
## Screenshots
|
||||
|
||||
### GUI Application
|
||||
### GUI Application & Launcher
|
||||
|
||||
| Login Screen | Account Overview |
|
||||
| Login Screen | Files Browser |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Files (Grid View) | Files (List View) |
|
||||
| Photos Timeline | Search Launcher Prompt |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
@@ -76,6 +77,7 @@ The compiled binaries will be available under `target/release/`:
|
||||
- `pdfs`: The CLI utility.
|
||||
- `pdfs-app`: The GTK4 application.
|
||||
- `pdfs-tray`: The tray status notifier.
|
||||
- `pdfs-prompt`: The launcher prompt for quick HUD search.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,7 +110,7 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
|
||||
- Sets up the Rust compiler and caches build targets to speed up runs.
|
||||
- Compiles the workspace members in release mode.
|
||||
3. **Artifact Packaging**:
|
||||
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`).
|
||||
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`, `pdfs-prompt`).
|
||||
- Packs them into a Debian package (`.deb`).
|
||||
4. **Publishing**:
|
||||
- Creates a GitHub Release matching the pushed tag and uploads the `.deb` and `.tar.gz` packages as release assets.
|
||||
|
||||
@@ -251,7 +251,9 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "mount failed; retrying in 5s");
|
||||
// `{:#}` so the whole anyhow chain lands in the log — the top
|
||||
// context alone ("mount failed") never says *why*.
|
||||
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
@@ -292,39 +294,11 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||
.block_on(auth::resume_client())
|
||||
.context("resume session (run `pdfs login` first)")?;
|
||||
|
||||
// Persist rotated tokens for the daemon's lifetime. Proton refresh tokens are
|
||||
// single-use: the 401-refresh path swaps in a new access+refresh pair, and if
|
||||
// that pair is never written back the keyring keeps a now-dead refresh token,
|
||||
// so the next `pdfs mount` fails with `InvalidRefreshToken`. The session
|
||||
// shares its token store with the Drive client (one `Arc`-backed token store),
|
||||
// so both the poll below and the shutdown flush see every rotation.
|
||||
//
|
||||
// `ProtonApiSession` is `Clone` over that shared store, so the polling task
|
||||
// gets its own handle and we keep `session` here for a final flush. The poll
|
||||
// is a backstop for long-lived mounts; the flush after `mount` returns is what
|
||||
// makes the common case correct — a refresh shortly before a clean unmount
|
||||
// would otherwise be lost in the poll gap, leaving a dead token behind.
|
||||
let poll_session = session.clone();
|
||||
rt.spawn(async move {
|
||||
let mut last = poll_session.current_tokens().await;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||
let current = poll_session.current_tokens().await;
|
||||
if current.access_token == last.access_token
|
||||
&& current.refresh_token == last.refresh_token
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match auth::persist(&poll_session).await {
|
||||
Ok(()) => {
|
||||
tracing::info!("persisted refreshed session tokens");
|
||||
last = current;
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "failed to persist refreshed tokens"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Token updates are persisted automatically in real-time. The SDK session has
|
||||
// a token refresh callback registered during `resume_client` that writes updated
|
||||
// credentials back to the OS keyring immediately upon rotation. This prevents
|
||||
// the system keyring from holding stale refresh tokens even if the daemon crashes,
|
||||
// the system is suspended, or the network changes.
|
||||
let handle = rt.handle().clone();
|
||||
let result = pdfs_fuse::mount(
|
||||
client,
|
||||
@@ -336,11 +310,9 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||
username,
|
||||
);
|
||||
|
||||
// Clean unmount: flush whatever tokens the store holds now so the keyring
|
||||
// always ends a session with the live refresh token, not a stale one. Runs
|
||||
// even if `mount` errored — a rotation may still have happened first.
|
||||
// Clean unmount check (as a best-effort final backup).
|
||||
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
||||
tracing::warn!(error = %e, "failed to persist tokens on shutdown");
|
||||
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
||||
}
|
||||
|
||||
let outcome = result.context("mount failed")?;
|
||||
|
||||
@@ -16,6 +16,7 @@ serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
directories.workspace = true
|
||||
ignore.workspace = true
|
||||
sha2.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
@@ -84,6 +84,8 @@ pub async fn login(
|
||||
session.apply_second_factor_code(code.trim()).await?;
|
||||
}
|
||||
|
||||
register_refresh_handler(&session, password.to_owned());
|
||||
|
||||
save(&session, password).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -137,6 +139,9 @@ pub fn logout() -> Result<()> {
|
||||
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||
let stored = load()?;
|
||||
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
||||
|
||||
register_refresh_handler(&session, stored.mailbox_password.clone());
|
||||
|
||||
let client = ProtonDriveClient::new(&session, stored.mailbox_password.clone().into_bytes());
|
||||
Ok((client, session))
|
||||
}
|
||||
@@ -151,3 +156,35 @@ pub async fn persist(session: &ProtonApiSession) -> Result<()> {
|
||||
let mailbox_password = load()?.mailbox_password;
|
||||
save(session, &mailbox_password).await
|
||||
}
|
||||
|
||||
fn register_refresh_handler(session: &ProtonApiSession, mailbox_password: String) {
|
||||
let session_id = session.session_id().as_str().to_owned();
|
||||
let username = session.username().to_owned();
|
||||
let user_id = session.user_id().as_str().to_owned();
|
||||
let scopes = session.scopes().to_vec();
|
||||
let password_mode = match session.password_mode() {
|
||||
PasswordMode::Single => 1,
|
||||
PasswordMode::Dual => 2,
|
||||
};
|
||||
|
||||
session.http().set_on_tokens_refreshed(move |tokens| {
|
||||
let stored = StoredSession {
|
||||
session_id: session_id.clone(),
|
||||
username: username.clone(),
|
||||
user_id: user_id.clone(),
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
scopes: scopes.clone(),
|
||||
password_mode,
|
||||
mailbox_password: mailbox_password.clone(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&stored)
|
||||
&& let Ok(entry) = keyring_entry() {
|
||||
if let Err(e) = entry.set_password(&json) {
|
||||
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring");
|
||||
} else {
|
||||
tracing::info!("successfully auto-persisted refreshed tokens in keyring");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -142,6 +142,12 @@ impl AppDirs {
|
||||
self.state_dir().join("tray.sock")
|
||||
}
|
||||
|
||||
/// The user's home directory — the root the daemon indexes for local
|
||||
/// (non-Drive) file search. `None` if it cannot be resolved.
|
||||
pub fn home_dir(&self) -> Option<PathBuf> {
|
||||
directories::UserDirs::new().map(|u| u.home_dir().to_path_buf())
|
||||
}
|
||||
|
||||
/// Default mountpoint when the user does not pass one explicitly.
|
||||
pub fn default_mountpoint(&self) -> PathBuf {
|
||||
directories::UserDirs::new()
|
||||
|
||||
@@ -63,6 +63,10 @@ pub enum Request {
|
||||
/// Full-text search node names against the daemon's local metadata index.
|
||||
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
||||
Search { query: String, limit: usize },
|
||||
/// Search the daemon's index of *local* (non-Drive) files on this machine.
|
||||
/// Independent of [`Request::Search`] so a front-end can fire both at once and
|
||||
/// render whichever lands first. Replies with [`Response::LocalResults`].
|
||||
SearchLocal { query: String, limit: usize },
|
||||
/// Rename a file or folder. `path` is mountpoint-relative; `new_name` is a
|
||||
/// single path component (no separators). Replies with [`Response::Ok`].
|
||||
Rename { path: String, new_name: String },
|
||||
@@ -162,6 +166,20 @@ pub struct SearchHit {
|
||||
pub uid: String,
|
||||
}
|
||||
|
||||
/// One hit in a [`Request::SearchLocal`] result: a file on this machine, outside
|
||||
/// Proton Drive. Unlike [`SearchHit`] there is no uid or pin state — the file is
|
||||
/// already local, so the front-end opens `path` directly.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct LocalHit {
|
||||
pub name: String,
|
||||
/// Absolute path on this machine.
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub size: u64,
|
||||
/// Modification time, epoch seconds.
|
||||
pub modified: i64,
|
||||
}
|
||||
|
||||
/// One photo in a [`Request::PhotosTimeline`] page.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PhotoItem {
|
||||
@@ -206,6 +224,10 @@ pub enum Response {
|
||||
FilePath { path: String },
|
||||
/// Full-text search results (reply to [`Request::Search`]).
|
||||
SearchResults { hits: Vec<SearchHit> },
|
||||
/// Local-file search results (reply to [`Request::SearchLocal`]). `indexing`
|
||||
/// is true while a scan of the machine is still running, so a front-end can
|
||||
/// say "still indexing" instead of "no matches" on a cold first launch.
|
||||
LocalResults { hits: Vec<LocalHit>, indexing: bool },
|
||||
/// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]).
|
||||
Transfers { items: Vec<TransferItem> },
|
||||
/// The request failed.
|
||||
|
||||
@@ -18,9 +18,10 @@ use proton_drive_rs::{Node, NodeKind};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::Result;
|
||||
use crate::localindex::LocalEntry;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
const SCHEMA_VERSION: i64 = 5;
|
||||
const SCHEMA_VERSION: i64 = 6;
|
||||
|
||||
/// Below this length the trigram tokenizer indexes nothing (it needs 3-char
|
||||
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
||||
@@ -119,6 +120,9 @@ impl Db {
|
||||
if current < 5 {
|
||||
tx.execute_batch(MIGRATION_V5)?;
|
||||
}
|
||||
if current < 6 {
|
||||
tx.execute_batch(MIGRATION_V6)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -213,9 +217,13 @@ impl Db {
|
||||
)?;
|
||||
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
|
||||
} else {
|
||||
// Quote the user input as a single FTS phrase so its characters are
|
||||
// matched literally, never parsed as FTS query syntax.
|
||||
let phrase = format!("\"{}\"", query.replace('"', "\"\""));
|
||||
// Escape double quotes and quote each term, then combine with AND so
|
||||
// all terms must match but can appear in any order or position.
|
||||
let phrase = query
|
||||
.split_whitespace()
|
||||
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT n.node_json, n.uid
|
||||
FROM nodes_fts f JOIN nodes n ON n.uid = f.uid
|
||||
@@ -522,6 +530,161 @@ impl Db {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- local (non-Drive) file index (schema v6) ----
|
||||
|
||||
/// Open a new local-index scan generation. Rows written under it survive
|
||||
/// [`local_finish_scan`](Self::local_finish_scan); rows still carrying an
|
||||
/// older generation are pruned there as "no longer on disk".
|
||||
pub fn local_begin_scan(&self) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let current: i64 = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'local_scan_gen'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let next = current + 1;
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('local_scan_gen', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
[next.to_string()],
|
||||
)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Write one batch of walked entries under scan generation `generation`. The
|
||||
/// FTS index is *not* touched here — it is rebuilt once in
|
||||
/// [`local_finish_scan`](Self::local_finish_scan).
|
||||
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
is_dir = excluded.is_dir,
|
||||
size = excluded.size,
|
||||
mtime = excluded.mtime,
|
||||
scan_gen = excluded.scan_gen",
|
||||
)?;
|
||||
for e in entries {
|
||||
stmt.execute(params![
|
||||
e.path,
|
||||
e.name,
|
||||
e.is_dir as i64,
|
||||
e.size,
|
||||
e.mtime,
|
||||
generation
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close scan generation `generation`: drop every row an older scan wrote
|
||||
/// (those paths are gone from disk), rebuild the FTS index over what remains,
|
||||
/// and stamp the completion time. Returns the number of indexed entries.
|
||||
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"DELETE FROM local_files WHERE scan_gen != ?1",
|
||||
params![generation],
|
||||
)?;
|
||||
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
[finished_at.to_string()],
|
||||
)?;
|
||||
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
|
||||
tx.commit()?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// When the last local scan completed (epoch seconds), or `None` if the index
|
||||
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||
/// needs an immediate rescan or can serve the existing index.
|
||||
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?
|
||||
.and_then(|v| v.parse().ok()))
|
||||
}
|
||||
|
||||
/// Substring search over indexed local file names, newest-modified first
|
||||
/// within a relevance tier. Mirrors [`search`](Self::search): the trigram
|
||||
/// index handles queries of at least [`TRIGRAM_MIN`] chars, shorter ones fall
|
||||
/// back to a `LIKE` scan.
|
||||
pub fn search_local(&self, query: &str, limit: usize) -> Result<Vec<LocalFileHit>> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt;
|
||||
let rows = if query.chars().count() < TRIGRAM_MIN {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
stmt = conn.prepare(
|
||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||
WHERE name LIKE ?1 ESCAPE '\\'
|
||||
ORDER BY mtime DESC LIMIT ?2",
|
||||
)?;
|
||||
stmt.query_map(params![pat, limit as i64], local_hit)?
|
||||
} else {
|
||||
let phrase = query
|
||||
.split_whitespace()
|
||||
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
stmt = conn.prepare(
|
||||
"SELECT f.path, f.name, f.is_dir, f.size, f.mtime
|
||||
FROM local_fts x JOIN local_files f ON f.id = x.rowid
|
||||
WHERE x.name MATCH ?1
|
||||
ORDER BY x.rank LIMIT ?2",
|
||||
)?;
|
||||
stmt.query_map(params![phrase, limit as i64], local_hit)?
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// One hit from [`Db::search_local`]: an indexed file on the machine itself, not
|
||||
/// in Drive. `path` is absolute.
|
||||
pub struct LocalFileHit {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: i64,
|
||||
pub mtime: i64,
|
||||
}
|
||||
|
||||
/// Row mapper for [`Db::search_local`]'s two query paths, which select the same
|
||||
/// columns in the same order.
|
||||
fn local_hit(row: &rusqlite::Row) -> rusqlite::Result<LocalFileHit> {
|
||||
Ok(LocalFileHit {
|
||||
path: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
is_dir: row.get::<_, i64>(2)? != 0,
|
||||
size: row.get(3)?,
|
||||
mtime: row.get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Row mapper for the `(node_json, uid)` pairs both search paths return.
|
||||
@@ -660,6 +823,32 @@ CREATE TABLE pins (
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v6: the index of *local* (non-Drive) files, so the launcher prompt can
|
||||
/// search the machine alongside Drive. `local_files` is keyed by absolute path;
|
||||
/// `scan_gen` stamps the scan that last saw a row, so a rescan prunes vanished
|
||||
/// files with one `DELETE` instead of diffing.
|
||||
///
|
||||
/// `local_fts` is an *external-content* FTS5 index over `local_files.name`: the
|
||||
/// text lives once in the base table and the index is rebuilt in bulk at the end
|
||||
/// of a scan (`INSERT INTO local_fts(local_fts) VALUES('rebuild')`). That is far
|
||||
/// cheaper than the delete-then-insert-per-row dance `nodes_fts` needs, because
|
||||
/// a scan rewrites most rows at once rather than trickling single updates.
|
||||
const MIGRATION_V6: &str = "
|
||||
CREATE TABLE local_files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
mtime INTEGER NOT NULL DEFAULT 0,
|
||||
scan_gen INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE local_fts USING fts5(
|
||||
name, content='local_files', content_rowid='id', tokenize='trigram'
|
||||
);
|
||||
";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -784,6 +973,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry {
|
||||
LocalEntry {
|
||||
path: path.into(),
|
||||
name: name.into(),
|
||||
is_dir,
|
||||
size: 10,
|
||||
mtime: 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// A local scan is searchable by substring, and a *later* scan prunes the
|
||||
/// paths it no longer sees — including out of the FTS index, so a deleted
|
||||
/// file cannot keep surfacing in the prompt.
|
||||
#[test]
|
||||
fn local_scan_indexes_then_prunes_stale_paths() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
let gen1 = db.local_begin_scan().unwrap();
|
||||
db.local_upsert_batch(
|
||||
gen1,
|
||||
&[
|
||||
local("/home/u/docs/report.pdf", "report.pdf", false),
|
||||
local("/home/u/docs/notes.md", "notes.md", false),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(db.local_finish_scan(gen1, 1_000).unwrap(), 2);
|
||||
|
||||
// Trigram index gives substring (not just prefix) matches.
|
||||
let hits = db.search_local("port", 10).unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].path, "/home/u/docs/report.pdf");
|
||||
assert!(!hits[0].is_dir);
|
||||
assert_eq!(db.local_indexed_at().unwrap(), Some(1_000));
|
||||
|
||||
// Second scan sees only notes.md → report.pdf is gone from disk.
|
||||
let gen2 = db.local_begin_scan().unwrap();
|
||||
db.local_upsert_batch(gen2, &[local("/home/u/docs/notes.md", "notes.md", false)])
|
||||
.unwrap();
|
||||
assert_eq!(db.local_finish_scan(gen2, 2_000).unwrap(), 1);
|
||||
assert!(db.search_local("report", 10).unwrap().is_empty());
|
||||
assert_eq!(db.search_local("notes", 10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// Queries below the trigram minimum still match, via the `LIKE` fallback.
|
||||
#[test]
|
||||
fn local_search_short_query_like_fallback() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let g = db.local_begin_scan().unwrap();
|
||||
db.local_upsert_batch(g, &[local("/home/u/a.txt", "a.txt", false)])
|
||||
.unwrap();
|
||||
db.local_finish_scan(g, 1).unwrap();
|
||||
assert_eq!(db.search_local("a", 10).unwrap().len(), 1);
|
||||
assert!(db.search_local("", 10).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_cursor_roundtrip() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -805,6 +1050,13 @@ mod tests {
|
||||
db.upsert_node(&file("f1", "docs", "report.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f2", "root", "notes.txt", 2)).unwrap();
|
||||
db.upsert_node(&file(
|
||||
"f3",
|
||||
"root",
|
||||
"Rampage Open Air 2026 - order 166765244.pdf",
|
||||
3,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Substring match (trigram), not just prefix.
|
||||
let hits = db.search("port", 10).unwrap();
|
||||
@@ -816,6 +1068,14 @@ mod tests {
|
||||
// Top-level file → bare name.
|
||||
let hits = db.search("notes", 10).unwrap();
|
||||
assert_eq!(hits[0].path, "notes.txt");
|
||||
|
||||
// Multi-term FTS5 query matching (out of order, separated terms)
|
||||
let hits = db.search("rampage 2026", 10).unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(
|
||||
hits[0].node.name,
|
||||
"Rampage Open Air 2026 - order 166765244.pdf"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod config;
|
||||
pub mod control;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod localindex;
|
||||
pub mod service;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Filesystem walker behind the daemon's index of *local* (non-Drive) files.
|
||||
//!
|
||||
//! The launcher prompt searches Proton Drive and the machine's own files side by
|
||||
//! side. Drive names come from the `nodes` FTS index; local names come from the
|
||||
//! `local_files` index this module feeds. The daemon runs [`scan`] on a
|
||||
//! background thread — never on a FUSE or control-socket thread — and streams
|
||||
//! batches into [`crate::db::Db::local_upsert_batch`].
|
||||
//!
|
||||
//! Walking is deliberately shallow on cost: we stat each entry once (the walker
|
||||
//! already has the `DirEntry` metadata) and skip the directories that dominate a
|
||||
//! home directory's inode count without ever being interesting to search
|
||||
//! (`node_modules`, `target`, caches, VCS internals). The Drive mountpoint is
|
||||
//! always excluded — walking it would fault in every remote node through FUSE.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use ignore::{WalkBuilder, WalkState};
|
||||
|
||||
/// Directory names skipped anywhere in the tree. These hold build artefacts,
|
||||
/// dependency trees, and caches: high inode count, no search value.
|
||||
const SKIP_DIRS: &[&str] = &[
|
||||
"node_modules",
|
||||
"target",
|
||||
"__pycache__",
|
||||
"venv",
|
||||
".venv",
|
||||
"vendor",
|
||||
"dist-newstyle",
|
||||
"Trash",
|
||||
];
|
||||
|
||||
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
||||
/// database without bound. Reaching it stops the walk early.
|
||||
const MAX_ENTRIES: usize = 400_000;
|
||||
|
||||
/// Rows handed to the sink at a time. Large enough that the per-transaction cost
|
||||
/// amortises, small enough that the writer lock is released often (FUSE
|
||||
/// write-through shares the same connection).
|
||||
const BATCH: usize = 2_000;
|
||||
|
||||
/// One indexed local file or directory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalEntry {
|
||||
/// Absolute path.
|
||||
pub path: String,
|
||||
/// Final path component.
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
/// Size in bytes (0 for directories).
|
||||
pub size: i64,
|
||||
/// Modification time, epoch seconds.
|
||||
pub mtime: i64,
|
||||
}
|
||||
|
||||
/// Walk `roots` in parallel, calling `sink` with batches of entries.
|
||||
///
|
||||
/// Hidden files and anything under `excludes` (the Drive mountpoint, our own
|
||||
/// state/cache dirs) are skipped, as are the [`SKIP_DIRS`] names at any depth.
|
||||
/// Symlinks are not followed, so a link loop cannot hang the scan. Returns the
|
||||
/// number of entries handed to `sink`.
|
||||
///
|
||||
/// `sink` is called from the walker's worker threads, serialized by an internal
|
||||
/// mutex; it must not block for long.
|
||||
pub fn scan(
|
||||
roots: &[PathBuf],
|
||||
excludes: &[PathBuf],
|
||||
sink: impl FnMut(Vec<LocalEntry>) + Send,
|
||||
) -> usize {
|
||||
let Some((first, rest)) = roots.split_first() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let mut builder = WalkBuilder::new(first);
|
||||
for root in rest {
|
||||
builder.add(root);
|
||||
}
|
||||
let excludes: Vec<PathBuf> = excludes.to_vec();
|
||||
builder
|
||||
// `standard_filters` would honour .gitignore/.ignore files: a source
|
||||
// tree's ignored-but-present files are still files the user may want to
|
||||
// find, so only the hidden filter stays on.
|
||||
.standard_filters(false)
|
||||
.hidden(true)
|
||||
.follow_links(false)
|
||||
.same_file_system(true)
|
||||
.threads(std::thread::available_parallelism().map_or(4, |n| n.get().min(8)))
|
||||
.filter_entry(move |entry| {
|
||||
let path = entry.path();
|
||||
if excludes.iter().any(|e| path.starts_with(e)) {
|
||||
return false;
|
||||
}
|
||||
!matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name))
|
||||
});
|
||||
|
||||
let state = std::sync::Mutex::new(SinkState {
|
||||
buf: Vec::with_capacity(BATCH),
|
||||
total: 0,
|
||||
sink,
|
||||
});
|
||||
|
||||
builder.build_parallel().run(|| {
|
||||
Box::new(|result| {
|
||||
let Ok(entry) = result else {
|
||||
return WalkState::Continue;
|
||||
};
|
||||
// Depth 0 is a root itself; indexing it adds nothing to search.
|
||||
if entry.depth() == 0 {
|
||||
return WalkState::Continue;
|
||||
}
|
||||
let Some(local) = to_entry(&entry) else {
|
||||
return WalkState::Continue;
|
||||
};
|
||||
|
||||
let mut state = state.lock().unwrap();
|
||||
state.push(local);
|
||||
if state.total >= MAX_ENTRIES {
|
||||
return WalkState::Quit;
|
||||
}
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
|
||||
let mut state = state.into_inner().unwrap();
|
||||
state.flush();
|
||||
state.total
|
||||
}
|
||||
|
||||
/// Batching buffer shared by the walker's worker threads.
|
||||
struct SinkState<F: FnMut(Vec<LocalEntry>)> {
|
||||
buf: Vec<LocalEntry>,
|
||||
total: usize,
|
||||
sink: F,
|
||||
}
|
||||
|
||||
impl<F: FnMut(Vec<LocalEntry>)> SinkState<F> {
|
||||
fn push(&mut self, entry: LocalEntry) {
|
||||
self.buf.push(entry);
|
||||
self.total += 1;
|
||||
if self.buf.len() >= BATCH {
|
||||
self.flush();
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
if !self.buf.is_empty() {
|
||||
let batch = std::mem::take(&mut self.buf);
|
||||
(self.sink)(batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
|
||||
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
|
||||
/// JSON wire format) is UTF-8 only.
|
||||
fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
|
||||
let path = entry.path().to_str()?.to_string();
|
||||
let name = entry.file_name().to_str()?.to_string();
|
||||
let meta = entry.metadata().ok()?;
|
||||
let is_dir = meta.is_dir();
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
Some(LocalEntry {
|
||||
path,
|
||||
name,
|
||||
is_dir,
|
||||
size: if is_dir { 0 } else { meta.len() as i64 },
|
||||
mtime,
|
||||
})
|
||||
}
|
||||
|
||||
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
||||
/// would fault every remote node in through FUSE) plus our own state and cache
|
||||
/// dirs, which hold blobs no user searches for by name.
|
||||
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
mountpoint.to_path_buf(),
|
||||
state_dir.to_path_buf(),
|
||||
cache_dir.to_path_buf(),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A scan indexes plain files, skips the excluded subtree and the junk dirs,
|
||||
/// and reports names/paths the search index can match on.
|
||||
#[test]
|
||||
fn scan_indexes_files_and_honours_excludes() {
|
||||
let tmp = std::env::temp_dir().join(format!("pdfs-scan-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("docs")).unwrap();
|
||||
std::fs::create_dir_all(tmp.join("node_modules/pkg")).unwrap();
|
||||
std::fs::create_dir_all(tmp.join("mnt")).unwrap();
|
||||
std::fs::write(tmp.join("docs/report.pdf"), b"x").unwrap();
|
||||
std::fs::write(tmp.join("node_modules/pkg/index.js"), b"x").unwrap();
|
||||
std::fs::write(tmp.join("mnt/remote.txt"), b"x").unwrap();
|
||||
|
||||
let mut got = Vec::new();
|
||||
let n = scan(std::slice::from_ref(&tmp), &[tmp.join("mnt")], |batch| {
|
||||
got.extend(batch)
|
||||
});
|
||||
|
||||
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(n, got.len());
|
||||
assert!(names.contains(&"report.pdf"));
|
||||
assert!(names.contains(&"docs"));
|
||||
// Junk dir pruned, mountpoint-style exclude pruned.
|
||||
assert!(!names.contains(&"index.js"));
|
||||
assert!(!names.contains(&"node_modules"));
|
||||
assert!(!names.contains(&"remote.txt"));
|
||||
|
||||
let report = got.iter().find(|e| e.name == "report.pdf").unwrap();
|
||||
assert!(!report.is_dir);
|
||||
assert_eq!(report.size, 1);
|
||||
assert!(report.path.ends_with("docs/report.pdf"));
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ use std::io::{BufRead, BufReader, Write as _};
|
||||
use std::os::unix::fs::FileExt;
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -51,10 +52,11 @@ use fuser::{
|
||||
use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit,
|
||||
DirEntry, LocalHit, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit,
|
||||
TransferDirection,
|
||||
};
|
||||
use pdfs_core::db::{Db, StoredNode};
|
||||
use pdfs_core::localindex;
|
||||
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{
|
||||
@@ -78,6 +80,15 @@ const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
/// in [`Core`] and serve every page from memory until it goes stale.
|
||||
const TIMELINE_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How stale the local-file index may get before the background scanner rebuilds
|
||||
/// it. A rescan is a full walk of `$HOME`, so this trades index freshness against
|
||||
/// disk churn; the scanner also always rebuilds once per daemon start when the
|
||||
/// index is older than this.
|
||||
const LOCAL_INDEX_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// How often the scanner thread wakes to check whether the index went stale.
|
||||
const LOCAL_INDEX_CHECK: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The FUSE root inode is always 1.
|
||||
const ROOT_INO: u64 = 1;
|
||||
|
||||
@@ -301,6 +312,9 @@ struct Core {
|
||||
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
|
||||
/// across the FUSE session and the control-socket task.
|
||||
transfers: Arc<TransferRegistry>,
|
||||
/// True while the background scanner is rebuilding the local-file index, so
|
||||
/// `SearchLocal` can tell a front-end "still indexing" apart from "no match".
|
||||
indexing: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// The whole photos timeline plus when it was fetched, for TTL freshness.
|
||||
@@ -1001,6 +1015,26 @@ impl Core {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Search the index of files on this machine (outside Drive), built by the
|
||||
/// background scanner in [`run_local_index`]. Pure local lookup, never hits
|
||||
/// the network — and never touches the FUSE mount, which the scanner excludes.
|
||||
fn search_local(&self, query: &str, limit: usize) -> Result<Vec<LocalHit>, String> {
|
||||
let hits = self
|
||||
.db
|
||||
.search_local(query, limit)
|
||||
.map_err(|e| format!("local search: {e}"))?;
|
||||
Ok(hits
|
||||
.into_iter()
|
||||
.map(|h| LocalHit {
|
||||
name: h.name,
|
||||
path: h.path,
|
||||
is_dir: h.is_dir,
|
||||
size: h.size.max(0) as u64,
|
||||
modified: h.mtime,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A page of the photos timeline (newest first), with the page's thumbnails
|
||||
/// fetched into the cache and their paths attached. `Ok(None)` when the
|
||||
/// account has no photos volume.
|
||||
@@ -2293,6 +2327,73 @@ async fn run_event_sync(
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the local-file index fresh for the launcher prompt's "This computer"
|
||||
/// results. Rebuilds the index whenever it is older than [`LOCAL_INDEX_TTL`],
|
||||
/// then sleeps; runs on its own thread for the life of the daemon.
|
||||
///
|
||||
/// The walk is the one part of the daemon that touches the wider filesystem, so
|
||||
/// it is deliberately kept off every hot path: it never runs on a FUSE or
|
||||
/// control-socket thread, and it excludes the mountpoint (walking it would fault
|
||||
/// every remote node in through FUSE, defeating on-demand hydration).
|
||||
fn run_local_index(db: Arc<Db>, indexing: Arc<AtomicBool>, mountpoint: PathBuf) {
|
||||
loop {
|
||||
let age = db.local_indexed_at().ok().flatten();
|
||||
let stale =
|
||||
age.is_none_or(|at| now_secs().saturating_sub(at) >= LOCAL_INDEX_TTL.as_secs() as i64);
|
||||
if stale {
|
||||
scan_local_once(&db, &indexing, &mountpoint);
|
||||
}
|
||||
std::thread::sleep(LOCAL_INDEX_CHECK);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `$HOME` once and replace the local-file index with what it finds.
|
||||
/// Batches stream straight into SQLite, so peak memory is one batch — not the
|
||||
/// whole home directory.
|
||||
fn scan_local_once(db: &Db, indexing: &AtomicBool, mountpoint: &Path) {
|
||||
let dirs = match AppDirs::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "local index: cannot resolve app dirs");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(home) = dirs.home_dir() else {
|
||||
warn!("local index: cannot resolve home directory");
|
||||
return;
|
||||
};
|
||||
let generation = match db.local_begin_scan() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "local index: cannot open scan generation");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
||||
indexing.store(true, Ordering::Relaxed);
|
||||
let started = Instant::now();
|
||||
|
||||
let walked = localindex::scan(&[home], &excludes, |batch| {
|
||||
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
||||
warn!(error = %e, "local index: batch write failed");
|
||||
}
|
||||
});
|
||||
|
||||
// Prune what this scan did not see and rebuild the FTS index over the rest,
|
||||
// even if some batches failed — a partial index still beats none.
|
||||
match db.local_finish_scan(generation, now_secs()) {
|
||||
Ok(indexed) => info!(
|
||||
walked,
|
||||
indexed,
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"local index rebuilt"
|
||||
),
|
||||
Err(e) => warn!(error = %e, "local index: finish failed"),
|
||||
}
|
||||
indexing.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||
/// the mount root.
|
||||
@@ -2436,6 +2537,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||
Err(e) => CtlResponse::Error { message: e },
|
||||
},
|
||||
Ok(CtlRequest::SearchLocal { query, limit }) => match core.search_local(&query, limit) {
|
||||
Ok(hits) => CtlResponse::LocalResults {
|
||||
hits,
|
||||
indexing: core.indexing.load(Ordering::Relaxed),
|
||||
},
|
||||
Err(e) => CtlResponse::Error { message: e },
|
||||
},
|
||||
Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.rename(&rel, &new_name) {
|
||||
Ok(name) => CtlResponse::Ok {
|
||||
@@ -2629,8 +2737,20 @@ pub fn mount(
|
||||
db,
|
||||
timeline: Arc::new(Mutex::new(None)),
|
||||
transfers: TransferRegistry::new(),
|
||||
indexing: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
||||
{
|
||||
let db = core.db.clone();
|
||||
let indexing = core.indexing.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-localindex".into())
|
||||
.spawn(move || run_local_index(db, indexing, mountpoint))?;
|
||||
}
|
||||
|
||||
let mut config = Config::default();
|
||||
config.mount_options = vec![
|
||||
MountOption::FSName("protondrive".to_string()),
|
||||
|
||||
@@ -28,3 +28,4 @@ ksni = "0.2"
|
||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
||||
async-channel = "2"
|
||||
exif = { version = "0.6", package = "kamadak-exif" }
|
||||
@@ -55,6 +55,13 @@ const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250);
|
||||
/// Cap on search hits requested from the daemon.
|
||||
const SEARCH_LIMIT: usize = 200;
|
||||
|
||||
/// Gallery thumbnail height in px: the zoom range, its default, and the step one
|
||||
/// Ctrl+scroll notch (or Ctrl+±) moves it by.
|
||||
const TILE_MIN: i32 = 90;
|
||||
const TILE_MAX: i32 = 300;
|
||||
const TILE_DEFAULT: i32 = 150;
|
||||
const TILE_STEP: i32 = 30;
|
||||
|
||||
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
|
||||
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
|
||||
struct PinRow {
|
||||
@@ -154,7 +161,15 @@ struct Ui {
|
||||
/// only the last pause actually fires a [`Request::Search`].
|
||||
search_source: RefCell<Option<glib::SourceId>>,
|
||||
// Photos (gallery) page.
|
||||
/// Every photo loaded so far, newest first — the order the lightbox's
|
||||
/// prev/next walks. The visible day sections are derived from this.
|
||||
gallery_model: gio::ListStore,
|
||||
/// The day sections rendered by the Photos ListView, rebuilt from
|
||||
/// [`Self::gallery_model`] by [`repaint_gallery`].
|
||||
gallery_groups: gio::ListStore,
|
||||
/// Thumbnail height in px, retuned by Ctrl+scroll / Ctrl+± (see
|
||||
/// [`zoom_gallery`]). Widths follow each photo's own aspect ratio.
|
||||
gallery_tile: Cell<i32>,
|
||||
gallery_status: gtk4::Label,
|
||||
gallery_retry: gtk4::Button,
|
||||
gallery_more: gtk4::Button,
|
||||
@@ -228,6 +243,8 @@ fn load_proton_theme() {
|
||||
.viewer-nav-btn {{ background-color: rgba(0, 0, 0, 0.5); color: white; margin: 24px; padding: 16px; border-radius: 50%; }}\n\
|
||||
.viewer-nav-btn:hover {{ background-color: rgba(0, 0, 0, 0.8); color: white; }}\n\
|
||||
.viewer-status {{ color: #ff5555; font-size: 1.1rem; background-color: rgba(0, 0, 0, 0.8); padding: 12px 24px; border-radius: 8px; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }}\n\
|
||||
.viewer-info-panel {{ background-color: rgba(0, 0, 0, 0.75); color: white; }}\n\
|
||||
.viewer-info-panel label {{ color: white; }}\n\
|
||||
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
|
||||
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n"
|
||||
);
|
||||
@@ -317,11 +334,13 @@ fn build_window(app: &adw::Application) {
|
||||
browser_path: RefCell::new(String::new()),
|
||||
browser_search: browser_widgets.7,
|
||||
search_source: RefCell::new(None),
|
||||
gallery_model: gallery_widgets.0,
|
||||
gallery_status: gallery_widgets.1,
|
||||
gallery_retry: gallery_widgets.4,
|
||||
gallery_more: gallery_widgets.2,
|
||||
gallery_upload: gallery_widgets.5,
|
||||
gallery_model: gallery_widgets.model.clone(),
|
||||
gallery_groups: gallery_widgets.groups.clone(),
|
||||
gallery_tile: Cell::new(TILE_DEFAULT),
|
||||
gallery_status: gallery_widgets.status.clone(),
|
||||
gallery_retry: gallery_widgets.retry.clone(),
|
||||
gallery_more: gallery_widgets.more.clone(),
|
||||
gallery_upload: gallery_widgets.upload.clone(),
|
||||
});
|
||||
|
||||
wire_login(&ui);
|
||||
@@ -334,7 +353,7 @@ fn build_window(app: &adw::Application) {
|
||||
wire_browser(&ui, &browser_widgets.4, &browser_widgets.5);
|
||||
wire_browser_actions(&ui, &browser_widgets.8, &browser_widgets.9);
|
||||
wire_search(&ui);
|
||||
wire_gallery(&ui, &gallery_widgets.3);
|
||||
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
|
||||
wire_retry(&ui);
|
||||
|
||||
// Lazily load the Files / Photos pages the first time they're shown, so the
|
||||
@@ -2295,54 +2314,48 @@ fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Photos page: a [`gtk4::GridView`] of thumbnails backed by a
|
||||
/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`PhotoItem`]s, plus a
|
||||
/// "Load more" button and a status label.
|
||||
fn build_gallery_page() -> (
|
||||
gtk4::Widget,
|
||||
(
|
||||
gio::ListStore,
|
||||
gtk4::Label,
|
||||
gtk4::Button,
|
||||
gtk4::GridView,
|
||||
gtk4::Button,
|
||||
gtk4::Button, // Upload Photo
|
||||
),
|
||||
) {
|
||||
/// One day-section of the photos timeline: a heading plus the photos captured
|
||||
/// that day, in timeline order. Built from the flat [`Ui::gallery_model`] by
|
||||
/// [`group_photos`] and rendered as one [`gtk4::ListView`] row.
|
||||
struct PhotoGroup {
|
||||
/// "Today", "Yesterday", or e.g. "3 June 2026".
|
||||
heading: String,
|
||||
photos: Vec<PhotoItem>,
|
||||
}
|
||||
|
||||
/// The widgets [`build_gallery_page`] hands back to [`build_window`].
|
||||
struct GalleryWidgets {
|
||||
/// Flat, newest-first list of every loaded photo. Backs the lightbox's
|
||||
/// prev/next navigation; the visible sections are derived from it.
|
||||
model: gio::ListStore,
|
||||
/// Day sections rendered by the ListView, derived from `model`.
|
||||
groups: gio::ListStore,
|
||||
status: gtk4::Label,
|
||||
more: gtk4::Button,
|
||||
list: gtk4::ListView,
|
||||
scroll: gtk4::ScrolledWindow,
|
||||
retry: gtk4::Button,
|
||||
upload: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Photos page: a [`gtk4::ListView`] of day sections, each a heading over a
|
||||
/// [`gtk4::FlowBox`] collage of that day's thumbnails at their original aspect
|
||||
/// ratios, plus a "Load more" button and a status label.
|
||||
///
|
||||
/// A ListView of sections rather than one flat GridView because GTK's grid has no
|
||||
/// row headers and forces square cells: the collage and the date headings both
|
||||
/// need per-row structure. The factory is installed by [`wire_gallery`], which has
|
||||
/// the [`Ui`] the tiles need (zoom level, click-to-open).
|
||||
fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||
let groups = gio::ListStore::new::<BoxedAnyObject>();
|
||||
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.width_request(150)
|
||||
.height_request(150)
|
||||
.build();
|
||||
picture.add_css_class("card");
|
||||
item.set_child(Some(&picture));
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let picture = item.child().and_downcast::<gtk4::Picture>().unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let photo = obj.borrow::<PhotoItem>();
|
||||
match photo.thumb_path.as_deref() {
|
||||
Some(p) => match gtk4::gdk::Texture::from_filename(p) {
|
||||
Ok(texture) => picture.set_paintable(Some(&texture)),
|
||||
Err(_) => picture.set_paintable(gtk4::gdk::Paintable::NONE),
|
||||
},
|
||||
None => picture.set_paintable(gtk4::gdk::Paintable::NONE),
|
||||
}
|
||||
});
|
||||
|
||||
let selection = gtk4::NoSelection::new(Some(model.clone()));
|
||||
let grid = gtk4::GridView::builder()
|
||||
let selection = gtk4::NoSelection::new(Some(groups.clone()));
|
||||
let list = gtk4::ListView::builder()
|
||||
.model(&selection)
|
||||
.factory(&factory)
|
||||
.min_columns(2)
|
||||
.max_columns(6)
|
||||
.single_click_activate(false)
|
||||
.build();
|
||||
list.add_css_class("gallery-sections");
|
||||
|
||||
let status = gtk4::Label::builder().wrap(true).build();
|
||||
status.add_css_class("dim-label");
|
||||
@@ -2365,7 +2378,7 @@ fn build_gallery_page() -> (
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&grid)
|
||||
.child(&list)
|
||||
.build();
|
||||
|
||||
// Modern header: "Photos" title + "Upload Photo" action button
|
||||
@@ -2398,24 +2411,93 @@ fn build_gallery_page() -> (
|
||||
inner.append(&scroll);
|
||||
inner.append(&more);
|
||||
|
||||
(inner.upcast(), (model, status, more, grid, retry, upload))
|
||||
(
|
||||
inner.upcast(),
|
||||
GalleryWidgets {
|
||||
model,
|
||||
groups,
|
||||
status,
|
||||
more,
|
||||
list,
|
||||
scroll,
|
||||
retry,
|
||||
upload,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Wire the gallery: activating a tile downloads the photo and opens it in our
|
||||
/// in-app lightbox viewer; the "Load more" button appends the next page.
|
||||
fn wire_gallery(ui: &Rc<Ui>, grid: >k4::GridView) {
|
||||
let ui_open = ui.clone();
|
||||
grid.connect_activate(move |grid, pos| {
|
||||
let Some(obj) = grid.model().and_then(|m| m.item(pos)) else {
|
||||
return;
|
||||
};
|
||||
let uid = obj
|
||||
.downcast_ref::<BoxedAnyObject>()
|
||||
.map(|o| o.borrow::<PhotoItem>().uid.clone());
|
||||
let Some(uid) = uid else { return };
|
||||
open_photo_viewer(&ui_open, uid);
|
||||
/// Wire the gallery: install the section factory, the zoom gestures, the pager
|
||||
/// and the upload button. Activating a thumbnail downloads the photo and opens it
|
||||
/// in the in-app lightbox.
|
||||
fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::ScrolledWindow) {
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||
section.set_margin_bottom(12);
|
||||
let heading = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
heading.add_css_class("heading");
|
||||
section.append(&heading);
|
||||
item.set_child(Some(§ion));
|
||||
item.set_activatable(false);
|
||||
});
|
||||
|
||||
let ui_bind = ui.clone();
|
||||
factory.connect_bind(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let section = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let group = obj.borrow::<PhotoGroup>();
|
||||
|
||||
let heading = section.first_child().and_downcast::<gtk4::Label>().unwrap();
|
||||
heading.set_label(&group.heading);
|
||||
|
||||
// Drop the previous day's collage and build this one's. The FlowBox is
|
||||
// rebuilt (not repopulated) so its child-activated handler closes over the
|
||||
// photos it actually shows — ListView recycles the row widget itself.
|
||||
while let Some(old) = heading.next_sibling() {
|
||||
section.remove(&old);
|
||||
}
|
||||
section.append(&photo_collage(&ui_bind, &group.photos));
|
||||
});
|
||||
list.set_factory(Some(&factory));
|
||||
|
||||
// Ctrl+scroll zoom. Capture phase so the ScrolledWindow doesn't eat the event
|
||||
// and scroll the page out from under the gesture.
|
||||
let zoom_scroll = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
||||
zoom_scroll.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||
let ui_zoom = ui.clone();
|
||||
zoom_scroll.connect_scroll(move |controller, _dx, dy| {
|
||||
if !controller
|
||||
.current_event_state()
|
||||
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
|
||||
|| dy == 0.0
|
||||
{
|
||||
return glib::Propagation::Proceed;
|
||||
}
|
||||
// Scroll up (negative dy) zooms in, i.e. bigger tiles.
|
||||
zoom_gallery(&ui_zoom, if dy < 0.0 { TILE_STEP } else { -TILE_STEP });
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
scroll.add_controller(zoom_scroll);
|
||||
|
||||
// Ctrl+plus / Ctrl+minus / Ctrl+0, the keyboard equivalents.
|
||||
let zoom_keys = gtk4::EventControllerKey::new();
|
||||
let ui_keys = ui.clone();
|
||||
zoom_keys.connect_key_pressed(move |_, key, _code, state| {
|
||||
if !state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) {
|
||||
return glib::Propagation::Proceed;
|
||||
}
|
||||
match key.name().as_deref() {
|
||||
Some("plus" | "equal" | "KP_Add") => zoom_gallery(&ui_keys, TILE_STEP),
|
||||
Some("minus" | "KP_Subtract") => zoom_gallery(&ui_keys, -TILE_STEP),
|
||||
Some("0" | "KP_0") => set_gallery_tile(&ui_keys, TILE_DEFAULT),
|
||||
_ => return glib::Propagation::Proceed,
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
list.add_controller(zoom_keys);
|
||||
|
||||
let ui_more = ui.clone();
|
||||
ui.gallery_more.clone().connect_clicked(move |_| {
|
||||
load_gallery(&ui_more, true);
|
||||
@@ -2507,6 +2589,125 @@ fn wire_gallery(ui: &Rc<Ui>, grid: >k4::GridView) {
|
||||
});
|
||||
}
|
||||
|
||||
/// One day's thumbnails as a masonry collage: a [`gtk4::FlowBox`] of pictures all
|
||||
/// [`Ui::gallery_tile`] tall and as wide as their own aspect ratio makes them, so
|
||||
/// rows justify like a phone gallery instead of square-cropping every photo.
|
||||
fn photo_collage(ui: &Rc<Ui>, photos: &[PhotoItem]) -> gtk4::FlowBox {
|
||||
let tile = ui.gallery_tile.get();
|
||||
let flow = gtk4::FlowBox::builder()
|
||||
.selection_mode(gtk4::SelectionMode::None)
|
||||
.homogeneous(false)
|
||||
.row_spacing(6)
|
||||
.column_spacing(6)
|
||||
.min_children_per_line(1)
|
||||
.max_children_per_line(64)
|
||||
.build();
|
||||
|
||||
for photo in photos {
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.build();
|
||||
picture.add_css_class("card");
|
||||
|
||||
// Width follows the thumbnail's own aspect ratio, clamped so a panorama
|
||||
// can't take a whole row and a tall portrait still stays clickable.
|
||||
let mut width = tile;
|
||||
if let Some(path) = photo.thumb_path.as_deref()
|
||||
&& let Ok(texture) = gtk4::gdk::Texture::from_filename(path)
|
||||
{
|
||||
if texture.height() > 0 {
|
||||
let ratio = f64::from(texture.width()) / f64::from(texture.height());
|
||||
width = (f64::from(tile) * ratio).round() as i32;
|
||||
}
|
||||
picture.set_paintable(Some(&texture));
|
||||
}
|
||||
picture.set_height_request(tile);
|
||||
picture.set_width_request(width.clamp(tile / 2, tile * 3));
|
||||
|
||||
flow.append(>k4::FlowBoxChild::builder().child(&picture).build());
|
||||
}
|
||||
|
||||
let ui_activate = ui.clone();
|
||||
let photos: Vec<PhotoItem> = photos.to_vec();
|
||||
flow.connect_child_activated(move |_, child| {
|
||||
if let Some(photo) = photos.get(child.index() as usize) {
|
||||
open_photo_viewer(&ui_activate, photo.uid.clone());
|
||||
}
|
||||
});
|
||||
|
||||
flow
|
||||
}
|
||||
|
||||
/// Step the thumbnail size by `delta` px and repaint, clamped to the zoom range.
|
||||
fn zoom_gallery(ui: &Rc<Ui>, delta: i32) {
|
||||
set_gallery_tile(ui, ui.gallery_tile.get() + delta);
|
||||
}
|
||||
|
||||
/// Set the thumbnail size (clamped) and rebuild the sections at the new size.
|
||||
fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
|
||||
let tile = tile.clamp(TILE_MIN, TILE_MAX);
|
||||
if tile == ui.gallery_tile.get() {
|
||||
return;
|
||||
}
|
||||
ui.gallery_tile.set(tile);
|
||||
repaint_gallery(ui);
|
||||
}
|
||||
|
||||
/// Rebuild the day sections from the flat photo model. The timeline arrives
|
||||
/// newest-first, so photos of the same day are already contiguous — one pass
|
||||
/// splits them.
|
||||
fn repaint_gallery(ui: &Rc<Ui>) {
|
||||
ui.gallery_groups.remove_all();
|
||||
for group in group_photos(&ui.gallery_model) {
|
||||
ui.gallery_groups.append(&BoxedAnyObject::new(group));
|
||||
}
|
||||
}
|
||||
|
||||
fn group_photos(model: &gio::ListStore) -> Vec<PhotoGroup> {
|
||||
let mut groups: Vec<PhotoGroup> = Vec::new();
|
||||
for i in 0..model.n_items() {
|
||||
let Some(obj) = model.item(i) else { continue };
|
||||
let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() else {
|
||||
continue;
|
||||
};
|
||||
let photo = boxed.borrow::<PhotoItem>().clone();
|
||||
let heading = day_heading(photo.capture_time);
|
||||
match groups.last_mut() {
|
||||
Some(group) if group.heading == heading => group.photos.push(photo),
|
||||
_ => groups.push(PhotoGroup {
|
||||
heading,
|
||||
photos: vec![photo],
|
||||
}),
|
||||
}
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Section heading for a capture time: "Today", "Yesterday", or the local date.
|
||||
fn day_heading(secs: i64) -> String {
|
||||
let Ok(date) = glib::DateTime::from_unix_local(secs) else {
|
||||
return "Unknown date".into();
|
||||
};
|
||||
let same_day = |other: &glib::DateTime| {
|
||||
other.year() == date.year()
|
||||
&& other.month() == date.month()
|
||||
&& other.day_of_month() == date.day_of_month()
|
||||
};
|
||||
if let Ok(now) = glib::DateTime::now_local() {
|
||||
if same_day(&now) {
|
||||
return "Today".into();
|
||||
}
|
||||
if let Ok(yesterday) = glib::DateTime::from_unix_local(now.to_unix() - 86_400)
|
||||
&& same_day(&yesterday)
|
||||
{
|
||||
return "Yesterday".into();
|
||||
}
|
||||
}
|
||||
date.format("%-d %B %Y")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|_| "Unknown date".into())
|
||||
}
|
||||
|
||||
fn show_error_dialog(ui: &Rc<Ui>, message: &str) {
|
||||
alert(ui, "Photo Operation", message);
|
||||
}
|
||||
@@ -2534,54 +2735,200 @@ fn format_capture_time(secs: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_photo(
|
||||
ui: &Rc<Ui>,
|
||||
uid: String,
|
||||
picture: >k4::Picture,
|
||||
spinner: >k4::Spinner,
|
||||
status_label: >k4::Label,
|
||||
current_path: Rc<RefCell<Option<String>>>,
|
||||
) {
|
||||
spinner.set_visible(true);
|
||||
spinner.start();
|
||||
picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||
status_label.set_visible(false);
|
||||
*current_path.borrow_mut() = None;
|
||||
/// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and
|
||||
/// the button/key handlers so each one takes a single handle instead of a dozen
|
||||
/// widget arguments.
|
||||
struct Viewer {
|
||||
picture: gtk4::Picture,
|
||||
spinner: gtk4::Spinner,
|
||||
status: gtk4::Label,
|
||||
title: gtk4::Label,
|
||||
prev: gtk4::Button,
|
||||
next: gtk4::Button,
|
||||
/// EXIF drawer: the toggle that reveals it, the body it fills in, and the
|
||||
/// "Show on map" button (hidden when the photo carries no GPS tags).
|
||||
info_toggle: gtk4::ToggleButton,
|
||||
info_revealer: gtk4::Revealer,
|
||||
info_body: gtk4::Label,
|
||||
info_map: gtk4::Button,
|
||||
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
|
||||
coords: RefCell<Option<(f64, f64)>>,
|
||||
/// uid of the photo currently on screen.
|
||||
uid: RefCell<String>,
|
||||
/// On-disk path of the full-size photo, once it has been downloaded.
|
||||
path: RefCell<Option<String>>,
|
||||
}
|
||||
|
||||
/// Camera/exposure/location facts pulled from a photo's own EXIF tags.
|
||||
struct ExifInfo {
|
||||
/// Rendered "Camera: … / Exposure: …" lines, empty when the file has no EXIF.
|
||||
lines: Vec<String>,
|
||||
/// Decimal degrees, if the photo is geotagged.
|
||||
coords: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Download the photo behind `uid` into the cache and show it, then fill the EXIF
|
||||
/// drawer from the file that lands on disk.
|
||||
fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
viewer.spinner.set_visible(true);
|
||||
viewer.spinner.start();
|
||||
viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||
viewer.status.set_visible(false);
|
||||
*viewer.path.borrow_mut() = None;
|
||||
clear_exif(viewer);
|
||||
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::OpenPhoto { uid: uid.clone() },
|
||||
);
|
||||
let picture_clone = picture.clone();
|
||||
let spinner_clone = spinner.clone();
|
||||
let status_clone = status_label.clone();
|
||||
let viewer = viewer.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
spinner_clone.stop();
|
||||
spinner_clone.set_visible(false);
|
||||
viewer.spinner.stop();
|
||||
viewer.spinner.set_visible(false);
|
||||
match result {
|
||||
Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) {
|
||||
Ok(texture) => {
|
||||
picture_clone.set_paintable(Some(&texture));
|
||||
*current_path.borrow_mut() = Some(path);
|
||||
viewer.picture.set_paintable(Some(&texture));
|
||||
*viewer.path.borrow_mut() = Some(path.clone());
|
||||
show_exif(&viewer, read_exif(&path));
|
||||
}
|
||||
Err(e) => {
|
||||
status_clone.set_label(&format!("Failed to load image: {e}"));
|
||||
status_clone.set_visible(true);
|
||||
tracing::error!("Failed to load texture for {path}: {e}");
|
||||
viewer.status.set_label("Couldn't render this photo.");
|
||||
viewer.status.set_visible(true);
|
||||
}
|
||||
},
|
||||
Ok(Ok(Response::Error { message })) => {
|
||||
status_clone.set_label(&format!("Error downloading photo: {message}"));
|
||||
status_clone.set_visible(true);
|
||||
viewer.status.set_label(&message);
|
||||
viewer.status.set_visible(true);
|
||||
}
|
||||
_ => {
|
||||
status_clone.set_label("Failed to connect to daemon");
|
||||
status_clone.set_visible(true);
|
||||
Ok(Ok(_)) => {
|
||||
viewer.status.set_label("Unexpected reply from daemon.");
|
||||
viewer.status.set_visible(true);
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
viewer.status.set_label("Couldn't reach Proton Drive.");
|
||||
viewer.status.set_visible(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset the EXIF drawer while the next photo is in flight, so it never shows the
|
||||
/// previous photo's camera against the new image.
|
||||
fn clear_exif(viewer: &Rc<Viewer>) {
|
||||
*viewer.coords.borrow_mut() = None;
|
||||
viewer.info_body.set_label("Reading photo details…");
|
||||
viewer.info_map.set_visible(false);
|
||||
}
|
||||
|
||||
fn show_exif(viewer: &Rc<Viewer>, info: ExifInfo) {
|
||||
if info.lines.is_empty() {
|
||||
viewer
|
||||
.info_body
|
||||
.set_label("No EXIF metadata in this photo.");
|
||||
} else {
|
||||
viewer.info_body.set_label(&info.lines.join("\n"));
|
||||
}
|
||||
viewer.info_map.set_visible(info.coords.is_some());
|
||||
*viewer.coords.borrow_mut() = info.coords;
|
||||
}
|
||||
|
||||
/// Read the EXIF tags a gallery viewer cares about out of a decrypted photo on
|
||||
/// disk. Anything missing is simply left out — phone screenshots and re-encoded
|
||||
/// images legitimately carry no EXIF at all.
|
||||
fn read_exif(path: &str) -> ExifInfo {
|
||||
let mut lines = Vec::new();
|
||||
let mut coords = None;
|
||||
|
||||
let reader = match std::fs::File::open(path) {
|
||||
Ok(file) => {
|
||||
match exif::Reader::new().read_from_container(&mut std::io::BufReader::new(file)) {
|
||||
Ok(reader) => reader,
|
||||
Err(e) => {
|
||||
tracing::debug!("no exif in {path}: {e}");
|
||||
return ExifInfo { lines, coords };
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot open {path} for exif: {e}");
|
||||
return ExifInfo { lines, coords };
|
||||
}
|
||||
};
|
||||
|
||||
let field = |tag: exif::Tag| {
|
||||
reader
|
||||
.get_field(tag, exif::In::PRIMARY)
|
||||
.map(|f| f.display_value().with_unit(&reader).to_string())
|
||||
};
|
||||
|
||||
let camera = [exif::Tag::Make, exif::Tag::Model]
|
||||
.iter()
|
||||
.filter_map(|tag| field(*tag))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !camera.is_empty() {
|
||||
lines.push(format!("Camera: {camera}"));
|
||||
}
|
||||
if let Some(lens) = field(exif::Tag::LensModel) {
|
||||
lines.push(format!("Lens: {lens}"));
|
||||
}
|
||||
|
||||
let exposure: Vec<String> = [
|
||||
exif::Tag::FNumber,
|
||||
exif::Tag::ExposureTime,
|
||||
exif::Tag::PhotographicSensitivity,
|
||||
exif::Tag::FocalLength,
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|tag| field(*tag))
|
||||
.collect();
|
||||
if !exposure.is_empty() {
|
||||
lines.push(format!("Exposure: {}", exposure.join(" · ")));
|
||||
}
|
||||
|
||||
if let Some(taken) = field(exif::Tag::DateTimeOriginal) {
|
||||
lines.push(format!("Taken: {taken}"));
|
||||
}
|
||||
if let Some(size) = field(exif::Tag::PixelXDimension)
|
||||
.zip(field(exif::Tag::PixelYDimension))
|
||||
.map(|(w, h)| format!("{w} × {h}"))
|
||||
{
|
||||
lines.push(format!("Dimensions: {size}"));
|
||||
}
|
||||
|
||||
if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef)
|
||||
&& let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef)
|
||||
{
|
||||
lines.push(format!("Location: {lat:.5}, {lon:.5}"));
|
||||
coords = Some((lat, lon));
|
||||
}
|
||||
|
||||
ExifInfo { lines, coords }
|
||||
}
|
||||
|
||||
/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to
|
||||
/// decimal degrees, negating for the S/W hemispheres.
|
||||
fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
|
||||
let field = reader.get_field(tag, exif::In::PRIMARY)?;
|
||||
let exif::Value::Rational(dms) = &field.value else {
|
||||
return None;
|
||||
};
|
||||
let [deg, min, sec] = dms.get(..3)? else {
|
||||
return None;
|
||||
};
|
||||
let degrees = deg.to_f64() + min.to_f64() / 60.0 + sec.to_f64() / 3600.0;
|
||||
|
||||
let hemisphere = reader
|
||||
.get_field(ref_tag, exif::In::PRIMARY)
|
||||
.map(|f| f.display_value().to_string())
|
||||
.unwrap_or_default();
|
||||
let negative = hemisphere.starts_with('S') || hemisphere.starts_with('W');
|
||||
Some(if negative { -degrees } else { degrees })
|
||||
}
|
||||
|
||||
fn save_photo_to_disk(window: >k4::Window, source_path: &str, original_name: &str) {
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Save Photo")
|
||||
@@ -2601,24 +2948,14 @@ fn save_photo_to_disk(window: >k4::Window, source_path: &str, original_name: &
|
||||
});
|
||||
}
|
||||
|
||||
fn navigate_photo(
|
||||
ui: &Rc<Ui>,
|
||||
current_uid: &Rc<RefCell<String>>,
|
||||
delta: i32,
|
||||
picture: >k4::Picture,
|
||||
spinner: >k4::Spinner,
|
||||
status_label: >k4::Label,
|
||||
title_label: >k4::Label,
|
||||
prev_btn: >k4::Button,
|
||||
next_btn: >k4::Button,
|
||||
current_path: Rc<RefCell<Option<String>>>,
|
||||
) {
|
||||
/// Step `delta` photos through the flat timeline model and load what lands there.
|
||||
fn navigate_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
|
||||
let model = &ui.gallery_model;
|
||||
let n = model.n_items();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let uid_val = current_uid.borrow().clone();
|
||||
let uid_val = viewer.uid.borrow().clone();
|
||||
let current_idx = find_photo_index(model, &uid_val).unwrap_or(0);
|
||||
let next_idx = (current_idx as i32 + delta).clamp(0, n as i32 - 1) as u32;
|
||||
|
||||
@@ -2630,22 +2967,17 @@ fn navigate_photo(
|
||||
};
|
||||
let photo = boxed.borrow::<PhotoItem>().clone();
|
||||
|
||||
*current_uid.borrow_mut() = photo.uid.clone();
|
||||
*viewer.uid.borrow_mut() = photo.uid.clone();
|
||||
|
||||
prev_btn.set_sensitive(next_idx > 0);
|
||||
next_btn.set_sensitive(next_idx < n - 1);
|
||||
viewer.prev.set_sensitive(next_idx > 0);
|
||||
viewer.next.set_sensitive(next_idx < n - 1);
|
||||
|
||||
let date_str = format_capture_time(photo.capture_time);
|
||||
title_label.set_label(&format!("Photo {} of {} • {}", next_idx + 1, n, date_str));
|
||||
viewer
|
||||
.title
|
||||
.set_label(&format!("Photo {} of {} • {}", next_idx + 1, n, date_str));
|
||||
|
||||
load_photo(
|
||||
ui,
|
||||
photo.uid.clone(),
|
||||
picture,
|
||||
spinner,
|
||||
status_label,
|
||||
current_path,
|
||||
);
|
||||
load_photo(ui, viewer, photo.uid.clone());
|
||||
}
|
||||
|
||||
fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
@@ -2704,6 +3036,14 @@ fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
title_label.add_css_class("viewer-title");
|
||||
top_bar.append(&title_label);
|
||||
|
||||
let info_toggle = gtk4::ToggleButton::builder()
|
||||
.icon_name("info-symbolic")
|
||||
.tooltip_text("Photo Details")
|
||||
.build();
|
||||
info_toggle.add_css_class("flat");
|
||||
info_toggle.add_css_class("viewer-action-btn");
|
||||
top_bar.append(&info_toggle);
|
||||
|
||||
let download_btn = gtk4::Button::builder()
|
||||
.icon_name("document-save-symbolic")
|
||||
.tooltip_text("Download to Disk")
|
||||
@@ -2730,6 +3070,45 @@ fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
overlay.add_overlay(&top_bar);
|
||||
|
||||
// EXIF drawer: slides in from the right over the photo, filled from the file
|
||||
// once it lands on disk.
|
||||
let info_body = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.wrap(true)
|
||||
.selectable(true)
|
||||
.build();
|
||||
let info_map = gtk4::Button::builder()
|
||||
.label("Show on map")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
info_map.add_css_class("pill");
|
||||
info_map.set_visible(false);
|
||||
|
||||
let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
info_panel.add_css_class("viewer-info-panel");
|
||||
info_panel.set_margin_top(56);
|
||||
info_panel.set_margin_bottom(12);
|
||||
info_panel.set_margin_start(12);
|
||||
info_panel.set_margin_end(12);
|
||||
info_panel.set_width_request(260);
|
||||
let info_title = gtk4::Label::builder()
|
||||
.label("Details")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
info_title.add_css_class("heading");
|
||||
info_panel.append(&info_title);
|
||||
info_panel.append(&info_body);
|
||||
info_panel.append(&info_map);
|
||||
|
||||
let info_revealer = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Fill)
|
||||
.child(&info_panel)
|
||||
.build();
|
||||
overlay.add_overlay(&info_revealer);
|
||||
|
||||
let spinner = gtk4::Spinner::builder()
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
@@ -2749,13 +3128,26 @@ fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
window.set_child(Some(&overlay));
|
||||
|
||||
let current_uid = Rc::new(RefCell::new(initial_uid.clone()));
|
||||
let current_path = Rc::new(RefCell::new(None::<String>));
|
||||
let viewer = Rc::new(Viewer {
|
||||
picture,
|
||||
spinner,
|
||||
status: status_label,
|
||||
title: title_label,
|
||||
prev: prev_btn.clone(),
|
||||
next: next_btn.clone(),
|
||||
info_toggle: info_toggle.clone(),
|
||||
info_revealer: info_revealer.clone(),
|
||||
info_body,
|
||||
info_map: info_map.clone(),
|
||||
coords: RefCell::new(None),
|
||||
uid: RefCell::new(initial_uid.clone()),
|
||||
path: RefCell::new(None),
|
||||
});
|
||||
|
||||
let n = ui.gallery_model.n_items();
|
||||
let initial_idx = find_photo_index(&ui.gallery_model, &initial_uid).unwrap_or(0);
|
||||
prev_btn.set_sensitive(initial_idx > 0);
|
||||
next_btn.set_sensitive(initial_idx < n - 1);
|
||||
viewer.prev.set_sensitive(initial_idx > 0);
|
||||
viewer.next.set_sensitive(initial_idx < n - 1);
|
||||
|
||||
let mut initial_capture_time = 0;
|
||||
if let Some(obj) = ui.gallery_model.item(initial_idx)
|
||||
@@ -2764,145 +3156,93 @@ fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
initial_capture_time = boxed.borrow::<PhotoItem>().capture_time;
|
||||
}
|
||||
let date_str = format_capture_time(initial_capture_time);
|
||||
title_label.set_label(&format!(
|
||||
viewer.title.set_label(&format!(
|
||||
"Photo {} of {} • {}",
|
||||
initial_idx + 1,
|
||||
n,
|
||||
date_str
|
||||
));
|
||||
|
||||
load_photo(
|
||||
ui,
|
||||
initial_uid,
|
||||
&picture,
|
||||
&spinner,
|
||||
&status_label,
|
||||
current_path.clone(),
|
||||
);
|
||||
load_photo(ui, &viewer, initial_uid);
|
||||
|
||||
let w_close = window.clone();
|
||||
close_btn.connect_clicked(move |_| {
|
||||
w_close.close();
|
||||
});
|
||||
|
||||
let viewer_info = viewer.clone();
|
||||
info_toggle.connect_toggled(move |toggle| {
|
||||
viewer_info
|
||||
.info_revealer
|
||||
.set_reveal_child(toggle.is_active());
|
||||
});
|
||||
|
||||
let viewer_map = viewer.clone();
|
||||
info_map.connect_clicked(move |_| {
|
||||
if let Some((lat, lon)) = *viewer_map.coords.borrow() {
|
||||
open_path(&format!(
|
||||
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}"
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
let w_download = window.clone();
|
||||
let path_download = current_path.clone();
|
||||
let uid_download = current_uid.clone();
|
||||
let viewer_download = viewer.clone();
|
||||
download_btn.connect_clicked(move |_| {
|
||||
if let Some(path) = path_download.borrow().as_deref() {
|
||||
let uid_val = uid_download.borrow();
|
||||
let name = format!("{}.jpg", uid_val);
|
||||
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
||||
let name = format!("{}.jpg", viewer_download.uid.borrow());
|
||||
save_photo_to_disk(&w_download, path, &name);
|
||||
}
|
||||
});
|
||||
|
||||
let path_ext = current_path.clone();
|
||||
let viewer_ext = viewer.clone();
|
||||
open_ext_btn.connect_clicked(move |_| {
|
||||
if let Some(path) = path_ext.borrow().as_deref() {
|
||||
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
||||
open_path(path);
|
||||
}
|
||||
});
|
||||
|
||||
let ui_prev = ui.clone();
|
||||
let uid_prev = current_uid.clone();
|
||||
let pic_prev = picture.clone();
|
||||
let spin_prev = spinner.clone();
|
||||
let status_prev = status_label.clone();
|
||||
let title_prev = title_label.clone();
|
||||
let p_btn = prev_btn.clone();
|
||||
let n_btn = next_btn.clone();
|
||||
let path_prev = current_path.clone();
|
||||
let viewer_prev = viewer.clone();
|
||||
prev_btn.connect_clicked(move |_| {
|
||||
navigate_photo(
|
||||
&ui_prev,
|
||||
&uid_prev,
|
||||
-1,
|
||||
&pic_prev,
|
||||
&spin_prev,
|
||||
&status_prev,
|
||||
&title_prev,
|
||||
&p_btn,
|
||||
&n_btn,
|
||||
path_prev.clone(),
|
||||
);
|
||||
navigate_photo(&ui_prev, &viewer_prev, -1);
|
||||
});
|
||||
|
||||
let ui_next = ui.clone();
|
||||
let uid_next = current_uid.clone();
|
||||
let pic_next = picture.clone();
|
||||
let spin_next = spinner.clone();
|
||||
let status_next = status_label.clone();
|
||||
let title_next = title_label.clone();
|
||||
let p_btn = prev_btn.clone();
|
||||
let n_btn = next_btn.clone();
|
||||
let path_next = current_path.clone();
|
||||
let viewer_next = viewer.clone();
|
||||
next_btn.connect_clicked(move |_| {
|
||||
navigate_photo(
|
||||
&ui_next,
|
||||
&uid_next,
|
||||
1,
|
||||
&pic_next,
|
||||
&spin_next,
|
||||
&status_next,
|
||||
&title_next,
|
||||
&p_btn,
|
||||
&n_btn,
|
||||
path_next.clone(),
|
||||
);
|
||||
navigate_photo(&ui_next, &viewer_next, 1);
|
||||
});
|
||||
|
||||
let key_controller = gtk4::EventControllerKey::new();
|
||||
let ui_key = ui.clone();
|
||||
let uid_key = current_uid.clone();
|
||||
let pic_key = picture.clone();
|
||||
let spin_key = spinner.clone();
|
||||
let status_key = status_label.clone();
|
||||
let title_key = title_label.clone();
|
||||
let p_btn = prev_btn.clone();
|
||||
let n_btn = next_btn.clone();
|
||||
let path_key = current_path.clone();
|
||||
let viewer_key = viewer.clone();
|
||||
let w_key = window.clone();
|
||||
key_controller.connect_key_pressed(move |_, key, _keycode, _state| {
|
||||
match key.name().as_deref() {
|
||||
Some("Left") => {
|
||||
let current_idx =
|
||||
find_photo_index(&ui_key.gallery_model, &uid_key.borrow()).unwrap_or(0);
|
||||
find_photo_index(&ui_key.gallery_model, &viewer_key.uid.borrow()).unwrap_or(0);
|
||||
if current_idx > 0 {
|
||||
navigate_photo(
|
||||
&ui_key,
|
||||
&uid_key,
|
||||
-1,
|
||||
&pic_key,
|
||||
&spin_key,
|
||||
&status_key,
|
||||
&title_key,
|
||||
&p_btn,
|
||||
&n_btn,
|
||||
path_key.clone(),
|
||||
);
|
||||
navigate_photo(&ui_key, &viewer_key, -1);
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
Some("Right") => {
|
||||
let n = ui_key.gallery_model.n_items();
|
||||
let current_idx =
|
||||
find_photo_index(&ui_key.gallery_model, &uid_key.borrow()).unwrap_or(0);
|
||||
if current_idx < n - 1 {
|
||||
navigate_photo(
|
||||
&ui_key,
|
||||
&uid_key,
|
||||
1,
|
||||
&pic_key,
|
||||
&spin_key,
|
||||
&status_key,
|
||||
&title_key,
|
||||
&p_btn,
|
||||
&n_btn,
|
||||
path_key.clone(),
|
||||
);
|
||||
find_photo_index(&ui_key.gallery_model, &viewer_key.uid.borrow()).unwrap_or(0);
|
||||
if current_idx + 1 < n {
|
||||
navigate_photo(&ui_key, &viewer_key, 1);
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
Some("i") => {
|
||||
viewer_key
|
||||
.info_toggle
|
||||
.set_active(!viewer_key.info_toggle.is_active());
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
Some("Escape") => {
|
||||
w_key.close();
|
||||
glib::Propagation::Stop
|
||||
@@ -2952,6 +3292,7 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
for item in &items {
|
||||
ui.gallery_model.append(&BoxedAnyObject::new(item.clone()));
|
||||
}
|
||||
repaint_gallery(&ui);
|
||||
if ui.gallery_model.n_items() == 0 {
|
||||
gallery_message(&ui, "No photos yet.");
|
||||
} else {
|
||||
|
||||
|
Before Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -1,21 +1,22 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
#
|
||||
# Local-build PKGBUILD. Builds the proton-drive-linux workspace from the
|
||||
# checkout this file lives in. Path deps point at a sibling ../proton-sdk-rs
|
||||
# checkout (the Proton SDK is unpublished), so this is NOT a source-fetch
|
||||
# package: it compiles the working tree in place.
|
||||
# checkout this file lives in: this is NOT a source-fetch package, it compiles
|
||||
# the working tree in place. The Proton SDK is unpublished and comes from its
|
||||
# GitHub repo (pinned by Cargo.lock), so the build needs network access — no
|
||||
# sibling checkout required any more.
|
||||
#
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=0.2.1
|
||||
pkgver=0.2.5
|
||||
pkgrel=1
|
||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/narl/proton-drive-linux"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita')
|
||||
makedepends=('cargo')
|
||||
makedepends=('cargo' 'git')
|
||||
provides=('pdfs')
|
||||
# Files-on-demand mount + keyring need these at runtime.
|
||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||
@@ -40,7 +41,9 @@ build() {
|
||||
cd "$_workspace"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR="$_workspace/target"
|
||||
cargo build --release --frozen \
|
||||
# `--locked` not `--frozen`: the lock is authoritative, but cargo must still be
|
||||
# allowed to fetch the git-sourced SDK.
|
||||
cargo build --release --locked \
|
||||
--bin pdfs \
|
||||
--bin pdfs-tray \
|
||||
--bin pdfs-app \
|
||||
|
||||