Compare commits

..
Author SHA1 Message Date
nvrl 7d445bb02c Merge pull request #84 from narrrl/feat/video-thumbnails
feat(gui,fuse,core): video thumbnails in file views
2026-11-14 03:43:36 +01:00
36 changed files with 5045 additions and 5955 deletions

No files matched your search

Generated
+4 -4
View File
@@ -3541,7 +3541,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3558,7 +3558,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3586,7 +3586,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3608,7 +3608,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "3.8.0" version = "3.7.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "3.8.0" version = "3.7.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
+1 -35
View File
@@ -157,17 +157,6 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
if ours { if ours {
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write"); debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
} }
// A file kept offline keeps its copy through a change, and the
// keeper replaces it once this batch's listings are read again.
// The copy is checked against the revision's `(mtime, size)`, so
// no read takes it for a newer revision. Evicted, it was gone until
// something read the file, which then downloaded it (B282). A
// node new in, or moved into, a kept folder is fetched by checking
// that folder.
let kept = match !*is_trashed && !ours {
true => kept_by_pin(core, node_uid, parent_node_uid.as_ref()),
false => None,
};
let mut had_node = false; let mut had_node = false;
core.for_each_mount(|st, notify| { core.for_each_mount(|st, notify| {
if *is_trashed { if *is_trashed {
@@ -183,12 +172,9 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
} }
}); });
// Once, not once per mount: the content cache is per-daemon. // Once, not once per mount: the content cache is per-daemon.
if had_node && kept.is_none() { if had_node {
core.cache.evict(node_uid); core.cache.evict(node_uid);
} }
if let Some(kept) = kept {
dirty.kept.insert(kept);
}
// A create, rename or move-in shows up as a change to the parent // A create, rename or move-in shows up as a change to the parent
// listing too, which is recorded for the end of the batch rather // listing too, which is recorded for the end of the batch rather
// than acted on here — see [`DirtyParents`]. // than acted on here — see [`DirtyParents`].
@@ -219,8 +205,6 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
| DriveEvent::ScopeAccessLost { .. } | DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. } => { | DriveEvent::SharedWithMeUpdated { .. } => {
warn!("event access continuity lost; downgrading shared trees and resyncing lazily"); warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
// Changes went by unseen, kept files among them (B282).
core.recheck_all_pins();
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS)); apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
core.for_each_mount(|st, notify| { core.for_each_mount(|st, notify| {
let dirs: Vec<u64> = st.children.keys().copied().collect(); let dirs: Vec<u64> = st.children.keys().copied().collect();
@@ -312,21 +296,6 @@ struct DirtyParents {
/// Checked per mount — a mount that already lists every one of those /// Checked per mount — a mount that already lists every one of those
/// children under that parent keeps its listing. /// children under that parent keeps its listing.
ours: HashMap<NodeUid, HashSet<NodeUid>>, ours: HashMap<NodeUid, HashSet<NodeUid>>,
/// Pinned files and folders the batch changed, for the pin keeper to
/// check once the listings above are dropped (`keeper`).
kept: HashSet<NodeUid>,
}
/// What the pin keeper has to check after a remote change to `node_uid`,
/// whose parent is now `parent_uid`: the node itself when a pin keeps it, or
/// else the parent, when a pinned folder holds it and the node is new there.
fn kept_by_pin(core: &Core, node_uid: &NodeUid, parent_uid: Option<&NodeUid>) -> Option<NodeUid> {
if core.cache.is_pinned(node_uid) {
return Some(node_uid.clone());
}
parent_uid
.filter(|parent| core.cache.is_pinned(parent))
.cloned()
} }
/// Whether this mount's cached listing of `parent_uid` already accounts for /// Whether this mount's cached listing of `parent_uid` already accounts for
@@ -507,9 +476,6 @@ pub(super) async fn run_event_sync(
cursor = Some(applied); cursor = Some(applied);
} }
flush_dirty_parents(&core, &dirty); flush_dirty_parents(&core, &dirty);
for uid in &dirty.kept {
core.recheck_pinned(uid);
}
// Another device changed the tree: whatever a front-end lists may have // Another device changed the tree: whatever a front-end lists may have
// moved, including what is shared and what is in the trash. // moved, including what is shared and what is in the trash.
core.events core.events
+9 -45
View File
@@ -204,42 +204,18 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
/// B99), so resolving one needs this too. A mirror folder has no mount to run /// B99), so resolving one needs this too. A mirror folder has no mount to run
/// against: a file there is an ordinary local file. /// against: a file there is an ordinary local file.
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> { fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
route_within(core, mountpoint, path, |path| {
format!("{path} is in a mirrored folder; change the local file there instead")
})
}
/// [`route_to_mount`] for keeping a node offline or releasing it. A file in an
/// on-demand synced folder is a different Drive node from any file of the
/// same name under the primary mount, and keeping one offline did nothing for
/// the other: a program reading the synced folder's copy downloaded it
/// (`docs/BUGS.md` B281). A mirror folder already holds every file on disk.
fn route_keep(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
route_within(core, mountpoint, path, |path| {
format!(
"{path} is in a mirrored folder, which keeps all its files on this computer already"
)
})
}
/// [`route_to_mount`], refusing a path that leaves the mount it routed to and
/// answering a path in a mirror folder, which has no mount, with `mirrored`.
fn route_within(
core: &Core,
mountpoint: &Path,
path: &str,
mirrored: impl FnOnce(&str) -> String,
) -> CoreResult<(Core, PathBuf)> {
let routed = route_to_mount(core, mountpoint, path).map_err(|e| { let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
let p = Path::new(path); let p = Path::new(path);
let in_mirror = core let mirrored = core
.db .db
.sync_folder_list() .sync_folder_list()
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path)); .any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
if in_mirror { if mirrored {
CoreError::invalid(mirrored(path)) CoreError::invalid(format!(
"{path} is in a mirrored folder; change the local file there instead"
))
} else { } else {
e e
} }
@@ -255,18 +231,6 @@ fn route_within(
} }
} }
/// The path the pin registry keeps for a pin made through `routed`: the
/// mount-relative path on the primary mount, as it always was, and the
/// absolute path the request named on an on-demand folder's mount, since
/// that node is not under the primary mountpoint (`docs/BUGS.md` B281).
fn pin_record(routed: &Core, rel: &Path, path: &str) -> PathBuf {
if routed.primary {
rel.to_path_buf()
} else {
Path::new(path).components().collect()
}
}
/// [`route_change`] for a request that trashes or renames the node it names. /// [`route_change`] for a request that trashes or renames the node it names.
/// A mount's root is My files or a whole synced folder. An on-demand folder's /// A mount's root is My files or a whole synced folder. An on-demand folder's
/// root has a parent on Drive, so nothing further down would stop `pdfs rm /// root has a parent on Drive, so nothing further down would stop `pdfs rm
@@ -557,8 +521,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(CtlRequest::Subscribe) => { Ok(CtlRequest::Subscribe) => {
CtlResponse::error(CoreError::invalid("a subscription is served on its own")) CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
} }
Ok(CtlRequest::Pin { path }) => match route_keep(core, mountpoint, &path) { Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) { Ok(rel) => match core.pin(&rel) {
Ok(name) => { Ok(name) => {
core.touch_profile(); core.touch_profile();
CtlResponse::Ok { CtlResponse::Ok {
@@ -569,8 +533,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::Unpin { path }) => match route_keep(core, mountpoint, &path) { Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
Ok((routed, rel)) => match routed.unpin(&rel) { Ok(rel) => match core.unpin(&rel) {
Ok(name) => { Ok(name) => {
core.touch_profile(); core.touch_profile();
CtlResponse::Ok { CtlResponse::Ok {
-271
View File
@@ -1,271 +0,0 @@
//! Keeping pinned files on this computer after they were pinned.
//!
//! A pin downloads a file once. Nothing downloaded it again afterwards: a
//! change from another device deleted the cached copy, a file added to a
//! pinned folder elsewhere was never fetched, and a download that failed while
//! the link was down stayed failed. Each of those left a file listed as kept
//! offline that the next read downloaded, and that could not be read at all
//! without a network (`docs/BUGS.md` B282).
//!
//! The keeper is one thread that checks pins again and downloads what is
//! missing or stale: every pin when the daemon starts and when the link comes
//! back, and the pins a batch of remote events touched once the batch is
//! applied. A remote change no longer deletes a pinned file's copy. The copy
//! is stored under the revision's `(mtime, size)`, so a read of a newer
//! revision does not take it, and the keeper replaces it once the new one is
//! downloaded.
use std::collections::HashSet;
use std::time::{Duration, Instant};
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, info, warn};
use super::{Core, is_local_uid, node_size, parse_uid};
/// How often the keeper looks for work.
const KEEPER_TICK: Duration = Duration::from_secs(1);
/// The first wait before a check that could not download everything is tried
/// again. It doubles on every failure, up to [`RETRY_MAX`].
const RETRY_FIRST: Duration = Duration::from_secs(30);
const RETRY_MAX: Duration = Duration::from_secs(30 * 60);
/// What the keeper owes a check.
#[derive(Debug, Default)]
pub(crate) struct PinWork {
/// Every pin.
all: bool,
/// The pinned files and folders named here. A folder is checked whole.
nodes: HashSet<NodeUid>,
}
impl PinWork {
fn is_empty(&self) -> bool {
!self.all && self.nodes.is_empty()
}
fn merge(&mut self, other: PinWork) {
self.all |= other.all;
self.nodes.extend(other.nodes);
}
}
/// A file a pin keeps, as the tree knows it now.
pub(crate) struct KeptFile {
pub(crate) uid: NodeUid,
pub(crate) name: String,
pub(crate) mtime: i64,
pub(crate) size: u64,
}
impl Core {
/// Have the keeper check every pin.
pub(crate) fn recheck_all_pins(&self) {
self.pin_work.lock().all = true;
}
/// Have the keeper check the pinned file or folder `uid`.
pub(crate) fn recheck_pinned(&self, uid: &NodeUid) {
self.pin_work.lock().nodes.insert(uid.clone());
}
/// The keeper's loop, until the daemon stops. Starts with a check of every
/// pin, which catches up on whatever changed while the daemon was down.
///
/// Work that failed waits out a backoff of its own. New work does not wait
/// for it, so a file that keeps failing does not hold up the next change.
pub(crate) fn run_pin_keeper(&self) {
self.recheck_all_pins();
let mut backlog = PinWork::default();
let mut backlog_due = Instant::now();
let mut retry = RETRY_FIRST;
while self.shutdown.sleep(KEEPER_TICK) {
if !self.is_online() {
continue;
}
let mut work = std::mem::take(&mut *self.pin_work.lock());
let retrying = !backlog.is_empty() && Instant::now() >= backlog_due;
if retrying {
work.merge(std::mem::take(&mut backlog));
}
if work.is_empty() {
continue;
}
let failed = self.keep_pins(&work);
if failed.is_empty() {
if retrying {
retry = RETRY_FIRST;
}
continue;
}
backlog.merge(failed);
backlog_due = Instant::now() + retry;
retry = (retry * 2).min(RETRY_MAX);
}
}
/// Download every file `work` covers whose cached copy is missing or
/// stale. Returns what could not be checked or downloaded.
fn keep_pins(&self, work: &PinWork) -> PinWork {
let mut failed = PinWork::default();
let roots: Vec<NodeUid> = if work.all {
self.cache
.list_pins()
.iter()
.filter_map(|pin| parse_uid(&pin.uid))
.chain(work.nodes.iter().cloned())
.collect()
} else {
work.nodes.iter().cloned().collect()
};
let mut seen: HashSet<u64> = HashSet::new();
let mut files: Vec<u64> = Vec::new();
for uid in roots {
if self.shutdown.is_stopping() {
return failed;
}
if !self.cache.is_pinned(&uid) {
continue;
}
let found = {
let st = self.state();
st.by_uid.get(&uid).and_then(|&ino| {
let e = st.entries.get(&ino)?;
Some((ino, e.parent, e.node.is_folder()))
})
};
let Some((ino, parent, is_folder)) = found else {
debug!(%uid, "pin keeper: pinned node is not in the tree");
continue;
};
if is_folder {
match self.pin_subtree_files(ino) {
Ok(inos) => files.extend(inos.into_iter().filter(|i| seen.insert(*i))),
Err(e) => {
warn!(%uid, error = %e, "pin keeper: listing a kept folder failed");
failed.nodes.insert(uid);
}
}
continue;
}
// An event about this file dropped its parent's listing. Listing
// it again brings the file's entry up to the revision Drive has
// now, which is what the cached copy is checked against.
if !work.all
&& parent != ino
&& let Err(e) = self.ensure_children(parent)
{
warn!(%uid, error = ?e, "pin keeper: listing a kept file's folder failed");
failed.nodes.insert(uid);
continue;
}
if seen.insert(ino) {
files.push(ino);
}
}
let stale: Vec<KeptFile> = files
.into_iter()
.filter_map(|ino| self.kept_file(ino))
.filter(|f| !self.cache.is_cached(&f.uid, f.mtime, f.size))
.collect();
if stale.is_empty() {
return failed;
}
let job = self.transfers.begin_job("Updating files kept offline");
job.set_total(stale.len() as u64);
let (mut fetched, mut failures) = (0usize, 0usize);
for file in stale {
if self.shutdown.is_stopping() {
break;
}
// Unpinned while the check ran.
if !self.cache.is_pinned(&file.uid) {
job.step();
continue;
}
job.detail(file.name.clone());
match self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size) {
Ok(()) => fetched += 1,
Err(e) => {
warn!(uid = %file.uid, error = %e, "pin keeper: download failed");
failed.nodes.insert(file.uid);
failures += 1;
}
}
job.step();
}
info!(
fetched,
failed = failures,
"pin keeper: brought files kept offline up to date"
);
failed
}
/// The file at `ino`, as a pin keeps it. `None` for a folder, a file this
/// daemon still owes an upload for, or a file whose real size cannot be
/// read.
///
/// A queued write is ahead of Drive. Downloading Drive's revision would
/// cache what the write replaces, and the drain keeps the uploaded bytes
/// of a pinned file once they land.
///
/// A listing carries the ciphertext size until its size upgrade lands
/// (B12). Stored under that size, the copy never matched the plaintext it
/// holds, so every read downloaded the file again and every check
/// downloaded it once more.
pub(crate) fn kept_file(&self, ino: u64) -> Option<KeptFile> {
let (uid, name, mtime, size, provisional) = {
let st = self.state();
let e = st.entries.get(&ino)?;
let NodeKind::File { claimed_size, .. } = &e.node.kind else {
return None;
};
(
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
claimed_size.is_none(),
)
};
if is_local_uid(&uid) || self.pending.lock().contains_key(&uid) {
return None;
}
let size = match provisional {
true => self.resolve_provisional_size(ino, &uid)?,
false => size,
};
Some(KeptFile {
uid,
name,
mtime,
size,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn work_that_failed_keeps_every_node_and_the_full_check() {
let mut backlog = PinWork::default();
assert!(backlog.is_empty());
let uid = parse_uid("vol~a").unwrap();
backlog.merge(PinWork {
all: false,
nodes: HashSet::from([uid.clone()]),
});
backlog.merge(PinWork {
all: true,
nodes: HashSet::from([uid.clone()]),
});
assert!(backlog.all);
assert_eq!(backlog.nodes.len(), 1);
assert!(!backlog.is_empty());
}
}
+16 -31
View File
@@ -91,7 +91,6 @@ mod drive;
use drive::{DriveApi, RevisionRead}; use drive::{DriveApi, RevisionRead};
mod events; mod events;
mod filesystem; mod filesystem;
mod keeper;
mod link; mod link;
pub use filesystem::ProtonFs; pub use filesystem::ProtonFs;
mod mount; mod mount;
@@ -590,8 +589,6 @@ struct Core {
/// Nudges the drain workers: set true and notify to have them re-examine /// Nudges the drain workers: set true and notify to have them re-examine
/// the queue instead of waiting out their backoff. /// the queue instead of waiting out their backoff.
drain_wake: Arc<(Mutex<bool>, Condvar)>, drain_wake: Arc<(Mutex<bool>, Condvar)>,
/// The pins the keeper owes a check (`keeper`).
pin_work: Arc<Mutex<keeper::PinWork>>,
/// The daemon's stop signal, shared with every background loop this `Core` /// The daemon's stop signal, shared with every background loop this `Core`
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44). /// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
shutdown: Arc<shutdown::Shutdown>, shutdown: Arc<shutdown::Shutdown>,
@@ -4273,12 +4270,7 @@ impl Core {
/// recursive pin and downloads every descendant file on a background thread, /// recursive pin and downloads every descendant file on a background thread,
/// reported as a job: a big folder far outlasts the socket read timeout. /// reported as a job: a big folder far outlasts the socket read timeout.
/// Returns a human message. /// Returns a human message.
/// fn pin(&self, rel: &Path) -> CoreResult<String> {
/// `recorded` is the path the pin registry keeps for the front ends to
/// open the pin by: `rel` itself on the primary mount, which they join to
/// the mountpoint, and the absolute local path on an on-demand folder's
/// mount, whose node is not reachable under the primary mountpoint at all.
fn pin(&self, rel: &Path, recorded: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
let (name, is_folder, mtime, size) = { let (name, is_folder, mtime, size) = {
let st = self.state(); let st = self.state();
@@ -4302,7 +4294,7 @@ impl Core {
// Record the recursive pin first so every descendant is eviction- // Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree. // exempt before we start filling the cache with the subtree.
self.cache self.cache
.add_pin(&uid, recorded, true) .add_pin(&uid, rel, true)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
let core = self.clone(); let core = self.clone();
let folder = name.clone(); let folder = name.clone();
@@ -4312,15 +4304,9 @@ impl Core {
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?; .map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
return Ok(format!("{name}: downloading its files in the background")); return Ok(format!("{name}: downloading its files in the background"));
} }
// Stored under a provisional size, the copy would never match the
// plaintext it holds (`docs/BUGS.md` B282).
let size = match self.kept_file(ino) {
Some(file) if file.uid == uid => file.size,
_ => size,
};
self.fetch_content(&uid, &name, mtime, size)?; self.fetch_content(&uid, &name, mtime, size)?;
self.cache self.cache
.add_pin(&uid, recorded, false) .add_pin(&uid, rel, false)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
Ok(name) Ok(name)
} }
@@ -4338,21 +4324,15 @@ impl Core {
Ok(files) => { Ok(files) => {
job.set_total(files.len() as u64); job.set_total(files.len() as u64);
let mut failed = 0usize; let mut failed = 0usize;
for ino in files { for (uid, file, mtime, size) in files {
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) { if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
return; return;
} }
// A file with a queued write, or no readable size, is job.detail(file.clone());
// left to the drain and the keeper (`keeper::kept_file`). if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
if let Some(file) = self.kept_file(ino) { warn!(%uid, error = %e, "pin folder: download failed");
job.detail(file.name.clone());
if let Err(e) =
self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size)
{
warn!(uid = %file.uid, error = %e, "pin folder: download failed");
failed += 1; failed += 1;
} }
}
job.step(); job.step();
} }
info!(folder = %name, failed, "pin folder: done"); info!(folder = %name, failed, "pin folder: done");
@@ -4385,9 +4365,9 @@ impl Core {
Ok(()) Ok(())
} }
/// The inode of every file in the subtree rooted at folder `ino`. /// Every file in the subtree rooted at folder `ino`.
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<u64>> { fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
let mut files: Vec<u64> = Vec::new(); let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
let mut stack = vec![ino]; let mut stack = vec![ino];
while let Some(dir) = stack.pop() { while let Some(dir) = stack.pop() {
if self.shutdown.is_stopping() { if self.shutdown.is_stopping() {
@@ -4402,7 +4382,12 @@ impl Core {
if e.node.is_folder() { if e.node.is_folder() {
stack.push(k); stack.push(k);
} else { } else {
files.push(k); files.push((
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
));
} }
} }
} }
-3
View File
@@ -286,9 +286,6 @@ impl Core {
warn!(error = %e, "moving backed-off ops up after a reconnect failed"); warn!(error = %e, "moving backed-off ops up after a reconnect failed");
} }
self.wake_drain(); self.wake_drain();
// Downloads for pins failed while the link was down, and the events
// that would have named the files are behind us (`docs/BUGS.md` B282).
self.recheck_all_pins();
} }
/// Wait up to `budget` for the link to come back. Returns whether it is up. /// Wait up to `budget` for the link to come back. Returns whether it is up.
-12
View File
@@ -511,7 +511,6 @@ pub(crate) fn mount_with(
landed: Arc::new(Mutex::new(HashMap::new())), landed: Arc::new(Mutex::new(HashMap::new())),
unadopted: Arc::new(Mutex::new(HashMap::new())), unadopted: Arc::new(Mutex::new(HashMap::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())), drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
pin_work: Arc::new(Mutex::new(Default::default())),
shutdown: Arc::new(crate::shutdown::Shutdown::default()), shutdown: Arc::new(crate::shutdown::Shutdown::default()),
upload_times: Arc::new(Mutex::new(HashMap::new())), upload_times: Arc::new(Mutex::new(HashMap::new())),
upload_cancel: Arc::new(Mutex::new(HashMap::new())), upload_cancel: Arc::new(Mutex::new(HashMap::new())),
@@ -622,17 +621,6 @@ pub(crate) fn mount_with(
keep_or_abandon(&core, control_socket, &mut workers, spawned)?; keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
} }
// Keep pinned files on this computer through changes made elsewhere, and
// fetch the ones a failed or missed download left out (`docs/BUGS.md`
// B282). Its own thread: a check lists folders and downloads files.
{
let keeper_core = core.clone();
let spawned = std::thread::Builder::new()
.name("pdfs-pin-keeper".into())
.spawn(move || keeper_core.run_pin_keeper());
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// Keep the launcher prompt's "This computer" index fresh. Its own thread: // Keep the launcher prompt's "This computer" index fresh. Its own thread:
// the walk is I/O-heavy and must never sit in front of a FUSE callback. // the walk is I/O-heavy and must never sit in front of a FUSE callback.
if matches!(host, Host::Daemon) { if matches!(host, Host::Daemon) {
-5
View File
@@ -111,11 +111,6 @@ impl Core {
// stale from here on, and the server may not have applied the swap yet. // stale from here on, and the server may not have applied the swap yet.
self.cache.evict(uid); self.cache.evict(uid);
self.evict_reader(uid); self.evict_reader(uid);
// A file kept offline gets the restored content back on disk, and
// again when the event of the swap arrives (`docs/BUGS.md` B282).
if self.cache.is_pinned(uid) {
self.recheck_pinned(uid);
}
self.log_activity( self.log_activity(
ActivityKind::Restore, ActivityKind::Restore,
name, name,
-112
View File
@@ -3447,118 +3447,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy() {
// Keeping a file offline took only paths under the primary mount, so
// the copy in an on-demand synced folder could not be kept, and a
// program reading it there downloaded it every time (B281).
let drive = FakeDrive::new();
let dir = scratch("pin-in-ondemand");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let ondemand = dir.join("ondemand");
sync_folder(&daemon, &ondemand, "ondemand");
let content = b"not a real png, but kept all the same".repeat(64);
let file = ondemand.join("photo.png");
std::fs::write(&file, &content).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
daemon.request(&Request::PurgeCache).unwrap();
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
let pinned = daemon
.request(&Request::Pin { path: path.clone() })
.unwrap();
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
let pins = || match daemon.request(&Request::ListPins) {
Ok(Response::Pins { pins }) => pins,
other => panic!("pins: {other:?}"),
};
let listed = pins();
assert!(
listed.iter().any(|pin| pin.path == path && pin.cached),
"the pin opens by the path it was made through: {listed:?}"
);
// With the link gone, only a kept copy can answer the read.
daemon.client.set_online(false);
assert_eq!(std::fs::read(&file).unwrap(), content);
daemon.client.set_online(true);
let unpinned = daemon
.request(&Request::Unpin { path: path.clone() })
.unwrap();
assert!(matches!(unpinned, Response::Ok { .. }), "{unpinned:?}");
assert!(pins().is_empty());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere() {
// A change from another device deleted a pinned file's copy and
// nothing downloaded it again, and a file added to a pinned folder
// elsewhere was never downloaded: both were listed as kept offline
// and read only with a network (B282).
let drive = FakeDrive::new();
let dir = scratch("pin-keeper");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"first").unwrap();
std::fs::write(daemon.mountpoint.join("solo.txt"), b"alone").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Docs/a.txt").is_some()
&& drive.lookup("solo.txt").is_some()
}));
daemon.request(&Request::PurgeCache).unwrap();
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
path: folder.into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
_ => None,
};
for path in ["Docs", "solo.txt"] {
let pinned = daemon.request(&Request::Pin { path: path.into() }).unwrap();
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
}
assert!(wait_until(Duration::from_secs(30), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached)
&& entry("", "solo.txt").is_some_and(|e| e.cached)
}));
// Another device edits both files and adds one to the kept folder.
drive.device().write("Docs/a.txt", b"second, and longer");
drive.device().write("solo.txt", b"alone, edited");
drive.device().write("Docs/new.txt", b"made elsewhere");
assert!(
wait_until(Duration::from_secs(60), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached && e.size == 18)
&& entry("", "solo.txt").is_some_and(|e| e.cached && e.size == 13)
&& entry("Docs", "new.txt").is_some_and(|e| e.cached)
}),
"the keeper downloaded the new content: {:?} {:?} {:?}",
entry("Docs", "a.txt"),
entry("", "solo.txt"),
entry("Docs", "new.txt"),
);
// With the link gone, only the kept copies can answer.
daemon.client.set_online(false);
let read = |rel: &str| std::fs::read(daemon.mountpoint.join(rel)).unwrap();
assert_eq!(read("Docs/a.txt"), b"second, and longer");
assert_eq!(read("solo.txt"), b"alone, edited");
assert_eq!(read("Docs/new.txt"), b"made elsewhere");
daemon.client.set_online(true);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// The synced folder `id` as the daemon lists it. /// The synced folder `id` as the daemon lists it.
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> { fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
match daemon.request(&Request::ListSyncFolders) { match daemon.request(&Request::ListSyncFolders) {
+2 -12
View File
@@ -128,18 +128,8 @@ fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
if changed(Topic::Shares) { if changed(Topic::Shares) {
refresh_invitations_badge(ui, Duration::ZERO); refresh_invitations_badge(ui, Duration::ZERO);
} }
let page = ui.stack.visible_child_name(); match ui.stack.visible_child_name().as_deref() {
// A timeline out of sight is read again when the user comes back to it; Some("browser") if changed(Topic::Files) => load_browser(ui),
// one on screen is left alone (below).
if changed(Topic::Photos) && (page.as_deref() != Some("gallery") || !photo_grid_shown(ui)) {
ui.gallery.timeline_stale.set(true);
}
match page.as_deref() {
// A search on screen is left alone: reloading would swap its hits for
// the folder's listing while the query stays in the box.
Some("browser") if changed(Topic::Files) && ui.browser.search.text().trim().is_empty() => {
load_browser(ui)
}
// The timeline is left alone: reloading it would throw away the place // The timeline is left alone: reloading it would throw away the place
// the user scrolled to. F5 reloads it on request. // the user scrolled to. F5 reloads it on request.
Some("gallery") Some("gallery")
+6 -42
View File
@@ -457,8 +457,6 @@ fn build_window(app: &adw::Application) {
files: browser_widgets.files.clone(), files: browser_widgets.files.clone(),
history: RefCell::new(Vec::new()), history: RefCell::new(Vec::new()),
future: RefCell::new(Vec::new()), future: RefCell::new(Vec::new()),
places: RefCell::new(HashMap::new()),
restore: RefCell::new(None),
crumb: browser_widgets.crumb.clone(), crumb: browser_widgets.crumb.clone(),
content: browser_widgets.content.clone(), content: browser_widgets.content.clone(),
status: browser_widgets.status.clone(), status: browser_widgets.status.clone(),
@@ -728,26 +726,17 @@ fn build_window(app: &adw::Application) {
cancel_file_thumbnails(&ui_nav); cancel_file_thumbnails(&ui_nav);
sync_sidebar(&ui_nav); sync_sidebar(&ui_nav);
match st.visible_child_name().as_deref() { match st.visible_child_name().as_deref() {
Some("browser") => reload_browser_view(&ui_nav), Some("browser") => load_browser(&ui_nav),
// The Albums and Places listings are re-read in place. Loading the // The Albums and Places listings are re-read in place. Loading the
// timeline instead would swap it in under the Albums or Places toggle. // timeline instead would swap it in under the Albums or Places toggle.
// The timeline, an album or a place comes back as it was left,
// scrolled where it was, unless photos changed while it was out of
// sight (see `follow_changes`) or nothing is loaded yet.
Some("gallery") if !ui_nav.gallery.in_collection() => { Some("gallery") if !ui_nav.gallery.in_collection() => {
match ui_nav.gallery.view_switch.active() { match ui_nav.gallery.view_switch.active() {
VIEW_ALBUMS => load_albums(&ui_nav), VIEW_ALBUMS => load_albums(&ui_nav),
VIEW_PLACES => load_places(&ui_nav), VIEW_PLACES => load_places(&ui_nav),
_ if ui_nav.gallery.timeline_stale.replace(false) _ => load_gallery(&ui_nav, false),
|| ui_nav.gallery.model.n_items() == 0 =>
{
load_gallery(&ui_nav, false)
}
_ => {}
} }
} }
Some("gallery") if ui_nav.gallery.model.n_items() == 0 => load_gallery(&ui_nav, false), Some("gallery") => load_gallery(&ui_nav, false),
Some("gallery") => {}
// Every visit reads the page again. The rows already on screen stay // Every visit reads the page again. The rows already on screen stay
// up while it does, so arriving costs no "Loading…" flash. // up while it does, so arriving costs no "Loading…" flash.
Some("sharedbyme") => load_shared_by_me(&ui_nav), Some("sharedbyme") => load_shared_by_me(&ui_nav),
@@ -1077,7 +1066,7 @@ fn reload_current_page(ui: &Rc<Ui>) {
match ui.stack.visible_child_name().as_deref() { match ui.stack.visible_child_name().as_deref() {
Some("browser") => { Some("browser") => {
let path = ui.browser.path.borrow().clone(); let path = ui.browser.path.borrow().clone();
refresh_then(ui, RefreshScope::Dir { path }, reload_browser_view); refresh_then(ui, RefreshScope::Dir { path }, load_browser);
} }
// One scope covers the whole photos view, so Refresh reloads whichever of // One scope covers the whole photos view, so Refresh reloads whichever of
// the views — a grid, or the timeline, an album or a place — is on screen. // the views — a grid, or the timeline, an album or a place — is on screen.
@@ -1550,7 +1539,6 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("<Control>minus", gettext_noop("Smaller thumbnails")), ("<Control>minus", gettext_noop("Smaller thumbnails")),
("<Control>0", gettext_noop("Reset thumbnail size")), ("<Control>0", gettext_noop("Reset thumbnail size")),
("<Control>a", gettext_noop("Select all")), ("<Control>a", gettext_noop("Select all")),
("Escape <Alt>Left", gettext_noop("Back")),
], ],
), ),
( (
@@ -1587,17 +1575,8 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
controller.connect_key_pressed(move |_, key, _, state| { controller.connect_key_pressed(move |_, key, _, state| {
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK); let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK); let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
let page = ui.stack.visible_child_name(); let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
let on_browser = page.as_deref() == Some("browser"); let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash");
let on_trash = page.as_deref() == Some("trash");
let on_gallery = page.as_deref() == Some("gallery");
// Pages with a level to go back to, besides My files' own history.
let in_collection = on_gallery && ui.gallery.in_collection();
let in_shared_folder =
page.as_deref() == Some("shared") && !ui.shared.nav.borrow().is_empty();
let in_device_folder =
page.as_deref() == Some("devices") && !ui.devices.nav.borrow().is_empty();
let back_key = (key == gtk4::gdk::Key::Left && alt) || key == gtk4::gdk::Key::BackSpace;
match key { match key {
// Refresh works on every page, so it is matched before the // Refresh works on every page, so it is matched before the
// browser-only bindings. // browser-only bindings.
@@ -1654,21 +1633,6 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
gtk4::gdk::Key::BackSpace if on_browser => { gtk4::gdk::Key::BackSpace if on_browser => {
ui.browser.actions.activate_action("back", None) ui.browser.actions.activate_action("back", None)
} }
// Selection mode ends before Escape leaves anything; the grid
// handles this itself while it has the focus.
gtk4::gdk::Key::Escape if on_gallery && ui.gallery.selecting.get() => {
set_selection_mode(&ui, false)
}
// An album or a place is a level below Photos, as a folder is
// below its parent: Back leaves it, and so does Escape.
_ if in_collection
&& !ui.gallery.selecting.get()
&& (back_key || key == gtk4::gdk::Key::Escape) =>
{
ui.gallery.back.emit_clicked()
}
_ if in_shared_folder && back_key => shared_back(&ui),
_ if in_device_folder && back_key => devices_back(&ui),
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui), gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => { gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => {
if let Some(list) = visible_file_list(&ui) { if let Some(list) = visible_file_list(&ui) {
+4 -17
View File
@@ -664,25 +664,12 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
} }
close_album(&ui_photos); close_album(&ui_photos);
close_place(&ui_photos); close_place(&ui_photos);
let gallery = &ui_photos.gallery; ui_photos.gallery.filters.set_visible(true);
gallery.filters.set_visible(true); ui_photos.gallery.content.set_visible_child_name("timeline");
gallery.content.set_visible_child_name("timeline");
// An album or a place cleared the kind and the month it does not use;
// the filter controls still show them, so the timeline comes back
// filtered the way they say.
let kind = kind_for_tab(gallery.tabs.active() as usize);
let range = gallery
.date_ranges
.borrow()
.get(gallery.dates.selected() as usize)
.copied()
.flatten();
let kind_changed = gallery.kind.replace(kind) != kind;
let range_changed = gallery.range.replace(range) != range;
let refiltered = kind_changed || range_changed;
// The timeline comes back as it was left unless an album took over the // The timeline comes back as it was left unless an album took over the
// model since; then it has to be reloaded. // model since; then it has to be reloaded.
if gallery.timeline_stale.replace(false) || refiltered || gallery.model.n_items() == 0 { if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0
{
load_gallery(&ui_photos, false); load_gallery(&ui_photos, false);
} else { } else {
update_gallery_subtitle(&ui_photos); update_gallery_subtitle(&ui_photos);
+1 -70
View File
@@ -1,14 +1,6 @@
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within}; use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
use crate::*; use crate::*;
/// How a folder in My files was left: scrolled `offset` px down, with the
/// entries named `selected` highlighted.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct FolderPlace {
pub(crate) offset: f64,
pub(crate) selected: Vec<String>,
}
pub(crate) struct BrowserState { pub(crate) struct BrowserState {
// Files (browser) page. // Files (browser) page.
/// The grid and list; the model is repopulated per directory. /// The grid and list; the model is repopulated per directory.
@@ -18,13 +10,6 @@ pub(crate) struct BrowserState {
/// breadcrumb. /// breadcrumb.
pub(crate) history: RefCell<Vec<String>>, pub(crate) history: RefCell<Vec<String>>,
pub(crate) future: RefCell<Vec<String>>, pub(crate) future: RefCell<Vec<String>>,
/// Where each folder left by Back, Forward, Up or a descent was scrolled
/// and what was selected in it, by path. Back, Forward and Up put the
/// folder back that way; opening it afresh starts at the top.
pub(crate) places: RefCell<HashMap<String, FolderPlace>>,
/// The folder whose [`Self::places`] entry the next listing restores, set
/// by Back, Forward and Up.
pub(crate) restore: RefCell<Option<String>>,
/// Clickable breadcrumb trail (a button per path segment); rebuilt per load /// Clickable breadcrumb trail (a button per path segment); rebuilt per load
/// by [`repaint_crumb`] so each ancestor folder navigates on click. /// by [`repaint_crumb`] so each ancestor folder navigates on click.
pub(crate) crumb: gtk4::Box, pub(crate) crumb: gtk4::Box,
@@ -1571,29 +1556,8 @@ pub(crate) fn sort_entries(entries: &mut [DirEntry], view: FilesView) {
}); });
} }
/// Remember how the folder on screen is scrolled and what is selected in it,
/// for Back, Forward or Up to bring it back that way. Only a listing of the
/// folder itself counts; search hits are not its rows.
fn remember_place(ui: &Rc<Ui>) {
let path = ui.browser.path.borrow().clone();
if ui.browser.listed.borrow().as_deref() != Some(path.as_str()) {
return;
}
let files = &ui.browser.files;
let place = FolderPlace {
offset: files.scroll_offset(),
selected: files
.selected()
.into_iter()
.map(|entry| entry.name)
.collect(),
};
ui.browser.places.borrow_mut().insert(path, place);
}
/// Open the folder on screen in a new place, remembering where we were. /// Open the folder on screen in a new place, remembering where we were.
pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) { pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) {
remember_place(ui);
let left = ui.browser.path.replace(target.clone()); let left = ui.browser.path.replace(target.clone());
if left != target { if left != target {
ui.browser.history.borrow_mut().push(left); ui.browser.history.borrow_mut().push(left);
@@ -1618,8 +1582,6 @@ fn browse_back(ui: &Rc<Ui>) {
let Some(previous) = ui.browser.history.borrow_mut().pop() else { let Some(previous) = ui.browser.history.borrow_mut().pop() else {
return; return;
}; };
remember_place(ui);
*ui.browser.restore.borrow_mut() = Some(previous.clone());
let left = ui.browser.path.replace(previous); let left = ui.browser.path.replace(previous);
ui.browser.future.borrow_mut().push(left); ui.browser.future.borrow_mut().push(left);
load_browser(ui); load_browser(ui);
@@ -1630,8 +1592,6 @@ fn browse_forward(ui: &Rc<Ui>) {
return; return;
}; };
ui.browser.search.set_text(""); ui.browser.search.set_text("");
remember_place(ui);
*ui.browser.restore.borrow_mut() = Some(next.clone());
let left = ui.browser.path.replace(next); let left = ui.browser.path.replace(next);
ui.browser.history.borrow_mut().push(left); ui.browser.history.borrow_mut().push(left);
load_browser(ui); load_browser(ui);
@@ -1644,7 +1604,6 @@ fn browse_up(ui: &Rc<Ui>) {
} }
ui.browser.search.set_text(""); ui.browser.search.set_text("");
let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or_default(); let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or_default();
*ui.browser.restore.borrow_mut() = Some(parent.to_string());
browse_to(ui, parent.to_string()); browse_to(ui, parent.to_string());
} }
@@ -2962,18 +2921,6 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
}); });
} }
/// Read again what My files shows: the search hits while there is a query,
/// the folder otherwise. [`load_browser`] alone would swap a search's hits
/// for the folder while the query stays in the box.
pub(crate) fn reload_browser_view(ui: &Rc<Ui>) {
let query = ui.browser.search.text().trim().to_string();
if query.is_empty() {
load_browser(ui);
} else {
run_search(ui, &query);
}
}
/// Clear the model and show the daemon's error on the status page. Used for /// Clear the model and show the daemon's error on the status page. Used for
/// in-band failures (a bad path, a permission error) — the mount is up, so Retry /// in-band failures (a bad path, a permission error) — the mount is up, so Retry
/// (which restarts the service) wouldn't help and isn't offered. /// (which restarts the service) wouldn't help and isn't offered.
@@ -3012,10 +2959,7 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) { pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
show_location_column(ui, false); show_location_column(ui, false);
*ui.browser.listing.borrow_mut() = entries.to_vec(); *ui.browser.listing.borrow_mut() = entries.to_vec();
// `listed` still names the folder the rows belong to only on a refresh; *ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone());
// any other load forgot it (see `load_browser`).
let path = ui.browser.path.borrow().clone();
let another_folder = ui.browser.listed.replace(Some(path.clone())).as_ref() != Some(&path);
ui.browser.summary.set_label(&listing_summary( ui.browser.summary.set_label(&listing_summary(
entries.iter().filter(|entry| !entry.is_dir).count(), entries.iter().filter(|entry| !entry.is_dir).count(),
entries.iter().filter(|entry| entry.is_dir).count(), entries.iter().filter(|entry| entry.is_dir).count(),
@@ -3044,16 +2988,6 @@ pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
// Only the rows that changed are swapped, so a refresh keeps the // Only the rows that changed are swapped, so a refresh keeps the
// selection, the scroll position and the thumbnails already drawn. // selection, the scroll position and the thumbnails already drawn.
replace_items(&ui.browser.files.model, &sorted); replace_items(&ui.browser.files.model, &sorted);
// Back, Forward and Up return to a folder as it was left; any other way
// in starts at the top.
let restore = ui.browser.restore.take().filter(|target| *target == path);
if another_folder {
let place = restore.and_then(|path| ui.browser.places.borrow().get(&path).cloned());
match place {
Some(place) => ui.browser.files.restore(place.offset, &place.selected),
None => ui.browser.files.scroll_to_top(),
}
}
} }
/// Forget which folder the rows belong to and drop the selection, before the /// Forget which folder the rows belong to and drop the selection, before the
@@ -3232,9 +3166,6 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
.collect(); .collect();
sort_entries(&mut entries, ui.browser.view.get()); sort_entries(&mut entries, ui.browser.view.get());
replace_items(&ui.browser.files.model, &entries); replace_items(&ui.browser.files.model, &entries);
// Every search starts over (`run_search` forgets the listing), so its hits
// read from the first one.
ui.browser.files.scroll_to_top();
} }
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String { fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
+4 -16
View File
@@ -190,9 +190,10 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
})); }));
let ui_back = ui.clone(); let ui_back = ui.clone();
widgets widgets.back.connect_clicked(move |_| {
.back ui_back.devices.nav.borrow_mut().pop();
.connect_clicked(move |_| devices_back(&ui_back)); load_devices(&ui_back);
});
let ui_retry = ui.clone(); let ui_retry = ui.clone();
widgets widgets
.retry .retry
@@ -210,14 +211,6 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
ui.devices.content.set_visible_child_name("status"); ui.devices.content.set_visible_child_name("status");
} }
/// Back out of a computer's folder to the level above: the page's Back button, and
/// Alt+Left or Backspace. A no-op at the top.
pub(crate) fn devices_back(ui: &Rc<Ui>) {
if ui.devices.nav.borrow_mut().pop().is_some() {
load_devices(ui);
}
}
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder /// Load the view [`DevicesState::nav`] points at: the device list, or a folder
/// of another computer's backup. /// of another computer's backup.
pub(crate) fn load_devices(ui: &Rc<Ui>) { pub(crate) fn load_devices(ui: &Rc<Ui>) {
@@ -304,7 +297,6 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
}; };
ui.devices.content.set_visible_child_name("browse"); ui.devices.content.set_visible_child_name("browse");
repaint_device_crumb(&ui, &nav); repaint_device_crumb(&ui, &nav);
let another_folder = ui.devices.listed.borrow().as_ref() != Some(&nav);
*ui.devices.listed.borrow_mut() = Some(nav); *ui.devices.listed.borrow_mut() = Some(nav);
if entries.is_empty() { if entries.is_empty() {
ui.devices.files.model.remove_all(); ui.devices.files.model.remove_all();
@@ -312,10 +304,6 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
} else { } else {
ui.devices.files_content.set_visible_child_name("list"); ui.devices.files_content.set_visible_child_name("list");
replace_items(&ui.devices.files.model, &entries); replace_items(&ui.devices.files.model, &entries);
// A refresh keeps its place; another folder starts at the top.
if another_folder {
ui.devices.files.scroll_to_top();
}
} }
}); });
} }
+1 -10
View File
@@ -1001,10 +1001,7 @@ fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_tim
/// ///
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close /// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
/// button, or a click on the backdrop beside the photo all dismiss it. /// button, or a click on the backdrop beside the photo all dismiss it.
/// pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
/// Returns the lightbox's window, for a caller that has to put the page
/// behind it back once it closes.
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Window {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap(); let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
// An `adw::Window` for its breakpoints: the details panel lies over the // An `adw::Window` for its breakpoints: the details panel lies over the
@@ -1428,10 +1425,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
// kept every closed lightbox, its window and its full-size photo in memory // kept every closed lightbox, its window and its full-size photo in memory
// for the life of the app. // for the life of the app.
let keep = RefCell::new(Some(viewer.clone())); let keep = RefCell::new(Some(viewer.clone()));
let ui_close = ui.clone();
window.connect_close_request(move |_| { window.connect_close_request(move |_| {
if let Some(viewer) = keep.borrow_mut().take() { if let Some(viewer) = keep.borrow_mut().take() {
reveal_photo(&ui_close, &viewer.uid.borrow());
stop_video(&viewer); stop_video(&viewer);
disarm_slide(&viewer); disarm_slide(&viewer);
if let Some(source) = viewer.chrome_source.borrow_mut().take() { if let Some(source) = viewer.chrome_source.borrow_mut().take() {
@@ -1515,9 +1510,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
let failed = match rx.recv().await { let failed = match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => { Ok(Ok(Response::Ok { .. })) => {
set_gallery_favorite(&ui_result, &uid, favorite); set_gallery_favorite(&ui_result, &uid, favorite);
// The tiles hold their own copy of the photo, so the star
// shows on the grid only once it is laid out again.
repaint_gallery(&ui_result);
None None
} }
Ok(Ok(Response::Error { message, .. })) => Some(message), Ok(Ok(Response::Error { message, .. })) => Some(message),
@@ -1726,7 +1718,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
window.add_controller(key_controller); window.add_controller(key_controller);
window.present(); window.present();
window
} }
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself /// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
+1 -51
View File
@@ -3288,16 +3288,6 @@ fn top_anchor(ui: &Rc<Ui>) -> Option<(String, f32)> {
.or(first) .or(first)
} }
/// Scroll the grid just far enough to show photo `uid`, as the lightbox
/// closes: paging with the arrow keys may have left the grid far behind.
pub(crate) fn reveal_photo(ui: &Rc<Ui>, uid: &str) {
if let Some(row) = row_of_photo(&ui.gallery.groups, uid) {
ui.gallery
.list
.scroll_to(row, gtk4::ListScrollFlags::NONE, None);
}
}
/// Which row of the rendered model holds `uid`, if any. /// Which row of the rendered model holds `uid`, if any.
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> { fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
(0..store.n_items()).find(|i| { (0..store.n_items()).find(|i| {
@@ -3962,9 +3952,7 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: &gtk4::Button, x: f64
} else { } else {
pgettext("verb", "Open") pgettext("verb", "Open")
}; };
menu.item(&label, move || { menu.item(&label, move || open_photo_viewer(&ui_c, uid.clone()));
open_photo_viewer(&ui_c, uid.clone());
});
let (ui_c, uid) = (ui.clone(), photo.uid.clone()); let (ui_c, uid) = (ui.clone(), photo.uid.clone());
// Translators: a check item in a photo's menu, on while the photo is a favorite. // Translators: a check item in a photo's menu, on while the photo is a favorite.
let favorite_label = pgettext("state", "Favorite"); let favorite_label = pgettext("state", "Favorite");
@@ -4032,23 +4020,8 @@ fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
}); });
} }
/// Whether the photo grid is the view the Photos page shows: an album or a
/// place, or the timeline itself. Under Albums or Places it is out of sight.
pub(crate) fn photo_grid_shown(ui: &Ui) -> bool {
ui.gallery.in_collection() || ui.gallery.view_switch.active() == VIEW_TIMELINE
}
pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) { pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
sync_filter_badge(ui); sync_filter_badge(ui);
// A load would put the grid, or its status page, on screen in place of
// the Albums or Places view the user is looking at. Leave the timeline to
// be read again when they switch back to it.
if !photo_grid_shown(ui) {
ui.gallery.timeline_stale.set(true);
// A scrubber jump was paging the grid the user has just left.
ui.gallery.jump.set(None);
return;
}
if ui.gallery.loading.get() { if ui.gallery.loading.get() {
// A filter flipped while a page is on its way: that page answers the // A filter flipped while a page is on its way: that page answers the
// old question, so fetch again once it lands. More pages can wait. // old question, so fetch again once it lands. More pages can wait.
@@ -4064,9 +4037,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
let place = ui.gallery.place.borrow().as_ref().map(|p| p.id); let place = ui.gallery.place.borrow().as_ref().map(|p| p.id);
let collection = album.is_some() || place.is_some(); let collection = album.is_some() || place.is_some();
if !append { if !append {
// A scrubber jump looks for a month of the listing being replaced:
// another filter, album or place has its own months, if any.
ui.gallery.jump.set(None);
// Fresh load: clear the timeline and show Loading until the first page lands. // Fresh load: clear the timeline and show Loading until the first page lands.
ui.gallery.model.remove_all(); ui.gallery.model.remove_all();
gallery_status( gallery_status(
@@ -4128,12 +4098,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
if !waiters.is_empty() { if !waiters.is_empty() {
glib::idle_add_local_once(move || waiters.into_iter().for_each(|waiter| waiter())); glib::idle_add_local_once(move || waiters.into_iter().for_each(|waiter| waiter()));
} }
// The user left for Albums or Places while the page was on its way;
// painting it now would switch the view under them.
if !photo_grid_shown(&ui) {
ui.gallery.timeline_stale.set(true);
return;
}
match result { match result {
Ok(Ok(Response::Photos { Ok(Ok(Response::Photos {
available, available,
@@ -4174,20 +4138,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
ui.gallery.model.append(&BoxedAnyObject::new(item.clone())); ui.gallery.model.append(&BoxedAnyObject::new(item.clone()));
} }
repaint_gallery(&ui); repaint_gallery(&ui);
// The rows are swapped in place, which keeps the old scroll
// offset; a fresh load (another kind, month, album or place)
// reads from its newest photo.
if !append
&& ui
.gallery
.list
.model()
.is_some_and(|rows| rows.n_items() > 0)
{
ui.gallery
.list
.scroll_to(0, gtk4::ListScrollFlags::NONE, None);
}
if ui.gallery.model.n_items() == 0 { if ui.gallery.model.n_items() == 0 {
let filtered = ui.gallery.kind.get().is_some() let filtered = ui.gallery.kind.get().is_some()
|| ui.gallery.favorites.get() || ui.gallery.favorites.get()
+7 -31
View File
@@ -392,45 +392,21 @@ fn load_place_locations(ui: &Rc<Ui>, id: u32) {
}); });
} }
/// Open photo `uid` of `place` from the map or a spot's list, paging through /// Open `place` and then photo `uid` in it, paging through the town until
/// the town until the photo has loaded. /// the photo has loaded.
///
/// The lightbox steps through the gallery's model, so the town is opened
/// behind it. Closing the lightbox goes back to the map rather than leaving
/// the user in a town timeline they never asked for.
fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) { fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
let id = place.id;
open_place(ui, place); open_place(ui, place);
view_when_loaded(ui, id, uid); view_when_loaded(ui, uid);
} }
/// The open place's id, if a place is open. fn view_when_loaded(ui: &Rc<Ui>, uid: String) {
fn open_place_id(ui: &Ui) -> Option<u32> {
ui.gallery.place.borrow().as_ref().map(|place| place.id)
}
fn view_when_loaded(ui: &Rc<Ui>, id: u32, uid: String) {
let ui_page = ui.clone(); let ui_page = ui.clone();
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || { ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
let ui = ui_page; let ui = ui_page;
// The user went elsewhere while the town loaded: whatever page lands
// now belongs to that, and must not pop the lightbox up over it.
if open_place_id(&ui) != Some(id) {
return;
}
if find_photo_index(&ui.gallery.model, &uid).is_some() { if find_photo_index(&ui.gallery.model, &uid).is_some() {
let window = open_photo_viewer(&ui, uid); open_photo_viewer(&ui, uid);
let ui_close = ui.clone(); } else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() {
window.connect_close_request(move |_| { view_when_loaded(&ui, uid);
// Only if the town is still what the lightbox was showing: "Show
// on map" in the lightbox has already moved on by itself.
if open_place_id(&ui_close) == Some(id) {
show_place_grid(&ui_close);
}
glib::Propagation::Proceed
});
} else if ui.gallery.has_more.get() {
view_when_loaded(&ui, id, uid);
ui.gallery.burst.set(true); ui.gallery.burst.set(true);
load_gallery(&ui, true); load_gallery(&ui, true);
} }
+5 -14
View File
@@ -279,7 +279,10 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
})); }));
let ui_back = ui.clone(); let ui_back = ui.clone();
widgets.back.connect_clicked(move |_| shared_back(&ui_back)); widgets.back.connect_clicked(move |_| {
ui_back.shared.nav.borrow_mut().pop();
load_shared(&ui_back);
});
let ui_retry = ui.clone(); let ui_retry = ui.clone();
widgets widgets
.retry .retry
@@ -315,14 +318,6 @@ pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &
ui.shared.content.set_visible_child_name("status"); ui.shared.content.set_visible_child_name("status");
} }
/// Back out of a shared folder to the level above: the page's Back button, and
/// Alt+Left or Backspace. A no-op at the top.
pub(crate) fn shared_back(ui: &Rc<Ui>) {
if ui.shared.nav.borrow_mut().pop().is_some() {
load_shared(ui);
}
}
/// Fetch the top level (shared-with-me, invitations, saved links) in parallel /// Fetch the top level (shared-with-me, invitations, saved links) in parallel
/// and repaint the page once all three land. /// and repaint the page once all three land.
/// ///
@@ -448,7 +443,6 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
let nav = ui.shared.nav.borrow().clone(); let nav = ui.shared.nav.borrow().clone();
repaint_shared_crumb(ui, &nav); repaint_shared_crumb(ui, &nav);
let top = nav.is_empty(); let top = nav.is_empty();
let another_folder = ui.shared.listed.borrow().as_ref() != Some(&nav);
*ui.shared.listed.borrow_mut() = Some(nav); *ui.shared.listed.borrow_mut() = Some(nav);
if entries.is_empty() { if entries.is_empty() {
ui.shared.files.model.remove_all(); ui.shared.files.model.remove_all();
@@ -473,11 +467,8 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
} }
ui.shared.files_content.set_visible_child_name("list"); ui.shared.files_content.set_visible_child_name("list");
// Only the rows that changed are swapped, so a refresh keeps the selection // Only the rows that changed are swapped, so a refresh keeps the selection
// and the scroll position. Another folder starts from its first entry. // and the scroll position.
replace_items(&ui.shared.files.model, entries); replace_items(&ui.shared.files.model, entries);
if another_folder {
ui.shared.files.scroll_to_top();
}
} }
/// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder /// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder
@@ -224,49 +224,6 @@ impl FileList {
.set_visible_child_name(if list { "list" } else { "grid" }); .set_visible_child_name(if list { "list" } else { "grid" });
} }
/// Bring both views back to the first entry. For a listing of something
/// else: [`replace_items`] keeps the scroll position, which is right for a
/// refresh, but another folder would open where the last one was left —
/// often at its bottom.
pub(crate) fn scroll_to_top(&self) {
if self.model.n_items() == 0 {
return;
}
self.grid.scroll_to(0, gtk4::ListScrollFlags::NONE, None);
self.column_view
.scroll_to(0, None, gtk4::ListScrollFlags::NONE, None);
}
/// How far the view on screen is scrolled, in px from the top.
pub(crate) fn scroll_offset(&self) -> f64 {
self.views
.visible_child()
.and_downcast::<gtk4::ScrolledWindow>()
.map_or(0.0, |scroll| scroll.vadjustment().value())
}
/// Select the entries named `selected` and scroll the view on screen to
/// `offset`, as they were when the folder was left. The scroll waits for
/// an idle, when the views have measured the rows just put in.
pub(crate) fn restore(&self, offset: f64, selected: &[String]) {
self.selection.unselect_all();
for i in 0..self.selection.n_items() {
if entry_at(Some(&self.selection), i)
.is_some_and(|entry| selected.contains(&entry.name))
{
self.selection.select_item(i, false);
}
}
let Some(scroll) = self
.views
.visible_child()
.and_downcast::<gtk4::ScrolledWindow>()
else {
return;
};
glib::idle_add_local_once(move || scroll.vadjustment().set_value(offset));
}
/// Whether the list, rather than the grid, is on screen. /// Whether the list, rather than the grid, is on screen.
pub(crate) fn shows_list(&self) -> bool { pub(crate) fn shows_list(&self) -> bool {
self.views.visible_child_name().as_deref() == Some("list") self.views.visible_child_name().as_deref() == Some("list")
-1
View File
@@ -467,7 +467,6 @@ both with `pdfs_core::search::relevance_score`, so the prompt can merge them int
| `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) | | `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) |
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) | | Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
| `pdfs-online-probe` | Probes Drive while offline | | `pdfs-online-probe` | Probes Drive while offline |
| `pdfs-pin-keeper` | Checks pins once a second for work: every pin at startup, when the link returns and after the event feed loses continuity, and the pins a batch of remote events changed. Downloads files kept offline that are missing or stale; failures retry with a backoff of 30 s to 30 min (`docs/BUGS.md` B282) |
| `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) | | `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) |
| `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one | | `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one |
| Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` | | Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` |
-72
View File
@@ -110,8 +110,6 @@ a live account since.
| Entry | Title | | Entry | Title |
|---|---| |---|---|
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code | | [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed | | [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
| [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back | | [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back |
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds | | [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
@@ -429,76 +427,6 @@ videos of over 70 MiB and takes nothing from a video with an uploader's thumbnai
`real_video_fixture_yields_a_frame` passed on 2026-11-14 with ffmpeg 9.0 for a 46 MB MP4 with its `real_video_fixture_yields_a_frame` passed on 2026-11-14 with ffmpeg 9.0 for a 46 MB MP4 with its
index at the end, an MPEG-TS and an MKV. index at the end, an MPEG-TS and an MKV.
### B282 — A file kept offline is downloaded again after a change made elsewhere
**Status:** Fixed, not verified live
**Found:** 2026-11-13, while looking into B281. Nothing downloaded a pinned file again after the
pin had downloaded it once.
**Where:** `apply_event` in `crates/pdfs-fuse/src/background.rs`; the new pin keeper in
`crates/pdfs-fuse/src/keeper.rs`
**Repro:** Pin a file, then change it from another device. The event deletes the cached copy.
The file stays listed as kept offline, `cached` is false, and the next read downloads it, or
fails without a network. Rename or move it elsewhere and the same happens, though its content
did not change. Add a file to a pinned folder from another device and it is never downloaded.
A folder pin whose downloads failed while the link was down never retries them.
**Cause:** A remote change to a known node called `ContentCache::evict`, which does not check
pins, and only the pin request itself downloaded anything. The copy was also stored under the
size the listing held at pin time. That is the ciphertext size until the size upgrade lands
(B12), and a copy stored under it never matches the plaintext it holds.
**Fix:** A remote change no longer evicts a pinned file's copy. The copy is checked against the
revision's `(mtime, size)`, so a read of a newer revision does not take it. A rename keeps the
revision's mtime (B123), so the copy stays valid. A pin keeper thread checks pins again and
downloads files that are missing or stale:
- the pins a batch of remote events changed, after the batch's listings are dropped;
- a pinned folder that gets a new file;
- every pin at startup, when the link comes back, and after the event feed loses continuity;
- a file whose version is restored.
Failed downloads are tried again with a backoff from 30 s to 30 min. Pinning settles a
provisional size before it stores the copy.
**Test:** `a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere` in
`crates/pdfs-fuse/src/sim/daemon.rs`. Another device edits a pinned file and a file in a pinned
folder, and adds a file to that folder. The test waits until all three are cached, then reads
them with the link down. With the keeper disabled, it fails with all three uncached.
---
### B281 — A file in a synced folder cannot be kept offline
**Status:** Fixed, not verified live
**Found:** 2026-11-13. An image was made available offline in the app, and it opened from the app
with no download. `feh` opened it from a terminal and downloaded it each time. The two were not
the same file. The app had kept `~/ProtonDrive/Pictures/photo.png`, in My files. The
terminal opened `~/Pictures/photo.png`, in the on-demand synced folder `~/Pictures`. That
copy is a separate node under Computers, uploaded on its own, with the same name and size but a
different mtime. The daemon treats every program the same way: it does not look at the process
that reads.
**Where:** the `Pin` and `Unpin` handlers in `crates/pdfs-fuse/src/control.rs`
**Repro:** Add a synced folder in on-demand mode and put a file in it. `pdfs pin` on its absolute
path fails with "is not under the mountpoint". A relative path is resolved against My files, so
it keeps a file of the same name there, if one exists, or fails. Reading the file in the synced
folder then downloads it.
**Cause:** Both handlers turned the path into a mount-relative one with `rel_to_mount`, which only
knows the primary mount. B86 moved `ls`, `refresh`, `rm` and `mv` to `route_to_mount`, which finds
the mount that serves an absolute path. Pin and unpin were left out.
**Fix:** Pin and unpin route through `route_to_mount` and run on the mount that owns the path. A
path in a mirror folder gets an error that says its files are already on this computer. The pin
registry records the absolute local path for a pin made through an on-demand folder, so
`pdfs pins`, the prompt and the HUD open the file through the folder it was kept from. Pins
under the primary mount still record the path relative to it.
**Test:** `pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy` in
`crates/pdfs-fuse/src/sim/daemon.rs`. It pins a file through an on-demand folder's absolute path,
takes the link down, and reads the file through that folder.
--- ---
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed ### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
+23 -88
View File
@@ -11,40 +11,32 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
## [3.8.0] — 2026-11-14 ### Changed
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files. - Update `proton-sdk` and `proton-drive-rs` to 0.11.0.
Existing folders stay device backups. Older builds cannot open the upgraded database. Convert epoch timestamps at the SDK boundary and explicitly preserve photo modification
times now that they are independent of capture times. See [the SDK review](SDK-0.11.0.md)
for features and breaking changes.
Schema: **45**. Synced folders now distinguish device backups from My files attachments.
Existing folders remain device backups; older builds cannot open the upgraded database.
### Added ### Added
- **Sync a local folder with a folder you already have in My files** (#67, #77). - Shell completion for `pdfs` in bash, zsh and fish, installed by every package, plus elvish
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both and PowerShell through `pdfs completions SHELL`. Besides commands and options it completes
ways with My files/Work. The first sync uploads files that are only on this computer, downloads paths in My files and the ids and uids the running service knows (synced folders, the queue,
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on computers, trash, albums, shares, invitations, bookmarks, conflicts and versions), each with
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is a description. Share roles are now checked by the CLI (`viewer`, `editor`, `admin`) before
uploaded too. Attached folders are always kept on this computer, never online only. They are the request.
listed apart from device backups and are not part of the device profile used to restore another - `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder My files folder. The first sync preserves one-sided files and differing versions (#67).
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
files stay. Attaching a folder is done from a terminal for now. the remote root when syncing is stopped.
- **Tab completion for `pdfs`** in bash, zsh and fish, installed by every package. Besides
commands and options it completes paths in My files (`pdfs pin Doc<Tab>`) and what the running
service knows, each with a description: synced folders, queued changes, computers, trash, albums,
items shared with you, invitations, bookmarks and conflict copies, and once a path is typed, the
file's versions or the share's members and link. Without the service it still completes commands
and options. Elvish and PowerShell load it from `pdfs completions SHELL`, and so does a source
build; the [command-line reference](CLI.md#shell-completion) has the line for each shell.
`pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before
sending the request, and `pdfs public-link` does the same for `viewer` and `editor`.
- **Video thumbnails in My files, Shared, Trash and the launcher**, as in Photos, with a play - **Video thumbnails in My files, Shared, Trash and the launcher**, as in Photos, with a play
badge in the grid. A video's thumbnail is the one its uploader attached when it has one. badge in the grid. A video's thumbnail is the one its uploader attached when it has one.
Otherwise the daemon takes a frame with ffmpeg from the start of the file, plus an MP4's Otherwise the daemon takes a frame with ffmpeg from the start of the file, plus an MP4's
index, and never downloads more than about 16 MiB of a video for it, however large the index, and never downloads more than about 16 MiB of a video for it, however large the
video is. **Build Thumbnails** covers videos too (B283). video is. **Build Thumbnails** covers videos too (B283).
- **A new layout for Photos.** - **A new layout for Photos.**
- The grid shows every photo at its own shape, in rows that fill the width (portraits - The grid shows every photo at its own shape, in rows that fill the width (portraits
are no longer cropped to squares). **Square Tiles** in the page menu brings the even are no longer cropped to squares). **Square Tiles** in the page menu brings the even
@@ -69,75 +61,18 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
- The zoom level and the tile shape are remembered between runs. - The zoom level and the tile shape are remembered between runs.
- In the photo viewer, the details panel lies over the photo when the window is - In the photo viewer, the details panel lies over the photo when the window is
narrow instead of squeezing it. narrow instead of squeezing it.
- **Larger icons in My files.** The icon size slider goes up to 256 px, big enough to - **Larger icons in My files.** The icon size slider goes up to 256 px, big enough to
tell photos apart by their thumbnail. Ctrl+scroll, Ctrl+plus, Ctrl+minus and Ctrl+0 tell photos apart by their thumbnail. Ctrl+scroll, Ctrl+plus, Ctrl+minus and Ctrl+0
change it too, and the size is remembered between runs. change it too, and the size is remembered between runs.
- **Back takes you to where you were.** Back, Forward and Up in My files return to a folder
scrolled where it was left, with the same items selected, and closing the photo viewer scrolls
the grid to the photo it was showing. Alt+Left and Backspace go back a level in Shared with me
and Computers and leave an open album or place in Photos, as Escape does too.
### Changed
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
from the SDK, and a photo keeps its own modification time now that it no longer follows the
capture time. [The SDK review](SDK-0.11.0.md) lists the new features and breaking changes.
### Fixed ### Fixed
- **My files comes back after the service was killed for hanging** (B279). A killed service left - Large `.ts` recordings (MPEG transport streams) show and open as videos: the video icon, a
its folders behind as dead mounts, and every restart then failed with `File exists` and retried thumbnail, and the `@media` open rule rather than `@text`. A `.ts` of 1 MiB or more counts
forever without opening anything, until the mounts were cleared by hand. The service now clears as a video, a smaller one stays TypeScript source, and the daemon checks the stream's sync
a dead mount before it opens the folder. On a large account a restart that did open My files was byte before it takes a frame (B283).
killed again, because loading the account took longer than the 2 minutes the service has to - A video over 256 MiB never got a thumbnail, and a smaller one was downloaded whole into
report in. Startup now gets 10 minutes, and the 2 minutes apply again once the service answers. memory for one frame (B283).
- **Uploading many large photos no longer runs the computer out of memory** (B280). Every image
upload decodes the whole picture to make its thumbnail, and up to 16 did so at once. A batch of
100-megapixel photos took about 9 GiB, filled the swap, and the service was killed on every
restart. Thumbnails now share 512 MiB between them: phone photos are still made side by side,
and a very large photo waits and is made on its own.
- **Keep a file offline in an on-demand synced folder** (B281). `pdfs pin` and `pdfs unpin` now
accept a path in a synced folder such as `~/Pictures`. Before, they only took paths under the
Proton Drive mount, so the copy in the synced folder was downloaded again by every program that
read it.
- **Files kept offline stay on this computer when they change elsewhere** (B282). A change from
another device, even a rename, deleted the copy of a file kept offline, and nothing downloaded
it again, so the next read downloaded it or failed without a network. Files added to a folder
kept offline from another device were never downloaded. The copy now stays until the new
version is downloaded, which happens on its own. Files kept offline are also checked at
startup and when the connection returns, and failed downloads are tried again.
- **Large `.ts` recordings show and open as videos** (B283). An MPEG transport stream got the
text icon, no thumbnail, and the `@text` open rule. It now gets the video icon, a thumbnail
and the `@media` rule. A `.ts` of 1 MiB or more counts as a video, a smaller one stays
TypeScript source, and the daemon checks the stream's sync byte before it takes a frame.
- **Videos over 256 MiB get a thumbnail** (B283). They never got one, and a smaller video was
downloaded whole into memory for one frame.
- **A folder opens at the top.** A folder opened in My files, Shared with me or Computers could
open scrolled to where its parent was left, often at the bottom. Another folder, a new search
and a new Photos filter, album or place now start at the top, and a refresh keeps its place.
- **Closing a photo opened from the Places map goes back to the map**, not to the town's
timeline.
- **Albums and Places stay on screen.** They could switch to the timeline by themselves when a
timeline page arrived late, after an upload or after a trash Undo. Coming back to Photos no
longer reloads the timeline and loses its scroll position, unless photos changed while it was
out of sight. After an album or a place, the timeline came back unfiltered while the kind tab
and the date still showed the filter.
- **A search in My files stays.** It was replaced by the folder's listing whenever files
changed, on refresh and on coming back to the page, while the query stayed in the box.
- **A date jump in Photos stops when you look elsewhere.** A jump still loading the timeline
kept going after another filter, album or place was opened.
## [3.7.0] — 2026-11-02 ## [3.7.0] — 2026-11-02
+1 -1
View File
@@ -102,7 +102,7 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). PATH can be under the Proton Drive mount or in an on-demand synced folder. A file in a mirror folder is already on this computer. | | `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. | | `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
| `pdfs pins` | List files and folders kept available offline. | | `pdfs pins` | List files and folders kept available offline. |
+1 -1
View File
@@ -102,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
the tag to update: the tag to update:
```nix ```nix
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.8.0"; inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.7.0";
``` ```
Each release tag is built with Nix before the release is published. Each release tag is built with Nix before the release is published.
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=3.8.0 pkgver=3.7.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
+1 -1
View File
@@ -10,7 +10,7 @@
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice. # Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
%global _lto_cflags %{nil} %global _lto_cflags %{nil}
%{!?version: %global version 3.8.0} %{!?version: %global version 3.7.0}
%global forge https://github.com/narrrl/proton-drive-linux %global forge https://github.com/narrrl/proton-drive-linux
# Files come from the checkout, or from the unpacked tag archive. # Files come from the checkout, or from the unpacked tag archive.
%global src %{?git_dir}%{!?git_dir:.} %global src %{?git_dir}%{!?git_dir:.}
+548 -549
View File
File diff suppressed because it is too large. Load diff
+571 -572
View File
File diff suppressed because it is too large. Load diff
+548 -549
View File
File diff suppressed because it is too large. Load diff
+548 -549
View File
File diff suppressed because it is too large. Load diff
+548 -549
View File
File diff suppressed because it is too large. Load diff
+548 -549
View File
File diff suppressed because it is too large. Load diff
+549 -550
View File
File diff suppressed because it is too large. Load diff
+548 -549
View File
File diff suppressed because it is too large. Load diff
+548 -549
View File
File diff suppressed because it is too large. Load diff