Compare commits

..
Author SHA1 Message Date
nvrl 16281ce5c6 feat(gui): play videos and music in the app
Opening a video or a song in My files, by a double-click or Open, now
plays it in an in-app player window, streamed from the mount as before.
Open With… is the way to another player, and the separate Play item is
gone. A file GStreamer can't play shows the error with Open With….

Both that player and the Photos lightbox share the new widgets/player.rs:
a fullscreen button, fullscreen on a double-click, Repeat in the ⋮ menu,
K to play and pause, J/L or Shift+arrows to jump 10 seconds, M to mute,
and a volume that carries over between videos. The lightbox's thumbnail
strip stays off a video, where it covered the seek bar.
2026-11-14 15:45:44 +01:00
nvrl 74cd6fd2a9 fix(gui): keep paging the timeline when zoomed out onto one screen
Zoomed all the way out, a 200-photo page fits on one screen. With
nothing to scroll, the scroll handler never asked for the next page and
the timeline stopped after the first rows. Ask for more whenever the
content no longer fills a screen and a half, also after a zoom step or
a resize, not only when a page lands.
2026-11-14 15:33:41 +01:00
nvrl 4b669168ee feat(gui): drag photos out of the timeline, upload progress bar
- A drag that starts on a selected tile carries the selection out as a
  text/uri-list. PhotoDrag, a gdk::ContentProvider, downloads the photos
  only when the receiver reads the list, and hands over hard links (or
  copies) under their Drive names in the user cache; copies older than
  a day are pruned. Drag-select steps aside for such a drag, and the
  timeline's own drop target ignores drags from inside the app.
- Uploading several photos shows "Uploading 3 of 12 items…" with a
  progress bar under the Photos header, replacing the start toast.
  Photo uploads report no byte progress from the daemon, so the bar
  counts files; per-tile rings would need that first.
2026-11-14 15:25:26 +01:00
nvrl 41212efdf3 feat(gui,fuse,core): share photos, copy image, thumbnail strip in the viewer
- Share… in the grid's photo menu and the viewer's ⋮ menu opens the Share
  dialog by uid. The daemon accepts a uid from the Photos timeline for
  sharing (resolve_shareable), since the photos volume is in no mount.
  Photos in an album someone else shared are not offered.
- Copy Image (Ctrl+C) puts the shown still on the clipboard. The viewer
  has its own toast overlay, so this and Save a Copy confirm over it
  instead of behind it.
- A thumbnail strip along the viewer's bottom edge, on the gallery model,
  marks the shown photo and jumps on click. Several pictures can now
  wait for one thumbnail, so the strip and the grid tile don't steal it
  from each other.
- PhotoItem carries place_id (serde default); the line under the
  viewer's title reads "Berlin · 12 of 340".
2026-11-14 13:58:25 +01:00
nvrl 5f0b15fe70 fix(gui): a photo viewer bar that reads on any photo
The bar's white-on-translucent-white buttons vanished over a bright photo,
and eight look-alike icons, a floppy disk and an open-document glyph among
them, left the user guessing. The bar now holds the favorite, the details
and a menu with Slideshow, Save a Copy, Open With, Add to Album, Show on
Map and Move to Trash, on dark discs that read over white and black
alike.

A shot stored as more than one file shows a labelled pill ("RAW", "Live",
"2 of 3") instead of a paged icon, lit while the second file is shown.
The title is the localized full date instead of a raw timestamp.

Delete asks first, as the grid does: the dialog opens over the viewer,
and the album prompts can open there too. Alt+Left closes the viewer and
F10 opens the menu; the shortcuts window lists F5, F10 and Alt+Left.
2026-11-14 13:37:50 +01:00
nvrl 5c184013f1 feat(gui): restore folder places and add keyboard Back
Back, Forward and Up in My files restore the folder's scroll offset and
selection as they were when it was left; opening a folder any other way
still starts at the top.

Closing the photo viewer scrolls the grid to the photo it showed last.

Alt+Left and Backspace go back a level in Shared with me and Computers
and leave an open album or place; Escape leaves an album or place too,
after ending selection mode. The shortcuts window lists it.

A fresh Photos load, or leaving the grid for Albums or Places, ends a
scrubber jump that was still paging.
2026-11-14 12:53:27 +01:00
nvrl 6be8a8f9bc fix(gui): keep the user's place when navigating
A folder opened in My files, Shared with me or Computers kept the scroll
offset of the folder before it, because the rows are swapped in place to
keep a refresh steady. Another folder, a new search and a fresh Photos
load (filter, album, place) now start at the top; a refresh still keeps
its place.

Closing a photo opened from the Places map returns to the map instead of
leaving the town's timeline behind, and the pending open is dropped when
the user leaves the town before it loads.

load_gallery no longer swaps Albums or Places for the timeline when a
page lands late (viewer paging, upload, trash Undo); the timeline is
marked stale and read when the user switches back. Coming back to Photos
reuses the timeline unless it is stale, and the timeline returns
filtered the way the kind tabs and date show after an album or place.

A search in My files is no longer replaced by the folder listing on file
changes, F5 or returning to the page. A favorite set in the viewer shows
on its tile at once.
2026-11-14 12:53:04 +01:00
nvrl 57acb0b849 chore(po): refresh source references after the merges 2026-11-14 03:08:17 +01:00
nvrl 68ff8c9e80 Merge remote-tracking branch 'origin/feat/photos-redesign' into release-prep
# Conflicts:
#	docs/BUGS.md
#	docs/CHANGELOG.md
2026-11-14 02:57:11 +01:00
nvrl 4552093c53 Merge remote-tracking branch 'origin/main' into release-prep
# Conflicts:
#	docs/CHANGELOG.md
2026-11-14 02:55:06 +01:00
nvrl 14a663cee9 fix(fuse): keep pinned files on disk through remote changes
A remote change to a pinned file called `ContentCache::evict`, which does
not check pins, and only the pin request itself ever downloaded anything.
An edit from another device, or even a rename, left the file listed as kept
offline with no copy, so the next read downloaded it or failed without a
network. A file added to a pinned folder elsewhere was never downloaded,
and a folder pin whose downloads failed while offline never retried them.

A remote change no longer evicts a pinned file's copy. The copy is checked
against the revision's (mtime, size), so a newer revision does not read
it, and a rename keeps the mtime (B123), so the copy stays valid. A new
pdfs-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, including a pinned folder that gets a new file;
- every pin at startup, when the link returns, and after the event feed
  loses continuity;
- a file whose version is restored.

Failed downloads retry with a backoff of 30 s to 30 min. Pinning and the
keeper settle a provisional size first: a copy stored under the
ciphertext size never matched the plaintext it holds.

Fixes B282.
2026-11-14 00:20:06 +01:00
nvrl 3f45e89391 fix(fuse): keep files offline in on-demand synced folders
`pdfs pin` and `pdfs unpin` resolved every path against the primary mount
with `rel_to_mount`. An absolute path in an on-demand synced folder such as
~/Pictures was refused as "not under the mountpoint", and a relative path
named a file in My files instead. A synced folder's file is a different
Drive node from a same-named file in My files, so keeping one offline did
nothing for the other, and every program reading the synced copy
downloaded it.

Pin and unpin now route through `route_to_mount`, like ls, refresh, rm and
mv since B86, and run on the mount that owns the path. A path in a mirror
folder gets an error saying its files are already on this computer. A pin
made through an on-demand folder records the absolute local path, so
`pdfs pins`, the prompt and the HUD open the file where it was kept; pins
under the primary mount still record the mount-relative path.

Fixes B281.
2026-11-14 00:20:06 +01:00
nvrl ad648ab5cc feat(cli,core): shell completions with live values from the service
`pdfs completions <shell>` prints a completion script for bash, zsh, fish,
elvish or PowerShell, built on clap_complete's dynamic engine: on Tab the
shell calls `pdfs` back, which completes commands and options and also the
values the running service knows, each with a description: paths in My
files, synced-folder and queue ids, computers, trash, albums, items shared
with me, invitations, bookmarks, conflict copies, and a file's versions or
a share's members and public link once its path is typed.

A completer that gets no answer from the service within two seconds offers
nothing rather than hanging the prompt. Listings that ask Proton are kept
for 30 s in $XDG_RUNTIME_DIR (0600), so repeated Tabs do not wait again.
Completion is triggered by PDFS_COMPLETE rather than clap's generic
COMPLETE, because the same binary runs the daemon.

Share roles are now checked by the CLI before the request. The package
recipes install the bash, zsh and fish scripts via packaging/completions.sh.
2026-11-12 11:47:29 +01:00
nvrl 2b95708260 chore(release): 3.8.0 2026-11-12 10:22:04 +01:00
50 changed files with 10397 additions and 7578 deletions

No files matched your search

Generated
+4 -4
View File
@@ -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
View File
@@ -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>"]
+5
View File
@@ -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
+4
View File
@@ -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<_>>()?;
+4 -1
View File
@@ -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)?,
})
}
+17
View File
@@ -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]
+35 -1
View File
@@ -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
+45 -9
View File
@@ -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 {
+271
View File
@@ -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
View File
@@ -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);
}
}
}
+3
View File
@@ -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.
+12
View File
@@ -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) {
+1
View File
@@ -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,
}
}
+5
View File
@@ -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,
+8 -8
View File
@@ -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)
}
+112
View File
@@ -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) {
+17 -4
View File
@@ -61,14 +61,27 @@ 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; }
.media-player-window video { background-color: black; }
.media-player-note { color: rgba(255, 255, 255, 0.5); }
.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; }
+12 -2
View File
@@ -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")
+95 -9
View File
@@ -40,6 +40,8 @@ use widgets::crumb::*;
use widgets::details::*;
use widgets::file_list::*;
use widgets::menu::*;
use widgets::photo_drag::*;
use widgets::player::*;
use widgets::scrubber::*;
use widgets::share_dialog::*;
use widgets::thumbnails::*;
@@ -457,6 +459,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(),
@@ -523,6 +527,7 @@ fn build_window(app: &adw::Application) {
jump: Cell::new(None),
burst: Cell::new(false),
import_banner: gallery_widgets.import_banner.clone(),
upload_bar: gallery_widgets.upload_bar.clone(),
upload: gallery_widgets.upload.clone(),
empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(),
@@ -726,17 +731,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 +1080,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 +1342,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);
@@ -1506,7 +1520,7 @@ fn debug_info(ui: &Rc<Ui>) -> String {
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
fn show_shortcuts(window: &adw::ApplicationWindow) {
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
const GROUPS: [(&str, &[(&str, &str)]); 5] = [
(
gettext_noop("General"),
&[
@@ -1539,6 +1553,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 +1563,20 @@ 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")),
],
),
(
gettext_noop("Video Player"),
&[
("space k", gettext_noop("Play / pause")),
("j l", gettext_noop("Back / forward 10 seconds")),
("m", gettext_noop("Mute")),
("f", gettext_noop("Fullscreen")),
],
),
];
@@ -1575,8 +1602,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 +1669,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 +2025,7 @@ mod tests {
favorite: false,
group_size: 2,
has_raw: kind == PhotoKind::Raw,
place_id: None,
}
}
@@ -1996,6 +2048,40 @@ mod tests {
assert!(next_group_member(&[], "jpeg").is_none());
}
/// The upload bar counts the upload under way, not the ones finished.
#[test]
fn the_upload_bar_counts_the_upload_under_way() {
assert_eq!(upload_progress_text(0, 12), "Uploading 1 of 12 items…");
assert_eq!(upload_progress_text(11, 12), "Uploading 12 of 12 items…");
}
/// 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]
+30 -17
View File
@@ -225,12 +225,13 @@ fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: &gtk4::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: &gtk4::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);
+79 -52
View File
@@ -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,
@@ -883,27 +898,15 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
let mut menu = ActionMenu::new();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
// Open does what a double-click does: a video or a song plays in the app,
// and Open With is the way to another player.
menu.item(&pgettext("verb", "Open"), move || {
// Open always means "download a local copy and hand off", even for a
// media file — `activate_entry` would otherwise stream it.
if is_streamable_media_entry(&entry_c) {
download_and_open(&ui_c, &entry_c);
} else {
activate_entry(&ui_c, &entry_c);
}
activate_entry(&ui_c, &entry_c)
});
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Open With…"), move || {
open_entry_with(&ui_c, &entry_c)
});
// Play streams from the mount, no download; Open above still fetches a
// local copy for anyone who wants one.
if is_streamable_media_entry(entry) {
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&pgettext("verb", "Play"), move || {
stream_entry(&ui_c, &entry_c)
});
}
menu.section();
// Kept through a pinned folder above it, the entry has no pin of its own
@@ -1092,38 +1095,8 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
Some(entry)
}
/// Whether an entry can be opened through FUSE instead of fully materialized.
pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia
}
/// Stream media straight from the mount, no download. Drive folders *are* part
/// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it
/// seeks and buffers — instead of waiting for the whole file to land like
/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC
/// `.mkv` starts playing in seconds.
pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
let abs = mounted_path(&mountpoint, &rel);
let Some(path) = abs.to_str() else {
toast_error(
ui,
&gettext("Couldn't play media"),
&gettext("The file path isn't valid UTF-8."),
);
return;
};
toast(
ui,
&gettext_f("Streaming “{name}”…", &[("name", &entry.name)]),
);
play_external(path);
}
/// Open an entry the Nautilus way: folders descend, media streams from the mount,
/// other files download-and-open.
/// Open an entry the Nautilus way: folders descend, media plays in the app,
/// streamed from the mount, other files download-and-open.
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
if entry.is_dir {
@@ -1136,16 +1109,16 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
// Media streams rather than downloads: that is exactly the "play it,
// don't fetch the whole thing" behaviour this is for.
stream_entry(ui, entry);
open_media_player(ui, entry);
} else {
download_and_open(ui, entry);
}
}
/// Hand a file to the user's default application: through the mount when it is
/// there, otherwise downloaded into the cache first. The open path behind both
/// a plain double-click and the context menu's "Open" (including for a video,
/// when the user wants their default application rather than the player).
/// there, otherwise downloaded into the cache first. The open path behind a
/// double-click and the context menu's "Open" for anything but a folder or
/// media, and for media the mount doesn't show.
///
/// The mount comes first because the cache blob is a *copy* keyed by content
/// hash — an application saving into it writes where Drive will never look, and
@@ -1556,8 +1529,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 +1576,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 +1588,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 +1602,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 +2920,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 +2970,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 +3002,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 +3190,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 {
+16 -4
View File
@@ -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();
}
}
});
}
+456 -141
View File
@@ -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 {
@@ -232,6 +279,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
clear_info(viewer);
stop_video(viewer);
viewer.media.set_visible_child_name("photo");
sync_strip(viewer);
reset_zoom(viewer);
// A slideshow waits for this photo to land before it starts counting.
disarm_slide(viewer);
@@ -309,6 +357,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
None => {
viewer.video.set_filename(Some(&path));
viewer.media.set_visible_child_name("video");
sync_strip(&viewer);
*viewer.path.borrow_mut() = Some(path.clone());
show_info(&viewer, size, ExifInfo::default());
}
@@ -341,14 +390,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);
sync_strip(viewer);
if !viewer.loading.get() {
arm_slide(ui, viewer);
}
@@ -356,13 +406,20 @@ 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);
sync_strip(viewer);
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: &gtk4::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: &gtk4::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,124 @@ 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);
sync_strip(viewer);
mark_in_strip(viewer, index);
}
/// Show the strip of thumbnails when there is more than one photo to pick
/// from. Not in a slideshow, and not over a video, where it would cover the
/// player's seek bar.
fn sync_strip(viewer: &Rc<Viewer>) {
let video = viewer.media.visible_child_name().as_deref() == Some("video");
viewer
.counter
.set_label(&position_label(index, loaded, more, gallery_total(ui)));
.strip_reveal
.set_visible(viewer.strip_selection.n_items() > 1 && !viewer.slideshow.get() && !video);
}
/// 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 +1207,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
@@ -1038,6 +1247,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
.hexpand(true)
.vexpand(true)
.build();
wire_player(&video, &window);
let media = gtk4::Stack::new();
media.add_named(&scroller, Some("photo"));
media.add_named(&video, Some("video"));
@@ -1122,22 +1332,21 @@ 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 fullscreen_btn = fullscreen_button(&window);
fullscreen_btn.add_css_class("flat");
fullscreen_btn.add_css_class("viewer-action-btn");
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
close_btn.add_css_class("viewer-close-btn");
@@ -1147,14 +1356,26 @@ 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(&fullscreen_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 +1463,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 +1496,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 +1538,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 +1654,52 @@ 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 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);
}
sync_strip(&viewer);
});
// 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 +1725,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 +1761,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 +1784,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 +1799,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,13 +1881,22 @@ 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");
if playing_video
&& let Some(name) = key.name()
&& player_key(&viewer_key.video, &name, state)
{
return glib::Propagation::Stop;
}
match key.name().as_deref() {
Some("space") if playing_video => {
if let Some(stream) = viewer_key.video.media_stream() {
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,30 +1905,20 @@ 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()),
Some("f" | "F11") => {
if w_key.is_fullscreen() {
w_key.unfullscreen();
} else {
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("f" | "F11") => toggle_fullscreen(&w_key),
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 +1926,113 @@ 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));
if viewer.media.visible_child_name().as_deref() == Some("video") {
let video = viewer.video.clone();
menu.toggle(&gettext("Repeat"), video.is_loop(), move |on| {
video.set_loop(on)
});
}
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
+318 -50
View File
@@ -37,6 +37,9 @@ pub(crate) struct GalleryState {
pub(crate) burst: Cell<bool>,
/// Says a Google Photos import is running, with a way to its page.
pub(crate) import_banner: adw::Banner,
/// The bar that counts uploads from this computer through, under the
/// header while any run.
pub(crate) upload_bar: UploadBar,
pub(crate) upload: adw::SplitButton,
/// Upload / Import offered on the *empty timeline* status page, so a fresh
/// account is a place to start rather than a dead end. Hidden on every other
@@ -157,10 +160,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 +247,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: &gtk4::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(&gtk4::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.
@@ -456,6 +481,9 @@ pub(crate) struct GalleryWidgets {
pub(crate) scrubber: Scrubber,
/// Says a Google Photos import is running, with a way to its page.
pub(crate) import_banner: adw::Banner,
/// The bar that counts uploads from this computer through, under the
/// header while any run.
pub(crate) upload_bar: UploadBar,
pub(crate) list: gtk4::ListView,
pub(crate) scroll: gtk4::ScrolledWindow,
pub(crate) retry: gtk4::Button,
@@ -881,6 +909,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.action_name("win.show-import")
.build();
let upload_bar = UploadBar::new();
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
@@ -891,6 +921,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&import_banner);
body.append(&upload_bar.revealer);
body.append(&inner);
// The title slot holds the view switch on the page itself, the album's or
// place's name inside one, and the count in selection mode (see
@@ -921,6 +952,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
pager,
scrubber,
import_banner,
upload_bar,
list,
scroll,
retry,
@@ -1314,14 +1346,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));
}
}
});
@@ -1366,6 +1394,13 @@ pub(crate) fn wire_gallery(
sync_float_heading(&ui_scroll);
ui_scroll.gallery.scrubber.flash();
});
// Zooming out or making the window taller can leave every loaded photo on
// one screen. Nothing scrolls then, so the scrolling above never asks for
// the next page: ask whenever the content shrinks under the viewport.
let ui_fill = ui.clone();
scroll
.vadjustment()
.connect_changed(move |_| fill_viewport(&ui_fill));
wire_scrubber(ui);
wire_drag_select(ui);
@@ -1441,7 +1476,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 |_| {
@@ -1495,9 +1530,11 @@ pub(crate) fn wire_gallery(
gtk4::gdk::DragAction::COPY,
);
let ui_drop = ui.clone();
drop.connect_accept(move |_, _| {
drop.connect_accept(move |_, drop| {
let gallery = &ui_drop.gallery;
!gallery.in_collection()
// Photos dragged out of the timeline are already in it.
drop.drag().is_none()
&& !gallery.in_collection()
&& gallery.view_switch.active() == VIEW_TIMELINE
&& gallery.library.get()
&& *ui_drop.mounted.borrow()
@@ -1895,6 +1932,86 @@ pub(crate) fn copy_without_overwrite(
unreachable!("some numbered name is free")
}
/// How far the uploads from this computer have come: a count and a bar, so a
/// drop of a few hundred photos shows it is moving. Batches started while one
/// runs join its count.
#[derive(Clone)]
pub(crate) struct UploadBar {
pub(crate) revealer: gtk4::Revealer,
label: gtk4::Label,
progress: gtk4::ProgressBar,
/// Uploads finished, and uploads asked for, since the bar last emptied.
count: Rc<Cell<(usize, usize)>>,
}
impl UploadBar {
fn new() -> Self {
let label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let progress = gtk4::ProgressBar::builder().hexpand(true).build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
content.set_margin_top(10);
content.set_margin_bottom(4);
content.set_margin_start(16);
content.set_margin_end(16);
content.append(&label);
content.append(&progress);
let revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideDown)
.child(&content)
.build();
Self {
revealer,
label,
progress,
count: Rc::default(),
}
}
/// Count `n` more uploads in.
pub(crate) fn add(&self, n: usize) {
let (done, total) = self.count.get();
self.count.set((done, total + n));
self.paint();
}
/// Count one upload as finished, whether or not it went through.
pub(crate) fn finish(&self) {
let (done, total) = self.count.get();
let done = (done + 1).min(total);
if done == total {
self.count.set((0, 0));
self.revealer.set_reveal_child(false);
return;
}
self.count.set((done, total));
self.paint();
}
fn paint(&self) {
let (done, total) = self.count.get();
if total == 0 {
return;
}
self.label.set_label(&upload_progress_text(done, total));
self.progress.set_fraction(done as f64 / total as f64);
self.revealer.set_reveal_child(true);
}
}
/// "Uploading 3 of 12 items…": the upload under way is number `done + 1`.
pub(crate) fn upload_progress_text(done: usize, total: usize) -> String {
// Translators: progress above the photo timeline while photos upload; {position} is the upload under way, {n} how many there are.
ngettext_f(
"Uploading {position} of {n} item…",
"Uploading {position} of {n} items…",
total as u64,
&[("position", &thousands(done + 1))],
)
}
/// Upload photos and videos from this computer into the timeline, one after
/// the other, then reload it once.
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
@@ -1909,15 +2026,7 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
let total = uploads.len();
ui.busy_begin();
if total > 1 {
toast(
ui,
&ngettext_f(
"Uploading {n} item…",
"Uploading {n} items…",
total as u64,
&[],
),
);
ui.gallery.upload_bar.add(total);
}
let ui = ui.clone();
glib::spawn_future_local(async move {
@@ -1955,6 +2064,9 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
))
}
}
if total > 1 {
ui.gallery.upload_bar.finish();
}
}
ui.busy_end();
if uploaded > 0 {
@@ -2363,6 +2475,23 @@ impl TileWidget {
});
button.add_controller(context);
// A picked photo drags out, with the rest of the selection, into a
// file manager or any app that takes files. A drag from anywhere else
// selects instead (see `wire_drag_select`).
let drag_out = gtk4::DragSource::new();
drag_out.set_actions(gtk4::gdk::DragAction::COPY);
let ui_drag = ui.clone();
let drag_uid = uid.clone();
drag_out.connect_prepare(move |source, x, y| {
let photos = dragged_photos(&ui_drag, &drag_uid()?)?;
if let Some(tile) = source.widget() {
let icon = gtk4::WidgetPaintable::new(Some(&tile));
source.set_icon(Some(&icon), x as i32, y as i32);
}
Some(PhotoDrag::new(ui_drag.dirs.control_socket(), photos).upcast())
});
button.add_controller(drag_out);
// A tile opens in the in-app lightbox, which plays a video in place.
// In selection mode a tile picks instead of opening: the lightbox is
// one Escape away.
@@ -2455,13 +2584,28 @@ 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);
}
}
/// The photos a drag that starts on `uid` carries out: the whole selection,
/// in timeline order, as uid and name. `None` unless `uid` is picked.
fn dragged_photos(ui: &Rc<Ui>, uid: &str) -> Option<Vec<(String, Option<String>)>> {
let selected = ui.gallery.selected.borrow();
if !ui.gallery.selecting.get() || !selected.contains(uid) {
return None;
}
let model = &ui.gallery.model;
let photos: Vec<(String, Option<String>)> = (0..model.n_items())
.filter_map(|index| model.item(index).and_downcast::<BoxedAnyObject>())
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
.filter(|photo| selected.contains(&photo.uid))
.map(|photo| (photo.uid, photo.name))
.collect();
(!photos.is_empty()).then_some(photos)
}
/// Add or remove one CSS class, for widgets that are rebound rather than
/// rebuilt.
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
@@ -2540,6 +2684,9 @@ struct DragSelect {
pointer: (f64, f64),
/// The edge autoscroll, while the pointer is near the top or bottom.
scroll: Option<glib::SourceId>,
/// The drag started on a picked photo, so it carries the selection out
/// of the app rather than selecting.
out: bool,
}
/// Dragging across the timeline selects every photo from where the drag
@@ -2556,21 +2703,33 @@ fn wire_drag_select(ui: &Rc<Ui>) {
let state = Rc::new(RefCell::new(DragSelect::default()));
let state_begin = state.clone();
drag.connect_drag_begin(move |_, x, y| {
let ui_begin = ui.clone();
drag.connect_drag_begin(move |gesture, x, y| {
let mut drag = state_begin.borrow_mut();
if let Some(source) = drag.scroll.take() {
source.remove();
}
let out = ui_begin.gallery.selecting.get()
&& tile_at(&ui_begin, x, y)
.is_some_and(|uid| ui_begin.gallery.selected.borrow().contains(&uid));
*drag = DragSelect {
start: (x, y),
pointer: (x, y),
out,
..DragSelect::default()
};
// Left to the tile's own drag source.
if out {
gesture.set_state(gtk4::EventSequenceState::Denied);
}
});
let ui_update = ui.clone();
let state_update = state.clone();
drag.connect_drag_update(move |gesture, dx, dy| {
if state_update.borrow().out {
return;
}
let (sx, sy) = state_update.borrow().start;
state_update.borrow_mut().pointer = (sx + dx, sy + dy);
if !state_update.borrow().active {
@@ -2764,6 +2923,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 +2974,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 +3123,7 @@ pub(crate) fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::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 +3342,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 +3458,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| {
@@ -3917,17 +4097,32 @@ fn continue_jump(ui: &Rc<Ui>) {
/// Load the next page when the timeline does not fill the window yet: with no
/// scrollbar there is no scrolling to trigger it.
fn fill_viewport(ui: &Rc<Ui>) {
if !ui.gallery.has_more.get() || ui.gallery.loading.get() {
return;
}
let ui = ui.clone();
glib::idle_add_local_once(move || {
let Some(adj) = ui.gallery.list.vadjustment() else {
return;
};
if ui.gallery.has_more.get() && adj.upper() <= adj.page_size() + 1.0 {
let timeline = ui.gallery.content.visible_child_name().as_deref() == Some("timeline");
if timeline
&& ui.gallery.has_more.get()
&& adj.page_size() > 0.0
&& viewport_unfilled(adj.upper(), adj.page_size())
{
load_gallery(&ui, true);
}
});
}
/// Whether content `upper` px tall leaves a viewport `page_size` px tall short
/// of a screen and a half to scroll through, the margin the scrolling pager
/// keeps. Below it the pager would never fire.
pub(crate) fn viewport_unfilled(upper: f64, page_size: f64) -> bool {
upper <= page_size * 2.5 + 1.0
}
/// Select every photo loaded so far (Ctrl+A).
pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
set_selection_mode(ui, true);
@@ -3952,7 +4147,9 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: &gtk4::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 +4164,18 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: &gtk4::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 +4227,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 +4280,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 +4344,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 +4390,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()
@@ -4236,24 +4502,13 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
}
/// Play a video with an external player. Prefers `mpv` — it sniffs the container
/// from the bytes, so the cache's extensionless blob plays fine, and it is the
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
/// user's default handler when mpv isn't installed.
pub(crate) fn play_external(path: &str) {
match Command::new("mpv").arg(path).spawn() {
Ok(child) => pdfs_core::opener::reap(child),
Err(_) => open_path(path),
}
}
#[cfg(test)]
mod tests {
use super::{
Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
Grouping, PHOTOS_PAGE, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for,
memory_years, month_index, month_range, plan_grid, plan_justified, section_heading,
tile_ratio,
tile_ratio, viewport_unfilled,
};
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth};
@@ -4376,6 +4631,18 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
/// A first page zoomed all the way out fits on one screen, with nothing
/// to scroll: the next page has to load without a scroll asking for it.
#[test]
fn a_page_zoomed_out_onto_one_screen_asks_for_more() {
let (columns, size) = plan_grid(1580, ROW_MIN);
let rows = PHOTOS_PAGE.div_ceil(columns) as i32;
let height = f64::from(rows * (size + TILE_GAP) + 40);
assert!(viewport_unfilled(height, 880.0));
assert!(viewport_unfilled(880.0, 880.0));
assert!(!viewport_unfilled(880.0 * 3.0, 880.0));
}
#[test]
fn zooming_in_puts_fewer_tiles_in_a_row() {
assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0);
@@ -4440,6 +4707,7 @@ mod tests {
favorite: false,
group_size: 1,
has_raw: false,
place_id: None,
}
};
// Newest first, the way the daemon sends them.
+43 -9
View File
@@ -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: &gtk4::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);
}
+14 -5
View File
@@ -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")
+9 -2
View File
@@ -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.
+2
View File
@@ -3,6 +3,8 @@ pub(crate) mod crumb;
pub(crate) mod details;
pub(crate) mod file_list;
pub(crate) mod menu;
pub(crate) mod photo_drag;
pub(crate) mod player;
pub(crate) mod scrubber;
pub(crate) mod share_dialog;
pub(crate) mod thumbnails;
@@ -0,0 +1,237 @@
//! Dragging photos out of the timeline into a file manager or another app.
//!
//! A photo is not a file anywhere the receiver can read until it has been
//! downloaded, and a drag has to start the moment the pointer moves. So the
//! drag offers a list of files without having them: [`PhotoDrag`] downloads
//! the photos only when the receiver asks for the list, which it does once the
//! photos are dropped on it, and then hands over copies under their own names.
use std::future::Future;
use std::pin::Pin;
use std::time::{Duration, SystemTime};
use gtk4::gdk;
use gtk4::gdk::subclass::prelude::*;
use crate::*;
/// The one format a drag of photos offers. GTK turns it into a file list for
/// a receiver in this app, and every file manager reads it.
const URI_LIST: &str = "text/uri-list";
/// How long the copies handed to a receiver are kept. The receiver copies
/// them on drop, so a day is far more than it needs.
const KEEP_DRAGGED: Duration = Duration::from_secs(24 * 60 * 60);
mod imp {
use super::*;
#[derive(Default)]
pub struct PhotoDrag {
/// The photos dragged, as uid and Drive name.
pub(super) photos: RefCell<Vec<(String, Option<String>)>>,
pub(super) socket: RefCell<PathBuf>,
}
#[glib::object_subclass]
impl ObjectSubclass for PhotoDrag {
const NAME: &'static str = "PdfsPhotoDrag";
type Type = super::PhotoDrag;
type ParentType = gdk::ContentProvider;
}
impl ObjectImpl for PhotoDrag {}
impl ContentProviderImpl for PhotoDrag {
fn formats(&self) -> gdk::ContentFormats {
gdk::ContentFormats::new(&[URI_LIST])
}
fn write_mime_type_future(
&self,
mime_type: &str,
stream: &gio::OutputStream,
io_priority: glib::Priority,
) -> Pin<Box<dyn Future<Output = Result<(), glib::Error>> + 'static>> {
let photos = self.photos.borrow().clone();
let socket = self.socket.borrow().clone();
let stream = stream.clone();
let wanted = mime_type == URI_LIST;
Box::pin(async move {
if !wanted {
return Err(glib::Error::new(
gio::IOErrorEnum::NotSupported,
"photos are offered as a list of files only",
));
}
let files = fetch_dragged(socket, photos).await?;
let list: String = files
.iter()
.map(|path| format!("{}\r\n", gio::File::for_path(path).uri()))
.collect();
stream
.write_all_future(list.into_bytes(), io_priority)
.await
.map_err(|(_, e)| e)?;
Ok(())
})
}
}
}
glib::wrapper! {
/// The content of a drag of photos: their files, fetched when dropped.
pub struct PhotoDrag(ObjectSubclass<imp::PhotoDrag>)
@extends gdk::ContentProvider;
}
impl PhotoDrag {
pub(crate) fn new(socket: PathBuf, photos: Vec<(String, Option<String>)>) -> Self {
let drag: Self = glib::Object::new();
*drag.imp().photos.borrow_mut() = photos;
*drag.imp().socket.borrow_mut() = socket;
drag
}
}
/// Download each dragged photo and put a copy under its Drive name where the
/// receiver can read it. A photo that fails is left out; only when every one
/// fails does the drop fail.
async fn fetch_dragged(
socket: PathBuf,
photos: Vec<(String, Option<String>)>,
) -> Result<Vec<PathBuf>, glib::Error> {
let base = glib::user_cache_dir().join("pdfs-drag");
let mut files = Vec::new();
let mut last_error = None;
for (uid, name) in photos {
let rx = spawn_request(socket.clone(), Request::OpenPhoto { uid: uid.clone() });
let source = match rx.recv().await {
Ok(Ok(Response::FilePath { path })) => path,
Ok(Ok(Response::Error { message, .. })) => {
last_error = Some(message);
continue;
}
_ => {
last_error = Some("the Proton Drive service didn't respond".to_string());
continue;
}
};
let base = base.clone();
let copied = gio::spawn_blocking(move || {
prune_dragged(&base, SystemTime::now());
let name = name.unwrap_or_else(|| "photo".to_string());
hand_over(&base, Path::new(&source), &uid, &name)
})
.await;
match copied {
Ok(Ok(path)) => files.push(path),
Ok(Err(e)) => last_error = Some(e.to_string()),
Err(_) => last_error = Some("the copy was interrupted".to_string()),
}
}
if files.is_empty() {
let message = last_error.unwrap_or_else(|| "nothing to drop".to_string());
tracing::warn!("dragging photos out failed: {message}");
return Err(glib::Error::new(gio::IOErrorEnum::Failed, &message));
}
Ok(files)
}
/// Put the cached photo `source` at `base/<uid>/<name>`, where a receiver
/// copies it from.
///
/// A hard link when the cache is on the same file system, so a large video is
/// not copied twice; a copy otherwise. Not a symlink: a file manager copies a
/// symlink as a link, which would leave the user a link into the cache.
pub(crate) fn hand_over(
base: &Path,
source: &Path,
uid: &str,
name: &str,
) -> std::io::Result<PathBuf> {
let file_name = Path::new(name)
.file_name()
.ok_or_else(|| std::io::Error::other("the photo has no usable name"))?;
let dir = base.join(uid.replace('/', "_"));
std::fs::create_dir_all(&dir)?;
let dest = dir.join(file_name);
let _ = std::fs::remove_file(&dest);
if std::fs::hard_link(source, &dest).is_err() {
std::fs::copy(source, &dest)?;
}
Ok(dest)
}
/// Remove the copies handed over more than [`KEEP_DRAGGED`] before `now`.
pub(crate) fn prune_dragged(base: &Path, now: SystemTime) {
let Ok(entries) = std::fs::read_dir(base) else {
return;
};
for entry in entries.flatten() {
let old = entry
.metadata()
.and_then(|meta| meta.modified())
.is_ok_and(|modified| {
now.duration_since(modified)
.is_ok_and(|age| age > KEEP_DRAGGED)
});
if old {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("pdfs-drag-test-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_dragged_photo_is_handed_over_under_its_own_name() {
let dir = scratch("name");
let source = dir.join("0f3a9c");
std::fs::write(&source, b"jpeg bytes").unwrap();
let base = dir.join("out");
let path = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
assert_eq!(path, base.join("vol~link").join("IMG_0001.JPG"));
assert_eq!(std::fs::read(&path).unwrap(), b"jpeg bytes");
assert!(!path.is_symlink());
// Dragged again, it replaces the copy rather than failing on it.
std::fs::write(&source, b"new bytes").unwrap();
let again = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
assert_eq!(std::fs::read(again).unwrap(), b"new bytes");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_name_cannot_climb_out_of_its_folder() {
let dir = scratch("climb");
let source = dir.join("blob");
std::fs::write(&source, b"x").unwrap();
let base = dir.join("out");
let path = hand_over(&base, &source, "u", "../../escape.jpg").unwrap();
assert_eq!(path, base.join("u").join("escape.jpg"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn only_old_handovers_are_pruned() {
let dir = scratch("prune");
std::fs::create_dir_all(dir.join("fresh")).unwrap();
prune_dragged(&dir, SystemTime::now());
assert!(dir.join("fresh").exists());
prune_dragged(&dir, SystemTime::now() + KEEP_DRAGGED * 2);
assert!(!dir.join("fresh").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+348
View File
@@ -0,0 +1,348 @@
//! Playing videos and music inside the app.
//!
//! The pieces both players share live here: the one My files opens a video or
//! a song in ([`open_media_player`]) and the Photos lightbox. A GTK
//! [`gtk4::Video`] brings play, pause, a seek bar and the volume; this adds
//! fullscreen, the keys every video player answers to, a repeat switch and a
//! volume that carries over from one video to the next.
use crate::activation::{mounted_path, present_within};
use crate::*;
/// How far `J` and `L` (or Shift+←/→) jump.
const SEEK_LONG: i64 = 10;
/// How far ← and → jump in the My files player, where they don't move to
/// another photo.
const SEEK_SHORT: i64 = 5;
/// How much ↑ and ↓ change the volume in the My files player.
const VOLUME_STEP: f64 = 0.1;
thread_local! {
/// The volume and mute the user last set, applied to the next stream so
/// muting one video doesn't leave the next one blaring. Kept for the run
/// of the app only.
static LEVEL: Cell<Option<(f64, bool)>> = const { Cell::new(None) };
}
/// Where a jump of `delta_secs` from `now` lands, in microseconds, as a
/// [`gtk4::MediaStream`] counts. Kept inside the stream; a `duration` of 0
/// means the stream doesn't know its length yet, which bounds only the start.
pub(crate) fn seek_target(now: i64, duration: i64, delta_secs: i64) -> i64 {
let target = now
.saturating_add(delta_secs.saturating_mul(1_000_000))
.max(0);
if duration > 0 {
target.min(duration)
} else {
target
}
}
/// Jump the stream `delta_secs` forward, or back when negative.
pub(crate) fn seek_by(video: &gtk4::Video, delta_secs: i64) {
if let Some(stream) = video.media_stream()
&& stream.is_seekable()
{
stream.seek(seek_target(
stream.timestamp(),
stream.duration(),
delta_secs,
));
}
}
/// Play a paused stream, pause a playing one.
pub(crate) fn toggle_playing(video: &gtk4::Video) {
if let Some(stream) = video.media_stream() {
stream.set_playing(!stream.is_playing());
}
}
fn toggle_muted(video: &gtk4::Video) {
if let Some(stream) = video.media_stream() {
stream.set_muted(!stream.is_muted());
}
}
fn change_volume(video: &gtk4::Video, delta: f64) {
if let Some(stream) = video.media_stream() {
stream.set_muted(false);
stream.set_volume((stream.volume() + delta).clamp(0.0, 1.0));
}
}
/// Enter fullscreen, or leave it.
pub(crate) fn toggle_fullscreen(window: &gtk4::Window) {
if window.is_fullscreen() {
window.unfullscreen();
} else {
window.fullscreen();
}
}
/// The keys both players answer to the same way: `K` plays and pauses, `J` and
/// `L` (or Shift+← and Shift+→) jump ten seconds, `M` mutes. True when `key`
/// was one of them.
pub(crate) fn player_key(video: &gtk4::Video, key: &str, state: gtk4::gdk::ModifierType) -> bool {
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
let plain = !state
.intersects(gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::ALT_MASK);
if !plain {
return false;
}
match key {
"k" | "K" => toggle_playing(video),
"j" | "J" => seek_by(video, -SEEK_LONG),
"l" | "L" => seek_by(video, SEEK_LONG),
"Left" if shift => seek_by(video, -SEEK_LONG),
"Right" if shift => seek_by(video, SEEK_LONG),
"m" | "M" => toggle_muted(video),
_ => return false,
}
true
}
/// Make `video` carry the volume over between streams and toggle `window`'s
/// fullscreen on a double-click.
pub(crate) fn wire_player(video: &gtk4::Video, window: &gtk4::Window) {
video.connect_media_stream_notify(|video| {
let Some(stream) = video.media_stream() else {
return;
};
if let Some((volume, muted)) = LEVEL.get() {
stream.set_volume(volume);
stream.set_muted(muted);
}
let remember = |stream: &gtk4::MediaStream| {
LEVEL.set(Some((stream.volume(), stream.is_muted())));
};
stream.connect_volume_notify(remember);
stream.connect_muted_notify(remember);
});
// The controls' own buttons and seek bar claim their clicks, so this only
// sees a double-click on the picture.
let click = gtk4::GestureClick::new();
let window = window.downgrade();
click.connect_pressed(move |_, n_press, _, _| {
if n_press == 2
&& let Some(window) = window.upgrade()
{
toggle_fullscreen(&window);
}
});
video.add_controller(click);
}
/// A button that enters and leaves `window`'s fullscreen, showing which one
/// it will do.
pub(crate) fn fullscreen_button(window: &gtk4::Window) -> gtk4::Button {
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
let paint = |button: &gtk4::Button, fullscreen: bool| {
if fullscreen {
button.set_icon_name("view-restore-symbolic");
button.set_tooltip_text(Some(&gettext("Leave Fullscreen (F)")));
} else {
button.set_icon_name("view-fullscreen-symbolic");
button.set_tooltip_text(Some(&gettext("Fullscreen (F)")));
}
};
paint(&button, window.is_fullscreen());
let weak = button.downgrade();
window.connect_fullscreened_notify(move |window| {
if let Some(button) = weak.upgrade() {
paint(&button, window.is_fullscreen());
}
});
let target = window.downgrade();
button.connect_clicked(move |_| {
if let Some(window) = target.upgrade() {
toggle_fullscreen(&window);
}
});
button
}
/// Play a video or a song from My files in a window of its own, streamed from
/// the mount: the player reads the blocks it needs as it plays and seeks, so a
/// long video starts in seconds instead of after a full download. A file the
/// mount doesn't show, or the service is down, opens in the default
/// application instead, after a download.
pub(crate) fn open_media_player(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
let (ui, entry) = (ui.clone(), entry.clone());
glib::spawn_future_local(async move {
match present_within(mounted_path(&mountpoint, &rel)).await {
Some(path) => build_media_player(&ui, &entry, &path),
None => download_and_open(&ui, &entry),
}
});
}
fn build_media_player(ui: &Rc<Ui>, entry: &DirEntry, path: &Path) {
let window = adw::Window::builder()
.title(&entry.name)
.default_width(960)
.default_height(600)
.build();
if let Some(parent) = ui_window(ui) {
window.set_transient_for(Some(&parent));
}
window.add_css_class("media-player-window");
let gtk_window: gtk4::Window = window.clone().upcast();
let video = gtk4::Video::builder()
.autoplay(true)
.hexpand(true)
.vexpand(true)
.build();
wire_player(&video, &gtk_window);
// A song has no picture, which would leave a black window above the
// controls: a note stands in for it.
let note = gtk4::Image::builder()
.icon_name("audio-x-generic-symbolic")
.pixel_size(128)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.can_target(false)
.visible(false)
.build();
note.add_css_class("media-player-note");
let overlay = gtk4::Overlay::new();
overlay.set_child(Some(&video));
overlay.add_overlay(&note);
let failed = adw::StatusPage::builder()
.icon_name("dialog-warning-symbolic")
.title(gettext("Couldn't play this file"))
.build();
let open_with = gtk4::Button::builder()
.label(gettext("Open With…"))
.halign(gtk4::Align::Center)
.build();
open_with.add_css_class("pill");
open_with.add_css_class("suggested-action");
failed.set_child(Some(&open_with));
let stack = gtk4::Stack::new();
stack.add_named(&overlay, Some("player"));
stack.add_named(&failed, Some("failed"));
let header = adw::HeaderBar::new();
header.pack_end(&fullscreen_button(&gtk_window));
let mut menu = ActionMenu::new();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Open With…"), move || {
open_entry_with(&ui_c, &entry_c)
});
let video_c = video.clone();
// Translators: menu item of the video player that plays the file again
// and again.
menu.toggle(&gettext("Repeat"), false, move |on| video_c.set_loop(on));
header.pack_end(&menu.button());
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&stack));
// Fullscreen is the video alone, its own controls still at hand.
window
.bind_property("fullscreened", &toolbar, "reveal-top-bars")
.invert_boolean()
.sync_create()
.build();
window.set_content(Some(&toolbar));
let (ui_c, entry_c) = (ui.clone(), entry.clone());
open_with.connect_clicked(move |_| open_entry_with(&ui_c, &entry_c));
let (stack_c, failed_c, note_c) = (stack.clone(), failed.clone(), note.clone());
video.connect_media_stream_notify(move |video| {
let Some(stream) = video.media_stream() else {
return;
};
let (stack, failed) = (stack_c.clone(), failed_c.clone());
stream.connect_error_notify(move |stream| {
if let Some(error) = stream.error() {
tracing::warn!("in-app player failed: {error}");
failed.set_description(Some(&error.to_string()));
stack.set_visible_child_name("failed");
}
});
let note = note_c.clone();
stream.connect_prepared_notify(move |stream| {
note.set_visible(stream.is_prepared() && stream.has_audio() && !stream.has_video());
});
});
video.set_filename(Some(path));
let keys = gtk4::EventControllerKey::new();
let (video_k, window_k) = (video.clone(), gtk_window.downgrade());
keys.connect_key_pressed(move |_, key, _code, state| {
let Some(window) = window_k.upgrade() else {
return glib::Propagation::Proceed;
};
let Some(name) = key.name() else {
return glib::Propagation::Proceed;
};
// The open menu keeps its arrows and Enter.
let in_menu = gtk4::prelude::GtkWindowExt::focus(&window)
.is_some_and(|focus| focus.ancestor(gtk4::Popover::static_type()).is_some());
if in_menu {
return glib::Propagation::Proceed;
}
if player_key(&video_k, &name, state) {
return glib::Propagation::Stop;
}
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
match name.as_str() {
"space" => toggle_playing(&video_k),
"Left" => seek_by(&video_k, -SEEK_SHORT),
"Right" => seek_by(&video_k, SEEK_SHORT),
"Up" => change_volume(&video_k, VOLUME_STEP),
"Down" => change_volume(&video_k, -VOLUME_STEP),
"f" | "F11" => toggle_fullscreen(&window),
// Escape leaves fullscreen before it closes anything.
"Escape" if window.is_fullscreen() => window.unfullscreen(),
"Escape" | "q" => window.close(),
"w" if ctrl => window.close(),
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
});
// Capture, so the arrows and Space reach the player rather than moving the
// focus between its controls.
keys.set_propagation_phase(gtk4::PropagationPhase::Capture);
window.add_controller(keys);
// Stop the sound with the window, not when the window is finally freed.
let video_c = video.clone();
window.connect_close_request(move |_| {
video_c.set_media_stream(gtk4::MediaStream::NONE);
glib::Propagation::Proceed
});
window.present();
}
#[cfg(test)]
mod tests {
use super::*;
const SEC: i64 = 1_000_000;
#[test]
fn a_jump_stays_inside_the_video() {
assert_eq!(seek_target(30 * SEC, 60 * SEC, 10), 40 * SEC);
assert_eq!(seek_target(30 * SEC, 60 * SEC, -10), 20 * SEC);
assert_eq!(seek_target(5 * SEC, 60 * SEC, -10), 0);
assert_eq!(seek_target(55 * SEC, 60 * SEC, 10), 60 * SEC);
}
#[test]
fn a_jump_forward_in_a_stream_of_unknown_length_is_not_held_back() {
assert_eq!(seek_target(30 * SEC, 0, 10), 40 * SEC);
}
}
@@ -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.
+3
View File
@@ -24,6 +24,7 @@ const STOCK: &[&str] = &[
// GTK's built-in icons
"info-outline-symbolic",
// Adwaita
"audio-x-generic-symbolic",
"checkbox-checked-symbolic",
"checkbox-symbolic",
"computer-symbolic",
@@ -68,12 +69,14 @@ const STOCK: &[&str] = &[
"text-x-generic-symbolic",
"user-trash-symbolic",
"video-x-generic-symbolic",
"view-fullscreen-symbolic",
"view-grid-symbolic",
"view-list-symbolic",
"view-more-symbolic",
"view-paged-symbolic",
"view-pin-symbolic",
"view-refresh-symbolic",
"view-restore-symbolic",
"window-close-symbolic",
"zoom-fit-best-symbolic",
"zoom-in-symbolic",
+1
View File
@@ -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` |
+72
View File
@@ -110,6 +110,8 @@ a live account since.
| Entry | Title |
|---|---|
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
| [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 |
@@ -427,6 +429,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
+125 -23
View File
@@ -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,112 @@ 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.
- **Drag photos out of Photos.** Select photos, then drag one of them into a file manager, a chat
or any app that takes files. The photos download when dropped and arrive under their own
names. A drag that starts on a photo that is not selected still selects, as before.
- **Upload progress in Photos.** Uploading several photos at once shows "Uploading 3 of 12
items…" with a progress bar above the timeline, instead of a single toast at the start.
- **Videos and music play in the app.** Opening a video or a song in My files, by a double-click
or **Open**, plays it in a player window, streamed from the mount like before. **Open With…**
still hands it to another player, and the separate **Play** item is gone. When the file can't
be played (a codec GStreamer lacks), the window says so and offers **Open With…**. Both this
player and the one in Photos gained a fullscreen button, fullscreen on a double-click,
**Repeat** in the ⋮ menu, `K` to play and pause, `J` and `L` (or Shift+←/→) to jump
10 seconds, and `M` to mute. The volume carries over from one video to the next, and the
thumbnail strip in Photos stays off a video, where it covered the seek bar.
### 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).
- **Photos zoomed all the way out shows the whole timeline.** The first 200 photos fit on one
screen there, and with nothing to scroll no more were loaded. More now load whenever the photos
don't fill the window, also after a zoom or a resize.
- **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
View File
@@ -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
View File
@@ -102,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
the tag to update:
```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.
+1 -1
View File
@@ -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')
+1 -1
View File
@@ -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:.}
+1
View File
@@ -24,6 +24,7 @@ crates/pdfs-gui/src/app/widgets/compat.rs
crates/pdfs-gui/src/app/widgets/details.rs
crates/pdfs-gui/src/app/widgets/file_list.rs
crates/pdfs-gui/src/app/widgets/menu.rs
crates/pdfs-gui/src/app/widgets/player.rs
crates/pdfs-gui/src/app/widgets/share_dialog.rs
crates/pdfs-gui/src/app/widgets/thumbnails.rs
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
+875 -799
View File
File diff suppressed because it is too large. Load diff
+898 -822
View File
File diff suppressed because it is too large. Load diff
+875 -799
View File
File diff suppressed because it is too large. Load diff
+875 -799
View File
File diff suppressed because it is too large. Load diff
+875 -799
View File
File diff suppressed because it is too large. Load diff
+875 -799
View File
File diff suppressed because it is too large. Load diff
+859 -799
View File
File diff suppressed because it is too large. Load diff
+876 -799
View File
File diff suppressed because it is too large. Load diff
+875 -799
View File
File diff suppressed because it is too large. Load diff