implemented database for search

This commit is contained in:
nvrl committed 2026-06-30 17:35:18 +02:00
1 parent 5e151bbfc5
commit 03dd8d8496
10 files changed
+2342 -167

No files matched your search

+33
View File
@@ -2,6 +2,7 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, anyhow, bail};
use clap::{Parser, Subcommand};
@@ -9,6 +10,7 @@ use pdfs_core::auth;
use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse};
use pdfs_core::db::Db;
#[derive(Parser)]
#[command(
@@ -76,6 +78,14 @@ enum Command {
/// Photo node uid in `volume~link` form (from `pdfs photos`).
uid: String,
},
/// Search file/folder names via the running daemon's local index.
Search {
/// Query string (substring match).
query: String,
/// Max hits to return.
#[arg(long, default_value_t = 50)]
limit: usize,
},
}
fn main() -> Result<()> {
@@ -102,6 +112,7 @@ fn main() -> Result<()> {
Command::Ls { path } => cmd_ls(path),
Command::Photos { limit, offset } => cmd_photos(limit, offset),
Command::OpenPhoto { uid } => cmd_open_photo(uid),
Command::Search { query, limit } => cmd_search(query, limit),
}
}
@@ -217,10 +228,15 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
let username = auth::load().map(|s| s.username).unwrap_or_default();
// The daemon owns the unified DB (single writer). Open it here so the content
// cache and the mount share one handle: the cache uses its `cache_entries`
// table as the LRU index, the mount uses it for nodes/search/event cursor.
let db = Arc::new(Db::open(&dirs.db_path()).context("open cache db")?);
let cache = ContentCache::open(
dirs.content_cache_dir(),
dirs.pins_path(),
pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES,
db.clone(),
)
.context("open content cache")?;
let control_socket = dirs.control_socket();
@@ -272,6 +288,7 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
&mountpoint,
cache,
&control_socket,
db,
username,
);
@@ -371,6 +388,22 @@ fn cmd_open_photo(uid: String) -> Result<()> {
Ok(())
}
fn cmd_search(query: String, limit: usize) -> Result<()> {
match control_request(CtlRequest::Search { query, limit })? {
CtlResponse::SearchResults { hits } if hits.is_empty() => println!("(no matches)"),
CtlResponse::SearchResults { hits } => {
for h in hits {
let kind = if h.is_dir { "d" } else { "-" };
let pin = if h.pinned { "*" } else { " " };
println!("{kind}{pin} {:>12} {}", h.size, h.path);
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
/// Normalize a user-supplied path argument for sending to the daemon. Absolute
/// paths are canonicalized (so the daemon can strip its mountpoint prefix);
/// relative paths are passed through and resolved against the mount root.
+1
View File
@@ -17,4 +17,5 @@ thiserror.workspace = true
tracing.workspace = true
directories.workspace = true
sha2.workspace = true
rusqlite.workspace = true
keyring.workspace = true
+249 -113
View File
@@ -19,14 +19,32 @@
use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::db::Db;
use crate::error::Result;
/// `cache_entries.kind` tag for whole-file content blobs.
const KIND_BLOB: &str = "blob";
/// `cache_entries.kind` tag for on-demand block-cache chunks.
const KIND_BLOCK: &str = "block";
/// Current wall-clock time in unix *milliseconds*, for the LRU `last_accessed`
/// column. Milliseconds (not seconds) so two cache events in the same second
/// still order correctly — a coarse second-granularity clock would make LRU
/// eviction order indeterminate under bursty access.
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// Block size for the on-demand block cache. Matches the SDK content block size
/// (`DEFAULT_BLOCK_SIZE`, 4 MiB) so each cached block maps to exactly one
/// `download_range` fetch with no straddling.
@@ -42,18 +60,22 @@ struct Meta {
size: u64,
}
/// One pinned file, as persisted to the pin registry.
/// One pin, as carried over the control socket and listed in `status`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Pin {
/// Node uid in `volume~link` display form.
pub uid: String,
/// Last path the file was pinned under, for display in `status`. Advisory
/// Last path the node was pinned under, for display in `status`. Advisory
/// only — the uid is the identity.
pub path: String,
/// A folder pin whose whole subtree is kept on disk. `false` for a single
/// file. Defaulted so a legacy `pins.json` (file pins only) still parses.
#[serde(default)]
pub recursive: bool,
}
/// The pin registry, persisted as JSON. Keyed by uid display string so a pin
/// survives renames/moves of the node.
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
/// the DB once on open. Live pins are owned by the `pins` table.
#[derive(Serialize, Deserialize, Default)]
struct PinFile {
pins: BTreeMap<String, Pin>,
@@ -80,13 +102,24 @@ pub struct ContentCache {
/// disables the cap. Pinned blobs are never evicted, so pins alone may push
/// the cache over budget.
max_bytes: u64,
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
/// store/read/evict updates it, and the budget enforcers query it instead of
/// scanning the cache directories (plan.md P4).
db: Arc<Db>,
}
impl ContentCache {
/// Open (and create) a cache under `content_dir`, with the pin registry at
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
/// directories are created if missing.
pub fn open(content_dir: PathBuf, pins_path: PathBuf, max_bytes: u64) -> Result<Self> {
/// directories are created if missing. `db` is the shared metadata database
/// whose `cache_entries` table backs LRU eviction; the on-disk cache is
/// reconciled into it on open.
pub fn open(
content_dir: PathBuf,
pins_path: PathBuf,
max_bytes: u64,
db: Arc<Db>,
) -> Result<Self> {
std::fs::create_dir_all(&content_dir)?;
let thumb_dir = content_dir.join("thumbs");
std::fs::create_dir_all(&thumb_dir)?;
@@ -100,14 +133,71 @@ impl ContentCache {
if let Some(parent) = pins_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(Self {
let cache = Self {
content_dir,
thumb_dir,
block_dir,
scratch_dir,
pins_path,
max_bytes,
})
db,
};
cache.reconcile()?;
cache.import_legacy_pins()?;
Ok(cache)
}
/// One-time migration of a pre-P5 `pins.json` into the DB `pins` table, then
/// delete the file so it never re-imports. Legacy pins were all whole-file
/// (non-recursive). Absent file → nothing to do.
fn import_legacy_pins(&self) -> Result<()> {
let Ok(bytes) = std::fs::read(&self.pins_path) else {
return Ok(());
};
if let Ok(file) = serde_json::from_slice::<PinFile>(&bytes) {
for (uid, pin) in file.pins {
self.db.pin_add(&uid, &pin.path, false)?;
}
}
let _ = std::fs::remove_file(&self.pins_path);
Ok(())
}
/// Rebuild the `cache_entries` LRU index from the on-disk cache. Called once
/// on open: the index is cleared, then every blob (`content_dir`) and block
/// (`block_dir`) file is re-registered with its on-disk size and last-modified
/// time as the initial LRU key. This makes the DB authoritative even after a
/// crash or an external file deletion, and picks up caches written by builds
/// predating the index. In-run accesses then refine the ordering.
fn reconcile(&self) -> Result<()> {
self.db.cache_clear()?;
for (dir, kind) in [
(&self.content_dir, KIND_BLOB),
(&self.block_dir, KIND_BLOCK),
] {
let Ok(rd) = std::fs::read_dir(dir) else {
continue;
};
for entry in rd.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.ends_with(".meta") || name.ends_with(".tmp") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
let at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
self.db.cache_touch(name, kind, meta.len(), at)?;
}
}
Ok(())
}
/// Filesystem-safe, fixed-length cache key for a uid display string: the
@@ -171,10 +261,10 @@ impl ContentCache {
len: u64,
) -> Option<Vec<u8>> {
let blob = self.valid_blob(uid, mtime, size)?;
// Record the access for LRU: bump the blob's mtime to now. Best effort —
// a failed touch only makes eviction order slightly less accurate.
// Record the access for LRU in the cache index. Best effort — a failed
// update only makes eviction order slightly less accurate.
let _ = self.db.cache_accessed(&Self::key(uid), now_millis());
let mut f = std::fs::File::open(&blob).ok()?;
let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now()));
if offset >= size {
return Some(Vec::new());
}
@@ -197,12 +287,22 @@ impl ContentCache {
std::fs::rename(&tmp, &blob)?;
let meta = serde_json::to_vec(&Meta { mtime, size })?;
std::fs::write(self.meta_path(uid), meta)?;
// Register the blob in the LRU index before enforcing the budget so the
// newest entry is weighed (and ordered most-recent) like any other.
self.db
.cache_touch(&Self::key(uid), KIND_BLOB, bytes.len() as u64, now_millis())?;
self.enforce_budget();
Ok(())
}
/// Cache-index key for block `idx` of `uid` — the block file's name, which
/// is also its `cache_entries.cache_key`.
fn block_key(&self, uid: &NodeUid, idx: u64) -> String {
format!("{}.b{idx}", Self::key(uid))
}
fn block_blob(&self, uid: &NodeUid, idx: u64) -> PathBuf {
self.block_dir.join(format!("{}.b{idx}", Self::key(uid)))
self.block_dir.join(self.block_key(uid, idx))
}
fn block_meta(&self, uid: &NodeUid, idx: u64) -> PathBuf {
@@ -222,7 +322,10 @@ impl ContentCache {
return None;
}
let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?;
let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now()));
// LRU touch in the cache index (best effort).
let _ = self
.db
.cache_accessed(&self.block_key(uid, idx), now_millis());
let mut buf = Vec::new();
f.read_to_end(&mut buf).ok()?;
Some(buf)
@@ -249,6 +352,12 @@ impl ContentCache {
self.block_meta(uid, idx),
serde_json::to_vec(&Meta { mtime, size })?,
)?;
self.db.cache_touch(
&self.block_key(uid, idx),
KIND_BLOCK,
bytes.len() as u64,
now_millis(),
)?;
self.enforce_block_budget();
Ok(())
}
@@ -260,42 +369,25 @@ impl ContentCache {
if self.max_bytes == 0 {
return;
}
let mut total: u64 = 0;
let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
let Ok(rd) = std::fs::read_dir(&self.block_dir) else {
// LRU-ordered (oldest first) block entries straight from the index — no
// directory scan. All blocks are evictable; pinned files are served from
// whole-file blobs, never blocks.
let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOCK) else {
return;
};
for entry in rd.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.ends_with(".meta") || name.ends_with(".tmp") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
total += meta.len();
let atime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
blobs.push((path, meta.len(), atime));
}
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
if total <= self.max_bytes {
return;
}
blobs.sort_by_key(|(_, _, atime)| *atime);
for (path, len, _) in blobs {
for (key, size) in entries {
if total <= self.max_bytes {
break;
}
let meta = path.with_file_name(format!(
"{}.meta",
path.file_name().unwrap_or_default().to_string_lossy()
));
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(meta);
total = total.saturating_sub(len);
// `key` is the block file's name (and its `.meta` sibling's stem).
let _ = std::fs::remove_file(self.block_dir.join(&key));
let _ = std::fs::remove_file(self.block_dir.join(format!("{key}.meta")));
let _ = self.db.cache_remove(&key);
total = total.saturating_sub(size);
}
}
@@ -328,7 +420,10 @@ impl ContentCache {
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
let _ = std::fs::remove_file(self.thumb_meta(uid, ttype));
}
// Drop every cached block (and its meta/tmp) for this uid.
// Drop every cached block (and its meta/tmp) for this uid. The block
// dir is still scanned here because eviction targets one uid's files by
// name prefix, not by LRU order; the index rows are removed in one shot
// by `cache_remove_all` below.
let prefix = format!("{}.b", Self::key(uid));
if let Ok(rd) = std::fs::read_dir(&self.block_dir) {
for entry in rd.flatten() {
@@ -341,6 +436,8 @@ impl ContentCache {
}
}
}
// Forget the blob and all of this uid's block rows in the LRU index.
let _ = self.db.cache_remove_all(&Self::key(uid));
}
fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
@@ -423,101 +520,80 @@ impl ContentCache {
if self.max_bytes == 0 {
return;
}
// Pinned blobs (by cache key) are exempt from eviction.
// Pinned blobs (by cache key) are exempt from eviction. Resolves direct
// pins plus every descendant of a recursively-pinned folder, hashed into
// cache keys to match on-disk filenames.
let pinned: HashSet<String> = self
.load_pins()
.pins
.keys()
.db
.pinned_uids()
.unwrap_or_default()
.iter()
.map(|uid| Self::key_str(uid))
.collect();
// Scan blobs (the `<key>` files; skip `.meta`/`.tmp` siblings), record
// size + last-access (mtime) for each.
let mut total: u64 = 0;
let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
let Ok(rd) = std::fs::read_dir(&self.content_dir) else {
// LRU-ordered (oldest first) blob entries from the index — no directory
// scan. Pinned blobs still count toward the total (so pins alone can hold
// the cache over budget) but are never chosen as victims.
let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOB) else {
return;
};
for entry in rd.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.ends_with(".meta") || name.ends_with(".tmp") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
total += meta.len();
if pinned.contains(name) {
continue; // counts toward total but is never a victim
}
let atime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
blobs.push((path, meta.len(), atime));
}
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
if total <= self.max_bytes {
return;
}
// Oldest access first.
blobs.sort_by_key(|(_, _, atime)| *atime);
for (path, len, _) in blobs {
for (key, size) in entries {
if total <= self.max_bytes {
break;
}
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("meta"));
total = total.saturating_sub(len);
if pinned.contains(&key) {
continue; // counts toward total but is never a victim
}
// `key` is the blob file's name (and its `.meta` sibling's stem).
let _ = std::fs::remove_file(self.content_dir.join(&key));
let _ = std::fs::remove_file(self.content_dir.join(format!("{key}.meta")));
let _ = self.db.cache_remove(&key);
total = total.saturating_sub(size);
}
}
fn load_pins(&self) -> PinFile {
std::fs::read(&self.pins_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save_pins(&self, pins: &PinFile) -> Result<()> {
std::fs::write(&self.pins_path, serde_json::to_vec_pretty(pins)?)?;
Ok(())
}
/// Whether `uid` is pinned (independent of whether its blob is currently
/// cached).
/// Whether `uid` is pinned — directly, or because an ancestor folder is
/// pinned recursively (resolved in the DB against the node tree). Independent
/// of whether its blob is currently cached. Best effort: a DB error reads as
/// "not pinned" rather than failing the calling FUSE op.
pub fn is_pinned(&self, uid: &NodeUid) -> bool {
self.load_pins().pins.contains_key(&uid.to_string())
self.db.is_pinned(&uid.to_string()).unwrap_or(false)
}
/// Record `uid` as pinned under `path`. The caller is responsible for having
/// already cached the content via [`store`](Self::store).
pub fn add_pin(&self, uid: &NodeUid, path: &Path) -> Result<()> {
let mut file = self.load_pins();
let key = uid.to_string();
file.pins.insert(
key.clone(),
Pin {
uid: key,
path: path.display().to_string(),
},
);
self.save_pins(&file)
/// Record `uid` as pinned under `path`. `recursive` marks a folder pin whose
/// whole subtree is kept. The caller is responsible for having cached the
/// content (a file via [`store`](Self::store); a folder's descendants by the
/// daemon walking the subtree).
pub fn add_pin(&self, uid: &NodeUid, path: &Path, recursive: bool) -> Result<()> {
self.db
.pin_add(&uid.to_string(), &path.display().to_string(), recursive)
}
/// Drop `uid` from the pin registry and evict its blob. No-op if not pinned.
/// A recursively-pinned folder's descendants are evicted by the daemon
/// (`Core::unpin`), which knows the node tree.
pub fn remove_pin(&self, uid: &NodeUid) -> Result<()> {
let mut file = self.load_pins();
if file.pins.remove(&uid.to_string()).is_some() {
self.save_pins(&file)?;
}
self.db.pin_remove(&uid.to_string())?;
self.evict(uid);
Ok(())
}
/// All pinned files, ordered by uid.
/// All pins (files and recursive folders), ordered by uid.
pub fn list_pins(&self) -> Vec<Pin> {
self.load_pins().pins.into_values().collect()
self.db
.pin_list()
.unwrap_or_default()
.into_iter()
.map(|(uid, path, recursive)| Pin {
uid,
path,
recursive,
})
.collect()
}
/// Total bytes of cached content blobs (pinned + unpinned), matching what
@@ -591,10 +667,12 @@ mod tests {
fn cache_capped(max_bytes: u64) -> (ContentCache, TempDir) {
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let c = ContentCache::open(
dir.path().join("content"),
dir.path().join("pins.json"),
max_bytes,
db,
)
.unwrap();
(c, dir)
@@ -618,6 +696,40 @@ mod tests {
);
}
#[test]
fn reconcile_rebuilds_index_from_disk() {
let dir = TempDir::new();
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
// First session: unlimited cap, store three blobs onto disk.
let db1 = Arc::new(Db::open_in_memory().unwrap());
{
let c = ContentCache::open(content.clone(), pins.clone(), 0, db1).unwrap();
c.store(&uid("a"), 1, 4, b"aaaa").unwrap();
c.store(&uid("b"), 1, 4, b"bbbb").unwrap();
c.store(&uid("c"), 1, 4, b"cccc").unwrap();
}
// Second session with a *fresh* index (as if predating P4, or after a
// crash) but the same on-disk cache: reconcile repopulates from disk.
let db2 = Arc::new(Db::open_in_memory().unwrap());
assert!(db2.cache_entries_by_kind("blob").unwrap().is_empty());
let c = ContentCache::open(content, pins, 8, db2.clone()).unwrap();
assert_eq!(db2.cache_entries_by_kind("blob").unwrap().len(), 3);
// With the index rebuilt, a further store enforces the budget against the
// reconciled entries instead of leaking them.
c.store(&uid("d"), 1, 4, b"dddd").unwrap();
let total: u64 = db2
.cache_entries_by_kind("blob")
.unwrap()
.iter()
.map(|(_, s)| s)
.sum();
assert!(total <= 8, "budget enforced after reconcile, total={total}");
}
#[test]
fn stale_metadata_is_a_miss() {
let (c, _d) = cache();
@@ -630,12 +742,36 @@ mod tests {
assert!(c.read_range(&u, 100, 4, 0, 3).is_none());
}
#[test]
fn legacy_pins_json_imported_then_removed() {
let dir = TempDir::new();
let pins = dir.path().join("pins.json");
// Hand-write a pre-P5 pins.json (file pins only, no `recursive` field).
std::fs::write(
&pins,
r#"{"pins":{"vol~a":{"uid":"vol~a","path":"docs/a.txt"}}}"#,
)
.unwrap();
let db = Arc::new(Db::open_in_memory().unwrap());
let c = ContentCache::open(dir.path().join("content"), pins.clone(), 0, db).unwrap();
// Imported into the DB as a non-recursive pin...
assert!(c.is_pinned(&uid("a")));
let listed = c.list_pins();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].path, "docs/a.txt");
assert!(!listed[0].recursive);
// ...and the legacy file is gone so it never re-imports.
assert!(!pins.exists());
}
#[test]
fn pin_lifecycle_evicts_on_unpin() {
let (c, _d) = cache();
let u = uid("a");
c.store(&u, 100, 3, b"abc").unwrap();
c.add_pin(&u, Path::new("docs/a.txt")).unwrap();
c.add_pin(&u, Path::new("docs/a.txt"), false).unwrap();
assert!(c.is_pinned(&u));
assert_eq!(c.list_pins().len(), 1);
assert_eq!(c.list_pins()[0].path, "docs/a.txt");
@@ -675,7 +811,7 @@ mod tests {
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store(&a, 1, 4, b"aaaa").unwrap();
c.add_pin(&a, Path::new("a")).unwrap();
c.add_pin(&a, Path::new("a"), false).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&b, 1, 4, b"bbbb").unwrap();
// Over budget: `a` is pinned (oldest) so `b` must be the victim.
+6
View File
@@ -94,6 +94,12 @@ impl AppDirs {
self.state_dir().join("pins.json")
}
/// Unified SQLite metadata cache (inodes, FTS, cache LRU, pins). Lives in
/// persistent state next to `control.sock`; only the daemon writes it.
pub fn db_path(&self) -> PathBuf {
self.state_dir().join("cache.db")
}
/// Unix domain socket the mount daemon listens on for CLI control commands.
pub fn control_socket(&self) -> PathBuf {
self.state_dir().join("control.sock")
+34
View File
@@ -49,10 +49,20 @@ pub enum Request {
PhotosTimeline { offset: usize, limit: usize },
/// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String },
/// Upload a photo with the given name, media type, and content bytes.
UploadPhoto {
name: String,
media_type: String,
bytes: Vec<u8>,
capture_time: Option<i64>,
},
/// Download a Drive file's full content into the cache; replies with the
/// on-disk path so the front-end can open it with the default app. `path`
/// is mountpoint-relative.
OpenFile { path: String },
/// Full-text search node names against the daemon's local metadata index.
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
Search { query: String, limit: usize },
}
/// One entry in a [`Request::ListDir`] listing.
@@ -69,6 +79,28 @@ pub struct DirEntry {
pub pinned: bool,
/// Node uid in `volume~link` form, for follow-up requests.
pub uid: String,
/// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing
/// (the entry lives in the requested directory, so the caller derives the
/// path by joining its name); populated when an entry can live anywhere in
/// the tree, as for search hits rendered through the browser.
#[serde(default)]
pub path: String,
}
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
/// full mountpoint-relative `path` (a hit can live anywhere in the tree), so the
/// front-end can navigate to or open it directly.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SearchHit {
pub name: String,
/// Mountpoint-relative path (`/`-joined, no leading slash).
pub path: String,
pub is_dir: bool,
pub size: u64,
pub modified: i64,
pub pinned: bool,
/// Node uid in `volume~link` form.
pub uid: String,
}
/// One photo in a [`Request::PhotosTimeline`] page.
@@ -113,6 +145,8 @@ pub enum Response {
},
/// An on-disk path the front-end can open (e.g. a downloaded photo).
FilePath { path: String },
/// Full-text search results (reply to [`Request::Search`]).
SearchResults { hits: Vec<SearchHit> },
/// The request failed.
Error { message: String },
}
File diff suppressed because it is too large. Load diff
+3
View File
@@ -16,6 +16,9 @@ pub enum Error {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("database: {0}")]
Db(#[from] rusqlite::Error),
/// No persisted session found — caller must run an interactive login.
#[error("not logged in (no saved session)")]
NotLoggedIn,
+1
View File
@@ -6,6 +6,7 @@ pub mod auth;
pub mod cache;
pub mod config;
pub mod control;
pub mod db;
pub mod error;
pub mod service;
+305 -26
View File
@@ -49,7 +49,10 @@ use fuser::{
ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
};
use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
use pdfs_core::control::{DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse};
use pdfs_core::control::{
DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit,
};
use pdfs_core::db::{Db, StoredNode};
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient,
@@ -186,11 +189,19 @@ struct State {
/// have no entry here.
handles: HashMap<u64, WriteHandle>,
next_fh: u64,
/// Unified SQLite metadata cache. Every map mutation below writes through to
/// it inside the `State` lock so the DB stays the authoritative copy across
/// restarts (see plan.md P1).
db: Arc<Db>,
}
impl State {
/// Allocate (or reuse) a stable inode for a node and store its metadata.
/// Allocate (or reuse) a stable inode for a node and store its metadata,
/// writing the node through to the DB.
fn intern(&mut self, parent: u64, node: Node) -> u64 {
if let Err(e) = self.db.upsert_node(&node) {
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
}
if let Some(&ino) = self.by_uid.get(&node.uid) {
if let Some(e) = self.entries.get_mut(&ino) {
e.node = node;
@@ -213,10 +224,14 @@ impl State {
}
/// Forget a node entirely: drop its inode, its uid mapping, its own cached
/// listing, and its slot in its parent's listing. Returns `(parent_ino,
/// name)` when the node was known, so the caller can notify the kernel.
/// listing, its slot in its parent's listing, and its DB row. Returns
/// `(parent_ino, name)` when the node was known, so the caller can notify
/// the kernel.
fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
let ino = self.by_uid.remove(uid)?;
if let Err(e) = self.db.delete_node(uid) {
warn!(%uid, error = %e, "db delete_node failed");
}
let entry = self.entries.remove(&ino)?;
self.children.remove(&ino);
if let Some(kids) = self.children.get_mut(&entry.parent) {
@@ -225,6 +240,20 @@ impl State {
Some((entry.parent, entry.node.name))
}
/// Drop a directory's cached child listing and mark it unlisted in the DB,
/// so the next access re-enumerates instead of trusting a stale listing.
fn invalidate_listing(&mut self, ino: u64) {
if self.children.remove(&ino).is_none() {
return;
}
if let Some(e) = self.entries.get(&ino) {
let uid = e.uid.clone();
if let Err(err) = self.db.set_listed(&uid, false) {
warn!(%uid, error = %err, "db set_listed(false) failed");
}
}
}
/// Update a file entry's recorded plaintext size so `getattr` reflects an
/// in-progress write before the new revision is sealed.
fn set_size(&mut self, ino: u64, size: u64) {
@@ -255,6 +284,10 @@ struct Core {
rt: tokio::runtime::Handle,
state: Arc<Mutex<State>>,
cache: Arc<ContentCache>,
/// Unified SQLite metadata cache: the persistence layer behind the in-memory
/// `State` maps. Every mutation writes through here, and the maps rehydrate
/// from it on mount (plan.md P1).
db: Arc<Db>,
/// Cached full photos timeline (newest first), so paged `PhotosTimeline`
/// requests slice memory instead of re-fetching the whole timeline per page.
/// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`].
@@ -268,6 +301,71 @@ struct TimelineCache {
}
impl Core {
/// Rehydrate the in-memory `State` maps from the DB on mount, so a cold
/// start serves previously-seen metadata (stable inodes, instant listings)
/// without re-hitting the API. The root inode is already installed by
/// [`ProtonFs::new`]; this fills in every other persisted node and rebuilds
/// the child listings of folders the DB records as fully enumerated.
fn hydrate(&self) {
let stored = match self.db.load_all() {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "db load_all failed; mounting cold");
return;
}
};
if stored.is_empty() {
return;
}
let mut st = self.state.lock().unwrap();
// Pass 1: assign a stable inode to every uid (root is already mapped).
for sn in &stored {
if st.by_uid.contains_key(&sn.node.uid) {
continue;
}
let ino = st.next_ino;
st.next_ino += 1;
st.by_uid.insert(sn.node.uid.clone(), ino);
}
// Pass 2: materialize entries, resolving each parent's inode by uid.
// Track folders flagged complete so their listings rebuild in pass 3.
let mut listed_dirs: Vec<u64> = Vec::new();
for sn in stored {
let StoredNode { node, listed } = sn;
let Some(&ino) = st.by_uid.get(&node.uid) else {
continue;
};
if listed && node.is_folder() {
listed_dirs.push(ino);
}
// The root entry is owned by `ProtonFs::new`; don't overwrite it.
if ino == ROOT_INO {
continue;
}
let parent = node
.parent_uid
.as_ref()
.and_then(|p| st.by_uid.get(p).copied())
.unwrap_or(ROOT_INO);
let uid = node.uid.clone();
st.entries.insert(ino, Entry { uid, parent, node });
}
// Pass 3: rebuild child listings for fully-enumerated folders.
for dir_ino in listed_dirs {
let kids: Vec<u64> = st
.entries
.iter()
.filter(|(_, e)| e.parent == dir_ino && !e.node.trashed)
.map(|(&ino, _)| ino)
.collect();
st.children.insert(dir_ino, kids);
}
info!(nodes = st.entries.len(), "hydrated metadata cache from db");
}
/// Enumerate `ino`'s children from the remote and cache them. No-op if the
/// directory has already been listed. Network I/O happens without the lock
/// held so concurrent metadata reads aren't blocked behind a fetch.
@@ -283,6 +381,29 @@ impl Core {
}
};
// Offline fast path: a folder the DB still records as fully enumerated
// can be rebuilt from disk without hitting the API, even if its listing
// was trimmed from the hot cache mid-run.
match self.db.children_if_listed(&folder_uid) {
Ok(Some(nodes)) => {
let mut st = self.state.lock().unwrap();
if st.children.contains_key(&ino) {
return Ok(());
}
let mut child_inos = Vec::with_capacity(nodes.len());
for node in nodes {
if node.trashed {
continue;
}
child_inos.push(st.intern(ino, node));
}
st.children.insert(ino, child_inos);
return Ok(());
}
Ok(None) => {}
Err(e) => warn!(%folder_uid, error = %e, "db children_if_listed failed"),
}
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(&folder_uid))
@@ -311,6 +432,11 @@ impl Core {
child_inos.push(st.intern(ino, node));
}
st.children.insert(ino, child_inos);
// Record the listing as complete so a later restart (or a trimmed hot
// cache) can rebuild it from the DB without the API.
if let Err(e) = self.db.set_listed(&folder_uid, true) {
warn!(%folder_uid, error = %e, "db set_listed(true) failed");
}
Ok(())
}
@@ -553,25 +679,32 @@ impl Core {
Ok(())
}
/// Pin the file at mountpoint-relative `rel`: download its full plaintext
/// into the content cache and record it in the pin registry. Returns the
/// resolved node name on success.
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
/// plaintext into the content cache; a folder records a recursive pin and
/// downloads every descendant file (selective sync). Returns a human message.
fn pin(&self, rel: &Path) -> Result<String, String> {
let (ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let (name, mtime, size) = {
let (name, is_folder, mtime, size) = {
let st = self.state.lock().unwrap();
let e = st.entries.get(&ino).ok_or("node vanished")?;
if !e.node.is_file() {
return Err("not a regular file".into());
}
(
e.node.name.clone(),
e.node.is_folder(),
e.node.modification_time,
node_size(&e.node),
)
};
if is_folder {
// Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree.
self.cache
.add_pin(&uid, rel, true)
.map_err(|e| format!("pin: {e}"))?;
let n = self.pin_subtree(ino)?;
return Ok(format!("{name} ({n} files)"));
}
let bytes = self
.rt
.block_on(self.client.download_file(&uid))
@@ -580,11 +713,56 @@ impl Core {
.store(&uid, mtime, size, &bytes)
.map_err(|e| format!("cache store: {e}"))?;
self.cache
.add_pin(&uid, rel)
.add_pin(&uid, rel, false)
.map_err(|e| format!("pin: {e}"))?;
Ok(name)
}
/// Download and cache every file in the subtree rooted at folder `ino`,
/// returning the count cached (already-fresh blobs counted, not re-fetched).
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
/// fully discovered; the lock is dropped before each network download.
fn pin_subtree(&self, ino: u64) -> Result<usize, String> {
let mut files: Vec<(NodeUid, i64, u64)> = Vec::new();
let mut stack = vec![ino];
while let Some(dir) = stack.pop() {
self.ensure_children(dir)
.map_err(|e| format!("enumerate: {e:?}"))?;
let st = self.state.lock().unwrap();
if let Some(kids) = st.children.get(&dir) {
for &k in kids {
if let Some(e) = st.entries.get(&k) {
if e.node.is_folder() {
stack.push(k);
} else {
files.push((
e.uid.clone(),
e.node.modification_time,
node_size(&e.node),
));
}
}
}
}
}
let mut count = 0;
for (uid, mtime, size) in files {
if self.cache.is_cached(&uid, mtime, size) {
count += 1;
continue;
}
match self.rt.block_on(self.client.download_file(&uid)) {
Ok(bytes) => {
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
count += 1;
}
}
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
}
}
Ok(count)
}
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
/// when fresh and otherwise downloaded from the remote and cached. Returns
/// `Ok(None)` when the node is not a file or has no thumbnail of that type.
@@ -615,21 +793,32 @@ impl Core {
Ok(bytes)
}
/// Unpin the file at `rel`, evicting its cached content.
/// Unpin the node at `rel`, evicting its cached content. For a folder, also
/// evicts every descendant's cached blob (the subtree is no longer kept).
fn unpin(&self, rel: &Path) -> Result<String, String> {
let (ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let name = {
let (name, is_folder) = {
let st = self.state.lock().unwrap();
st.entries
.get(&ino)
.map(|e| e.node.name.clone())
.map(|e| (e.node.name.clone(), e.node.is_folder()))
.unwrap_or_default()
};
self.cache
.remove_pin(&uid)
.map_err(|e| format!("unpin: {e}"))?;
// A recursively-pinned folder's descendants were eviction-exempt; now
// that the pin is gone, reclaim their blobs eagerly instead of waiting
// for budget pressure. Descendants come from the DB node tree.
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
for s in uids {
if let Some(u) = parse_uid(&s) {
self.cache.evict(&u);
}
}
}
Ok(name)
}
@@ -681,6 +870,31 @@ impl Core {
modified,
pinned: self.cache.is_pinned(&uid),
uid: uid.to_string(),
// Listing entries live in the requested dir; the caller derives
// the path from its name. Left empty.
path: String::new(),
})
.collect())
}
/// Full-text search node names against the local SQLite index, mapping each
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
/// which the DB doesn't track). Pure local lookup — never hits the network.
fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, String> {
let hits = self
.db
.search(query, limit)
.map_err(|e| format!("search: {e}"))?;
Ok(hits
.into_iter()
.map(|h| SearchHit {
name: h.node.name.clone(),
path: h.path,
is_dir: h.node.is_folder(),
size: node_size(&h.node),
modified: h.node.modification_time,
pinned: self.cache.is_pinned(&h.node.uid),
uid: h.node.uid.to_string(),
})
.collect())
}
@@ -855,6 +1069,9 @@ impl ProtonFs {
fn new(core: Core, root: Node) -> Self {
{
let mut st = core.state.lock().unwrap();
if let Err(e) = st.db.upsert_node(&root) {
warn!(uid = %root.uid, error = %e, "db upsert root failed");
}
st.by_uid.insert(root.uid.clone(), ROOT_INO);
st.entries.insert(
ROOT_INO,
@@ -1710,7 +1927,7 @@ fn apply_event(
// Known node changed: drop its cached attrs/data (and listing if
// it is a directory) so the next access re-fetches. Its content
// blob may now be stale, so evict it too.
st.children.remove(&ino);
st.invalidate_listing(ino);
content.evict(node_uid);
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
@@ -1718,8 +1935,8 @@ fn apply_event(
// drop it so the new child is picked up on the next readdir.
if let Some(parent_uid) = parent_node_uid
&& let Some(&parent) = st.by_uid.get(parent_uid)
&& st.children.remove(&parent).is_some()
{
st.invalidate_listing(parent);
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
}
}
@@ -1746,8 +1963,8 @@ fn apply_event(
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
let mut st = state.lock().unwrap();
let dirs: Vec<u64> = st.children.keys().copied().collect();
st.children.clear();
for ino in dirs {
for &ino in &dirs {
st.invalidate_listing(ino);
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
}
@@ -1758,20 +1975,41 @@ fn apply_event(
}
/// Poll the remote event cursor forever, applying each batch to the shared
/// state. Seeds the cursor from the current server head so we only react to
/// changes made after mount. Runs as a Tokio task; returns only on fatal error.
/// state. Resumes from the cursor persisted in the DB so changes made while
/// unmounted are applied; only a first-ever mount seeds from the server head.
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
/// only on fatal error.
async fn run_event_sync(
client: ProtonDriveClient,
scope: DriveEventScopeId,
state: Arc<Mutex<State>>,
content: Arc<ContentCache>,
db: Arc<Db>,
notifier: Notifier,
) {
// Seed: a `None` cursor yields a single `CursorAdvanced` at the server head.
let mut cursor: Option<DriveEventId> = match client.enumerate_events(&scope, None).await {
Ok(events) => events.last().map(|e| e.id().clone()),
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
// Resume: pick up exactly where the last run left off.
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
// First mount: a `None` cursor yields a single `CursorAdvanced` at the
// server head; persist it so the next restart resumes instead of
// reseeding (which would skip everything that changed offline).
Ok(None) => match client.enumerate_events(&scope, None).await {
Ok(events) => {
let head = events.last().map(|e| e.id().clone());
if let Some(c) = &head
&& let Err(e) = db.set_event_cursor(c.as_str())
{
warn!(error = %e, "persist seed cursor failed");
}
head
}
Err(e) => {
error!(error = %e, "failed to seed event cursor; live sync disabled");
return;
}
},
Err(e) => {
error!(error = %e, "failed to seed event cursor; live sync disabled");
error!(error = %e, "read persisted cursor failed; live sync disabled");
return;
}
};
@@ -1794,6 +2032,11 @@ async fn run_event_sync(
apply_event(&state, &content, &notifier, event);
}
cursor = events.last().map(|e| e.id().clone());
if let Some(c) = &cursor
&& let Err(e) = db.set_event_cursor(c.as_str())
{
warn!(error = %e, "persist event cursor failed");
}
}
}
@@ -1889,6 +2132,32 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: format!("bad uid: {uid}"),
},
},
Ok(CtlRequest::UploadPhoto {
name,
media_type,
bytes,
capture_time,
}) => {
let photos = core.photos();
let metadata = proton_drive_rs::PhotoUploadMetadata {
capture_time,
..Default::default()
};
match core
.rt
.block_on(photos.upload_photo(&name, &media_type, &bytes, metadata))
{
Ok(uid) => {
*core.timeline.lock().unwrap() = None;
CtlResponse::Ok {
message: format!("uploaded photo with uid {uid}"),
}
}
Err(e) => CtlResponse::Error {
message: format!("upload photo failed: {e}"),
},
}
}
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.open_file(&rel) {
Ok(p) => CtlResponse::FilePath {
@@ -1898,6 +2167,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) {
Ok(hits) => CtlResponse::SearchResults { hits },
Err(e) => CtlResponse::Error { message: e },
},
Err(e) => CtlResponse::Error {
message: format!("bad request: {e}"),
},
@@ -1981,6 +2254,7 @@ pub fn mount(
mountpoint: &Path,
cache: ContentCache,
control_socket: &Path,
db: Arc<Db>,
username: String,
) -> std::io::Result<MountOutcome> {
let root = rt
@@ -1998,8 +2272,10 @@ pub fn mount(
next_ino: 2,
handles: HashMap::new(),
next_fh: 1,
db: db.clone(),
})),
cache: Arc::new(cache),
db,
timeline: Arc::new(Mutex::new(None)),
};
@@ -2029,6 +2305,9 @@ pub fn mount(
}
let fs = ProtonFs::new(core.clone(), root);
// Warm the in-memory maps from the DB so a cold start serves previously
// seen metadata without re-hitting the API (plan.md P1).
core.hydrate();
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
// for the event task. `spawn` runs the session loop on its own background
@@ -2036,7 +2315,7 @@ pub fn mount(
let bg = Session::new(fs, mountpoint, &config)?.spawn()?;
let notifier = bg.notifier();
rt.spawn(run_event_sync(
client, scope, core.state, core.cache, notifier,
client, scope, core.state, core.cache, core.db, notifier,
));
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
+662 -28
View File
@@ -32,7 +32,7 @@ use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, send};
use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, SearchHit, send};
use pdfs_core::service;
/// How many photos to pull per [`Request::PhotosTimeline`] page.
@@ -47,6 +47,11 @@ const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
/// Backoff between auto-retries of a Files/Photos load while the mount service
/// is still coming up (see [`load_browser`] / [`load_gallery`]).
const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2);
/// Idle pause after the last keystroke before a search query is sent, so typing
/// doesn't fire a request per character.
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250);
/// Cap on search hits requested from the daemon.
const SEARCH_LIMIT: usize = 200;
/// 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.
@@ -112,11 +117,17 @@ struct Ui {
browser_retry: gtk4::Button,
/// Mountpoint-relative path the browser is showing (empty = root).
browser_path: RefCell<String>,
/// Debounced full-text search box in the browser header.
browser_search: gtk4::SearchEntry,
/// Pending debounce timer for the search box; replaced on every keystroke so
/// only the last pause actually fires a [`Request::Search`].
search_source: RefCell<Option<glib::SourceId>>,
// Photos (gallery) page.
gallery_model: gio::ListStore,
gallery_status: gtk4::Label,
gallery_retry: gtk4::Button,
gallery_more: gtk4::Button,
gallery_upload: gtk4::Button,
}
impl Ui {
@@ -173,7 +184,17 @@ fn load_proton_theme() {
.brand-icon {{ color: {PROTON_PURPLE}; }}\n\
.file-grid {{ padding: 6px; }}\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\
.photo-viewer-window {{ background-color: #000000; }}\n\
.viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 12px 24px; color: white; }}\n\
.viewer-title {{ font-weight: bold; font-size: 1.1rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\
.viewer-action-btn {{ color: white; background-color: rgba(255, 255, 255, 0.15); border-radius: 50%; padding: 8px; margin-left: 4px; }}\n\
.viewer-action-btn:hover {{ background-color: rgba(255, 255, 255, 0.3); color: white; }}\n\
.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\
.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"
);
let provider = gtk4::CssProvider::new();
provider.load_from_string(&css);
@@ -252,15 +273,19 @@ fn build_window(app: &adw::Application) {
browser_status: browser_widgets.3,
browser_retry: browser_widgets.6,
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,
});
wire_login(&ui);
wire_logout(&ui, &main_widgets.5);
wire_browser(&ui, &browser_widgets.4, &browser_widgets.5);
wire_search(&ui);
wire_gallery(&ui, &gallery_widgets.3);
wire_retry(&ui);
@@ -831,6 +856,7 @@ fn build_browser_page() -> (
gtk4::GridView,
gtk4::ColumnView,
gtk4::Button,
gtk4::SearchEntry,
),
) {
let model = gio::ListStore::new::<BoxedAnyObject>();
@@ -866,9 +892,16 @@ fn build_browser_page() -> (
toggles.append(&grid_toggle);
toggles.append(&list_toggle);
let search = gtk4::SearchEntry::builder()
.placeholder_text("Search Drive")
.valign(gtk4::Align::Center)
.build();
search.set_width_chars(18);
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&back);
header.append(&crumb);
header.append(&search);
header.append(&toggles);
let status = gtk4::Label::builder().wrap(true).build();
@@ -938,7 +971,7 @@ fn build_browser_page() -> (
(
inner.upcast(),
(model, back, crumb, status, grid, column_view, retry),
(model, back, crumb, status, grid, column_view, retry, search),
)
}
@@ -1165,6 +1198,11 @@ fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Option<DirEnt
fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
if entry.is_dir {
// Descending into a search hit: clear the query so the folder listing
// isn't immediately re-masked by a stale search.
if !entry.path.is_empty() {
ui.browser_search.set_text("");
}
*ui.browser_path.borrow_mut() = rel;
load_browser(ui);
} else {
@@ -1217,6 +1255,11 @@ fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
/// Join the entry name onto the current browser directory to get its
/// mountpoint-relative path.
fn entry_rel(ui: &Rc<Ui>, entry: &DirEntry) -> String {
// Search hits carry an absolute (mountpoint-relative) path since they can
// live anywhere; plain listing entries derive it from the current folder.
if !entry.path.is_empty() {
return entry.path.clone();
}
let base = ui.browser_path.borrow();
if base.is_empty() {
entry.name.clone()
@@ -1357,6 +1400,97 @@ fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
}
}
/// Wire the browser header's search box: debounce keystrokes, then either run a
/// search or — when cleared — restore the current directory listing.
fn wire_search(ui: &Rc<Ui>) {
let ui_s = ui.clone();
ui.browser_search.connect_search_changed(move |_| {
// Replace any pending debounce so only the last keystroke's pause fires.
if let Some(src) = ui_s.search_source.borrow_mut().take() {
src.remove();
}
let ui_t = ui_s.clone();
let src = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || {
ui_t.search_source.borrow_mut().take();
let query = ui_t.browser_search.text().trim().to_string();
if query.is_empty() {
load_browser(&ui_t);
} else {
run_search(&ui_t, &query);
}
});
*ui_s.search_source.borrow_mut() = Some(src);
});
}
/// Send a [`Request::Search`] to the daemon and render the hits in the browser
/// views, reusing the same row model so click-to-open and pin work unchanged
/// (each hit carries its full path; see [`entry_rel`]).
fn run_search(ui: &Rc<Ui>, query: &str) {
ui.browser_model.remove_all();
ui.browser_retry.set_visible(false);
ui.browser_status.set_label("Searching…");
ui.browser_status.set_visible(true);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::Search {
query: query.to_string(),
limit: SEARCH_LIMIT,
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
// The box may have been cleared (or changed) while the reply was in
// flight; if so a fresh load/search already owns the model — drop this.
if ui.browser_search.text().trim().is_empty() {
return;
}
match result {
Ok(Ok(Response::SearchResults { hits })) => repaint_search(&ui, &hits),
Ok(Ok(Response::Error { message })) => browser_message(&ui, &message),
Ok(Ok(_)) => browser_message(&ui, "Unexpected reply from daemon."),
Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
}
});
}
/// Repopulate the model with search hits — folders first, then by name — mapping
/// each [`SearchHit`] to a path-carrying [`DirEntry`] the existing renderers and
/// handlers already understand.
fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
ui.browser_model.remove_all();
if hits.is_empty() {
browser_message(ui, "No matches.");
return;
}
ui.browser_status.set_visible(false);
let mut entries: Vec<DirEntry> = hits
.iter()
.map(|h| DirEntry {
name: h.name.clone(),
is_dir: h.is_dir,
size: h.size,
modified: h.modified,
pinned: h.pinned,
uid: h.uid.clone(),
path: h.path.clone(),
})
.collect();
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
for entry in entries {
ui.browser_model.append(&BoxedAnyObject::new(entry));
}
}
/// 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.
@@ -1368,6 +1502,7 @@ fn build_gallery_page() -> (
gtk4::Button,
gtk4::GridView,
gtk4::Button,
gtk4::Button, // Upload Photo
),
) {
let model = gio::ListStore::new::<BoxedAnyObject>();
@@ -1429,21 +1564,41 @@ fn build_gallery_page() -> (
.child(&grid)
.build();
// Modern header: "Photos" title + "Upload Photo" action button
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let title_label = gtk4::Label::builder()
.label("Photos")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
title_label.add_css_class("heading");
let upload = gtk4::Button::builder()
.label("Upload Photo")
.icon_name("list-add-symbolic")
.valign(gtk4::Align::Center)
.build();
upload.add_css_class("pill");
upload.add_css_class("suggested-action");
header_box.append(&title_label);
header_box.append(&upload);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header_box);
inner.append(&status);
inner.append(&retry);
inner.append(&scroll);
inner.append(&more);
(inner.upcast(), (model, status, more, grid, retry))
(inner.upcast(), (model, status, more, grid, retry, upload))
}
/// Wire the gallery: activating a tile downloads the photo and opens it; the
/// "Load more" button appends the next page.
/// 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: &gtk4::GridView) {
let ui_open = ui.clone();
grid.connect_activate(move |grid, pos| {
@@ -1454,34 +1609,513 @@ fn wire_gallery(ui: &Rc<Ui>, grid: &gtk4::GridView) {
.downcast_ref::<BoxedAnyObject>()
.map(|o| o.borrow::<PhotoItem>().uid.clone());
let Some(uid) = uid else { return };
// Ignore a repeat activation of a photo already downloading.
if !ui_open.opening.borrow_mut().insert(uid.clone()) {
return;
}
ui_open.busy_begin();
let rx = spawn_request(
ui_open.dirs.control_socket(),
Request::OpenPhoto { uid: uid.clone() },
);
let ui = ui_open.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.opening.borrow_mut().remove(&uid);
match result {
Ok(Ok(Response::FilePath { path })) => open_path(&path),
Ok(Ok(Response::Error { message })) => {
tracing::error!("open photo failed: {message}")
}
_ => tracing::error!("open photo request failed"),
}
});
open_photo_viewer(&ui_open, uid);
});
let ui_more = ui.clone();
ui.gallery_more.clone().connect_clicked(move |_| {
load_gallery(&ui_more, true);
});
let ui_upload = ui.clone();
ui.gallery_upload.connect_clicked(move |_| {
let dialog = gtk4::FileDialog::builder()
.title("Select Photo to Upload")
.build();
let filter = gtk4::FileFilter::new();
filter.set_name(Some("Images"));
filter.add_mime_type("image/*");
let filters = gio::ListStore::new::<gtk4::FileFilter>();
filters.append(&filter);
dialog.set_filters(Some(&filters));
let ui = ui_upload.clone();
let parent_win = ui.stack.root().and_downcast::<gtk4::Window>();
dialog.open(parent_win.as_ref(), gio::Cancellable::NONE, move |res| {
if let Ok(file) = res {
if let Some(path) = file.path() {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("photo.jpg")
.to_string();
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
tracing::error!("Failed to read photo: {e}");
return;
}
};
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("jpg")
.to_lowercase();
let media_type = match ext.as_str() {
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"tiff" | "tif" => "image/tiff",
_ => "image/jpeg",
};
let capture_time = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::UploadPhoto {
name,
media_type: media_type.to_string(),
bytes,
capture_time,
},
);
let ui_clone = ui.clone();
glib::spawn_future_local(async move {
let res = rx.recv().await;
ui_clone.busy_end();
match res {
Ok(Ok(Response::Ok { message })) => {
tracing::info!("Photo uploaded: {message}");
load_gallery(&ui_clone, false);
}
Ok(Ok(Response::Error { message })) => {
tracing::error!("Upload failed: {message}");
show_error_dialog(&ui_clone, &format!("Upload failed: {message}"));
}
_ => {
tracing::error!("Upload request failed");
show_error_dialog(
&ui_clone,
"Upload request failed (daemon unreachable).",
);
}
}
});
}
}
});
});
}
fn show_error_dialog(ui: &Rc<Ui>, message: &str) {
let parent = ui.stack.root().and_downcast::<gtk4::Window>();
let dialog = adw::AlertDialog::builder()
.heading("Photo Operation")
.body(message)
.build();
dialog.add_response("ok", "OK");
dialog.present(parent.as_ref());
}
fn find_photo_index(model: &gio::ListStore, uid: &str) -> Option<u32> {
for i in 0..model.n_items() {
if let Some(obj) = model.item(i) {
if let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() {
if boxed.borrow::<PhotoItem>().uid == uid {
return Some(i);
}
}
}
}
None
}
fn format_capture_time(secs: i64) -> String {
let date = glib::DateTime::from_unix_local(secs);
match date {
Ok(d) => match d.format("%Y-%m-%d %H:%M:%S") {
Ok(s) => s.to_string(),
Err(_) => "Unknown Date".to_string(),
},
Err(_) => "Unknown Date".to_string(),
}
}
fn load_photo(
ui: &Rc<Ui>,
uid: String,
picture: &gtk4::Picture,
spinner: &gtk4::Spinner,
status_label: &gtk4::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;
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();
glib::spawn_future_local(async move {
let result = rx.recv().await;
spinner_clone.stop();
spinner_clone.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);
}
Err(e) => {
status_clone.set_label(&format!("Failed to load image: {e}"));
status_clone.set_visible(true);
}
},
Ok(Ok(Response::Error { message })) => {
status_clone.set_label(&format!("Error downloading photo: {message}"));
status_clone.set_visible(true);
}
_ => {
status_clone.set_label("Failed to connect to daemon");
status_clone.set_visible(true);
}
}
});
}
fn save_photo_to_disk(window: &gtk4::Window, source_path: &str, original_name: &str) {
let dialog = gtk4::FileDialog::builder()
.title("Save Photo")
.initial_name(original_name)
.build();
let source_path_str = source_path.to_string();
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
if let Ok(file) = res {
if let Some(dest_path) = file.path() {
if let Err(e) = std::fs::copy(&source_path_str, &dest_path) {
tracing::error!("Failed to copy file to {:?}: {}", dest_path, e);
} else {
tracing::info!("Saved photo to {:?}", dest_path);
}
}
}
});
}
fn navigate_photo(
ui: &Rc<Ui>,
current_uid: &Rc<RefCell<String>>,
delta: i32,
picture: &gtk4::Picture,
spinner: &gtk4::Spinner,
status_label: &gtk4::Label,
title_label: &gtk4::Label,
prev_btn: &gtk4::Button,
next_btn: &gtk4::Button,
current_path: Rc<RefCell<Option<String>>>,
) {
let model = &ui.gallery_model;
let n = model.n_items();
if n == 0 {
return;
}
let uid_val = current_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;
let Some(obj) = model.item(next_idx) else {
return;
};
let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() else {
return;
};
let photo = boxed.borrow::<PhotoItem>().clone();
*current_uid.borrow_mut() = photo.uid.clone();
prev_btn.set_sensitive(next_idx > 0);
next_btn.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));
load_photo(
ui,
photo.uid.clone(),
picture,
spinner,
status_label,
current_path,
);
}
fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
let window = gtk4::Window::builder()
.title("Photo Viewer")
.modal(true)
.transient_for(&parent)
.default_width(800)
.default_height(600)
.build();
window.add_css_class("photo-viewer-window");
let overlay = gtk4::Overlay::new();
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Contain)
.hexpand(true)
.vexpand(true)
.build();
overlay.set_child(Some(&picture));
let prev_btn = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text("Previous Photo")
.halign(gtk4::Align::Start)
.valign(gtk4::Align::Center)
.build();
prev_btn.add_css_class("circular");
prev_btn.add_css_class("flat");
prev_btn.add_css_class("viewer-nav-btn");
overlay.add_overlay(&prev_btn);
let next_btn = gtk4::Button::builder()
.icon_name("go-next-symbolic")
.tooltip_text("Next Photo")
.halign(gtk4::Align::End)
.valign(gtk4::Align::Center)
.build();
next_btn.add_css_class("circular");
next_btn.add_css_class("flat");
next_btn.add_css_class("viewer-nav-btn");
overlay.add_overlay(&next_btn);
let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
top_bar.add_css_class("viewer-top-bar");
top_bar.set_valign(gtk4::Align::Start);
top_bar.set_hexpand(true);
let title_label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.hexpand(true)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
title_label.add_css_class("viewer-title");
top_bar.append(&title_label);
let download_btn = gtk4::Button::builder()
.icon_name("document-save-symbolic")
.tooltip_text("Download to Disk")
.build();
download_btn.add_css_class("flat");
download_btn.add_css_class("viewer-action-btn");
top_bar.append(&download_btn);
let open_ext_btn = gtk4::Button::builder()
.icon_name("document-open-symbolic")
.tooltip_text("Open in External App")
.build();
open_ext_btn.add_css_class("flat");
open_ext_btn.add_css_class("viewer-action-btn");
top_bar.append(&open_ext_btn);
let close_btn = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Close")
.build();
close_btn.add_css_class("flat");
close_btn.add_css_class("viewer-action-btn");
top_bar.append(&close_btn);
overlay.add_overlay(&top_bar);
let spinner = gtk4::Spinner::builder()
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.width_request(48)
.height_request(48)
.build();
overlay.add_overlay(&spinner);
let status_label = gtk4::Label::builder()
.wrap(true)
.justify(gtk4::Justification::Center)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.build();
status_label.add_css_class("viewer-status");
overlay.add_overlay(&status_label);
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 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);
let mut initial_capture_time = 0;
if let Some(obj) = ui.gallery_model.item(initial_idx) {
if let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() {
initial_capture_time = boxed.borrow::<PhotoItem>().capture_time;
}
}
let date_str = format_capture_time(initial_capture_time);
title_label.set_label(&format!(
"Photo {} of {} • {}",
initial_idx + 1,
n,
date_str
));
load_photo(
ui,
initial_uid,
&picture,
&spinner,
&status_label,
current_path.clone(),
);
let w_close = window.clone();
close_btn.connect_clicked(move |_| {
w_close.close();
});
let w_download = window.clone();
let path_download = current_path.clone();
let uid_download = current_uid.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);
save_photo_to_disk(&w_download, path, &name);
}
});
let path_ext = current_path.clone();
open_ext_btn.connect_clicked(move |_| {
if let Some(path) = path_ext.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();
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(),
);
});
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();
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(),
);
});
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 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);
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(),
);
}
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(),
);
}
glib::Propagation::Stop
}
Some("Escape") => {
w_key.close();
glib::Propagation::Stop
}
_ => glib::Propagation::Proceed,
}
});
window.add_controller(key_controller);
window.present();
}
/// Fetch a timeline page from the daemon. When `append` is false the model is