persistent entity cache, streaming batche

This commit is contained in:
nvrl committed 2026-08-10 15:22:04 +02:00
1 parent 2336ad9e87
commit a5e03ad8da
33 files changed
+1936 -65

No files matched your search

+3
View File
@@ -18,6 +18,7 @@ use pages::trash::*;
use pages::verify::*;
use widgets::details::*;
use widgets::share_dialog::*;
use widgets::versions_dialog::*;
use std::cell::{Cell, RefCell};
@@ -424,6 +425,8 @@ fn build_window(app: &adw::Application) {
dates: gallery_widgets.dates.clone(),
date_ranges: RefCell::new(vec![None]),
range: Cell::new(None),
favorites_btn: gallery_widgets.favorites_btn.clone(),
favorites: Cell::new(false),
date_suppress: Cell::new(false),
loading: Cell::new(false),
width: Cell::new(0),
@@ -19,6 +19,11 @@ pub(crate) struct Viewer {
pub(crate) info_map: gtk4::Button,
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
pub(crate) coords: RefCell<Option<(f64, f64)>>,
/// The favourite toggle in the top bar, and whether it is being set from
/// the model rather than by the user — the same suppression the details pane
/// 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>,
/// uid of the photo currently on screen.
pub(crate) uid: RefCell<String>,
/// On-disk path of the full-size photo, once it has been downloaded.
@@ -41,6 +46,38 @@ pub(crate) struct ExifInfo {
pub(crate) coords: Option<(f64, f64)>,
}
/// Paint the favourite toggle without firing its handler.
pub(crate) fn show_favorite(viewer: &Rc<Viewer>, favorite: bool) {
viewer.favorite_suppress.set(true);
viewer.favorite.set_active(favorite);
set_favorite_icon(&viewer.favorite, favorite);
viewer.favorite_suppress.set(false);
}
/// Filled star for a favourite, outline for the rest.
fn set_favorite_icon(button: &gtk4::ToggleButton, favorite: bool) {
button.set_icon_name(if favorite {
"starred-symbolic"
} else {
"non-starred-symbolic"
});
}
/// Write a favourite change back into the loaded gallery page, so returning to
/// the grid (or reopening the photo) shows what was just set without a reload.
fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
let Some(idx) = find_photo_index(&ui.gallery.model, uid) else {
return;
};
let Some(boxed) = ui.gallery.model.item(idx).and_downcast::<BoxedAnyObject>() else {
return;
};
let mut item = boxed.borrow::<PhotoItem>().clone();
item.favorite = favorite;
ui.gallery.model.remove(idx);
ui.gallery.model.insert(idx, &BoxedAnyObject::new(item));
}
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
/// so the lightbox never opens on a blank screen, ask the daemon for the
/// full-size file, and swap it in — plus its EXIF — when it lands.
@@ -52,6 +89,14 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
*viewer.path.borrow_mut() = None;
clear_info(viewer);
// The favourite state comes from the timeline page the gallery already has,
// so the button is right on the first frame instead of after a round-trip.
let favorite = find_photo_index(&ui.gallery.model, &uid)
.and_then(|idx| ui.gallery.model.item(idx))
.and_downcast::<BoxedAnyObject>()
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite);
show_favorite(viewer, favorite);
// The thumbnail the gallery already decoded stands in for the full photo
// while it downloads: blurry for a moment beats black for a second.
match ui.gallery.photo_tex.borrow().get(&uid) {
@@ -450,6 +495,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_toggle.add_css_class("flat");
info_toggle.add_css_class("viewer-action-btn");
let favorite_btn = gtk4::ToggleButton::builder()
.icon_name("non-starred-symbolic")
.tooltip_text("Favourite")
.valign(gtk4::Align::Center)
.build();
favorite_btn.add_css_class("flat");
favorite_btn.add_css_class("viewer-action-btn");
let download_btn = action("document-save-symbolic", "Save a copy…");
let open_ext_btn = action("document-open-symbolic", "Open with another app");
let close_btn = action("window-close-symbolic", "Close (Esc)");
@@ -460,6 +513,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
top_bar.set_valign(gtk4::Align::Start);
top_bar.set_hexpand(true);
top_bar.append(&titles);
top_bar.append(&favorite_btn);
top_bar.append(&info_toggle);
top_bar.append(&download_btn);
top_bar.append(&open_ext_btn);
@@ -558,6 +612,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_revealer: info_revealer.clone(),
info_rows,
info_map: info_map.clone(),
favorite: favorite_btn.clone(),
favorite_suppress: Cell::new(false),
coords: RefCell::new(None),
uid: RefCell::new(initial_uid.clone()),
path: RefCell::new(None),
@@ -601,6 +657,44 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
}
});
let ui_fav = ui.clone();
let viewer_fav = viewer.clone();
favorite_btn.connect_toggled(move |btn| {
if viewer_fav.favorite_suppress.get() {
return;
}
let favorite = btn.is_active();
set_favorite_icon(btn, favorite);
let uid = viewer_fav.uid.borrow().clone();
let rx = spawn_request(
ui_fav.dirs.control_socket(),
Request::SetPhotoFavorite {
uid: uid.clone(),
favorite,
},
);
let ui_result = ui_fav.clone();
let viewer_result = viewer_fav.clone();
glib::spawn_future_local(async move {
let failed = match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
set_gallery_favorite(&ui_result, &uid, favorite);
None
}
Ok(Ok(Response::Error { message, .. })) => Some(message),
_ => Some("The mount service didn't respond.".to_string()),
};
if let Some(detail) = failed {
toast_error(&ui_result, "Couldn't change the favourite", &detail);
// The server refused, so the button must go back to describing
// what is actually stored — without firing this handler again.
if *viewer_result.uid.borrow() == uid {
show_favorite(&viewer_result, !favorite);
}
}
});
});
let w_download = window.clone();
let viewer_download = viewer.clone();
download_btn.connect_clicked(move |_| {
+30
View File
@@ -63,6 +63,10 @@ pub(crate) struct GalleryState {
/// The capture-time window the timeline is currently filtered to, or `None`
/// for the whole span. Read by [`load_gallery`], set by the date dropdown.
pub(crate) range: Cell<Option<(i64, i64)>>,
/// The favourites toggle, and whether it is on. When on, the timeline is
/// restricted to photos carrying Proton's `Favorite` tag.
pub(crate) favorites_btn: gtk4::ToggleButton,
pub(crate) favorites: Cell<bool>,
/// Set while the date dropdown is being repopulated, so resetting its model
/// doesn't fire the selection handler and kick off a spurious reload.
pub(crate) date_suppress: Cell<bool>,
@@ -188,6 +192,7 @@ pub(crate) struct GalleryWidgets {
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
/// to [`kind_for_tab`]).
pub(crate) tabs: [gtk4::ToggleButton; 4],
pub(crate) favorites_btn: gtk4::ToggleButton,
/// The date-jump dropdown, populated with the timeline's months.
pub(crate) dates: gtk4::DropDown,
}
@@ -314,6 +319,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
tab_group.append(btn);
}
// Favourites: a filter, not a tab — it cuts across Photos / Videos / Raw, so
// it stays outside the segmented control rather than becoming a fifth option
// that would silently drop the kind the user picked.
let favorites_btn = gtk4::ToggleButton::builder()
.icon_name("starred-symbolic")
.tooltip_text("Show only favourites")
.build();
favorites_btn.add_css_class("pill");
// Date jump: "All dates" plus a row per month, filled in once the timeline's
// months are known (see [`refresh_photo_months`]). Pushed to the far end of
// the filter row, opposite the kind toggles.
@@ -326,6 +340,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
filters.set_hexpand(true);
filters.append(&tab_group);
filters.append(&favorites_btn);
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
filters.append(&spacer);
@@ -415,6 +430,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
upload,
refresh,
tabs,
favorites_btn,
dates,
albums,
albums_stack,
@@ -679,6 +695,19 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::Sc
});
}
// Favourites: reload the timeline restricted to favourites (or back to all).
// Independent of the kind tabs and the date jump, both of which keep their
// current value across the toggle.
let ui_fav = ui.clone();
ui.gallery.favorites_btn.connect_toggled(move |btn| {
let on = btn.is_active();
if ui_fav.gallery.favorites.get() == on {
return;
}
ui_fav.gallery.favorites.set(on);
load_gallery(&ui_fav, false);
});
// Date jump: selecting a month loads that window; "All dates" (row 0) clears
// it. Skipped while the model is being repopulated (see `gallery_date_suppress`).
let ui_dates = ui.clone();
@@ -1378,6 +1407,7 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
limit: PHOTOS_PAGE,
kind: ui.gallery.kind.get(),
range: ui.gallery.range.get(),
favorites: ui.gallery.favorites.get(),
},
};
let rx = spawn_request(ui.dirs.control_socket(), request);
@@ -26,6 +26,8 @@ pub(crate) struct DetailsWidgets {
pub(crate) pin_row: adw::SwitchRow,
pub(crate) open_button: gtk4::Button,
pub(crate) rename_button: gtk4::Button,
/// Files only — folders have no revision history.
pub(crate) versions_button: gtk4::Button,
pub(crate) trash_button: gtk4::Button,
pub(crate) close_button: gtk4::Button,
}
@@ -96,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
open_button.add_css_class("pill");
let rename_button = gtk4::Button::builder().label("Rename").build();
rename_button.add_css_class("pill");
let versions_button = gtk4::Button::builder().label("Versions").build();
versions_button.add_css_class("pill");
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
trash_button.add_css_class("destructive-action");
trash_button.add_css_class("pill");
@@ -104,6 +108,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
actions.set_margin_top(6);
actions.append(&open_button);
actions.append(&rename_button);
actions.append(&versions_button);
actions.append(&trash_button);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
@@ -137,6 +142,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
pin_row,
open_button,
rename_button,
versions_button,
trash_button,
close_button,
},
@@ -190,6 +196,16 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
prompt_rename(&ui_rename, &entry);
}
});
let ui_versions = ui.clone();
ui.details
.details
.versions_button
.connect_clicked(move |_| {
let entry = ui_versions.details.details_entry.borrow().clone();
if let Some(entry) = entry {
open_versions_dialog(&ui_versions, &entry);
}
});
let ui_trash = ui.clone();
ui.details.details.trash_button.connect_clicked(move |_| {
let entry = ui_trash.details.details_entry.borrow().clone();
@@ -246,6 +262,9 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
d.pin_row.set_sensitive(*ui.mounted.borrow());
d.open_button
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
// Only files have revisions, and only a connected daemon can fetch them.
d.versions_button.set_visible(!entry.is_dir);
d.versions_button.set_sensitive(*ui.mounted.borrow());
ui.details.details_suppress.set(false);
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
+1
View File
@@ -1,2 +1,3 @@
pub(crate) mod details;
pub(crate) mod share_dialog;
pub(crate) mod versions_dialog;
@@ -0,0 +1,353 @@
//! The per-file **Versions** dialog: what Proton Drive still holds of a file's
//! earlier content, and the three things a user can do with one.
//!
//! Every row is one revision the server kept. Restoring re-points the file at an
//! old revision (server-side, so nothing is uploaded and the mount may take a
//! moment to catch up); saving writes an old revision out to a local file
//! without touching the live one; deleting is permanent, and the active revision
//! cannot be deleted at all — that row simply has no delete button.
use crate::*;
use pdfs_core::control::RevisionInfo;
/// How an open Versions dialog addresses its file — the same split as
/// [`ShareTarget`](super::share_dialog::ShareTarget), for the same reason: a
/// node reached from Shared or a device folder may have no path in the primary
/// mount's namespace, and an older daemon must reject the request rather than
/// resolve an empty path to the mount root.
pub(crate) enum VersionTarget {
Path(String),
Uid(String),
}
impl VersionTarget {
fn list(&self) -> Request {
match self {
VersionTarget::Path(path) => Request::ListRevisions { path: path.clone() },
VersionTarget::Uid(uid) => Request::ListRevisionsByUid { uid: uid.clone() },
}
}
fn restore(&self, revision_id: String) -> Request {
match self {
VersionTarget::Path(path) => Request::RestoreRevision {
path: path.clone(),
revision_id,
},
VersionTarget::Uid(uid) => Request::RestoreRevisionByUid {
uid: uid.clone(),
revision_id,
},
}
}
fn delete(&self, revision_id: String) -> Request {
match self {
VersionTarget::Path(path) => Request::DeleteRevision {
path: path.clone(),
revision_id,
},
VersionTarget::Uid(uid) => Request::DeleteRevisionByUid {
uid: uid.clone(),
revision_id,
},
}
}
fn save_as(&self, revision_id: String, dest: String) -> Request {
match self {
VersionTarget::Path(path) => Request::SaveRevisionAs {
path: path.clone(),
revision_id,
dest,
},
VersionTarget::Uid(uid) => Request::SaveRevisionAsByUid {
uid: uid.clone(),
revision_id,
dest,
},
}
}
}
/// The state behind an open Versions dialog, so an action can rebuild the list
/// in place instead of tearing the dialog down.
pub(crate) struct VersionsDialog {
pub(crate) ui: Rc<Ui>,
pub(crate) target: VersionTarget,
/// The file's name, for the save dialog's suggested filename and for toasts.
pub(crate) name: String,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
}
/// Open the Versions dialog for a file.
pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
if !*ui.mounted.borrow() {
toast_error(ui, "Can't load versions", "Proton Drive isn't connected.");
return;
}
if entry.is_dir {
toast_error(ui, "No versions", "Folders don't have versions.");
return;
}
let target = if entry.path.is_empty() {
VersionTarget::Uid(entry.uid.clone())
} else {
VersionTarget::Path(entry_rel(ui, entry))
};
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&adw::WindowTitle::new("Versions", &entry.name)));
toolbar.add_top_bar(&header);
let group = adw::PreferencesGroup::builder()
.title("Version history")
.description("Proton Drive keeps earlier versions of a file until you delete them.")
.build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
content.set_margin_top(12);
content.set_margin_bottom(12);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&group);
let clamp = adw::Clamp::builder().child(&content).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&clamp)
.build();
toolbar.set_content(Some(&scroll));
let dialog = adw::Dialog::builder()
.title("Versions")
.content_width(520)
.content_height(520)
.child(&toolbar)
.build();
let state = Rc::new(VersionsDialog {
ui: ui.clone(),
target,
name: entry.name.clone(),
group,
rows: RefCell::new(Vec::new()),
});
versions_dialog_reload(&state);
dialog.present(ui_window(ui).as_ref());
}
/// Re-fetch the file's revisions and rebuild the rows.
pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items),
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't load versions", &message)
}
_ => toast_error(
&state.ui,
"Couldn't load versions",
"The mount service didn't respond.",
),
}
});
}
/// Rebuild the version rows from a fresh listing.
fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
for row in state.rows.borrow_mut().drain(..) {
state.group.remove(&row);
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
if items.is_empty() {
let row = dim_row("No earlier versions.");
state.group.add(&row);
rows.push(row.upcast());
*state.rows.borrow_mut() = rows;
return;
}
for item in items {
// The uploader's claimed plaintext size is the one that matches what the
// user sees in a listing; the storage figure counts ciphertext.
let size = item
.claimed_size
.filter(|size| *size >= 0)
.unwrap_or(item.size_on_storage)
.max(0) as u64;
let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size));
if let Some(email) = &item.signed_by {
subtitle.push_str(" · ");
subtitle.push_str(email);
}
let title = if item.is_active {
"Current version".to_string()
} else {
format!("Version {}", item.id)
};
let row = adw::ActionRow::builder()
.title(title)
.subtitle(subtitle)
.build();
let save = gtk4::Button::builder()
.icon_name("document-save-symbolic")
.tooltip_text("Save a copy…")
.valign(gtk4::Align::Center)
.build();
save.add_css_class("flat");
let state_save = state.clone();
let id = item.id.clone();
save.connect_clicked(move |_| prompt_save_version(&state_save, &id));
row.add_suffix(&save);
if !item.is_active {
let restore = gtk4::Button::builder()
.icon_name("edit-undo-symbolic")
.tooltip_text("Restore this version")
.valign(gtk4::Align::Center)
.build();
restore.add_css_class("flat");
let state_restore = state.clone();
let id = item.id.clone();
restore.connect_clicked(move |_| prompt_restore_version(&state_restore, &id));
row.add_suffix(&restore);
let delete = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Delete this version permanently")
.valign(gtk4::Align::Center)
.build();
delete.add_css_class("flat");
let state_delete = state.clone();
let id = item.id.clone();
delete.connect_clicked(move |_| prompt_delete_version(&state_delete, &id));
row.add_suffix(&delete);
}
state.group.add(&row);
rows.push(row.upcast());
}
*state.rows.borrow_mut() = rows;
}
/// Confirm, then restore. Confirmed because it replaces the file's content for
/// every device on the account, not only this one.
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder()
.heading("Restore version")
.body(format!(
"Make this version the current content of “{}”? The version it replaces stays in the history.",
state.name
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("restore", "Restore");
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let window = ui_window(&state.ui);
let state = state.clone();
let revision_id = revision_id.to_string();
dialog.connect_response(None, move |_, resp| {
if resp != "restore" {
return;
}
versions_dialog_op(
&state,
state.target.restore(revision_id.clone()),
// The server applies the swap in the background, so the wording
// promises the request, not the result.
"Restoring that version",
"Couldn't restore that version",
);
});
dialog.present(window.as_ref());
}
/// Confirm, then permanently delete one revision.
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder()
.heading("Delete version")
.body("Delete this version permanently? Its content can't be recovered.")
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("delete", "Delete");
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let window = ui_window(&state.ui);
let state = state.clone();
let revision_id = revision_id.to_string();
dialog.connect_response(None, move |_, resp| {
if resp != "delete" {
return;
}
versions_dialog_op(
&state,
state.target.delete(revision_id.clone()),
"Version deleted",
"Couldn't delete that version",
);
});
dialog.present(window.as_ref());
}
/// Ask where to write an old version, then have the daemon download it there.
///
/// The daemon writes the file, so the chosen location has to be one it can see:
/// a path from the portal-free `FileDialog` is a real filesystem path, which it
/// can.
fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let window = ui_window(&state.ui);
let dialog = gtk4::FileDialog::builder()
.title("Save version")
.initial_name(&state.name)
.build();
let state = state.clone();
let revision_id = revision_id.to_string();
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(file) = res else { return };
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
toast_error(
&state.ui,
"Couldn't save that version",
"That location isn't a local file.",
);
return;
};
versions_dialog_op(
&state,
state.target.save_as(revision_id.clone(), dest),
"Version saved",
"Couldn't save that version",
);
});
}
/// Send one version request, toast the outcome, and refresh the list.
fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str, failed: &str) {
let rx = spawn_request(state.ui.dirs.control_socket(), request);
let state = state.clone();
let done = done.to_string();
let failed = failed.to_string();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
toast(&state.ui, &done);
versions_dialog_reload(&state);
}
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
_ => toast_error(&state.ui, &failed, "The mount service didn't respond."),
}
});
}