feat(core,fuse,gui): find and trash duplicate photos

A "Find duplicates" button in the Photos header opens a review of every
set of files that share a content hash. Each set shows its copies side by
side with a Keep toggle; the copy in the most albums, then in the larger
RAW+JPEG group, then the oldest, is kept by default, and a set can't lose
its last kept copy. The copies not kept move to Trash one file at a time.

- core: Db::photo_duplicates, Request::PhotoDuplicates, and a files_only
  flag on TrashNodes (defaults to false, so old clients still trash whole
  groups).
- core: deleting a group's representative now clears the group key of its
  remaining files instead of leaving them pointing at a missing uid.
- fuse: handlers for both requests.
This commit is contained in:
nvrl committed 2026-09-28 02:12:59 +02:00
1 parent 7dbf818d83
commit 2f14c1688f
20 files changed
+2475 -1579

No files matched your search

+24 -2
View File
@@ -88,6 +88,9 @@ pub enum Request {
/// `limit` of them — the "On this day" strip above the timeline. Replies
/// with [`Response::Photos`]; a front-end groups them by year itself.
PhotosOnThisDay { limit: usize },
/// Sets of photos that are byte-identical copies of each other, going by
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
PhotoDuplicates,
/// List the account's photo albums, newest activity first, including the
/// albums other people share with us. Metadata only — an album's cover
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
@@ -134,8 +137,14 @@ pub enum Request {
///
/// A photo that belongs to a group takes the whole group with it: trashing
/// the JPEG of a shot and leaving its RAW behind is not what anyone means by
/// deleting that photo.
TrashNodes { uids: Vec<String> },
/// deleting that photo. With `files_only` set, exactly the listed files
/// go and their groups stay: the duplicate finder removes one copy of a
/// shot, not the shot. Older front-ends omit it and trash whole groups.
TrashNodes {
uids: Vec<String>,
#[serde(default)]
files_only: bool,
},
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
/// timeline. The modification time is the cache validity tag, so replacing
/// an image can never reuse its previous revision's thumbnail.
@@ -1784,6 +1793,9 @@ pub enum Response {
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
/// newest first.
PhotoMonths { months: Vec<PhotoMonth> },
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
/// first. Each set lists the copy worth keeping first.
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> },
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
@@ -2180,7 +2192,17 @@ mod tests {
fn trash_requests_and_outcomes_roundtrip() {
let request = Request::TrashNodes {
uids: vec!["volume~first".into(), "volume~second".into()],
files_only: false,
};
// An older front-end's request trashes whole groups.
let old: Request = serde_json::from_str(r#"{"TrashNodes":{"uids":["v~a"]}}"#).unwrap();
assert!(matches!(
old,
Request::TrashNodes {
files_only: false,
..
}
));
let line = serde_json::to_string(&request).unwrap();
assert!(!line.contains('\n'), "wire form must be a single line");
let decoded: Request = serde_json::from_str(&line).unwrap();
+60 -1
View File
@@ -3,7 +3,7 @@
use rusqlite::params;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use super::Db;
use crate::Result;
@@ -447,6 +447,14 @@ impl Db {
albums.execute([uid])?;
}
}
// A group whose representative went is left pointing at nothing, which
// drops its other files off every page. Make them stand alone until the
// next refresh groups them again.
tx.execute(
"UPDATE photos SET group_key = NULL WHERE group_key IS NOT NULL \
AND group_key NOT IN (SELECT uid FROM photos)",
[],
)?;
tx.commit()?;
Ok(removed)
}
@@ -609,6 +617,57 @@ impl Db {
Ok(rows)
}
/// Sets of photos with the same content hash, each set ordered with the
/// copy worth keeping first, and the sets newest first.
///
/// The copy to keep is the one in the most albums, then the one that is part
/// of a larger group (the JPEG's RAW is filed with it, a stray re-upload is
/// not), then the earliest in server order. At most one file per group is
/// listed: the files of one RAW+JPEG shot are not copies of each other, and a
/// set left with one member is no set.
pub fn photo_duplicates(&self) -> Result<Vec<Vec<StoredPhoto>>> {
let rows: Vec<(String, String, String)> = {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT p.uid, p.content_hash, COALESCE(p.group_key, p.uid) AS k FROM photos p \
WHERE p.content_hash IN (SELECT content_hash FROM photos \
WHERE content_hash IS NOT NULL GROUP BY content_hash HAVING COUNT(*) > 1) \
ORDER BY p.content_hash, \
(SELECT COUNT(*) FROM album_photos a WHERE a.uid = p.uid) DESC, \
(SELECT COUNT(*) FROM photos m WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid)) DESC, \
p.seq",
)?;
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<rusqlite::Result<_>>()?
};
let uids: Vec<String> = rows.iter().map(|(uid, _, _)| uid.clone()).collect();
let mut stored: HashMap<String, StoredPhoto> = self
.photos_by_uid(&uids)?
.into_iter()
.map(|photo| (photo.uid.clone(), photo))
.collect();
let mut sets: Vec<Vec<StoredPhoto>> = Vec::new();
let mut hash_of_set: Option<&str> = None;
let mut groups_in_set: HashSet<&str> = HashSet::new();
for (uid, hash, group) in &rows {
if hash_of_set != Some(hash.as_str()) {
hash_of_set = Some(hash);
groups_in_set.clear();
sets.push(Vec::new());
}
if !groups_in_set.insert(group) {
continue;
}
if let (Some(set), Some(photo)) = (sets.last_mut(), stored.remove(uid)) {
set.push(photo);
}
}
sets.retain(|set| set.len() > 1);
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
Ok(sets)
}
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
use crate::control::PhotoKind;
+58
View File
@@ -285,6 +285,64 @@ fn on_this_day_finds_the_same_date_in_earlier_years() {
);
}
fn hashed(uid: &str, capture_time: i64, name: &str, hash: &str) -> TimelineRow {
TimelineRow {
name: Some(name.into()),
content_hash: Some(hash.into()),
..TimelineRow::new(uid, capture_time)
}
}
/// A copy an album holds is the one kept; the files of one RAW+JPEG shot are
/// never copies of each other; a hash only one file carries is no set.
#[test]
fn duplicates_list_the_copy_to_keep_first() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
hashed("copy", 500, "IMG_2.JPG", "h2"),
hashed("filed", 400, "IMG_2(1).JPG", "h2"),
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
hashed("raw", 300, "IMG_1.CR2", "h1"),
hashed("stray", 200, "IMG_1(1).CR2", "h1"),
hashed("alone", 100, "IMG_3.JPG", "h3"),
])
.unwrap();
db.album_photos_replace("a1", &[("filed".into(), 400, None, None)])
.unwrap();
let sets = db.photo_duplicates().unwrap();
let uids: Vec<Vec<&str>> = sets
.iter()
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
.collect();
assert_eq!(uids, [vec!["filed", "copy"], vec!["jpeg", "stray"]]);
}
/// Trashing the file a group is shown as leaves the rest of the group on the
/// timeline instead of pointing at a photo that is gone.
#[test]
fn deleting_a_group_representative_keeps_its_other_files() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
hashed("raw", 300, "IMG_1.CR2", "h2"),
])
.unwrap();
assert_eq!(
db.photos_page(0, 10, None, None, false, false)
.unwrap()
.len(),
1
);
db.photos_delete(&["jpeg".into()]).unwrap();
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["raw"]
);
}
#[test]
fn not_in_album_hides_photos_any_album_holds() {
let db = Db::open_in_memory().unwrap();
+6 -2
View File
@@ -461,6 +461,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PhotoDuplicates) => match core.photo_duplicates() {
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PhotosOnThisDay { limit }) => {
match core.photos_on_this_day(clamp_limit(limit)) {
Ok(items) => CtlResponse::Photos {
@@ -564,8 +568,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
items: core.photo_thumbs(&parsed),
}
}
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) {
Ok(parsed) => match core.trash_photos(&parsed) {
Ok(CtlRequest::TrashNodes { uids, files_only }) => match Core::parse_uids(&uids) {
Ok(parsed) => match core.trash_photos(&parsed, files_only) {
Ok((trashed, failed)) => CtlResponse::Trashed {
trashed,
failed: failed
+24 -3
View File
@@ -729,6 +729,16 @@ impl Core {
Ok(())
}
/// The timeline's duplicate sets, as wire items (see
/// [`db::Db::photo_duplicates`]).
pub(crate) fn photo_duplicates(&self) -> CoreResult<Vec<Vec<PhotoItem>>> {
let sets = self.db.photo_duplicates().map_err(CoreError::from)?;
Ok(sets
.into_iter()
.map(|set| set.into_iter().map(|p| self.photo_item(p)).collect())
.collect())
}
/// Photos taken on today's date in earlier years, as wire items. Served
/// from the stored timeline only: the strip is a nicety, not worth a wait
/// on the network, and the timeline's own load keeps that store fresh.
@@ -822,12 +832,23 @@ impl Core {
/// A photo that is one file of a group takes its whole group with it. The
/// gallery shows a RAW+JPEG shot as one tile, so deleting that tile has to
/// delete the shot — leaving the RAW behind would put the photo back on the
/// Raw tab and nowhere else.
pub(crate) fn trash_photos(&self, uids: &[NodeUid]) -> CoreResult<TrashOutcome> {
/// Raw tab and nowhere else. `files_only` turns that off, for removing
/// one copy of a shot that is stored twice.
pub(crate) fn trash_photos(
&self,
uids: &[NodeUid],
files_only: bool,
) -> CoreResult<TrashOutcome> {
if uids.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
let uids = &self.expand_photo_groups(uids);
let expanded;
let uids = if files_only {
uids
} else {
expanded = self.expand_photo_groups(uids);
&expanded
};
for uid in uids {
self.require_photo_writable(uid)
.map_err(|errno| self.errno_error(errno, "trash access"))?;
+5
View File
@@ -9,6 +9,7 @@ use pages::activity::*;
use pages::albums::*;
use pages::browser::*;
use pages::devices::*;
use pages::duplicates::*;
use pages::locations::*;
use pages::login::*;
use pages::photo_viewer::*;
@@ -726,6 +727,10 @@ fn build_window(app: &adw::Application) {
&gallery_widgets.empty_upload,
&gallery_widgets.empty_import,
);
let ui_duplicates = ui.clone();
gallery_widgets
.duplicates
.connect_clicked(move |_| show_duplicates(&ui_duplicates));
wire_albums(&ui);
wire_trash(&ui, &trash_widgets);
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
+246
View File
@@ -0,0 +1,246 @@
use crate::*;
/// Side of a copy's thumbnail in the duplicates dialog, in px.
const COPY_SIZE: i32 = 150;
/// Each set's copies with their Keep toggles.
type SetToggles = Vec<Vec<(PhotoItem, gtk4::ToggleButton)>>;
/// Open the duplicate finder: every set of photos stored more than once, side
/// by side, with the copy worth keeping ticked. The copies left unticked go to
/// Trash — one file each, never the rest of their shot.
pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
let status = adw::StatusPage::builder()
.icon_name("edit-copy-symbolic")
.title(gettext("Looking for duplicates…"))
.vexpand(true)
.build();
status.add_css_class("compact");
let stack = gtk4::Stack::new();
stack.add_named(&status, Some("status"));
let trash = gtk4::Button::builder()
.label(gettext("Move to Trash"))
.halign(gtk4::Align::End)
.sensitive(false)
.build();
trash.add_css_class("destructive-action");
trash.add_css_class("pill");
let bottom = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
bottom.add_css_class("toolbar");
bottom.set_halign(gtk4::Align::End);
bottom.append(&trash);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&adw::HeaderBar::new());
toolbar.set_content(Some(&stack));
toolbar.add_bottom_bar(&bottom);
toolbar.set_reveal_bottom_bars(false);
let dialog = adw::Dialog::builder()
.title(gettext("Duplicates"))
.content_width(760)
.content_height(640)
.child(&toolbar)
.build();
dialog.present(ui_window(ui).as_ref());
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoDuplicates);
let ui = ui.clone();
glib::spawn_future_local(async move {
let sets = match rx.recv().await {
Ok(Ok(Response::PhotoDuplicates { sets })) => sets,
Ok(Ok(Response::Error { message, .. })) => {
status.set_icon_name(Some("dialog-warning-symbolic"));
status.set_title(&gettext("Couldn't look for duplicates"));
status.set_description(Some(&message));
return;
}
_ => {
status.set_icon_name(Some("dialog-warning-symbolic"));
status.set_title(&gettext("Couldn't look for duplicates"));
status.set_description(Some(&gettext("The mount service didn't respond.")));
return;
}
};
if sets.is_empty() {
status.set_icon_name(Some("emblem-ok-symbolic"));
status.set_title(&gettext("No duplicates"));
status.set_description(Some(&gettext(
"Every photo in your library is stored once.",
)));
return;
}
let review = duplicate_review(&ui, sets, &trash);
stack.add_named(&review, Some("review"));
stack.set_visible_child_name("review");
toolbar.set_reveal_bottom_bars(true);
schedule_thumbs(&ui);
});
}
/// The review list: one section per set, each copy a thumbnail with a Keep
/// toggle. Wires `trash` to move every copy not kept to Trash.
fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: &gtk4::Button) -> gtk4::Widget {
let intro = gtk4::Label::builder()
.label(gettext(
"These photos are stored more than once. The copies you don't keep move to Trash, where you can restore them.",
))
.wrap(true)
.xalign(0.0)
.build();
intro.add_css_class("dim-label");
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
content.set_margin_top(12);
content.set_margin_bottom(12);
content.set_margin_start(18);
content.set_margin_end(18);
content.append(&intro);
// Every copy's toggle, by set, so the button can count what goes.
let toggles: Rc<RefCell<SetToggles>> = Rc::new(RefCell::new(Vec::new()));
for set in sets {
let Some(first) = set.first() else {
continue;
};
let date = section_heading(first.capture_time, Grouping::Day);
// Translators: heading of one set in the duplicates dialog; {date} is the day the photo was taken.
let title = ngettext_f(
"{date}, {n} copy",
"{date}, {n} copies",
set.len() as u64,
&[("date", &date)],
);
let heading = gtk4::Label::builder().label(title).xalign(0.0).build();
heading.add_css_class("heading");
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
let mut set_toggles = Vec::new();
for (index, photo) in set.into_iter().enumerate() {
let (card, keep) = copy_card(ui, &photo, index == 0);
row.append(&card);
set_toggles.push((photo, keep));
}
let row_scroll = gtk4::ScrolledWindow::builder()
.vscrollbar_policy(gtk4::PolicyType::Never)
.propagate_natural_height(true)
.child(&row)
.build();
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
section.append(&heading);
section.append(&row_scroll);
content.append(&section);
toggles.borrow_mut().push(set_toggles);
}
let sync = {
let toggles = toggles.clone();
let trash = trash.clone();
move || {
let n = doomed(&toggles.borrow()).len();
trash.set_label(&ngettext_f(
"Move {n} copy to Trash",
"Move {n} copies to Trash",
n as u64,
&[],
));
trash.set_sensitive(n > 0);
}
};
sync();
for set in toggles.borrow().iter() {
for (_, keep) in set {
let toggles = toggles.clone();
let sync = sync.clone();
keep.connect_toggled(move |keep| {
// A set always keeps a copy: unticking the last one would trash
// the photo itself, which is not what removing duplicates means.
if !keep.is_active()
&& toggles.borrow().iter().any(|set| {
set.iter().any(|(_, t)| t == keep)
&& set.iter().all(|(_, t)| !t.is_active())
})
{
keep.set_active(true);
return;
}
sync();
});
}
}
let ui_trash = ui.clone();
trash.connect_clicked(move |button| {
let uids = doomed(&toggles.borrow());
if uids.is_empty() {
return;
}
trash_photos(&ui_trash, uids, true);
if let Some(dialog) = button
.ancestor(adw::Dialog::static_type())
.and_downcast::<adw::Dialog>()
{
dialog.close();
}
});
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vexpand(true)
.child(&content)
.build();
scroll.upcast()
}
/// The uids of every copy not kept.
fn doomed(toggles: &SetToggles) -> Vec<String> {
toggles
.iter()
.flatten()
.filter(|(_, keep)| !keep.is_active())
.map(|(photo, _)| photo.uid.clone())
.collect()
}
/// One copy: its thumbnail, its file name, and whether to keep it.
fn copy_card(ui: &Rc<Ui>, photo: &PhotoItem, keep: bool) -> (gtk4::Box, gtk4::ToggleButton) {
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Cover)
.can_shrink(true)
.build();
let placeholder = gtk4::Image::builder()
.icon_name("image-x-generic-symbolic")
.pixel_size(24)
.build();
placeholder.add_css_class("photo-placeholder");
let overlay = gtk4::Overlay::new();
overlay.set_size_request(COPY_SIZE, COPY_SIZE);
overlay.set_child(Some(&placeholder));
overlay.add_overlay(&picture);
overlay.set_overflow(gtk4::Overflow::Hidden);
overlay.add_css_class("memory-cover");
want_thumb(ui, photo, &picture);
let name = photo.name.clone().unwrap_or_default();
let label = gtk4::Label::builder()
.label(&name)
.tooltip_text(&name)
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
.max_width_chars(18)
.build();
label.add_css_class("caption");
let toggle = gtk4::ToggleButton::builder()
.label(gettext("Keep"))
.active(keep)
.build();
toggle.add_css_class("pill");
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
card.set_size_request(COPY_SIZE, -1);
card.append(&overlay);
card.append(&label);
card.append(&toggle);
(card, toggle)
}
+1
View File
@@ -2,6 +2,7 @@ pub(crate) mod activity;
pub(crate) mod albums;
pub(crate) mod browser;
pub(crate) mod devices;
pub(crate) mod duplicates;
pub(crate) mod locations;
pub(crate) mod login;
pub(crate) mod photo_viewer;
@@ -1447,7 +1447,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
delete_btn.connect_clicked(move |_| {
let uid = viewer_delete.uid.borrow().clone();
w_delete.close();
trash_photos(&ui_delete, vec![uid]);
trash_photos(&ui_delete, vec![uid], false);
});
let ui_group = ui.clone();
@@ -1573,7 +1573,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
Some("Delete" | "KP_Delete") => {
let uid = viewer_key.uid.borrow().clone();
w_key.close();
trash_photos(&ui_key, vec![uid]);
trash_photos(&ui_key, vec![uid], false);
}
Some("Escape" | "q") => w_key.close(),
Some("w") if ctrl => w_key.close(),
+21 -3
View File
@@ -361,6 +361,7 @@ pub(crate) struct GalleryWidgets {
pub(crate) empty_upload: gtk4::Button,
pub(crate) empty_import: gtk4::Button,
pub(crate) refresh: gtk4::Button,
pub(crate) duplicates: gtk4::Button,
/// The Select toggle and the bar it reveals.
pub(crate) select_btn: gtk4::ToggleButton,
pub(crate) select_bar: gtk4::Revealer,
@@ -520,6 +521,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
import.add_css_class("circular");
let refresh = refresh_button();
// The duplicate finder opens over the page: a review, not a view of it.
let duplicates = gtk4::Button::builder()
.icon_name("edit-copy-symbolic")
.tooltip_text(gettext("Find duplicates"))
.valign(gtk4::Align::Center)
.build();
duplicates.add_css_class("flat");
duplicates.add_css_class("circular");
// Picking photos is a mode, so its control is a toggle rather than a button.
let select_btn = gtk4::ToggleButton::builder()
.icon_name("selection-mode-symbolic")
@@ -749,6 +759,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
header.pack_end(&refresh);
header.pack_end(&select_btn);
header.pack_end(&import);
header.pack_end(&duplicates);
(
frame.upcast(),
@@ -770,6 +781,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
empty_upload,
empty_import,
refresh,
duplicates,
select_btn,
select_bar,
select_label,
@@ -1849,7 +1861,7 @@ pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
let ui = ui.clone();
dialog.connect_response(None, move |_, response| {
if response == "trash" {
trash_photos(&ui, uids.clone());
trash_photos(&ui, uids.clone(), false);
}
});
dialog.present(win.as_ref());
@@ -1861,13 +1873,19 @@ pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
/// photos the user just deleted until a refresh lands — reads as a failure. What
/// the server refuses comes back, so the grid still ends up telling the truth,
/// and what succeeded is one Undo away for as long as the toast is up.
pub(crate) fn trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
///
/// `files_only` trashes exactly `uids` and leaves the rest of their groups, for
/// the duplicate finder; otherwise each photo takes its whole group along.
pub(crate) fn trash_photos(ui: &Rc<Ui>, uids: Vec<String>, files_only: bool) {
let removed = remove_photos(ui, &uids);
set_selection_mode(ui, false);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::TrashNodes { uids: uids.clone() },
Request::TrashNodes {
uids: uids.clone(),
files_only,
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
+1
View File
@@ -4,6 +4,7 @@ crates/pdfs-gui/src/app/pages/activity.rs
crates/pdfs-gui/src/app/pages/albums.rs
crates/pdfs-gui/src/app/pages/browser.rs
crates/pdfs-gui/src/app/pages/devices.rs
crates/pdfs-gui/src/app/pages/duplicates.rs
crates/pdfs-gui/src/app/pages/locations.rs
crates/pdfs-gui/src/app/pages/login.rs
crates/pdfs-gui/src/app/pages/photo_viewer.rs
+225 -174
View File
File diff suppressed because it is too large. Load diff
+225 -174
View File
File diff suppressed because it is too large. Load diff
+225 -174
View File
File diff suppressed because it is too large. Load diff
+225 -174
View File
File diff suppressed because it is too large. Load diff
+225 -174
View File
File diff suppressed because it is too large. Load diff
+225 -174
View File
File diff suppressed because it is too large. Load diff
+225 -174
View File
File diff suppressed because it is too large. Load diff
+227 -174
View File
File diff suppressed because it is too large. Load diff
+225 -174
View File
File diff suppressed because it is too large. Load diff