mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
implemented database for search
This commit is contained in:
1 parent
5e151bbfc5
commit
03dd8d8496
10 files changed
+2342
-167
No files matched your search
@@ -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.
|
||||
|
||||
@@ -17,4 +17,5 @@ thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
directories.workspace = true
|
||||
sha2.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
+249
-113
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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, ¬ifier, 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
@@ -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: >k4::GridView) {
|
||||
let ui_open = ui.clone();
|
||||
grid.connect_activate(move |grid, pos| {
|
||||
@@ -1454,34 +1609,513 @@ fn wire_gallery(ui: &Rc<Ui>, grid: >k4::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: >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;
|
||||
|
||||
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: >k4::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: >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>>>,
|
||||
) {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user