mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 17:32:00 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d445bb02c |
No files matched your search
Generated
+4
-4
@@ -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
@@ -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>"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: >k4::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()
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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:.}
|
||||||
|
|||||||
+571
-572
File diff suppressed because it is too large.
Load diff
+549
-550
File diff suppressed because it is too large.
Load diff
+548
-549
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user