mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 17:32:00 +01:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba5b6a8bf2 | ||
|
|
3295eda1f0 | ||
|
|
41212efdf3 | ||
|
|
5f0b15fe70 | ||
|
|
033c8d3d71 | ||
|
|
5c184013f1 | ||
|
|
6be8a8f9bc | ||
|
|
57acb0b849 | ||
|
|
68ff8c9e80 | ||
|
|
4552093c53 | ||
|
|
14a663cee9 | ||
|
|
3f45e89391 | ||
|
|
ad648ab5cc | ||
|
|
2b95708260 |
No files matched your search
Generated
+4
-4
@@ -3541,7 +3541,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "3.7.0"
|
||||
version = "3.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3558,7 +3558,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "3.7.0"
|
||||
version = "3.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3586,7 +3586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "3.7.0"
|
||||
version = "3.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3608,7 +3608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "3.7.0"
|
||||
version = "3.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "3.7.0"
|
||||
version = "3.8.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
|
||||
@@ -996,21 +996,15 @@ impl TokenSink {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the unsaved rotation, if there is one. A blob that moved on, or
|
||||
/// is gone with a logout, drops it: there is nothing it may still go into.
|
||||
/// Write the unsaved rotation, if there is one. A blob that moved on drops
|
||||
/// it. A missing blob is retried: some Secret Service providers also report
|
||||
/// locked or temporarily hidden items as absent. Never recreate it here.
|
||||
fn flush(&self) -> Result<()> {
|
||||
let mut state = self.state.lock();
|
||||
let Some(tokens) = state.unsaved.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut stored = match load() {
|
||||
Ok(stored) => stored,
|
||||
Err(Error::NotLoggedIn) => {
|
||||
state.settle();
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let mut stored = load()?;
|
||||
match take_rotation(
|
||||
&mut stored,
|
||||
&self.session_id,
|
||||
@@ -1306,6 +1300,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// oo7 0.7.0 reports a locked collection's items as absent (upstream #614).
|
||||
/// Treating that as logout loses the only live tokens (B284, issue #78).
|
||||
#[test]
|
||||
fn a_temporarily_missing_session_does_not_discard_a_rotation() {
|
||||
let _turn = mock_keyring();
|
||||
write_stored(&stored_with("r")).unwrap();
|
||||
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||
let entry = keyring_entry().unwrap();
|
||||
entry
|
||||
.as_any()
|
||||
.downcast_ref::<keyring_core::mock::Cred>()
|
||||
.unwrap()
|
||||
.set_error(keyring_core::Error::NoEntry);
|
||||
|
||||
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
||||
assert!(sink.state.lock().unsaved.is_some());
|
||||
assert_eq!(load().unwrap().refresh_token, "r");
|
||||
|
||||
sink.flush().unwrap();
|
||||
assert_eq!(load().unwrap().refresh_token, "r2");
|
||||
assert!(sink.state.lock().unsaved.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login() {
|
||||
let _turn = mock_keyring();
|
||||
let sink = TokenSink::new("s", "r".into());
|
||||
sink.state.lock().unsaved = Some(tokens("a2", "r2"));
|
||||
|
||||
assert!(matches!(sink.flush(), Err(Error::NotLoggedIn)));
|
||||
assert!(matches!(load(), Err(Error::NotLoggedIn)));
|
||||
assert!(sink.state.lock().unsaved.is_some());
|
||||
|
||||
let mut login = stored_with("fresh");
|
||||
login.session_id = "new".into();
|
||||
write_stored(&login).unwrap();
|
||||
sink.flush().unwrap();
|
||||
assert!(sink.state.lock().unsaved.is_none());
|
||||
assert_eq!(load().unwrap().refresh_token, "fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
||||
let unsaved = UnsavedRotation {
|
||||
|
||||
@@ -1792,6 +1792,11 @@ pub struct PhotoItem {
|
||||
/// the JPEG it shows is not the whole of what is stored.
|
||||
#[serde(default)]
|
||||
pub has_raw: bool,
|
||||
/// GeoNames id of the town the photo was taken in, when its location is
|
||||
/// known; [`crate::places::city`] names it. Older daemons omit it, and
|
||||
/// album entries do not carry it.
|
||||
#[serde(default)]
|
||||
pub place_id: Option<u32>,
|
||||
}
|
||||
|
||||
/// A daemon too old to classify a timeline entry is assumed to have served a
|
||||
|
||||
@@ -186,6 +186,8 @@ impl Db {
|
||||
// made, and it shows exactly what they put in it.
|
||||
group_size: 1,
|
||||
has_raw: false,
|
||||
// Album rows carry no location.
|
||||
place_id: None,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
@@ -247,6 +249,8 @@ impl Db {
|
||||
// made, and it shows exactly what they put in it.
|
||||
group_size: 1,
|
||||
has_raw: false,
|
||||
// Album rows carry no location.
|
||||
place_id: None,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
|
||||
@@ -40,6 +40,8 @@ pub struct StoredPhoto {
|
||||
/// Whether any member of the group is a camera raw file. The grid badges
|
||||
/// that, because the JPEG it shows is not the whole of what is stored.
|
||||
pub has_raw: bool,
|
||||
/// The town the photo was taken in, once its location has been read.
|
||||
pub place_id: Option<u32>,
|
||||
}
|
||||
|
||||
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||
@@ -256,7 +258,7 @@ fn group_photos(entries: &[Grouped]) -> Vec<String> {
|
||||
/// own group: the column is `NULL` until the next refresh computes it, and a
|
||||
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
||||
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
||||
p.kind, p.favorite, g.n, g.raw \
|
||||
p.kind, p.favorite, g.n, g.raw, p.place_id \
|
||||
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
||||
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
||||
|
||||
@@ -272,6 +274,7 @@ fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
||||
has_raw: r.get::<_, i64>(8)? != 0,
|
||||
place_id: r.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -363,6 +363,23 @@ fn place_photos_carry_their_own_location() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A timeline photo names the town it was taken in, so the viewer can show it;
|
||||
/// one without a location names none.
|
||||
#[test]
|
||||
fn timeline_photos_name_their_town() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
located("b", 500, "IMG_5.JPG", BERLIN),
|
||||
TimelineRow::new("n", 400),
|
||||
])
|
||||
.unwrap();
|
||||
let berlin = db.photo_places().unwrap()[0].id;
|
||||
let mut photos = db.photos_by_uid(&["b".into(), "n".into()]).unwrap();
|
||||
photos.sort_by(|a, b| a.uid.cmp(&b.uid));
|
||||
let towns: Vec<Option<u32>> = photos.iter().map(|photo| photo.place_id).collect();
|
||||
assert_eq!(towns, [Some(berlin), None]);
|
||||
}
|
||||
|
||||
/// A refresh that skips an already-resolved photo keeps where it was taken; one
|
||||
/// that reads the node again takes what the node now says.
|
||||
#[test]
|
||||
|
||||
@@ -157,6 +157,17 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
||||
if ours {
|
||||
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;
|
||||
core.for_each_mount(|st, notify| {
|
||||
if *is_trashed {
|
||||
@@ -172,9 +183,12 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
||||
}
|
||||
});
|
||||
// Once, not once per mount: the content cache is per-daemon.
|
||||
if had_node {
|
||||
if had_node && kept.is_none() {
|
||||
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
|
||||
// listing too, which is recorded for the end of the batch rather
|
||||
// than acted on here — see [`DirtyParents`].
|
||||
@@ -205,6 +219,8 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
||||
| DriveEvent::ScopeAccessLost { .. }
|
||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||
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));
|
||||
core.for_each_mount(|st, notify| {
|
||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||
@@ -296,6 +312,21 @@ struct DirtyParents {
|
||||
/// Checked per mount — a mount that already lists every one of those
|
||||
/// children under that parent keeps its listing.
|
||||
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
|
||||
@@ -476,6 +507,9 @@ pub(super) async fn run_event_sync(
|
||||
cursor = Some(applied);
|
||||
}
|
||||
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
|
||||
// moved, including what is shared and what is in the trash.
|
||||
core.events
|
||||
|
||||
@@ -204,18 +204,42 @@ 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
|
||||
/// against: a file there is an ordinary local file.
|
||||
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 p = Path::new(path);
|
||||
let mirrored = core
|
||||
let in_mirror = core
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
||||
if mirrored {
|
||||
CoreError::invalid(format!(
|
||||
"{path} is in a mirrored folder; change the local file there instead"
|
||||
))
|
||||
if in_mirror {
|
||||
CoreError::invalid(mirrored(path))
|
||||
} else {
|
||||
e
|
||||
}
|
||||
@@ -231,6 +255,18 @@ fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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
|
||||
@@ -521,8 +557,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(CtlRequest::Subscribe) => {
|
||||
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
||||
}
|
||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.pin(&rel) {
|
||||
Ok(CtlRequest::Pin { path }) => match route_keep(core, mountpoint, &path) {
|
||||
Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) {
|
||||
Ok(name) => {
|
||||
core.touch_profile();
|
||||
CtlResponse::Ok {
|
||||
@@ -533,8 +569,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.unpin(&rel) {
|
||||
Ok(CtlRequest::Unpin { path }) => match route_keep(core, mountpoint, &path) {
|
||||
Ok((routed, rel)) => match routed.unpin(&rel) {
|
||||
Ok(name) => {
|
||||
core.touch_profile();
|
||||
CtlResponse::Ok {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
+54
-17
@@ -91,6 +91,7 @@ mod drive;
|
||||
use drive::{DriveApi, RevisionRead};
|
||||
mod events;
|
||||
mod filesystem;
|
||||
mod keeper;
|
||||
mod link;
|
||||
pub use filesystem::ProtonFs;
|
||||
mod mount;
|
||||
@@ -589,6 +590,8 @@ struct Core {
|
||||
/// Nudges the drain workers: set true and notify to have them re-examine
|
||||
/// the queue instead of waiting out their backoff.
|
||||
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`
|
||||
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
|
||||
shutdown: Arc<shutdown::Shutdown>,
|
||||
@@ -3165,6 +3168,28 @@ impl Core {
|
||||
)
|
||||
}
|
||||
|
||||
/// [`Core::resolve_anywhere`], widened to the Photos timeline for sharing.
|
||||
///
|
||||
/// A photo lives on the photos volume, which no location shows, so the
|
||||
/// residency proof above never finds it. The timeline in the `photos` table
|
||||
/// is this daemon's proof instead: it lists only photos on the user's own
|
||||
/// volume, which are theirs to share. A photo in an album someone else
|
||||
/// shared is not in it and stays refused.
|
||||
pub(crate) fn resolve_shareable(&self, uid: &str) -> CoreResult<NodeUid> {
|
||||
resolve_anywhere_with(
|
||||
uid,
|
||||
|uid| self.owns_visible_uid(uid),
|
||||
|uid| {
|
||||
let uid = uid.to_string();
|
||||
Ok(self.db.mirror_contains_uid(&uid)?
|
||||
|| !self
|
||||
.db
|
||||
.photos_by_uid(std::slice::from_ref(&uid))?
|
||||
.is_empty())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult<NodeUid> {
|
||||
let state = self.state();
|
||||
let entry = state
|
||||
@@ -4270,7 +4295,12 @@ impl Core {
|
||||
/// recursive pin and downloads every descendant file on a background thread,
|
||||
/// reported as a job: a big folder far outlasts the socket read timeout.
|
||||
/// 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 (name, is_folder, mtime, size) = {
|
||||
let st = self.state();
|
||||
@@ -4294,7 +4324,7 @@ impl Core {
|
||||
// Record the recursive pin first so every descendant is eviction-
|
||||
// exempt before we start filling the cache with the subtree.
|
||||
self.cache
|
||||
.add_pin(&uid, rel, true)
|
||||
.add_pin(&uid, recorded, true)
|
||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||
let core = self.clone();
|
||||
let folder = name.clone();
|
||||
@@ -4304,9 +4334,15 @@ impl Core {
|
||||
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
||||
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.cache
|
||||
.add_pin(&uid, rel, false)
|
||||
.add_pin(&uid, recorded, false)
|
||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||
Ok(name)
|
||||
}
|
||||
@@ -4324,14 +4360,20 @@ impl Core {
|
||||
Ok(files) => {
|
||||
job.set_total(files.len() as u64);
|
||||
let mut failed = 0usize;
|
||||
for (uid, file, mtime, size) in files {
|
||||
for ino in files {
|
||||
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
||||
return;
|
||||
}
|
||||
job.detail(file.clone());
|
||||
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
|
||||
warn!(%uid, error = %e, "pin folder: download failed");
|
||||
failed += 1;
|
||||
// A file with a queued write, or no readable size, is
|
||||
// left to the drain and the keeper (`keeper::kept_file`).
|
||||
if let Some(file) = self.kept_file(ino) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
job.step();
|
||||
}
|
||||
@@ -4365,9 +4407,9 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every file in the subtree rooted at folder `ino`.
|
||||
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
|
||||
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
||||
/// The inode of every file in the subtree rooted at folder `ino`.
|
||||
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<u64>> {
|
||||
let mut files: Vec<u64> = Vec::new();
|
||||
let mut stack = vec![ino];
|
||||
while let Some(dir) = stack.pop() {
|
||||
if self.shutdown.is_stopping() {
|
||||
@@ -4382,12 +4424,7 @@ impl Core {
|
||||
if e.node.is_folder() {
|
||||
stack.push(k);
|
||||
} else {
|
||||
files.push((
|
||||
e.uid.clone(),
|
||||
e.node.name.clone(),
|
||||
e.node.modification_time,
|
||||
node_size(&e.node),
|
||||
));
|
||||
files.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,9 @@ impl Core {
|
||||
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -511,6 +511,7 @@ pub(crate) fn mount_with(
|
||||
landed: Arc::new(Mutex::new(HashMap::new())),
|
||||
unadopted: Arc::new(Mutex::new(HashMap::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()),
|
||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||
@@ -621,6 +622,17 @@ pub(crate) fn mount_with(
|
||||
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:
|
||||
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
||||
if matches!(host, Host::Daemon) {
|
||||
|
||||
@@ -1027,6 +1027,7 @@ impl Core {
|
||||
favorite: photo.favorite,
|
||||
group_size: photo.group_size as u32,
|
||||
has_raw: photo.has_raw,
|
||||
place_id: photo.place_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ impl Core {
|
||||
// stale from here on, and the server may not have applied the swap yet.
|
||||
self.cache.evict(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(
|
||||
ActivityKind::Restore,
|
||||
name,
|
||||
|
||||
@@ -105,7 +105,7 @@ impl Core {
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let uid = self.resolve_shareable(uid)?;
|
||||
self.share_node_for_uid(&uid, emails, role, message)
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ impl Core {
|
||||
&self,
|
||||
uid: &str,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let uid = self.resolve_shareable(uid)?;
|
||||
self.list_share_for_uid(&uid)
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ impl Core {
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let uid = self.resolve_shareable(uid)?;
|
||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ impl Core {
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let uid = self.resolve_shareable(uid)?;
|
||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ impl Core {
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let uid = self.resolve_shareable(uid)?;
|
||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ impl Core {
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let uid = self.resolve_shareable(uid)?;
|
||||
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ impl Core {
|
||||
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let uid = self.resolve_shareable(uid)?;
|
||||
self.remove_public_link_for_uid(&uid, id)
|
||||
}
|
||||
|
||||
@@ -486,7 +486,7 @@ impl Core {
|
||||
|
||||
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
||||
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let uid = self.resolve_shareable(uid)?;
|
||||
self.stop_sharing_for_uid(&uid)
|
||||
}
|
||||
|
||||
|
||||
@@ -3447,6 +3447,118 @@ mod tests {
|
||||
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.
|
||||
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
|
||||
match daemon.request(&Request::ListSyncFolders) {
|
||||
|
||||
@@ -61,14 +61,25 @@ scale.browser-status-meter trough { min-width: 140px; min-height: 6px; }
|
||||
|
||||
/* Photo viewer: always light-on-dark, whatever the system style */
|
||||
.photo-viewer-window { background-color: #111014; }
|
||||
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
|
||||
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.45) 60%, rgba(0, 0, 0, 0)); padding: 10px 16px 40px 20px; color: white; }
|
||||
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
||||
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
||||
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
|
||||
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
|
||||
/* Dark discs with a white glyph read on a white photo as well as on a black one. */
|
||||
.viewer-action-btn { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 50%; min-width: 36px; min-height: 36px; margin-left: 4px; -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
|
||||
.viewer-action-btn:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
|
||||
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
|
||||
.viewer-close-btn { margin-left: 12px; }
|
||||
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
||||
.viewer-group-pill { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 999px; min-height: 36px; padding: 0 14px; font-weight: 700; font-size: 0.85rem; }
|
||||
.viewer-group-pill:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
|
||||
.viewer-group-pill.lit { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||
/* The thumbnail strip along the lightbox's bottom edge, the photo on screen ringed in the accent. */
|
||||
.viewer-strip-bar { background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 28px 12px 10px 12px; }
|
||||
.viewer-strip { background: none; }
|
||||
.viewer-strip > row { padding: 2px; margin: 0 2px; border-radius: 8px; background: none; }
|
||||
.viewer-strip > row:hover { background-color: rgba(255, 255, 255, 0.15); }
|
||||
.viewer-strip > row:selected { background-color: var(--accent-bg-color); }
|
||||
.viewer-strip-thumb { border-radius: 6px; background-color: rgba(255, 255, 255, 0.08); }
|
||||
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
||||
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
||||
.viewer-nav-btn:disabled { opacity: 0; }
|
||||
|
||||
@@ -128,8 +128,18 @@ fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
|
||||
if changed(Topic::Shares) {
|
||||
refresh_invitations_badge(ui, Duration::ZERO);
|
||||
}
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("browser") if changed(Topic::Files) => load_browser(ui),
|
||||
let page = ui.stack.visible_child_name();
|
||||
// A timeline out of sight is read again when the user comes back to it;
|
||||
// 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 user scrolled to. F5 reloads it on request.
|
||||
Some("gallery")
|
||||
|
||||
@@ -457,6 +457,8 @@ fn build_window(app: &adw::Application) {
|
||||
files: browser_widgets.files.clone(),
|
||||
history: RefCell::new(Vec::new()),
|
||||
future: RefCell::new(Vec::new()),
|
||||
places: RefCell::new(HashMap::new()),
|
||||
restore: RefCell::new(None),
|
||||
crumb: browser_widgets.crumb.clone(),
|
||||
content: browser_widgets.content.clone(),
|
||||
status: browser_widgets.status.clone(),
|
||||
@@ -726,17 +728,26 @@ fn build_window(app: &adw::Application) {
|
||||
cancel_file_thumbnails(&ui_nav);
|
||||
sync_sidebar(&ui_nav);
|
||||
match st.visible_child_name().as_deref() {
|
||||
Some("browser") => load_browser(&ui_nav),
|
||||
Some("browser") => reload_browser_view(&ui_nav),
|
||||
// The Albums and Places listings are re-read in place. Loading the
|
||||
// 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() => {
|
||||
match ui_nav.gallery.view_switch.active() {
|
||||
VIEW_ALBUMS => load_albums(&ui_nav),
|
||||
VIEW_PLACES => load_places(&ui_nav),
|
||||
_ => load_gallery(&ui_nav, false),
|
||||
_ if ui_nav.gallery.timeline_stale.replace(false)
|
||||
|| ui_nav.gallery.model.n_items() == 0 =>
|
||||
{
|
||||
load_gallery(&ui_nav, false)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some("gallery") => load_gallery(&ui_nav, false),
|
||||
Some("gallery") if ui_nav.gallery.model.n_items() == 0 => load_gallery(&ui_nav, false),
|
||||
Some("gallery") => {}
|
||||
// Every visit reads the page again. The rows already on screen stay
|
||||
// up while it does, so arriving costs no "Loading…" flash.
|
||||
Some("sharedbyme") => load_shared_by_me(&ui_nav),
|
||||
@@ -1066,7 +1077,7 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("browser") => {
|
||||
let path = ui.browser.path.borrow().clone();
|
||||
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
||||
refresh_then(ui, RefreshScope::Dir { path }, reload_browser_view);
|
||||
}
|
||||
// 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.
|
||||
@@ -1328,7 +1339,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
// The Albums view's New Album button.
|
||||
let new_album = gio::SimpleAction::new("new-album", None);
|
||||
let ui_album = ui.clone();
|
||||
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
|
||||
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new(), None));
|
||||
window.add_action(&new_album);
|
||||
|
||||
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
||||
@@ -1539,6 +1550,7 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||
("<Control>minus", gettext_noop("Smaller thumbnails")),
|
||||
("<Control>0", gettext_noop("Reset thumbnail size")),
|
||||
("<Control>a", gettext_noop("Select all")),
|
||||
("Escape <Alt>Left", gettext_noop("Back")),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1548,8 +1560,11 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||
("Home End", gettext_noop("First / last photo")),
|
||||
("i", gettext_noop("Show details")),
|
||||
("f", gettext_noop("Fullscreen")),
|
||||
("F5", gettext_noop("Slideshow")),
|
||||
("F10", gettext_noop("More")),
|
||||
("<Control>c", gettext_noop("Copy Image")),
|
||||
("Delete", gettext_noop("Move to Trash")),
|
||||
("Escape", gettext_noop("Close")),
|
||||
("Escape <Alt>Left", gettext_noop("Close")),
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -1575,8 +1590,17 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
controller.connect_key_pressed(move |_, key, _, state| {
|
||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
||||
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
|
||||
let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash");
|
||||
let page = ui.stack.visible_child_name();
|
||||
let on_browser = page.as_deref() == Some("browser");
|
||||
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 {
|
||||
// Refresh works on every page, so it is matched before the
|
||||
// browser-only bindings.
|
||||
@@ -1633,6 +1657,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
gtk4::gdk::Key::BackSpace if on_browser => {
|
||||
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::Menu if visible_file_list(&ui).is_some() => {
|
||||
if let Some(list) = visible_file_list(&ui) {
|
||||
@@ -1974,6 +2013,7 @@ mod tests {
|
||||
favorite: false,
|
||||
group_size: 2,
|
||||
has_raw: kind == PhotoKind::Raw,
|
||||
place_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1996,6 +2036,33 @@ mod tests {
|
||||
assert!(next_group_member(&[], "jpeg").is_none());
|
||||
}
|
||||
|
||||
/// The pill names what kind of shot it switches through, and lights up
|
||||
/// on the file that is not the shot's main one.
|
||||
#[test]
|
||||
fn the_group_pill_names_the_kind_of_shot() {
|
||||
let pair = [
|
||||
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
|
||||
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
|
||||
];
|
||||
assert_eq!(group_pill(&pair, "jpeg"), Some(("RAW".into(), false)));
|
||||
assert_eq!(group_pill(&pair, "raw"), Some(("RAW".into(), true)));
|
||||
|
||||
let live = [
|
||||
member("still", Some("IMG_2.HEIC"), PhotoKind::Photo),
|
||||
member("clip", Some("IMG_2.MOV"), PhotoKind::Video),
|
||||
];
|
||||
assert_eq!(group_pill(&live, "still"), Some(("Live".into(), false)));
|
||||
assert_eq!(group_pill(&live, "clip"), Some(("Live".into(), true)));
|
||||
|
||||
let burst = [
|
||||
member("a", None, PhotoKind::Photo),
|
||||
member("b", None, PhotoKind::Photo),
|
||||
member("c", None, PhotoKind::Photo),
|
||||
];
|
||||
assert_eq!(group_pill(&burst, "b"), Some(("2 of 3".into(), false)));
|
||||
assert_eq!(group_pill(&burst, "elsewhere"), None);
|
||||
}
|
||||
|
||||
/// The switch names what it will show; a file whose name the daemon has not
|
||||
/// resolved still gets a sentence rather than a blank.
|
||||
#[test]
|
||||
|
||||
@@ -225,12 +225,13 @@ fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: >k4::Widget, x: f64
|
||||
/// while the entry is blank.
|
||||
fn prompt_album_name(
|
||||
ui: &Rc<Ui>,
|
||||
parent: Option<gtk4::Window>,
|
||||
heading: &str,
|
||||
action: &str,
|
||||
current: &str,
|
||||
on_name: impl Fn(String) + 'static,
|
||||
) {
|
||||
let win = ui_window(ui);
|
||||
let win = parent.or_else(|| ui_window(ui));
|
||||
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder()
|
||||
@@ -260,10 +261,12 @@ fn prompt_album_name(
|
||||
}
|
||||
|
||||
/// Ask for a name and create an album, then add `photos` to it, if any.
|
||||
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
///
|
||||
/// The dialogs open over `parent`, or over the main window without one.
|
||||
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
|
||||
let ui_c = ui.clone();
|
||||
let (heading, action) = (gettext("New Album"), gettext("Create"));
|
||||
prompt_album_name(ui, &heading, &action, "", move |name| {
|
||||
prompt_album_name(ui, parent, &heading, &action, "", move |name| {
|
||||
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
|
||||
let ui = ui_c.clone();
|
||||
let photos = photos.clone();
|
||||
@@ -296,7 +299,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
|
||||
prompt_album_name(ui, &heading, &action, &album.name, move |name| {
|
||||
prompt_album_name(ui, None, &heading, &action, &album.name, move |name| {
|
||||
run_album_request(
|
||||
&ui_c,
|
||||
Request::RenameAlbum {
|
||||
@@ -368,7 +371,7 @@ fn reload_album_grid(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
/// Offer our own albums to add `photos` to, plus a new one.
|
||||
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
@@ -390,7 +393,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
}
|
||||
};
|
||||
if albums.is_empty() {
|
||||
prompt_new_album(&ui, photos);
|
||||
prompt_new_album(&ui, photos, parent);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -436,11 +439,11 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
||||
}
|
||||
});
|
||||
let ui_c = ui.clone();
|
||||
let (ui_c, parent_c) = (ui.clone(), parent.clone());
|
||||
dialog.connect_response(Some("new"), move |_, _| {
|
||||
prompt_new_album(&ui_c, photos.clone())
|
||||
prompt_new_album(&ui_c, photos.clone(), parent_c.clone())
|
||||
});
|
||||
dialog.present(ui_window(&ui).as_ref());
|
||||
dialog.present(parent.or_else(|| ui_window(&ui)).as_ref());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -595,10 +598,7 @@ pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
|
||||
return;
|
||||
}
|
||||
ui.gallery
|
||||
.thumb_wanted
|
||||
.borrow_mut()
|
||||
.insert(uid.clone(), picture.clone());
|
||||
ui.gallery.await_thumb(&uid, picture);
|
||||
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
||||
if !queue.contains(&uid) {
|
||||
queue.push_back(uid);
|
||||
@@ -664,12 +664,25 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
}
|
||||
close_album(&ui_photos);
|
||||
close_place(&ui_photos);
|
||||
ui_photos.gallery.filters.set_visible(true);
|
||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||
let gallery = &ui_photos.gallery;
|
||||
gallery.filters.set_visible(true);
|
||||
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
|
||||
// model since; then it has to be reloaded.
|
||||
if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0
|
||||
{
|
||||
if gallery.timeline_stale.replace(false) || refiltered || gallery.model.n_items() == 0 {
|
||||
load_gallery(&ui_photos, false);
|
||||
} else {
|
||||
update_gallery_subtitle(&ui_photos);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
|
||||
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 {
|
||||
// Files (browser) page.
|
||||
/// The grid and list; the model is repopulated per directory.
|
||||
@@ -10,6 +18,13 @@ pub(crate) struct BrowserState {
|
||||
/// breadcrumb.
|
||||
pub(crate) history: 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
|
||||
/// by [`repaint_crumb`] so each ancestor folder navigates on click.
|
||||
pub(crate) crumb: gtk4::Box,
|
||||
@@ -1556,8 +1571,29 @@ 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.
|
||||
pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) {
|
||||
remember_place(ui);
|
||||
let left = ui.browser.path.replace(target.clone());
|
||||
if left != target {
|
||||
ui.browser.history.borrow_mut().push(left);
|
||||
@@ -1582,6 +1618,8 @@ fn browse_back(ui: &Rc<Ui>) {
|
||||
let Some(previous) = ui.browser.history.borrow_mut().pop() else {
|
||||
return;
|
||||
};
|
||||
remember_place(ui);
|
||||
*ui.browser.restore.borrow_mut() = Some(previous.clone());
|
||||
let left = ui.browser.path.replace(previous);
|
||||
ui.browser.future.borrow_mut().push(left);
|
||||
load_browser(ui);
|
||||
@@ -1592,6 +1630,8 @@ fn browse_forward(ui: &Rc<Ui>) {
|
||||
return;
|
||||
};
|
||||
ui.browser.search.set_text("");
|
||||
remember_place(ui);
|
||||
*ui.browser.restore.borrow_mut() = Some(next.clone());
|
||||
let left = ui.browser.path.replace(next);
|
||||
ui.browser.history.borrow_mut().push(left);
|
||||
load_browser(ui);
|
||||
@@ -1604,6 +1644,7 @@ fn browse_up(ui: &Rc<Ui>) {
|
||||
}
|
||||
ui.browser.search.set_text("");
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -2921,6 +2962,18 @@ 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
|
||||
/// 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.
|
||||
@@ -2959,7 +3012,10 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
|
||||
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
show_location_column(ui, false);
|
||||
*ui.browser.listing.borrow_mut() = entries.to_vec();
|
||||
*ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone());
|
||||
// `listed` still names the folder the rows belong to only on a refresh;
|
||||
// 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(
|
||||
entries.iter().filter(|entry| !entry.is_dir).count(),
|
||||
entries.iter().filter(|entry| entry.is_dir).count(),
|
||||
@@ -2988,6 +3044,16 @@ pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
// Only the rows that changed are swapped, so a refresh keeps the
|
||||
// selection, the scroll position and the thumbnails already drawn.
|
||||
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
|
||||
@@ -3166,6 +3232,9 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
.collect();
|
||||
sort_entries(&mut entries, ui.browser.view.get());
|
||||
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 {
|
||||
|
||||
@@ -190,10 +190,9 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
|
||||
}));
|
||||
|
||||
let ui_back = ui.clone();
|
||||
widgets.back.connect_clicked(move |_| {
|
||||
ui_back.devices.nav.borrow_mut().pop();
|
||||
load_devices(&ui_back);
|
||||
});
|
||||
widgets
|
||||
.back
|
||||
.connect_clicked(move |_| devices_back(&ui_back));
|
||||
let ui_retry = ui.clone();
|
||||
widgets
|
||||
.retry
|
||||
@@ -211,6 +210,14 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
|
||||
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
|
||||
/// of another computer's backup.
|
||||
pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
@@ -297,6 +304,7 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
};
|
||||
ui.devices.content.set_visible_child_name("browse");
|
||||
repaint_device_crumb(&ui, &nav);
|
||||
let another_folder = ui.devices.listed.borrow().as_ref() != Some(&nav);
|
||||
*ui.devices.listed.borrow_mut() = Some(nav);
|
||||
if entries.is_empty() {
|
||||
ui.devices.files.model.remove_all();
|
||||
@@ -304,6 +312,10 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
} else {
|
||||
ui.devices.files_content.set_visible_child_name("list");
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ pub(crate) struct Viewer {
|
||||
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
||||
pub(crate) favorite: gtk4::ToggleButton,
|
||||
pub(crate) favorite_suppress: Cell<bool>,
|
||||
/// The button that switches between the files of one shot, and the files it
|
||||
/// The pill that switches between the files of one shot, and the files it
|
||||
/// switches between (the JPEG and the RAW of a photo, a live photo and its
|
||||
/// clip). Empty, and the button hidden, for a photo stored as one file.
|
||||
/// clip). Empty, and the pill hidden, for a photo stored as one file.
|
||||
pub(crate) group_btn: gtk4::Button,
|
||||
pub(crate) group: RefCell<Vec<PhotoItem>>,
|
||||
/// uid of the photo currently on screen.
|
||||
@@ -81,14 +81,25 @@ pub(crate) struct Viewer {
|
||||
/// rests; whether the pointer is over one of them (which keeps them up);
|
||||
/// and the pending fade-out.
|
||||
pub(crate) chrome: Vec<gtk4::Revealer>,
|
||||
/// The strip of thumbnails along the bottom, its selection (the photo on
|
||||
/// screen), and the revealer it fades with. Hidden in a slideshow.
|
||||
pub(crate) strip: gtk4::ListView,
|
||||
pub(crate) strip_selection: gtk4::SingleSelection,
|
||||
pub(crate) strip_reveal: gtk4::Revealer,
|
||||
pub(crate) chrome_hover: Cell<bool>,
|
||||
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
||||
/// The slideshow toggle; the timer that moves it on; and whether it put
|
||||
/// the window in fullscreen, so stopping it only leaves a fullscreen it
|
||||
/// The ⋮ button, and whether its menu is open, which keeps the controls
|
||||
/// up the way resting the pointer on them does.
|
||||
pub(crate) more: gtk4::Button,
|
||||
pub(crate) menu_open: Cell<bool>,
|
||||
/// Whether a slideshow runs; the timer that moves it on; and whether it
|
||||
/// put the window in fullscreen, so stopping it only leaves a fullscreen it
|
||||
/// entered itself.
|
||||
pub(crate) slideshow: gtk4::ToggleButton,
|
||||
pub(crate) slideshow: Cell<bool>,
|
||||
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
|
||||
pub(crate) slide_fullscreened: Cell<bool>,
|
||||
/// Toasts over the lightbox. The main window's would land behind it.
|
||||
pub(crate) toasts: adw::ToastOverlay,
|
||||
}
|
||||
|
||||
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
||||
@@ -163,6 +174,14 @@ fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
|
||||
.group_btn
|
||||
.set_tooltip_text(Some(&member_tooltip(next)));
|
||||
}
|
||||
if let Some((label, lit)) = group_pill(&items, &uid) {
|
||||
viewer.group_btn.set_label(&label);
|
||||
if lit {
|
||||
viewer.group_btn.add_css_class("lit");
|
||||
} else {
|
||||
viewer.group_btn.remove_css_class("lit");
|
||||
}
|
||||
}
|
||||
*viewer.group.borrow_mut() = items;
|
||||
viewer.group_btn.set_visible(true);
|
||||
});
|
||||
@@ -175,6 +194,34 @@ pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Opti
|
||||
members.get((at + 1) % members.len())
|
||||
}
|
||||
|
||||
/// What the group pill reads for the shot whose files are `members`, with
|
||||
/// `uid` on screen, and whether it is lit. A raw pair reads "RAW", lit while
|
||||
/// the raw file is shown; a live photo reads "Live", lit while its clip plays;
|
||||
/// a burst counts its frames.
|
||||
pub(crate) fn group_pill(members: &[PhotoItem], uid: &str) -> Option<(String, bool)> {
|
||||
let at = members.iter().position(|item| item.uid == uid)?;
|
||||
let shown = members[at].kind;
|
||||
if members.iter().any(|item| item.kind == PhotoKind::Raw) {
|
||||
// Translators: badge on a photo tile whose shot includes a raw camera file.
|
||||
return Some((gettext("RAW"), shown == PhotoKind::Raw));
|
||||
}
|
||||
if members.len() == 2 && members.iter().any(|item| item.kind == PhotoKind::Video) {
|
||||
// Translators: the switch between a live photo and its short clip in the photo viewer.
|
||||
return Some((pgettext("live photo", "Live"), shown == PhotoKind::Video));
|
||||
}
|
||||
Some((
|
||||
// Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
|
||||
gettext_f(
|
||||
"{position} of {total}",
|
||||
&[
|
||||
("position", &thousands(at + 1)),
|
||||
("total", &thousands(members.len())),
|
||||
],
|
||||
),
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
/// What to call one file of a shot: its name, or its kind when the daemon has
|
||||
/// not resolved a name for it.
|
||||
pub(crate) fn member_label(item: &PhotoItem) -> String {
|
||||
@@ -341,14 +388,15 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
|
||||
/// screen counted down once it has loaded.
|
||||
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
if viewer.slideshow.replace(true) {
|
||||
return;
|
||||
}
|
||||
if !viewer.window.is_fullscreen() {
|
||||
viewer.window.fullscreen();
|
||||
viewer.slide_fullscreened.set(true);
|
||||
}
|
||||
viewer
|
||||
.slideshow
|
||||
.set_icon_name("media-playback-pause-symbolic");
|
||||
hide_chrome(viewer);
|
||||
viewer.strip_reveal.set_visible(false);
|
||||
if !viewer.loading.get() {
|
||||
arm_slide(ui, viewer);
|
||||
}
|
||||
@@ -356,13 +404,22 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
|
||||
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
|
||||
fn stop_slideshow(viewer: &Rc<Viewer>) {
|
||||
viewer.slideshow.set(false);
|
||||
viewer
|
||||
.strip_reveal
|
||||
.set_visible(viewer.strip_selection.n_items() > 1);
|
||||
disarm_slide(viewer);
|
||||
if viewer.slide_fullscreened.replace(false) {
|
||||
viewer.window.unfullscreen();
|
||||
}
|
||||
viewer.slideshow.set_icon_name("pdfs-slideshow-symbolic");
|
||||
if viewer.slideshow.is_active() {
|
||||
viewer.slideshow.set_active(false);
|
||||
}
|
||||
|
||||
/// Start the slideshow, or stop the one running.
|
||||
fn toggle_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
if viewer.slideshow.get() {
|
||||
stop_slideshow(viewer);
|
||||
} else {
|
||||
start_slideshow(ui, viewer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +427,7 @@ fn stop_slideshow(viewer: &Rc<Viewer>) {
|
||||
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
|
||||
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
disarm_slide(viewer);
|
||||
if !viewer.slideshow.is_active() {
|
||||
if !viewer.slideshow.get() {
|
||||
return;
|
||||
}
|
||||
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
|
||||
@@ -790,7 +847,7 @@ fn show_chrome(viewer: &Rc<Viewer>) {
|
||||
};
|
||||
// This timer has fired, so its id must not be removed again.
|
||||
viewer.chrome_source.borrow_mut().take();
|
||||
if !viewer.chrome_hover.get() {
|
||||
if !viewer.chrome_hover.get() && !viewer.menu_open.get() {
|
||||
hide_chrome(&viewer);
|
||||
}
|
||||
});
|
||||
@@ -808,14 +865,14 @@ fn hide_chrome(viewer: &Rc<Viewer>) {
|
||||
}
|
||||
|
||||
/// Copy the photo on screen to where the user picks, under its Drive name.
|
||||
pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: >k4::Window, source: &str, name: &str) {
|
||||
pub(crate) fn save_photo_to_disk(viewer: &Rc<Viewer>, source: &str, name: &str) {
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title(gettext("Save a Copy"))
|
||||
.initial_name(name)
|
||||
.build();
|
||||
let source = source.to_string();
|
||||
let ui = ui.clone();
|
||||
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
|
||||
let toasts = viewer.toasts.clone();
|
||||
dialog.save(Some(&viewer.window), gio::Cancellable::NONE, move |res| {
|
||||
// An error here is the dialog being dismissed.
|
||||
let Some(dest) = res.ok().and_then(|file| file.path()) else {
|
||||
return;
|
||||
@@ -824,26 +881,67 @@ pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: >k4::Window, source: &st
|
||||
glib::spawn_future_local(async move {
|
||||
let copied =
|
||||
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
|
||||
match copied {
|
||||
let message = match copied {
|
||||
Ok(Ok(dest)) => {
|
||||
let saved = dest.file_name().map_or_else(
|
||||
|| dest.display().to_string(),
|
||||
|name| name.to_string_lossy().into_owned(),
|
||||
);
|
||||
// Translators: {name} is the saved file's name.
|
||||
toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)]));
|
||||
gettext_f("Saved “{name}”", &[("name", &saved)])
|
||||
}
|
||||
Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
|
||||
Err(_) => toast_error(
|
||||
&ui,
|
||||
Ok(Err(e)) => failure(&gettext("Couldn't save the copy"), &e.to_string()),
|
||||
Err(_) => failure(
|
||||
&gettext("Couldn't save the copy"),
|
||||
&gettext("The copy was interrupted."),
|
||||
),
|
||||
}
|
||||
};
|
||||
toasts.add_toast(
|
||||
adw::Toast::builder()
|
||||
.title(&message)
|
||||
.use_markup(false)
|
||||
.build(),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A failed action's toast text, worded like [`toast_error`]'s.
|
||||
fn failure(what: &str, detail: &str) -> String {
|
||||
tracing::warn!("{what}: {detail}");
|
||||
// Translators: a failed action's toast. {what} says what failed, such as
|
||||
// "Couldn't rename"; {detail} is the reason, usually untranslated text
|
||||
// from the mount service.
|
||||
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
|
||||
}
|
||||
|
||||
/// Put the photo on screen on the clipboard as an image, for pasting into a
|
||||
/// chat or a document.
|
||||
fn copy_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
let Some(texture) = viewer
|
||||
.picture
|
||||
.paintable()
|
||||
.and_downcast::<gtk4::gdk::Texture>()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
viewer.window.clipboard().set_texture(&texture);
|
||||
viewer.toasts.add_toast(
|
||||
adw::Toast::builder()
|
||||
// Translators: toast after the photo viewer put the photo on the clipboard.
|
||||
.title(gettext("Image copied"))
|
||||
.use_markup(false)
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Open the Share dialog for the photo on screen, over the lightbox.
|
||||
fn share_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
let uid = viewer.uid.borrow().clone();
|
||||
let name = viewer.name.borrow().clone().unwrap_or_default();
|
||||
share_photo(ui, uid, name, Some(viewer.window.clone()));
|
||||
}
|
||||
|
||||
/// A symlink to the cached file `path` under its Drive `name`, in a directory
|
||||
/// of its own below `base`. The cache names files by content hash, with no
|
||||
/// extension; the app chooser and the app it launches both need the real name
|
||||
@@ -983,16 +1081,116 @@ pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
|
||||
/// The lightbox's title: when the photo was taken, as "Wednesday, October 7,
|
||||
/// 2026, 13:42".
|
||||
fn photo_title(capture_time: i64) -> String {
|
||||
let title = dates::full(capture_time);
|
||||
if title.is_empty() {
|
||||
gettext("Unknown Date")
|
||||
} else {
|
||||
title
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the lightbox's title (the photo's date) and the line under it: the town
|
||||
/// it was taken in, when known, and its "12 of 340" counter.
|
||||
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
|
||||
let loaded = ui.gallery.model.n_items();
|
||||
let more = ui.gallery.has_more.get();
|
||||
viewer.prev.set_sensitive(index > 0);
|
||||
viewer.next.set_sensitive(index + 1 < loaded || more);
|
||||
viewer.title.set_label(&format_capture_time(capture_time));
|
||||
viewer.title.set_label(&photo_title(capture_time));
|
||||
let position = position_label(index, loaded, more, gallery_total(ui));
|
||||
let place = viewer_item(ui, viewer, &viewer.uid.borrow())
|
||||
.and_then(|item| item.place_id)
|
||||
.and_then(pdfs_core::places::city)
|
||||
.map(|city| city.name);
|
||||
let line = match place {
|
||||
Some(place) => {
|
||||
let args = [("place", place), ("position", position.as_str())];
|
||||
// Translators: the line under the photo viewer's title, such as "Berlin · 12 of 340"; {place} is a town, {position} the counter.
|
||||
gettext_f("{place} · {position}", &args)
|
||||
}
|
||||
None => position,
|
||||
};
|
||||
viewer.counter.set_label(&line);
|
||||
viewer
|
||||
.counter
|
||||
.set_label(&position_label(index, loaded, more, gallery_total(ui)));
|
||||
.strip_reveal
|
||||
.set_visible(loaded > 1 && !viewer.slideshow.get());
|
||||
mark_in_strip(viewer, index);
|
||||
}
|
||||
|
||||
/// Mark the photo at `index` in the strip and bring it into view.
|
||||
fn mark_in_strip(viewer: &Rc<Viewer>, index: u32) {
|
||||
if index >= viewer.strip_selection.n_items() {
|
||||
return;
|
||||
}
|
||||
viewer.strip_selection.set_selected(index);
|
||||
viewer
|
||||
.strip
|
||||
.scroll_to(index, gtk4::ListScrollFlags::NONE, None);
|
||||
}
|
||||
|
||||
/// The strip of thumbnails along the lightbox's bottom edge: the gallery's
|
||||
/// photos in its order, the one on screen marked. A click steps straight to a
|
||||
/// photo; the thumbnails come through the gallery's own pipeline and cache.
|
||||
fn build_strip(ui: &Rc<Ui>) -> (gtk4::ListView, gtk4::SingleSelection) {
|
||||
let selection = gtk4::SingleSelection::new(Some(ui.gallery.model.clone()));
|
||||
selection.set_autoselect(false);
|
||||
selection.set_can_unselect(true);
|
||||
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.width_request(56)
|
||||
.height_request(56)
|
||||
.can_shrink(true)
|
||||
.build();
|
||||
picture.add_css_class("viewer-strip-thumb");
|
||||
// The arrow keys belong to the lightbox, not to the strip.
|
||||
item.set_focusable(false);
|
||||
item.set_child(Some(&picture));
|
||||
});
|
||||
let ui_bind = ui.clone();
|
||||
factory.connect_bind(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let Some(picture) = item.child().and_downcast::<gtk4::Picture>() else {
|
||||
return;
|
||||
};
|
||||
let Some(boxed) = item.item().and_downcast::<BoxedAnyObject>() else {
|
||||
return;
|
||||
};
|
||||
let photo = boxed.borrow::<PhotoItem>().clone();
|
||||
picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||
picture.set_tooltip_text(Some(&dates::full(photo.capture_time)));
|
||||
want_thumb(&ui_bind, &photo, &picture);
|
||||
schedule_thumbs(&ui_bind);
|
||||
});
|
||||
let ui_unbind = ui.clone();
|
||||
factory.connect_unbind(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let (Some(picture), Some(boxed)) = (
|
||||
item.child().and_downcast::<gtk4::Picture>(),
|
||||
item.item().and_downcast::<BoxedAnyObject>(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let uid = boxed.borrow::<PhotoItem>().uid.clone();
|
||||
ui_unbind
|
||||
.gallery
|
||||
.release_thumb(&uid, |waiting| waiting == &picture);
|
||||
});
|
||||
|
||||
let list = gtk4::ListView::builder()
|
||||
.model(&selection)
|
||||
.factory(&factory)
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.focusable(false)
|
||||
.build();
|
||||
list.add_css_class("viewer-strip");
|
||||
(list, selection)
|
||||
}
|
||||
|
||||
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
|
||||
@@ -1001,7 +1199,10 @@ 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
|
||||
/// 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();
|
||||
|
||||
// An `adw::Window` for its breakpoints: the details panel lies over the
|
||||
@@ -1122,22 +1323,18 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
favorite_btn.add_css_class("flat");
|
||||
favorite_btn.add_css_class("viewer-action-btn");
|
||||
|
||||
let slideshow_btn = gtk4::ToggleButton::builder()
|
||||
.icon_name("pdfs-slideshow-symbolic")
|
||||
.tooltip_text(gettext("Slideshow (F5)"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
slideshow_btn.add_css_class("flat");
|
||||
slideshow_btn.add_css_class("viewer-action-btn");
|
||||
|
||||
// Only shown for a shot stored as more than one file; `load_photo_group`
|
||||
// decides that per photo.
|
||||
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
|
||||
group_btn.set_visible(false);
|
||||
// decides that per photo and labels it ("RAW", "Live", "2 of 3").
|
||||
let group_btn = gtk4::Button::builder()
|
||||
.valign(gtk4::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
group_btn.add_css_class("flat");
|
||||
group_btn.add_css_class("viewer-group-pill");
|
||||
|
||||
let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
|
||||
let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
|
||||
let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
|
||||
// Everything used less often than the favorite and the details sits in
|
||||
// one menu, named in words rather than in icons.
|
||||
let more_btn = action("view-more-symbolic", &gettext("More"));
|
||||
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
|
||||
close_btn.add_css_class("viewer-close-btn");
|
||||
|
||||
@@ -1147,14 +1344,25 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
top_bar.append(&group_btn);
|
||||
top_bar.append(&favorite_btn);
|
||||
top_bar.append(&info_toggle);
|
||||
top_bar.append(&slideshow_btn);
|
||||
top_bar.append(&download_btn);
|
||||
top_bar.append(&delete_btn);
|
||||
top_bar.append(&open_ext_btn);
|
||||
top_bar.append(&more_btn);
|
||||
top_bar.append(&close_btn);
|
||||
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
|
||||
overlay.add_overlay(&top_reveal);
|
||||
|
||||
let (strip, strip_selection) = build_strip(ui);
|
||||
let strip_scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::External)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&strip)
|
||||
.build();
|
||||
strip_scroll.add_css_class("viewer-strip-bar");
|
||||
let strip_reveal = fade(
|
||||
strip_scroll.upcast_ref(),
|
||||
gtk4::Align::Fill,
|
||||
gtk4::Align::End,
|
||||
);
|
||||
overlay.add_overlay(&strip_reveal);
|
||||
|
||||
// Details panel: docked beside the image as a real surface, so opening it
|
||||
// narrows the photo instead of covering part of it.
|
||||
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
@@ -1242,7 +1450,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
narrow.add_setter(&info_split, "collapsed", Some(&true.to_value()));
|
||||
adw_window.add_breakpoint(narrow);
|
||||
let body = info_split.clone();
|
||||
adw_window.set_content(Some(&body));
|
||||
let toasts = adw::ToastOverlay::new();
|
||||
toasts.set_child(Some(&body));
|
||||
adw_window.set_content(Some(&toasts));
|
||||
|
||||
let viewer = Rc::new(Viewer {
|
||||
window: window.clone(),
|
||||
@@ -1273,12 +1483,23 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
pinch_start: Cell::new(1.0),
|
||||
drag_start: Cell::new((0.0, 0.0)),
|
||||
pointer: Cell::new((0.0, 0.0)),
|
||||
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
|
||||
chrome: vec![
|
||||
top_reveal.clone(),
|
||||
prev_reveal.clone(),
|
||||
next_reveal.clone(),
|
||||
strip_reveal.clone(),
|
||||
],
|
||||
strip: strip.clone(),
|
||||
strip_selection: strip_selection.clone(),
|
||||
strip_reveal: strip_reveal.clone(),
|
||||
chrome_hover: Cell::new(false),
|
||||
chrome_source: RefCell::new(None),
|
||||
slideshow: slideshow_btn.clone(),
|
||||
more: more_btn.clone(),
|
||||
menu_open: Cell::new(false),
|
||||
slideshow: Cell::new(false),
|
||||
slide_source: RefCell::new(None),
|
||||
slide_fullscreened: Cell::new(false),
|
||||
toasts,
|
||||
});
|
||||
|
||||
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
||||
@@ -1304,7 +1525,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
}
|
||||
});
|
||||
body.add_controller(motion);
|
||||
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
|
||||
for revealer in [&top_reveal, &prev_reveal, &next_reveal, &strip_reveal] {
|
||||
let hover = gtk4::EventControllerMotion::new();
|
||||
let viewer_enter = Rc::downgrade(&viewer);
|
||||
hover.connect_enter(move |_, _, _| {
|
||||
@@ -1420,13 +1641,55 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
w_close.close();
|
||||
});
|
||||
|
||||
// A click on a thumbnail selects it; step there. Every selection the
|
||||
// lightbox sets itself is the photo on screen, and the model's own changes
|
||||
// (a page landing, a favorite rewritten in place) keep the selected photo
|
||||
// or drop it, so a selection elsewhere can only be the user's.
|
||||
let ui_strip = ui.clone();
|
||||
let viewer_strip = Rc::downgrade(&viewer);
|
||||
strip_selection.connect_selection_changed(move |selection, _, _| {
|
||||
let Some(viewer) = viewer_strip.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let picked = selection.selected();
|
||||
let Some(current) = find_photo_index(&ui_strip.gallery.model, &viewer.uid.borrow()) else {
|
||||
return;
|
||||
};
|
||||
if picked != gtk4::INVALID_LIST_POSITION && picked != current {
|
||||
navigate_photo(&ui_strip, &viewer, picked as i32 - current as i32);
|
||||
}
|
||||
});
|
||||
let viewer_items = Rc::downgrade(&viewer);
|
||||
let ui_items = ui.clone();
|
||||
let items_handler = ui
|
||||
.gallery
|
||||
.model
|
||||
.connect_items_changed(move |model, _, _, _| {
|
||||
let Some(viewer) = viewer_items.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if let Some(index) = find_photo_index(model, &viewer.uid.borrow()) {
|
||||
viewer.strip_selection.set_selected(index);
|
||||
}
|
||||
viewer
|
||||
.strip_reveal
|
||||
.set_visible(ui_items.gallery.model.n_items() > 1 && !viewer.slideshow.get());
|
||||
});
|
||||
|
||||
// The one strong handle on the viewer. Every handler holds a weak one: a
|
||||
// strong one in a handler of a widget the viewer holds is a cycle, which
|
||||
// kept every closed lightbox, its window and its full-size photo in memory
|
||||
// for the life of the app.
|
||||
let keep = RefCell::new(Some(viewer.clone()));
|
||||
let ui_close = ui.clone();
|
||||
let items_handler = RefCell::new(Some(items_handler));
|
||||
window.connect_close_request(move |_| {
|
||||
// The gallery's model outlives the lightbox.
|
||||
if let Some(handler) = items_handler.borrow_mut().take() {
|
||||
ui_close.gallery.model.disconnect(handler);
|
||||
}
|
||||
if let Some(viewer) = keep.borrow_mut().take() {
|
||||
reveal_photo(&ui_close, &viewer.uid.borrow());
|
||||
stop_video(&viewer);
|
||||
disarm_slide(&viewer);
|
||||
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
|
||||
@@ -1452,36 +1715,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
}
|
||||
});
|
||||
|
||||
let ui_slides = ui.clone();
|
||||
let viewer_slides = Rc::downgrade(&viewer);
|
||||
slideshow_btn.connect_toggled(move |toggle| {
|
||||
let Some(viewer_slides) = viewer_slides.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if toggle.is_active() {
|
||||
start_slideshow(&ui_slides, &viewer_slides);
|
||||
} else {
|
||||
stop_slideshow(&viewer_slides);
|
||||
}
|
||||
});
|
||||
|
||||
let toggle_off = info_toggle.clone();
|
||||
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||
|
||||
let ui_map = ui.clone();
|
||||
let viewer_map = Rc::downgrade(&viewer);
|
||||
let w_map = window.downgrade();
|
||||
info_map.connect_clicked(move |_| {
|
||||
let Some(w_map) = w_map.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(viewer_map) = viewer_map.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let coords = *viewer_map.coords.borrow();
|
||||
if let Some((lat, lon)) = coords {
|
||||
w_map.close();
|
||||
show_on_map(&ui_map, lat, lon);
|
||||
if let Some(viewer_map) = viewer_map.upgrade() {
|
||||
show_photo_on_map(&ui_map, &viewer_map);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1510,6 +1751,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
let failed = match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
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
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
||||
@@ -1530,24 +1774,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
});
|
||||
});
|
||||
|
||||
// Deleting the photo on screen closes the lightbox: what it was showing is
|
||||
// gone, and leaving it open on a trashed photo invites a second Delete on
|
||||
// something the user cannot see.
|
||||
let ui_delete = ui.clone();
|
||||
let viewer_delete = Rc::downgrade(&viewer);
|
||||
let w_delete = window.downgrade();
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
let Some(w_delete) = w_delete.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(viewer_delete) = viewer_delete.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let uid = viewer_delete.uid.borrow().clone();
|
||||
w_delete.close();
|
||||
trash_photos(&ui_delete, vec![uid], false);
|
||||
});
|
||||
|
||||
let ui_group = ui.clone();
|
||||
let viewer_group = Rc::downgrade(&viewer);
|
||||
group_btn.connect_clicked(move |_| {
|
||||
@@ -1563,38 +1789,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
}
|
||||
});
|
||||
|
||||
let ui_download = ui.clone();
|
||||
let w_download = window.downgrade();
|
||||
let viewer_download = Rc::downgrade(&viewer);
|
||||
download_btn.connect_clicked(move |_| {
|
||||
let Some(w_download) = w_download.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(viewer_download) = viewer_download.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
||||
let name = viewer_download.name.borrow();
|
||||
save_photo_to_disk(
|
||||
&ui_download,
|
||||
&w_download,
|
||||
path,
|
||||
name.as_deref().unwrap_or("photo"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let w_ext = window.downgrade();
|
||||
let viewer_ext = Rc::downgrade(&viewer);
|
||||
open_ext_btn.connect_clicked(move |_| {
|
||||
let Some(w_ext) = w_ext.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(viewer_ext) = viewer_ext.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
||||
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
|
||||
let ui_more = ui.clone();
|
||||
let viewer_more = Rc::downgrade(&viewer);
|
||||
more_btn.connect_clicked(move |_| {
|
||||
if let Some(viewer_more) = viewer_more.upgrade() {
|
||||
open_viewer_menu(&ui_more, &viewer_more);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1672,6 +1871,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
||||
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
||||
match key.name().as_deref() {
|
||||
Some("space") if playing_video => {
|
||||
@@ -1679,6 +1879,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
stream.set_playing(!stream.is_playing());
|
||||
}
|
||||
}
|
||||
// Alt+Left is Back everywhere else in the app.
|
||||
Some("Left") if alt => w_key.close(),
|
||||
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
|
||||
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
|
||||
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
|
||||
@@ -1687,13 +1889,10 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
||||
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
||||
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
||||
Some("F5") => viewer_key
|
||||
.slideshow
|
||||
.set_active(!viewer_key.slideshow.is_active()),
|
||||
Some("F5") => toggle_slideshow(&ui_key, &viewer_key),
|
||||
// Escape ends a slideshow before it closes anything.
|
||||
Some("Escape") if viewer_key.slideshow.is_active() => {
|
||||
viewer_key.slideshow.set_active(false)
|
||||
}
|
||||
Some("Escape") if viewer_key.slideshow.get() => stop_slideshow(&viewer_key),
|
||||
Some("F10" | "Menu") => open_viewer_menu(&ui_key, &viewer_key),
|
||||
Some("i") => viewer_key
|
||||
.info_toggle
|
||||
.set_active(!viewer_key.info_toggle.is_active()),
|
||||
@@ -1704,13 +1903,12 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
w_key.fullscreen();
|
||||
}
|
||||
}
|
||||
Some("Delete" | "KP_Delete") => {
|
||||
let uid = viewer_key.uid.borrow().clone();
|
||||
w_key.close();
|
||||
trash_photos(&ui_key, vec![uid], false);
|
||||
}
|
||||
Some("Delete" | "KP_Delete") => trash_shown_photo(&ui_key, &viewer_key),
|
||||
Some("Escape" | "q") => w_key.close(),
|
||||
Some("w") if ctrl => w_key.close(),
|
||||
Some("c") if ctrl && viewer_key.path.borrow().is_some() && !playing_video => {
|
||||
copy_shown_photo(&ui_key, &viewer_key)
|
||||
}
|
||||
_ => return glib::Propagation::Proceed,
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
@@ -1718,6 +1916,107 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
window.add_controller(key_controller);
|
||||
|
||||
window.present();
|
||||
window
|
||||
}
|
||||
|
||||
/// The ⋮ menu: what the lightbox can do with the photo on screen besides
|
||||
/// starring it and showing its details. Built when it opens, so it offers
|
||||
/// only what applies to this photo.
|
||||
fn open_viewer_menu(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
let weak = Rc::downgrade(viewer);
|
||||
let with_viewer = move |run: fn(&Rc<Ui>, &Rc<Viewer>), ui: &Rc<Ui>| {
|
||||
let (ui, weak) = (ui.clone(), weak.clone());
|
||||
move || {
|
||||
if let Some(viewer) = weak.upgrade() {
|
||||
run(&ui, &viewer);
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut menu = ActionMenu::new();
|
||||
menu.item(&gettext("Slideshow"), with_viewer(toggle_slideshow, ui));
|
||||
menu.section();
|
||||
// These need the full-size file, which may still be downloading.
|
||||
if viewer.path.borrow().is_some() {
|
||||
let still = viewer.media.visible_child_name().as_deref() == Some("photo");
|
||||
if still {
|
||||
// Translators: menu item that puts the photo on the clipboard as an image.
|
||||
menu.item(&gettext("Copy Image"), with_viewer(copy_shown_photo, ui));
|
||||
}
|
||||
menu.item(&gettext("Save a Copy…"), with_viewer(save_shown_photo, ui));
|
||||
menu.item(&gettext("Open With…"), with_viewer(open_shown_photo, ui));
|
||||
}
|
||||
if photo_shareable(ui) {
|
||||
menu.item(&gettext("Share…"), with_viewer(share_shown_photo, ui));
|
||||
}
|
||||
// A RAW is not on the timeline, and an album holds the shot it belongs to.
|
||||
if find_photo_index(&ui.gallery.model, &viewer.uid.borrow()).is_some() {
|
||||
menu.item(
|
||||
&gettext("Add to Album…"),
|
||||
with_viewer(add_shown_photo_to_album, ui),
|
||||
);
|
||||
}
|
||||
if viewer.coords.borrow().is_some() {
|
||||
menu.item(&gettext("Show on Map"), with_viewer(show_photo_on_map, ui));
|
||||
}
|
||||
menu.section();
|
||||
menu.item(
|
||||
&gettext("Move to Trash…"),
|
||||
with_viewer(trash_shown_photo, ui),
|
||||
);
|
||||
|
||||
let button = &viewer.more;
|
||||
let popover = menu.popup_at(
|
||||
button,
|
||||
f64::from(button.width()) / 2.0,
|
||||
f64::from(button.height()),
|
||||
);
|
||||
viewer.menu_open.set(true);
|
||||
let weak = Rc::downgrade(viewer);
|
||||
popover.connect_closed(move |_| {
|
||||
if let Some(viewer) = weak.upgrade() {
|
||||
viewer.menu_open.set(false);
|
||||
// A slideshow started from the menu keeps the controls hidden.
|
||||
if !viewer.slideshow.get() {
|
||||
show_chrome(&viewer);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn save_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
if let Some(path) = viewer.path.borrow().as_deref() {
|
||||
let name = viewer.name.borrow();
|
||||
save_photo_to_disk(viewer, path, name.as_deref().unwrap_or("photo"));
|
||||
}
|
||||
}
|
||||
|
||||
fn open_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
if let Some(path) = viewer.path.borrow().as_deref() {
|
||||
open_with(&viewer.window, path, viewer.name.borrow().as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
fn add_shown_photo_to_album(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
let uid = viewer.uid.borrow().clone();
|
||||
prompt_add_to_album(ui, vec![uid], Some(viewer.window.clone()));
|
||||
}
|
||||
|
||||
/// Close the lightbox and show where the photo was taken.
|
||||
fn show_photo_on_map(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
let coords = *viewer.coords.borrow();
|
||||
if let Some((lat, lon)) = coords {
|
||||
viewer.window.close();
|
||||
show_on_map(ui, lat, lon);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask, then move the photo on screen to the trash. Confirmed, the lightbox
|
||||
/// closes first: what it was showing is gone, and leaving it open on a
|
||||
/// trashed photo invites a second Delete on something the user cannot see.
|
||||
fn trash_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
let uid = viewer.uid.borrow().clone();
|
||||
let window = viewer.window.clone();
|
||||
ask_trash_photos(ui, vec![uid], Some(window.clone()), move || window.close());
|
||||
}
|
||||
|
||||
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
|
||||
|
||||
@@ -157,10 +157,11 @@ pub(crate) struct GalleryState {
|
||||
/// Photos the daemon reported as having no thumbnail at all, so a tile that
|
||||
/// can never be filled isn't requested again on every scroll past it.
|
||||
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
||||
/// Tiles on screen still waiting for their thumbnail, by uid. Populated as
|
||||
/// sections are bound, drained as batches land, cleared on unbind — so a
|
||||
/// batch only ever paints a widget that is still showing that photo.
|
||||
pub(crate) thumb_wanted: RefCell<HashMap<String, gtk4::Picture>>,
|
||||
/// Pictures on screen still waiting for their thumbnail, by uid. Populated
|
||||
/// as they are bound, drained as batches land, cleared on unbind — so a
|
||||
/// batch only ever paints a widget that is still showing that photo. One
|
||||
/// photo can be waited for twice: by its tile and by the lightbox's strip.
|
||||
pub(crate) thumb_wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
||||
/// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a
|
||||
/// batch is already in flight (only one at a time, so a long scroll can't
|
||||
/// stack requests on the daemon).
|
||||
@@ -243,6 +244,27 @@ impl GalleryState {
|
||||
pub(crate) fn in_collection(&self) -> bool {
|
||||
self.album.borrow().is_some() || self.place.borrow().is_some()
|
||||
}
|
||||
|
||||
/// Have `picture` painted with the thumbnail of `uid` once it lands.
|
||||
pub(crate) fn await_thumb(&self, uid: &str, picture: >k4::Picture) {
|
||||
let mut wanted = self.thumb_wanted.borrow_mut();
|
||||
let waiting = wanted.entry(uid.to_string()).or_default();
|
||||
if !waiting.contains(picture) {
|
||||
waiting.push(picture.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop waiting for `uid` in the pictures `drop` picks, because they show
|
||||
/// another photo now.
|
||||
pub(crate) fn release_thumb(&self, uid: &str, drop: impl Fn(>k4::Picture) -> bool) {
|
||||
let mut wanted = self.thumb_wanted.borrow_mut();
|
||||
if let Some(waiting) = wanted.get_mut(uid) {
|
||||
waiting.retain(|picture| !drop(picture));
|
||||
if waiting.is_empty() {
|
||||
wanted.remove(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||
@@ -1314,14 +1336,10 @@ pub(crate) fn wire_gallery(
|
||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
|
||||
&& let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>()
|
||||
{
|
||||
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
|
||||
for tile in tiles {
|
||||
if wanted
|
||||
.get(&tile.photo.uid)
|
||||
.is_some_and(|picture| picture.is_ancestor(&row_box))
|
||||
{
|
||||
wanted.remove(&tile.photo.uid);
|
||||
}
|
||||
ui_unbind
|
||||
.gallery
|
||||
.release_thumb(&tile.photo.uid, |picture| picture.is_ancestor(&row_box));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1441,7 +1459,7 @@ pub(crate) fn wire_gallery(
|
||||
let ui_album = ui.clone();
|
||||
selection.album.connect_clicked(move |_| {
|
||||
let uids: Vec<String> = ui_album.gallery.selected.borrow().iter().cloned().collect();
|
||||
prompt_add_to_album(&ui_album, uids);
|
||||
prompt_add_to_album(&ui_album, uids, None);
|
||||
});
|
||||
let ui_remove = ui.clone();
|
||||
selection.remove.connect_clicked(move |_| {
|
||||
@@ -2455,10 +2473,8 @@ impl TileWidget {
|
||||
let Some(photo) = self.photo.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
let mut wanted = ui.gallery.thumb_wanted.borrow_mut();
|
||||
if wanted.get(&photo.uid) == Some(&self.picture) {
|
||||
wanted.remove(&photo.uid);
|
||||
}
|
||||
ui.gallery
|
||||
.release_thumb(&photo.uid, |picture| picture == &self.picture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2764,6 +2780,18 @@ pub(crate) fn delete_selected(ui: &Rc<Ui>) {
|
||||
|
||||
/// Confirm, then move the photos behind `uids` to Proton trash.
|
||||
pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
||||
ask_trash_photos(ui, uids, None, || {});
|
||||
}
|
||||
|
||||
/// [`confirm_trash_photos`] over `parent` rather than the main window, for
|
||||
/// the photo viewer, which covers it. `before` runs once the user confirms,
|
||||
/// before the photos go.
|
||||
pub(crate) fn ask_trash_photos(
|
||||
ui: &Rc<Ui>,
|
||||
uids: Vec<String>,
|
||||
parent: Option<gtk4::Window>,
|
||||
before: impl Fn() + 'static,
|
||||
) {
|
||||
if uids.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -2803,10 +2831,11 @@ pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let win = ui_window(ui);
|
||||
let win = parent.or_else(|| ui_window(ui));
|
||||
let ui = ui.clone();
|
||||
dialog.connect_response(None, move |_, response| {
|
||||
if response == "trash" {
|
||||
before();
|
||||
trash_photos(&ui, uids.clone(), false);
|
||||
}
|
||||
});
|
||||
@@ -2951,10 +2980,7 @@ pub(crate) fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture
|
||||
return;
|
||||
}
|
||||
|
||||
ui.gallery
|
||||
.thumb_wanted
|
||||
.borrow_mut()
|
||||
.insert(photo.uid.clone(), picture.clone());
|
||||
ui.gallery.await_thumb(&photo.uid, picture);
|
||||
|
||||
match photo.thumb_path.as_deref() {
|
||||
Some(path) => {
|
||||
@@ -3173,7 +3199,8 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
||||
}
|
||||
};
|
||||
ui.store_texture(&uid, texture.clone());
|
||||
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
||||
let waiting = ui.gallery.thumb_wanted.borrow_mut().remove(&uid);
|
||||
for picture in waiting.into_iter().flatten() {
|
||||
picture.set_paintable(Some(&texture));
|
||||
}
|
||||
}
|
||||
@@ -3288,6 +3315,16 @@ fn top_anchor(ui: &Rc<Ui>) -> Option<(String, f32)> {
|
||||
.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.
|
||||
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
|
||||
(0..store.n_items()).find(|i| {
|
||||
@@ -3952,7 +3989,9 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: >k4::Button, x: f64
|
||||
} else {
|
||||
pgettext("verb", "Open")
|
||||
};
|
||||
menu.item(&label, move || open_photo_viewer(&ui_c, uid.clone()));
|
||||
menu.item(&label, move || {
|
||||
open_photo_viewer(&ui_c, 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.
|
||||
let favorite_label = pgettext("state", "Favorite");
|
||||
@@ -3967,8 +4006,18 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: >k4::Button, x: f64
|
||||
menu.section();
|
||||
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||
menu.item(&gettext("Add to Album…"), move || {
|
||||
prompt_add_to_album(&ui_c, vec![uid.clone()])
|
||||
prompt_add_to_album(&ui_c, vec![uid.clone()], None)
|
||||
});
|
||||
if photo_shareable(ui) {
|
||||
let (ui_c, uid, name) = (
|
||||
ui.clone(),
|
||||
photo.uid.clone(),
|
||||
photo.name.clone().unwrap_or_default(),
|
||||
);
|
||||
menu.item(&gettext("Share…"), move || {
|
||||
share_photo(&ui_c, uid.clone(), name.clone(), None)
|
||||
});
|
||||
}
|
||||
let open_album = ui.gallery.album.borrow().clone();
|
||||
if let Some(album) = open_album.filter(|album| !album.shared) {
|
||||
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
||||
@@ -4020,8 +4069,44 @@ fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the photos on show are the user's own to share: everything but an
|
||||
/// album someone else shared, whose photos live on that person's volume.
|
||||
pub(crate) fn photo_shareable(ui: &Ui) -> bool {
|
||||
!ui.gallery
|
||||
.album
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|album| album.shared)
|
||||
}
|
||||
|
||||
/// Open the Share dialog for one photo, addressed by uid: the photos volume
|
||||
/// has no path in any mount. Over `parent` when the lightbox is open.
|
||||
pub(crate) fn share_photo(ui: &Rc<Ui>, uid: String, name: String, parent: Option<gtk4::Window>) {
|
||||
let entry = DirEntry {
|
||||
uid,
|
||||
name,
|
||||
..DirEntry::default()
|
||||
};
|
||||
open_share_dialog_over(ui, &entry, parent);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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() {
|
||||
// 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.
|
||||
@@ -4037,6 +4122,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
let place = ui.gallery.place.borrow().as_ref().map(|p| p.id);
|
||||
let collection = album.is_some() || place.is_some();
|
||||
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.
|
||||
ui.gallery.model.remove_all();
|
||||
gallery_status(
|
||||
@@ -4098,6 +4186,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
if !waiters.is_empty() {
|
||||
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 {
|
||||
Ok(Ok(Response::Photos {
|
||||
available,
|
||||
@@ -4138,6 +4232,20 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
ui.gallery.model.append(&BoxedAnyObject::new(item.clone()));
|
||||
}
|
||||
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 {
|
||||
let filtered = ui.gallery.kind.get().is_some()
|
||||
|| ui.gallery.favorites.get()
|
||||
@@ -4440,6 +4548,7 @@ mod tests {
|
||||
favorite: false,
|
||||
group_size: 1,
|
||||
has_raw: false,
|
||||
place_id: None,
|
||||
}
|
||||
};
|
||||
// Newest first, the way the daemon sends them.
|
||||
|
||||
@@ -267,7 +267,12 @@ fn want_map_thumb(ui: &Rc<Ui>, photo: &PhotoItem) {
|
||||
}
|
||||
// A town's card may be waiting for the same photo: share its picture
|
||||
// rather than take its place in the queue.
|
||||
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
||||
let waiting = ui
|
||||
.gallery
|
||||
.thumb_wanted
|
||||
.borrow()
|
||||
.get(&photo.uid)
|
||||
.and_then(|waiting| waiting.first().cloned());
|
||||
let picture = waiting.unwrap_or_else(|| {
|
||||
let picture = gtk4::Picture::new();
|
||||
want_thumb(ui, photo, &picture);
|
||||
@@ -362,7 +367,12 @@ fn show_spot_photos(ui: &Rc<Ui>, photos: Vec<(PlaceInfo, PhotoItem)>, (x, y): (f
|
||||
/// Show photo's thumbnail in `picture`, sharing one another widget is
|
||||
/// already waiting for.
|
||||
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
||||
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
||||
let waiting = ui
|
||||
.gallery
|
||||
.thumb_wanted
|
||||
.borrow()
|
||||
.get(&photo.uid)
|
||||
.and_then(|waiting| waiting.first().cloned());
|
||||
match waiting {
|
||||
Some(waiting) => {
|
||||
waiting
|
||||
@@ -392,21 +402,45 @@ fn load_place_locations(ui: &Rc<Ui>, id: u32) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Open `place` and then photo `uid` in it, paging through the town until
|
||||
/// the photo has loaded.
|
||||
/// Open photo `uid` of `place` from the map or a spot's list, paging through
|
||||
/// the town until 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) {
|
||||
let id = place.id;
|
||||
open_place(ui, place);
|
||||
view_when_loaded(ui, uid);
|
||||
view_when_loaded(ui, id, uid);
|
||||
}
|
||||
|
||||
fn view_when_loaded(ui: &Rc<Ui>, uid: String) {
|
||||
/// The open place's id, if a place is open.
|
||||
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();
|
||||
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
|
||||
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() {
|
||||
open_photo_viewer(&ui, uid);
|
||||
} else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() {
|
||||
view_when_loaded(&ui, uid);
|
||||
let window = open_photo_viewer(&ui, uid);
|
||||
let ui_close = ui.clone();
|
||||
window.connect_close_request(move |_| {
|
||||
// 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);
|
||||
load_gallery(&ui, true);
|
||||
}
|
||||
|
||||
@@ -279,10 +279,7 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
|
||||
}));
|
||||
|
||||
let ui_back = ui.clone();
|
||||
widgets.back.connect_clicked(move |_| {
|
||||
ui_back.shared.nav.borrow_mut().pop();
|
||||
load_shared(&ui_back);
|
||||
});
|
||||
widgets.back.connect_clicked(move |_| shared_back(&ui_back));
|
||||
let ui_retry = ui.clone();
|
||||
widgets
|
||||
.retry
|
||||
@@ -318,6 +315,14 @@ pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &
|
||||
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
|
||||
/// and repaint the page once all three land.
|
||||
///
|
||||
@@ -443,6 +448,7 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
let nav = ui.shared.nav.borrow().clone();
|
||||
repaint_shared_crumb(ui, &nav);
|
||||
let top = nav.is_empty();
|
||||
let another_folder = ui.shared.listed.borrow().as_ref() != Some(&nav);
|
||||
*ui.shared.listed.borrow_mut() = Some(nav);
|
||||
if entries.is_empty() {
|
||||
ui.shared.files.model.remove_all();
|
||||
@@ -467,8 +473,11 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
}
|
||||
ui.shared.files_content.set_visible_child_name("list");
|
||||
// Only the rows that changed are swapped, so a refresh keeps the selection
|
||||
// and the scroll position.
|
||||
// and the scroll position. Another folder starts from its first entry.
|
||||
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
|
||||
|
||||
@@ -224,6 +224,49 @@ impl FileList {
|
||||
.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.
|
||||
pub(crate) fn shows_list(&self) -> bool {
|
||||
self.views.visible_child_name().as_deref() == Some("list")
|
||||
|
||||
@@ -101,8 +101,14 @@ impl ActionMenu {
|
||||
|
||||
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
|
||||
/// popover removes itself once dismissed; it waits for an idle turn, so the
|
||||
/// item that closed it still finds its action.
|
||||
pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
|
||||
/// item that closed it still finds its action. Returned for a caller that
|
||||
/// has to know when it closes.
|
||||
pub(crate) fn popup_at(
|
||||
self,
|
||||
anchor: &impl IsA<gtk4::Widget>,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> gtk4::PopoverMenu {
|
||||
let (menu, group) = self.finish();
|
||||
let anchor = anchor.as_ref();
|
||||
let host = popover_host(anchor);
|
||||
@@ -122,6 +128,7 @@ impl ActionMenu {
|
||||
glib::idle_add_local_once(move || p.unparent());
|
||||
});
|
||||
popover.popup();
|
||||
popover
|
||||
}
|
||||
|
||||
/// A flat ⋮ button that opens this menu.
|
||||
|
||||
@@ -334,6 +334,13 @@ pub(crate) struct ShareDialog {
|
||||
/// Open the per-node Share dialog: invite Proton/external users, manage who has
|
||||
/// access and their roles, and create/copy/remove a public link.
|
||||
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
open_share_dialog_over(ui, entry, None);
|
||||
}
|
||||
|
||||
/// [`open_share_dialog`] over `parent` rather than the main window. The photo
|
||||
/// viewer is a window of its own, and a dialog on the main window would open
|
||||
/// behind it.
|
||||
pub(crate) fn open_share_dialog_over(ui: &Rc<Ui>, entry: &DirEntry, parent: Option<gtk4::Window>) {
|
||||
if !*ui.mounted.borrow() {
|
||||
toast_error(
|
||||
ui,
|
||||
@@ -491,7 +498,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
}
|
||||
});
|
||||
share_dialog_reload(&state);
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
dialog.present(parent.or_else(|| ui_window(ui)).as_ref());
|
||||
}
|
||||
|
||||
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
||||
|
||||
@@ -467,6 +467,7 @@ 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) |
|
||||
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
|
||||
| `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-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` |
|
||||
|
||||
+101
@@ -109,7 +109,10 @@ a live account since.
|
||||
|
||||
| Entry | Title |
|
||||
|---|---|
|
||||
| [B284](#b284--a-keyring-entry-reported-missing-discards-refreshed-session-tokens) | A keyring entry reported missing discards refreshed session tokens |
|
||||
| [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 |
|
||||
| [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 |
|
||||
@@ -384,6 +387,34 @@ a live account since.
|
||||
|
||||
Newest first.
|
||||
|
||||
### B284 — A keyring entry reported missing discards refreshed session tokens
|
||||
|
||||
**Status:** Fixed (unverified)
|
||||
**Found:** 2026-11-14, investigating [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78)
|
||||
after the reporter's oo7 0.7.0 retest ruled out the original persistence fix as a sufficient
|
||||
explanation. See [the investigation and isolated reproducer](ISSUE-78.md).
|
||||
**Where:** `crates/pdfs-core/src/auth.rs`, `TokenSink::flush`
|
||||
|
||||
**Repro:** Refresh a session while the keyring lookup reports `NoEntry` for its existing blob.
|
||||
oo7 0.7.0 does this for a locked collection (upstream #614), reproduced with fake tokens on a
|
||||
private D-Bus connection. pdfs dropped the pending rotation and returned success, leaving the
|
||||
spent refresh token in the saved blob without triggering B61's retry or kernel recovery paths.
|
||||
The running session can still work, but restarting it exposes the stale saved credentials.
|
||||
|
||||
**Fix:** Propagate the missing-session error so the rotation stays pending and is retried.
|
||||
Persistence still requires the matching existing blob; a retry never recreates a deleted
|
||||
session or overwrites a new login. This does not make a locked keyring writable, and pending
|
||||
tokens still need to reach it before power-off to survive a cold boot.
|
||||
|
||||
**Verified:** The two regression tests
|
||||
`auth::tests::a_temporarily_missing_session_does_not_discard_a_rotation` and
|
||||
`auth::tests::retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`
|
||||
failed before the fix and pass afterward. The oo7 fixture confirms its locked lookup behavior
|
||||
and repeated binary `SetSecret` persistence. The reporter's complete Fedora cold-boot sequence
|
||||
has not been reproduced; issue #78's precise trigger remains unconfirmed.
|
||||
|
||||
---
|
||||
|
||||
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
@@ -427,6 +458,76 @@ 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
|
||||
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
|
||||
|
||||
+105
-23
@@ -11,32 +11,40 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
## [3.8.0] — 2026-11-14
|
||||
|
||||
- Update `proton-sdk` and `proton-drive-rs` to 0.11.0.
|
||||
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.
|
||||
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files.
|
||||
Existing folders stay device backups. Older builds cannot open the upgraded database.
|
||||
|
||||
### Added
|
||||
|
||||
- Shell completion for `pdfs` in bash, zsh and fish, installed by every package, plus elvish
|
||||
and PowerShell through `pdfs completions SHELL`. Besides commands and options it completes
|
||||
paths in My files and the ids and uids the running service knows (synced folders, the queue,
|
||||
computers, trash, albums, shares, invitations, bookmarks, conflicts and versions), each with
|
||||
a description. Share roles are now checked by the CLI (`viewer`, `editor`, `admin`) before
|
||||
the request.
|
||||
- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
|
||||
My files folder. The first sync preserves one-sided files and differing versions (#67).
|
||||
Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
|
||||
the remote root when syncing is stopped.
|
||||
- **Sync a local folder with a folder you already have in My files** (#67, #77).
|
||||
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both
|
||||
ways with My files/Work. The first sync uploads files that are only on this computer, downloads
|
||||
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on
|
||||
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is
|
||||
uploaded too. Attached folders are always kept on this computer, never online only. They are
|
||||
listed apart from device backups and are not part of the device profile used to restore another
|
||||
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder
|
||||
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local
|
||||
files stay. Attaching a folder is done from a terminal for now.
|
||||
|
||||
- **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
|
||||
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
|
||||
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).
|
||||
|
||||
- **A new layout for Photos.**
|
||||
- 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
|
||||
@@ -61,18 +69,92 @@ Existing folders remain device backups; older builds cannot open the upgraded da
|
||||
- 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
|
||||
narrow instead of squeezing it.
|
||||
|
||||
- **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
|
||||
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.
|
||||
|
||||
- **Share photos, and more in the photo viewer.**
|
||||
- **Share…** in a photo's menu and in the viewer's ⋮ menu opens the usual Share dialog: invite
|
||||
people or make a public link. It is not offered for a photo in an album someone else shared.
|
||||
- **Copy Image** (Ctrl+C) puts the photo on screen on the clipboard.
|
||||
- A strip of thumbnails along the bottom of the viewer shows the photos around the one on
|
||||
screen; click one to jump to it. It fades with the other controls and stays hidden during a
|
||||
slideshow.
|
||||
- The line under the date names the town the photo was taken in, when it is known.
|
||||
|
||||
### 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
|
||||
|
||||
- Large `.ts` recordings (MPEG transport streams) show and open as videos: the video icon, a
|
||||
thumbnail, and the `@media` open rule rather than `@text`. 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 (B283).
|
||||
- A video over 256 MiB never got a thumbnail, and a smaller one was downloaded whole into
|
||||
memory for one frame (B283).
|
||||
- **My files comes back after the service was killed for hanging** (B279). A killed service left
|
||||
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
|
||||
forever without opening anything, until the mounts were cleared by hand. The service now clears
|
||||
a dead mount before it opens the folder. On a large account a restart that did open My files was
|
||||
killed again, because loading the account took longer than the 2 minutes the service has to
|
||||
report in. Startup now gets 10 minutes, and the 2 minutes apply again once the service answers.
|
||||
|
||||
- **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.
|
||||
|
||||
- **The photo viewer's buttons are readable on any photo.** They were white on a see-through
|
||||
white, and vanished over a bright photo. The bar now keeps only the favorite star, the details
|
||||
and a menu, which names Slideshow, Save a Copy, Open With, Add to Album, Show on Map and Move to
|
||||
Trash in words where eight icons stood. A shot stored as more than one file shows a "RAW" or
|
||||
"Live" switch, or "2 of 3" for a burst, where an unlabelled icon was. The title reads
|
||||
"Wednesday, October 7, 2026, 13:42" rather than a timestamp. Delete asks first, as it does in
|
||||
the grid, and Alt+Left closes the viewer.
|
||||
|
||||
- **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
|
||||
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ commands are for scripts and for cases where the mount is not available.
|
||||
|
||||
| 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). |
|
||||
| `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 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. |
|
||||
|
||||
|
||||
+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:
|
||||
|
||||
```nix
|
||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.7.0";
|
||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.8.0";
|
||||
```
|
||||
|
||||
Each release tag is built with Nix before the release is published.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Issue #78: session loss on Fedora 45
|
||||
|
||||
Investigation on 2026-11-14 of [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78),
|
||||
using its report and comments through `gh`, pdfs 3.7.0/current source, and the oo7 0.7.0 tag.
|
||||
|
||||
## What is established
|
||||
|
||||
The reporter still saw the problem with oo7 0.7.0 on November 7. The original
|
||||
[SetSecret persistence fix](https://github.com/linux-credentials/oo7/commit/7fb11dd111c4be8fec01996e3caa31c44fa962b3)
|
||||
is present in that release. In an isolated test, three successive binary `SetSecret` updates
|
||||
survived reopening the keyring file. Repeating the original persistence explanation is therefore
|
||||
insufficient.
|
||||
|
||||
A different, reproducible failure exists at the boundary between oo7 and pdfs:
|
||||
|
||||
1. oo7 0.7.0's `Collection::search_inner_items` returns an empty list for a locked collection.
|
||||
`SearchItems` consequently returns **both** the unlocked and locked lists empty even when the
|
||||
matching item is on disk. The isolated test reproduces this with pdfs's exact lookup attributes.
|
||||
This is upstream [oo7 #614](https://github.com/linux-credentials/oo7/issues/614).
|
||||
2. `dbus-secret-service-keyring-store` 1.0.1 maps an empty search to `keyring_core::Error::NoEntry`;
|
||||
pdfs maps that to `Error::NotLoggedIn`. The client cannot offer an unlock when no locked item
|
||||
is reported.
|
||||
3. In pdfs 3.7.0 and the code before this change, `TokenSink::flush` treated `NotLoggedIn` as a
|
||||
logout, cleared the pending rotation and returned success. It skipped the error path that
|
||||
retains the new tokens in the kernel keyring and retries their persistence. The code was
|
||||
unchanged between v3.7.0 and this investigation.
|
||||
4. If a token refresh encounters that reply, the saved blob retains a spent refresh token. The
|
||||
running process can keep working with the new tokens in memory; a restart then exposes the
|
||||
stale saved credentials. A regression test failed before the fix and passes afterward.
|
||||
|
||||
The same unsafe assumption also applies to transient empty lookups such as the race reported in
|
||||
[oo7 #652](https://github.com/linux-credentials/oo7/issues/652), fixed in 0.7.1. That issue's
|
||||
replacement race was not independently reproduced here; pdfs normally updates existing items
|
||||
with `SetSecret`, so it must not be assumed to encounter the same-item `CreateItem` race.
|
||||
|
||||
## Change and limits
|
||||
|
||||
`TokenSink::flush` now propagates a missing-session error instead of discarding the rotation.
|
||||
The existing retry, kernel-keyring recovery and unsaved-token status paths can then run. Once
|
||||
the collection is unlocked and readable, the pending rotation is saved. A genuinely deleted
|
||||
session is never recreated: flushing still requires a readable blob with the matching session
|
||||
ID and expected refresh token. A new login causes the old rotation to be discarded as before.
|
||||
|
||||
This is a confirmed pdfs defect (B284), **not a confirmed complete explanation of the reporter's
|
||||
cold-boot failure**. The issue has no oo7 logs or record showing that its collection was locked
|
||||
when a token rotated. A locked keyring only at startup can hide a login, but does not by itself
|
||||
explain `InvalidRefreshToken`. The token-loss path requires a successful refresh while the saved
|
||||
entry is unreadable.
|
||||
|
||||
The fix does not make a locked keyring writable. If it stays unreadable until power-off, tokens
|
||||
retained in process/kernel memory still cannot survive a cold boot. No new disk credential store
|
||||
or credential format is introduced.
|
||||
|
||||
Other upstream Fedora-upgrade issues were checked: [oo7 #651](https://github.com/linux-credentials/oo7/issues/651)
|
||||
creates duplicate placeholder/migrated collections and refuses writes; [oo7 #662](https://github.com/linux-credentials/oo7/issues/662)
|
||||
can resolve the default alias to a locked collection. Both have fixes in
|
||||
[0.7.1](https://github.com/linux-credentials/oo7/releases/tag/0.7.1), released November 10,
|
||||
after the reporter's November 7 retest. Neither can be assigned as this report's cause without
|
||||
matching runtime evidence.
|
||||
|
||||
## Reproduce without a Proton account or desktop keyring
|
||||
|
||||
The oo7 fixture uses fake tokens, a temporary disk keyring and a private peer-to-peer D-Bus
|
||||
connection. It asserts the observed 0.7.0 behavior, including the incorrect empty locked lookup;
|
||||
passing means that behavior was reproduced. It does not simulate Proton refresh or a full boot.
|
||||
|
||||
From the pdfs repository, choose an unused temporary checkout path:
|
||||
|
||||
```sh
|
||||
git clone --depth 1 --branch 0.7.0 https://github.com/linux-credentials/oo7.git /tmp/pdfs-oo7-repro
|
||||
git -C /tmp/pdfs-oo7-repro apply "$PWD/docs/repro/oo7-0.7.0-issue78.patch"
|
||||
cargo test --manifest-path /tmp/pdfs-oo7-repro/Cargo.toml \
|
||||
-p oo7-daemon --lib --features test-util issue78 -- --nocapture
|
||||
```
|
||||
|
||||
Observed output:
|
||||
|
||||
```text
|
||||
oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)
|
||||
oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring
|
||||
test item::tests::issue78_locked_lookup_hides_saved_tokens ... ok
|
||||
```
|
||||
|
||||
The pdfs side is covered by `cargo test -p pdfs-core auth::tests`, specifically
|
||||
`a_temporarily_missing_session_does_not_discard_a_rotation` and
|
||||
`retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`.
|
||||
|
||||
Remaining live validation: reproduce on a Fedora 45 GNOME session across logout/shutdown,
|
||||
correlating oo7 lock/write errors with pdfs refresh and retry logs, and checking the running
|
||||
daemon's package version rather than just the CLI version. The investigation did not use a real
|
||||
Proton account, alter a desktop keyring, or post to either GitHub issue.
|
||||
@@ -0,0 +1,53 @@
|
||||
diff --git a/server/src/item/tests.rs b/server/src/item/tests.rs
|
||||
index 99c25dc..2ba705d 100644
|
||||
--- a/server/src/item/tests.rs
|
||||
+++ b/server/src/item/tests.rs
|
||||
@@ -5,6 +5,48 @@ use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{service::PrompterType, tests::TestServiceSetup};
|
||||
|
||||
+// Investigation for proton-drive-linux #78. Fake tokens and a private p2p bus;
|
||||
+// no desktop keyring or Proton account is used.
|
||||
+#[tokio::test]
|
||||
+async fn issue78_locked_lookup_hides_saved_tokens() -> Result<(), Box<dyn std::error::Error>> {
|
||||
+ let setup =
|
||||
+ TestServiceSetup::with_login_keyring(oo7::Secret::from("test-password-long-enough"))
|
||||
+ .await?;
|
||||
+ let collection = setup.default_collection().await?;
|
||||
+ let attributes = [("service", "proton-drive-linux"), ("username", "session")];
|
||||
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(b"fake-refresh-r1"))?;
|
||||
+ collection
|
||||
+ .create_item("pdfs fake session", &attributes, &secret, false, None)
|
||||
+ .await?;
|
||||
+
|
||||
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
|
||||
+ setup.lock_collection(collection).await?;
|
||||
+ assert!(collection.is_locked().await?);
|
||||
+
|
||||
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||
+ // This is the problematic reply, not the Secret Service behavior expected
|
||||
+ // by keyring-store: the saved item is neither unlocked nor reported locked.
|
||||
+ assert_eq!((unlocked.len(), locked.len()), (0, 0));
|
||||
+ assert_eq!(setup.login_keyring().await?.items().await?.len(), 1);
|
||||
+ eprintln!("oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)");
|
||||
+
|
||||
+ setup.unlock_collection(collection).await?;
|
||||
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
|
||||
+
|
||||
+ // Exercise the binary SetSecret path used by pdfs's keyring-store. Reopen
|
||||
+ // the disk keyring after each update, rather than trusting a memory read.
|
||||
+ for refresh in [b"fake-refresh-r2", b"fake-refresh-r3", b"fake-refresh-r4"] {
|
||||
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(refresh))?;
|
||||
+ unlocked[0].set_secret(&secret).await?;
|
||||
+ let items = setup.login_keyring().await?.items().await?;
|
||||
+ assert_eq!(items[0].secret().as_bytes(), refresh);
|
||||
+ }
|
||||
+ eprintln!("oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring");
|
||||
+ Ok(())
|
||||
+}
|
||||
+
|
||||
#[tokio::test]
|
||||
async fn label_property() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let setup = TestServiceSetup::plain_session(true).await?;
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=3.7.0
|
||||
pkgver=3.8.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
||||
%global _lto_cflags %{nil}
|
||||
|
||||
%{!?version: %global version 3.7.0}
|
||||
%{!?version: %global version 3.8.0}
|
||||
%global forge https://github.com/narrrl/proton-drive-linux
|
||||
# Files come from the checkout, or from the unpacked tag archive.
|
||||
%global src %{?git_dir}%{!?git_dir:.}
|
||||
|
||||
+859
-822
File diff suppressed because it is too large.
Load diff
+830
-799
File diff suppressed because it is too large.
Load diff
+836
-799
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@ SECTIONS = [
|
||||
]
|
||||
|
||||
# Too long or too internal for the wiki; links to them go to GitHub.
|
||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "SDK-0.11.0.md"}
|
||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "ISSUE-78.md", "SDK-0.11.0.md"}
|
||||
|
||||
# Files outside docs/ whose links are checked but not published.
|
||||
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
||||
|
||||
Reference in new issue
Block a user