mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +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::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
@@ -9,6 +10,7 @@ use pdfs_core::auth;
|
|||||||
use pdfs_core::cache::ContentCache;
|
use pdfs_core::cache::ContentCache;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse};
|
use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse};
|
||||||
|
use pdfs_core::db::Db;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
@@ -76,6 +78,14 @@ enum Command {
|
|||||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||||
uid: String,
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -102,6 +112,7 @@ fn main() -> Result<()> {
|
|||||||
Command::Ls { path } => cmd_ls(path),
|
Command::Ls { path } => cmd_ls(path),
|
||||||
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
||||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
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()))?;
|
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
|
||||||
|
|
||||||
let username = auth::load().map(|s| s.username).unwrap_or_default();
|
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(
|
let cache = ContentCache::open(
|
||||||
dirs.content_cache_dir(),
|
dirs.content_cache_dir(),
|
||||||
dirs.pins_path(),
|
dirs.pins_path(),
|
||||||
pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES,
|
pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES,
|
||||||
|
db.clone(),
|
||||||
)
|
)
|
||||||
.context("open content cache")?;
|
.context("open content cache")?;
|
||||||
let control_socket = dirs.control_socket();
|
let control_socket = dirs.control_socket();
|
||||||
@@ -272,6 +288,7 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
&mountpoint,
|
&mountpoint,
|
||||||
cache,
|
cache,
|
||||||
&control_socket,
|
&control_socket,
|
||||||
|
db,
|
||||||
username,
|
username,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -371,6 +388,22 @@ fn cmd_open_photo(uid: String) -> Result<()> {
|
|||||||
Ok(())
|
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
|
/// Normalize a user-supplied path argument for sending to the daemon. Absolute
|
||||||
/// paths are canonicalized (so the daemon can strip its mountpoint prefix);
|
/// paths are canonicalized (so the daemon can strip its mountpoint prefix);
|
||||||
/// relative paths are passed through and resolved against the mount root.
|
/// relative paths are passed through and resolved against the mount root.
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ thiserror.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
rusqlite.workspace = true
|
||||||
keyring.workspace = true
|
keyring.workspace = true
|
||||||
+249
-113
@@ -19,14 +19,32 @@
|
|||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::{Path, PathBuf};
|
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 proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::db::Db;
|
||||||
use crate::error::Result;
|
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
|
/// 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
|
/// (`DEFAULT_BLOCK_SIZE`, 4 MiB) so each cached block maps to exactly one
|
||||||
/// `download_range` fetch with no straddling.
|
/// `download_range` fetch with no straddling.
|
||||||
@@ -42,18 +60,22 @@ struct Meta {
|
|||||||
size: u64,
|
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)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct Pin {
|
pub struct Pin {
|
||||||
/// Node uid in `volume~link` display form.
|
/// Node uid in `volume~link` display form.
|
||||||
pub uid: String,
|
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.
|
/// only — the uid is the identity.
|
||||||
pub path: String,
|
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
|
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
||||||
/// survives renames/moves of the node.
|
/// the DB once on open. Live pins are owned by the `pins` table.
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
struct PinFile {
|
struct PinFile {
|
||||||
pins: BTreeMap<String, Pin>,
|
pins: BTreeMap<String, Pin>,
|
||||||
@@ -80,13 +102,24 @@ pub struct ContentCache {
|
|||||||
/// disables the cap. Pinned blobs are never evicted, so pins alone may push
|
/// disables the cap. Pinned blobs are never evicted, so pins alone may push
|
||||||
/// the cache over budget.
|
/// the cache over budget.
|
||||||
max_bytes: u64,
|
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 {
|
impl ContentCache {
|
||||||
/// Open (and create) a cache under `content_dir`, with the pin registry at
|
/// 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
|
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
|
||||||
/// directories are created if missing.
|
/// directories are created if missing. `db` is the shared metadata database
|
||||||
pub fn open(content_dir: PathBuf, pins_path: PathBuf, max_bytes: u64) -> Result<Self> {
|
/// 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)?;
|
std::fs::create_dir_all(&content_dir)?;
|
||||||
let thumb_dir = content_dir.join("thumbs");
|
let thumb_dir = content_dir.join("thumbs");
|
||||||
std::fs::create_dir_all(&thumb_dir)?;
|
std::fs::create_dir_all(&thumb_dir)?;
|
||||||
@@ -100,14 +133,71 @@ impl ContentCache {
|
|||||||
if let Some(parent) = pins_path.parent() {
|
if let Some(parent) = pins_path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
Ok(Self {
|
let cache = Self {
|
||||||
content_dir,
|
content_dir,
|
||||||
thumb_dir,
|
thumb_dir,
|
||||||
block_dir,
|
block_dir,
|
||||||
scratch_dir,
|
scratch_dir,
|
||||||
pins_path,
|
pins_path,
|
||||||
max_bytes,
|
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
|
/// Filesystem-safe, fixed-length cache key for a uid display string: the
|
||||||
@@ -171,10 +261,10 @@ impl ContentCache {
|
|||||||
len: u64,
|
len: u64,
|
||||||
) -> Option<Vec<u8>> {
|
) -> Option<Vec<u8>> {
|
||||||
let blob = self.valid_blob(uid, mtime, size)?;
|
let blob = self.valid_blob(uid, mtime, size)?;
|
||||||
// Record the access for LRU: bump the blob's mtime to now. Best effort —
|
// Record the access for LRU in the cache index. Best effort — a failed
|
||||||
// a failed touch only makes eviction order slightly less accurate.
|
// 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 mut f = std::fs::File::open(&blob).ok()?;
|
||||||
let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now()));
|
|
||||||
if offset >= size {
|
if offset >= size {
|
||||||
return Some(Vec::new());
|
return Some(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -197,12 +287,22 @@ impl ContentCache {
|
|||||||
std::fs::rename(&tmp, &blob)?;
|
std::fs::rename(&tmp, &blob)?;
|
||||||
let meta = serde_json::to_vec(&Meta { mtime, size })?;
|
let meta = serde_json::to_vec(&Meta { mtime, size })?;
|
||||||
std::fs::write(self.meta_path(uid), meta)?;
|
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();
|
self.enforce_budget();
|
||||||
Ok(())
|
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 {
|
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 {
|
fn block_meta(&self, uid: &NodeUid, idx: u64) -> PathBuf {
|
||||||
@@ -222,7 +322,10 @@ impl ContentCache {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?;
|
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();
|
let mut buf = Vec::new();
|
||||||
f.read_to_end(&mut buf).ok()?;
|
f.read_to_end(&mut buf).ok()?;
|
||||||
Some(buf)
|
Some(buf)
|
||||||
@@ -249,6 +352,12 @@ impl ContentCache {
|
|||||||
self.block_meta(uid, idx),
|
self.block_meta(uid, idx),
|
||||||
serde_json::to_vec(&Meta { mtime, size })?,
|
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();
|
self.enforce_block_budget();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -260,42 +369,25 @@ impl ContentCache {
|
|||||||
if self.max_bytes == 0 {
|
if self.max_bytes == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut total: u64 = 0;
|
// LRU-ordered (oldest first) block entries straight from the index — no
|
||||||
let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
|
// directory scan. All blocks are evictable; pinned files are served from
|
||||||
let Ok(rd) = std::fs::read_dir(&self.block_dir) else {
|
// whole-file blobs, never blocks.
|
||||||
|
let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOCK) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for entry in rd.flatten() {
|
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
|
||||||
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));
|
|
||||||
}
|
|
||||||
if total <= self.max_bytes {
|
if total <= self.max_bytes {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
blobs.sort_by_key(|(_, _, atime)| *atime);
|
for (key, size) in entries {
|
||||||
for (path, len, _) in blobs {
|
|
||||||
if total <= self.max_bytes {
|
if total <= self.max_bytes {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let meta = path.with_file_name(format!(
|
// `key` is the block file's name (and its `.meta` sibling's stem).
|
||||||
"{}.meta",
|
let _ = std::fs::remove_file(self.block_dir.join(&key));
|
||||||
path.file_name().unwrap_or_default().to_string_lossy()
|
let _ = std::fs::remove_file(self.block_dir.join(format!("{key}.meta")));
|
||||||
));
|
let _ = self.db.cache_remove(&key);
|
||||||
let _ = std::fs::remove_file(&path);
|
total = total.saturating_sub(size);
|
||||||
let _ = std::fs::remove_file(meta);
|
|
||||||
total = total.saturating_sub(len);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +420,10 @@ impl ContentCache {
|
|||||||
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
|
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
|
||||||
let _ = std::fs::remove_file(self.thumb_meta(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));
|
let prefix = format!("{}.b", Self::key(uid));
|
||||||
if let Ok(rd) = std::fs::read_dir(&self.block_dir) {
|
if let Ok(rd) = std::fs::read_dir(&self.block_dir) {
|
||||||
for entry in rd.flatten() {
|
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 {
|
fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
|
||||||
@@ -423,101 +520,80 @@ impl ContentCache {
|
|||||||
if self.max_bytes == 0 {
|
if self.max_bytes == 0 {
|
||||||
return;
|
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
|
let pinned: HashSet<String> = self
|
||||||
.load_pins()
|
.db
|
||||||
.pins
|
.pinned_uids()
|
||||||
.keys()
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
.map(|uid| Self::key_str(uid))
|
.map(|uid| Self::key_str(uid))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Scan blobs (the `<key>` files; skip `.meta`/`.tmp` siblings), record
|
// LRU-ordered (oldest first) blob entries from the index — no directory
|
||||||
// size + last-access (mtime) for each.
|
// scan. Pinned blobs still count toward the total (so pins alone can hold
|
||||||
let mut total: u64 = 0;
|
// the cache over budget) but are never chosen as victims.
|
||||||
let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
|
let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOB) else {
|
||||||
let Ok(rd) = std::fs::read_dir(&self.content_dir) else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for entry in rd.flatten() {
|
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
|
||||||
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));
|
|
||||||
}
|
|
||||||
if total <= self.max_bytes {
|
if total <= self.max_bytes {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Oldest access first.
|
for (key, size) in entries {
|
||||||
blobs.sort_by_key(|(_, _, atime)| *atime);
|
|
||||||
for (path, len, _) in blobs {
|
|
||||||
if total <= self.max_bytes {
|
if total <= self.max_bytes {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(&path);
|
if pinned.contains(&key) {
|
||||||
let _ = std::fs::remove_file(path.with_extension("meta"));
|
continue; // counts toward total but is never a victim
|
||||||
total = total.saturating_sub(len);
|
}
|
||||||
|
// `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 {
|
/// Whether `uid` is pinned — directly, or because an ancestor folder is
|
||||||
std::fs::read(&self.pins_path)
|
/// pinned recursively (resolved in the DB against the node tree). Independent
|
||||||
.ok()
|
/// of whether its blob is currently cached. Best effort: a DB error reads as
|
||||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
/// "not pinned" rather than failing the calling FUSE op.
|
||||||
.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).
|
|
||||||
pub fn is_pinned(&self, uid: &NodeUid) -> bool {
|
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
|
/// Record `uid` as pinned under `path`. `recursive` marks a folder pin whose
|
||||||
/// already cached the content via [`store`](Self::store).
|
/// whole subtree is kept. The caller is responsible for having cached the
|
||||||
pub fn add_pin(&self, uid: &NodeUid, path: &Path) -> Result<()> {
|
/// content (a file via [`store`](Self::store); a folder's descendants by the
|
||||||
let mut file = self.load_pins();
|
/// daemon walking the subtree).
|
||||||
let key = uid.to_string();
|
pub fn add_pin(&self, uid: &NodeUid, path: &Path, recursive: bool) -> Result<()> {
|
||||||
file.pins.insert(
|
self.db
|
||||||
key.clone(),
|
.pin_add(&uid.to_string(), &path.display().to_string(), recursive)
|
||||||
Pin {
|
|
||||||
uid: key,
|
|
||||||
path: path.display().to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.save_pins(&file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop `uid` from the pin registry and evict its blob. No-op if not pinned.
|
/// 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<()> {
|
pub fn remove_pin(&self, uid: &NodeUid) -> Result<()> {
|
||||||
let mut file = self.load_pins();
|
self.db.pin_remove(&uid.to_string())?;
|
||||||
if file.pins.remove(&uid.to_string()).is_some() {
|
|
||||||
self.save_pins(&file)?;
|
|
||||||
}
|
|
||||||
self.evict(uid);
|
self.evict(uid);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All pinned files, ordered by uid.
|
/// All pins (files and recursive folders), ordered by uid.
|
||||||
pub fn list_pins(&self) -> Vec<Pin> {
|
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
|
/// Total bytes of cached content blobs (pinned + unpinned), matching what
|
||||||
@@ -591,10 +667,12 @@ mod tests {
|
|||||||
|
|
||||||
fn cache_capped(max_bytes: u64) -> (ContentCache, TempDir) {
|
fn cache_capped(max_bytes: u64) -> (ContentCache, TempDir) {
|
||||||
let dir = TempDir::new();
|
let dir = TempDir::new();
|
||||||
|
let db = Arc::new(Db::open_in_memory().unwrap());
|
||||||
let c = ContentCache::open(
|
let c = ContentCache::open(
|
||||||
dir.path().join("content"),
|
dir.path().join("content"),
|
||||||
dir.path().join("pins.json"),
|
dir.path().join("pins.json"),
|
||||||
max_bytes,
|
max_bytes,
|
||||||
|
db,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
(c, dir)
|
(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]
|
#[test]
|
||||||
fn stale_metadata_is_a_miss() {
|
fn stale_metadata_is_a_miss() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
@@ -630,12 +742,36 @@ mod tests {
|
|||||||
assert!(c.read_range(&u, 100, 4, 0, 3).is_none());
|
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]
|
#[test]
|
||||||
fn pin_lifecycle_evicts_on_unpin() {
|
fn pin_lifecycle_evicts_on_unpin() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
let u = uid("a");
|
let u = uid("a");
|
||||||
c.store(&u, 100, 3, b"abc").unwrap();
|
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!(c.is_pinned(&u));
|
||||||
assert_eq!(c.list_pins().len(), 1);
|
assert_eq!(c.list_pins().len(), 1);
|
||||||
assert_eq!(c.list_pins()[0].path, "docs/a.txt");
|
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"));
|
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
|
||||||
|
|
||||||
c.store(&a, 1, 4, b"aaaa").unwrap();
|
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));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
c.store(&b, 1, 4, b"bbbb").unwrap();
|
c.store(&b, 1, 4, b"bbbb").unwrap();
|
||||||
// Over budget: `a` is pinned (oldest) so `b` must be the victim.
|
// Over budget: `a` is pinned (oldest) so `b` must be the victim.
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ impl AppDirs {
|
|||||||
self.state_dir().join("pins.json")
|
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.
|
/// Unix domain socket the mount daemon listens on for CLI control commands.
|
||||||
pub fn control_socket(&self) -> PathBuf {
|
pub fn control_socket(&self) -> PathBuf {
|
||||||
self.state_dir().join("control.sock")
|
self.state_dir().join("control.sock")
|
||||||
|
|||||||
@@ -49,10 +49,20 @@ pub enum Request {
|
|||||||
PhotosTimeline { offset: usize, limit: usize },
|
PhotosTimeline { offset: usize, limit: usize },
|
||||||
/// Download a photo's full content into the cache; replies with its path.
|
/// Download a photo's full content into the cache; replies with its path.
|
||||||
OpenPhoto { uid: String },
|
OpenPhoto { uid: String },
|
||||||
|
/// 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
|
/// 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`
|
/// on-disk path so the front-end can open it with the default app. `path`
|
||||||
/// is mountpoint-relative.
|
/// is mountpoint-relative.
|
||||||
OpenFile { path: String },
|
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.
|
/// One entry in a [`Request::ListDir`] listing.
|
||||||
@@ -69,6 +79,28 @@ pub struct DirEntry {
|
|||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
/// Node uid in `volume~link` form, for follow-up requests.
|
/// Node uid in `volume~link` form, for follow-up requests.
|
||||||
pub uid: String,
|
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.
|
/// 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).
|
/// An on-disk path the front-end can open (e.g. a downloaded photo).
|
||||||
FilePath { path: String },
|
FilePath { path: String },
|
||||||
|
/// Full-text search results (reply to [`Request::Search`]).
|
||||||
|
SearchResults { hits: Vec<SearchHit> },
|
||||||
/// The request failed.
|
/// The request failed.
|
||||||
Error { message: String },
|
Error { message: String },
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -16,6 +16,9 @@ pub enum Error {
|
|||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("database: {0}")]
|
||||||
|
Db(#[from] rusqlite::Error),
|
||||||
|
|
||||||
/// No persisted session found — caller must run an interactive login.
|
/// No persisted session found — caller must run an interactive login.
|
||||||
#[error("not logged in (no saved session)")]
|
#[error("not logged in (no saved session)")]
|
||||||
NotLoggedIn,
|
NotLoggedIn,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod auth;
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod control;
|
pub mod control;
|
||||||
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
|
||||||
|
|||||||
+305
-26
@@ -49,7 +49,10 @@ use fuser::{
|
|||||||
ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
||||||
};
|
};
|
||||||
use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
|
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::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient,
|
DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient,
|
||||||
@@ -186,11 +189,19 @@ struct State {
|
|||||||
/// have no entry here.
|
/// have no entry here.
|
||||||
handles: HashMap<u64, WriteHandle>,
|
handles: HashMap<u64, WriteHandle>,
|
||||||
next_fh: u64,
|
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 {
|
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 {
|
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(&ino) = self.by_uid.get(&node.uid) {
|
||||||
if let Some(e) = self.entries.get_mut(&ino) {
|
if let Some(e) = self.entries.get_mut(&ino) {
|
||||||
e.node = node;
|
e.node = node;
|
||||||
@@ -213,10 +224,14 @@ impl State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Forget a node entirely: drop its inode, its uid mapping, its own cached
|
/// 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,
|
/// listing, its slot in its parent's listing, and its DB row. Returns
|
||||||
/// name)` when the node was known, so the caller can notify the kernel.
|
/// `(parent_ino, name)` when the node was known, so the caller can notify
|
||||||
|
/// the kernel.
|
||||||
fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
||||||
let ino = self.by_uid.remove(uid)?;
|
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)?;
|
let entry = self.entries.remove(&ino)?;
|
||||||
self.children.remove(&ino);
|
self.children.remove(&ino);
|
||||||
if let Some(kids) = self.children.get_mut(&entry.parent) {
|
if let Some(kids) = self.children.get_mut(&entry.parent) {
|
||||||
@@ -225,6 +240,20 @@ impl State {
|
|||||||
Some((entry.parent, entry.node.name))
|
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
|
/// Update a file entry's recorded plaintext size so `getattr` reflects an
|
||||||
/// in-progress write before the new revision is sealed.
|
/// in-progress write before the new revision is sealed.
|
||||||
fn set_size(&mut self, ino: u64, size: u64) {
|
fn set_size(&mut self, ino: u64, size: u64) {
|
||||||
@@ -255,6 +284,10 @@ struct Core {
|
|||||||
rt: tokio::runtime::Handle,
|
rt: tokio::runtime::Handle,
|
||||||
state: Arc<Mutex<State>>,
|
state: Arc<Mutex<State>>,
|
||||||
cache: Arc<ContentCache>,
|
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`
|
/// Cached full photos timeline (newest first), so paged `PhotosTimeline`
|
||||||
/// requests slice memory instead of re-fetching the whole timeline per page.
|
/// requests slice memory instead of re-fetching the whole timeline per page.
|
||||||
/// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`].
|
/// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`].
|
||||||
@@ -268,6 +301,71 @@ struct TimelineCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Core {
|
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
|
/// 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
|
/// directory has already been listed. Network I/O happens without the lock
|
||||||
/// held so concurrent metadata reads aren't blocked behind a fetch.
|
/// 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
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_folder_children_node_uids(&folder_uid))
|
.block_on(self.client.enumerate_folder_children_node_uids(&folder_uid))
|
||||||
@@ -311,6 +432,11 @@ impl Core {
|
|||||||
child_inos.push(st.intern(ino, node));
|
child_inos.push(st.intern(ino, node));
|
||||||
}
|
}
|
||||||
st.children.insert(ino, child_inos);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,25 +679,32 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pin the file at mountpoint-relative `rel`: download its full plaintext
|
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
|
||||||
/// into the content cache and record it in the pin registry. Returns the
|
/// plaintext into the content cache; a folder records a recursive pin and
|
||||||
/// resolved node name on success.
|
/// downloads every descendant file (selective sync). Returns a human message.
|
||||||
fn pin(&self, rel: &Path) -> Result<String, String> {
|
fn pin(&self, rel: &Path) -> Result<String, String> {
|
||||||
let (ino, uid) = self
|
let (ino, uid) = self
|
||||||
.resolve_path(rel)
|
.resolve_path(rel)
|
||||||
.map_err(|e| format!("resolve path: {e:?}"))?;
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
let (name, mtime, size) = {
|
let (name, is_folder, mtime, size) = {
|
||||||
let st = self.state.lock().unwrap();
|
let st = self.state.lock().unwrap();
|
||||||
let e = st.entries.get(&ino).ok_or("node vanished")?;
|
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.name.clone(),
|
||||||
|
e.node.is_folder(),
|
||||||
e.node.modification_time,
|
e.node.modification_time,
|
||||||
node_size(&e.node),
|
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
|
let bytes = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.download_file(&uid))
|
.block_on(self.client.download_file(&uid))
|
||||||
@@ -580,11 +713,56 @@ impl Core {
|
|||||||
.store(&uid, mtime, size, &bytes)
|
.store(&uid, mtime, size, &bytes)
|
||||||
.map_err(|e| format!("cache store: {e}"))?;
|
.map_err(|e| format!("cache store: {e}"))?;
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, rel)
|
.add_pin(&uid, rel, false)
|
||||||
.map_err(|e| format!("pin: {e}"))?;
|
.map_err(|e| format!("pin: {e}"))?;
|
||||||
Ok(name)
|
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
|
/// 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
|
/// 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.
|
/// `Ok(None)` when the node is not a file or has no thumbnail of that type.
|
||||||
@@ -615,21 +793,32 @@ impl Core {
|
|||||||
Ok(bytes)
|
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> {
|
fn unpin(&self, rel: &Path) -> Result<String, String> {
|
||||||
let (ino, uid) = self
|
let (ino, uid) = self
|
||||||
.resolve_path(rel)
|
.resolve_path(rel)
|
||||||
.map_err(|e| format!("resolve path: {e:?}"))?;
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
let name = {
|
let (name, is_folder) = {
|
||||||
let st = self.state.lock().unwrap();
|
let st = self.state.lock().unwrap();
|
||||||
st.entries
|
st.entries
|
||||||
.get(&ino)
|
.get(&ino)
|
||||||
.map(|e| e.node.name.clone())
|
.map(|e| (e.node.name.clone(), e.node.is_folder()))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
self.cache
|
self.cache
|
||||||
.remove_pin(&uid)
|
.remove_pin(&uid)
|
||||||
.map_err(|e| format!("unpin: {e}"))?;
|
.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)
|
Ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,6 +870,31 @@ impl Core {
|
|||||||
modified,
|
modified,
|
||||||
pinned: self.cache.is_pinned(&uid),
|
pinned: self.cache.is_pinned(&uid),
|
||||||
uid: uid.to_string(),
|
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())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -855,6 +1069,9 @@ impl ProtonFs {
|
|||||||
fn new(core: Core, root: Node) -> Self {
|
fn new(core: Core, root: Node) -> Self {
|
||||||
{
|
{
|
||||||
let mut st = core.state.lock().unwrap();
|
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.by_uid.insert(root.uid.clone(), ROOT_INO);
|
||||||
st.entries.insert(
|
st.entries.insert(
|
||||||
ROOT_INO,
|
ROOT_INO,
|
||||||
@@ -1710,7 +1927,7 @@ fn apply_event(
|
|||||||
// Known node changed: drop its cached attrs/data (and listing if
|
// Known node changed: drop its cached attrs/data (and listing if
|
||||||
// it is a directory) so the next access re-fetches. Its content
|
// it is a directory) so the next access re-fetches. Its content
|
||||||
// blob may now be stale, so evict it too.
|
// blob may now be stale, so evict it too.
|
||||||
st.children.remove(&ino);
|
st.invalidate_listing(ino);
|
||||||
content.evict(node_uid);
|
content.evict(node_uid);
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
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.
|
// drop it so the new child is picked up on the next readdir.
|
||||||
if let Some(parent_uid) = parent_node_uid
|
if let Some(parent_uid) = parent_node_uid
|
||||||
&& let Some(&parent) = st.by_uid.get(parent_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);
|
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");
|
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
|
||||||
let mut st = state.lock().unwrap();
|
let mut st = state.lock().unwrap();
|
||||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
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);
|
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
|
/// 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
|
/// state. Resumes from the cursor persisted in the DB so changes made while
|
||||||
/// changes made after mount. Runs as a Tokio task; returns only on fatal error.
|
/// 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(
|
async fn run_event_sync(
|
||||||
client: ProtonDriveClient,
|
client: ProtonDriveClient,
|
||||||
scope: DriveEventScopeId,
|
scope: DriveEventScopeId,
|
||||||
state: Arc<Mutex<State>>,
|
state: Arc<Mutex<State>>,
|
||||||
content: Arc<ContentCache>,
|
content: Arc<ContentCache>,
|
||||||
|
db: Arc<Db>,
|
||||||
notifier: Notifier,
|
notifier: Notifier,
|
||||||
) {
|
) {
|
||||||
// Seed: a `None` cursor yields a single `CursorAdvanced` at the server head.
|
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
|
||||||
let mut cursor: Option<DriveEventId> = match client.enumerate_events(&scope, None).await {
|
// Resume: pick up exactly where the last run left off.
|
||||||
Ok(events) => events.last().map(|e| e.id().clone()),
|
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) => {
|
Err(e) => {
|
||||||
error!(error = %e, "failed to seed event cursor; live sync disabled");
|
error!(error = %e, "read persisted cursor failed; live sync disabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1794,6 +2032,11 @@ async fn run_event_sync(
|
|||||||
apply_event(&state, &content, ¬ifier, event);
|
apply_event(&state, &content, ¬ifier, event);
|
||||||
}
|
}
|
||||||
cursor = events.last().map(|e| e.id().clone());
|
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}"),
|
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(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.open_file(&rel) {
|
Ok(rel) => match core.open_file(&rel) {
|
||||||
Ok(p) => CtlResponse::FilePath {
|
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 },
|
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 {
|
Err(e) => CtlResponse::Error {
|
||||||
message: format!("bad request: {e}"),
|
message: format!("bad request: {e}"),
|
||||||
},
|
},
|
||||||
@@ -1981,6 +2254,7 @@ pub fn mount(
|
|||||||
mountpoint: &Path,
|
mountpoint: &Path,
|
||||||
cache: ContentCache,
|
cache: ContentCache,
|
||||||
control_socket: &Path,
|
control_socket: &Path,
|
||||||
|
db: Arc<Db>,
|
||||||
username: String,
|
username: String,
|
||||||
) -> std::io::Result<MountOutcome> {
|
) -> std::io::Result<MountOutcome> {
|
||||||
let root = rt
|
let root = rt
|
||||||
@@ -1998,8 +2272,10 @@ pub fn mount(
|
|||||||
next_ino: 2,
|
next_ino: 2,
|
||||||
handles: HashMap::new(),
|
handles: HashMap::new(),
|
||||||
next_fh: 1,
|
next_fh: 1,
|
||||||
|
db: db.clone(),
|
||||||
})),
|
})),
|
||||||
cache: Arc::new(cache),
|
cache: Arc::new(cache),
|
||||||
|
db,
|
||||||
timeline: Arc::new(Mutex::new(None)),
|
timeline: Arc::new(Mutex::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2029,6 +2305,9 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fs = ProtonFs::new(core.clone(), root);
|
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`
|
// 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
|
// 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 bg = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||||
let notifier = bg.notifier();
|
let notifier = bg.notifier();
|
||||||
rt.spawn(run_event_sync(
|
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)
|
// 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::auth;
|
||||||
use pdfs_core::config::AppDirs;
|
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;
|
use pdfs_core::service;
|
||||||
|
|
||||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
/// 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
|
/// Backoff between auto-retries of a Files/Photos load while the mount service
|
||||||
/// is still coming up (see [`load_browser`] / [`load_gallery`]).
|
/// is still coming up (see [`load_browser`] / [`load_gallery`]).
|
||||||
const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2);
|
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
|
/// 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.
|
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
|
||||||
@@ -112,11 +117,17 @@ struct Ui {
|
|||||||
browser_retry: gtk4::Button,
|
browser_retry: gtk4::Button,
|
||||||
/// Mountpoint-relative path the browser is showing (empty = root).
|
/// Mountpoint-relative path the browser is showing (empty = root).
|
||||||
browser_path: RefCell<String>,
|
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.
|
// Photos (gallery) page.
|
||||||
gallery_model: gio::ListStore,
|
gallery_model: gio::ListStore,
|
||||||
gallery_status: gtk4::Label,
|
gallery_status: gtk4::Label,
|
||||||
gallery_retry: gtk4::Button,
|
gallery_retry: gtk4::Button,
|
||||||
gallery_more: gtk4::Button,
|
gallery_more: gtk4::Button,
|
||||||
|
gallery_upload: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ui {
|
impl Ui {
|
||||||
@@ -173,7 +184,17 @@ fn load_proton_theme() {
|
|||||||
.brand-icon {{ color: {PROTON_PURPLE}; }}\n\
|
.brand-icon {{ color: {PROTON_PURPLE}; }}\n\
|
||||||
.file-grid {{ padding: 6px; }}\n\
|
.file-grid {{ padding: 6px; }}\n\
|
||||||
.file-tile {{ padding: 8px; border-radius: 10px; }}\n\
|
.file-tile {{ padding: 8px; border-radius: 10px; }}\n\
|
||||||
.file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n"
|
.file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\
|
||||||
|
.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();
|
let provider = gtk4::CssProvider::new();
|
||||||
provider.load_from_string(&css);
|
provider.load_from_string(&css);
|
||||||
@@ -252,15 +273,19 @@ fn build_window(app: &adw::Application) {
|
|||||||
browser_status: browser_widgets.3,
|
browser_status: browser_widgets.3,
|
||||||
browser_retry: browser_widgets.6,
|
browser_retry: browser_widgets.6,
|
||||||
browser_path: RefCell::new(String::new()),
|
browser_path: RefCell::new(String::new()),
|
||||||
|
browser_search: browser_widgets.7,
|
||||||
|
search_source: RefCell::new(None),
|
||||||
gallery_model: gallery_widgets.0,
|
gallery_model: gallery_widgets.0,
|
||||||
gallery_status: gallery_widgets.1,
|
gallery_status: gallery_widgets.1,
|
||||||
gallery_retry: gallery_widgets.4,
|
gallery_retry: gallery_widgets.4,
|
||||||
gallery_more: gallery_widgets.2,
|
gallery_more: gallery_widgets.2,
|
||||||
|
gallery_upload: gallery_widgets.5,
|
||||||
});
|
});
|
||||||
|
|
||||||
wire_login(&ui);
|
wire_login(&ui);
|
||||||
wire_logout(&ui, &main_widgets.5);
|
wire_logout(&ui, &main_widgets.5);
|
||||||
wire_browser(&ui, &browser_widgets.4, &browser_widgets.5);
|
wire_browser(&ui, &browser_widgets.4, &browser_widgets.5);
|
||||||
|
wire_search(&ui);
|
||||||
wire_gallery(&ui, &gallery_widgets.3);
|
wire_gallery(&ui, &gallery_widgets.3);
|
||||||
wire_retry(&ui);
|
wire_retry(&ui);
|
||||||
|
|
||||||
@@ -831,6 +856,7 @@ fn build_browser_page() -> (
|
|||||||
gtk4::GridView,
|
gtk4::GridView,
|
||||||
gtk4::ColumnView,
|
gtk4::ColumnView,
|
||||||
gtk4::Button,
|
gtk4::Button,
|
||||||
|
gtk4::SearchEntry,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||||
@@ -866,9 +892,16 @@ fn build_browser_page() -> (
|
|||||||
toggles.append(&grid_toggle);
|
toggles.append(&grid_toggle);
|
||||||
toggles.append(&list_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);
|
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
header.append(&back);
|
header.append(&back);
|
||||||
header.append(&crumb);
|
header.append(&crumb);
|
||||||
|
header.append(&search);
|
||||||
header.append(&toggles);
|
header.append(&toggles);
|
||||||
|
|
||||||
let status = gtk4::Label::builder().wrap(true).build();
|
let status = gtk4::Label::builder().wrap(true).build();
|
||||||
@@ -938,7 +971,7 @@ fn build_browser_page() -> (
|
|||||||
|
|
||||||
(
|
(
|
||||||
inner.upcast(),
|
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) {
|
fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
if entry.is_dir {
|
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;
|
*ui.browser_path.borrow_mut() = rel;
|
||||||
load_browser(ui);
|
load_browser(ui);
|
||||||
} else {
|
} 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
|
/// Join the entry name onto the current browser directory to get its
|
||||||
/// mountpoint-relative path.
|
/// mountpoint-relative path.
|
||||||
fn entry_rel(ui: &Rc<Ui>, entry: &DirEntry) -> String {
|
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();
|
let base = ui.browser_path.borrow();
|
||||||
if base.is_empty() {
|
if base.is_empty() {
|
||||||
entry.name.clone()
|
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
|
/// The Photos page: a [`gtk4::GridView`] of thumbnails backed by a
|
||||||
/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`PhotoItem`]s, plus a
|
/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`PhotoItem`]s, plus a
|
||||||
/// "Load more" button and a status label.
|
/// "Load more" button and a status label.
|
||||||
@@ -1368,6 +1502,7 @@ fn build_gallery_page() -> (
|
|||||||
gtk4::Button,
|
gtk4::Button,
|
||||||
gtk4::GridView,
|
gtk4::GridView,
|
||||||
gtk4::Button,
|
gtk4::Button,
|
||||||
|
gtk4::Button, // Upload Photo
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||||
@@ -1429,21 +1564,41 @@ fn build_gallery_page() -> (
|
|||||||
.child(&grid)
|
.child(&grid)
|
||||||
.build();
|
.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);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(12);
|
inner.set_margin_top(12);
|
||||||
inner.set_margin_bottom(12);
|
inner.set_margin_bottom(12);
|
||||||
inner.set_margin_start(12);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(12);
|
||||||
|
inner.append(&header_box);
|
||||||
inner.append(&status);
|
inner.append(&status);
|
||||||
inner.append(&retry);
|
inner.append(&retry);
|
||||||
inner.append(&scroll);
|
inner.append(&scroll);
|
||||||
inner.append(&more);
|
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
|
/// Wire the gallery: activating a tile downloads the photo and opens it in our
|
||||||
/// "Load more" button appends the next page.
|
/// in-app lightbox viewer; the "Load more" button appends the next page.
|
||||||
fn wire_gallery(ui: &Rc<Ui>, grid: >k4::GridView) {
|
fn wire_gallery(ui: &Rc<Ui>, grid: >k4::GridView) {
|
||||||
let ui_open = ui.clone();
|
let ui_open = ui.clone();
|
||||||
grid.connect_activate(move |grid, pos| {
|
grid.connect_activate(move |grid, pos| {
|
||||||
@@ -1454,34 +1609,513 @@ fn wire_gallery(ui: &Rc<Ui>, grid: >k4::GridView) {
|
|||||||
.downcast_ref::<BoxedAnyObject>()
|
.downcast_ref::<BoxedAnyObject>()
|
||||||
.map(|o| o.borrow::<PhotoItem>().uid.clone());
|
.map(|o| o.borrow::<PhotoItem>().uid.clone());
|
||||||
let Some(uid) = uid else { return };
|
let Some(uid) = uid else { return };
|
||||||
// Ignore a repeat activation of a photo already downloading.
|
open_photo_viewer(&ui_open, uid);
|
||||||
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"),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_more = ui.clone();
|
let ui_more = ui.clone();
|
||||||
ui.gallery_more.clone().connect_clicked(move |_| {
|
ui.gallery_more.clone().connect_clicked(move |_| {
|
||||||
load_gallery(&ui_more, true);
|
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
|
/// Fetch a timeline page from the daemon. When `append` is false the model is
|
||||||
|
|||||||
Reference in new issue
Block a user