mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 17:32:00 +01:00
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.
This commit is contained in:
1 parent
41212efdf3
commit
4b669168ee
14 files changed
+2410
-1965
No files matched your search
@@ -40,6 +40,7 @@ use widgets::crumb::*;
|
|||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::file_list::*;
|
use widgets::file_list::*;
|
||||||
use widgets::menu::*;
|
use widgets::menu::*;
|
||||||
|
use widgets::photo_drag::*;
|
||||||
use widgets::scrubber::*;
|
use widgets::scrubber::*;
|
||||||
use widgets::share_dialog::*;
|
use widgets::share_dialog::*;
|
||||||
use widgets::thumbnails::*;
|
use widgets::thumbnails::*;
|
||||||
@@ -525,6 +526,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
jump: Cell::new(None),
|
jump: Cell::new(None),
|
||||||
burst: Cell::new(false),
|
burst: Cell::new(false),
|
||||||
import_banner: gallery_widgets.import_banner.clone(),
|
import_banner: gallery_widgets.import_banner.clone(),
|
||||||
|
upload_bar: gallery_widgets.upload_bar.clone(),
|
||||||
upload: gallery_widgets.upload.clone(),
|
upload: gallery_widgets.upload.clone(),
|
||||||
empty_actions: gallery_widgets.empty_actions.clone(),
|
empty_actions: gallery_widgets.empty_actions.clone(),
|
||||||
title: gallery_widgets.title.clone(),
|
title: gallery_widgets.title.clone(),
|
||||||
@@ -2036,6 +2038,13 @@ mod tests {
|
|||||||
assert!(next_group_member(&[], "jpeg").is_none());
|
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
|
/// The pill names what kind of shot it switches through, and lights up
|
||||||
/// on the file that is not the shot's main one.
|
/// on the file that is not the shot's main one.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) burst: Cell<bool>,
|
pub(crate) burst: Cell<bool>,
|
||||||
/// Says a Google Photos import is running, with a way to its page.
|
/// Says a Google Photos import is running, with a way to its page.
|
||||||
pub(crate) import_banner: adw::Banner,
|
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,
|
pub(crate) upload: adw::SplitButton,
|
||||||
/// Upload / Import offered on the *empty timeline* status page, so a fresh
|
/// 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
|
/// account is a place to start rather than a dead end. Hidden on every other
|
||||||
@@ -478,6 +481,9 @@ pub(crate) struct GalleryWidgets {
|
|||||||
pub(crate) scrubber: Scrubber,
|
pub(crate) scrubber: Scrubber,
|
||||||
/// Says a Google Photos import is running, with a way to its page.
|
/// Says a Google Photos import is running, with a way to its page.
|
||||||
pub(crate) import_banner: adw::Banner,
|
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) list: gtk4::ListView,
|
||||||
pub(crate) scroll: gtk4::ScrolledWindow,
|
pub(crate) scroll: gtk4::ScrolledWindow,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
@@ -903,6 +909,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
.action_name("win.show-import")
|
.action_name("win.show-import")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
let upload_bar = UploadBar::new();
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(12);
|
inner.set_margin_top(12);
|
||||||
inner.set_margin_bottom(12);
|
inner.set_margin_bottom(12);
|
||||||
@@ -913,6 +921,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
|
|
||||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
body.append(&import_banner);
|
body.append(&import_banner);
|
||||||
|
body.append(&upload_bar.revealer);
|
||||||
body.append(&inner);
|
body.append(&inner);
|
||||||
// The title slot holds the view switch on the page itself, the album's or
|
// 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
|
// place's name inside one, and the count in selection mode (see
|
||||||
@@ -943,6 +952,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
pager,
|
pager,
|
||||||
scrubber,
|
scrubber,
|
||||||
import_banner,
|
import_banner,
|
||||||
|
upload_bar,
|
||||||
list,
|
list,
|
||||||
scroll,
|
scroll,
|
||||||
retry,
|
retry,
|
||||||
@@ -1513,9 +1523,11 @@ pub(crate) fn wire_gallery(
|
|||||||
gtk4::gdk::DragAction::COPY,
|
gtk4::gdk::DragAction::COPY,
|
||||||
);
|
);
|
||||||
let ui_drop = ui.clone();
|
let ui_drop = ui.clone();
|
||||||
drop.connect_accept(move |_, _| {
|
drop.connect_accept(move |_, drop| {
|
||||||
let gallery = &ui_drop.gallery;
|
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.view_switch.active() == VIEW_TIMELINE
|
||||||
&& gallery.library.get()
|
&& gallery.library.get()
|
||||||
&& *ui_drop.mounted.borrow()
|
&& *ui_drop.mounted.borrow()
|
||||||
@@ -1913,6 +1925,86 @@ pub(crate) fn copy_without_overwrite(
|
|||||||
unreachable!("some numbered name is free")
|
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
|
/// Upload photos and videos from this computer into the timeline, one after
|
||||||
/// the other, then reload it once.
|
/// the other, then reload it once.
|
||||||
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
||||||
@@ -1927,15 +2019,7 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
|||||||
let total = uploads.len();
|
let total = uploads.len();
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
if total > 1 {
|
if total > 1 {
|
||||||
toast(
|
ui.gallery.upload_bar.add(total);
|
||||||
ui,
|
|
||||||
&ngettext_f(
|
|
||||||
"Uploading {n} item…",
|
|
||||||
"Uploading {n} items…",
|
|
||||||
total as u64,
|
|
||||||
&[],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -1973,6 +2057,9 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if total > 1 {
|
||||||
|
ui.gallery.upload_bar.finish();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
if uploaded > 0 {
|
if uploaded > 0 {
|
||||||
@@ -2381,6 +2468,23 @@ impl TileWidget {
|
|||||||
});
|
});
|
||||||
button.add_controller(context);
|
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.
|
// 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
|
// In selection mode a tile picks instead of opening: the lightbox is
|
||||||
// one Escape away.
|
// one Escape away.
|
||||||
@@ -2478,6 +2582,23 @@ impl TileWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Add or remove one CSS class, for widgets that are rebound rather than
|
||||||
/// rebuilt.
|
/// rebuilt.
|
||||||
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
|
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
|
||||||
@@ -2556,6 +2677,9 @@ struct DragSelect {
|
|||||||
pointer: (f64, f64),
|
pointer: (f64, f64),
|
||||||
/// The edge autoscroll, while the pointer is near the top or bottom.
|
/// The edge autoscroll, while the pointer is near the top or bottom.
|
||||||
scroll: Option<glib::SourceId>,
|
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
|
/// Dragging across the timeline selects every photo from where the drag
|
||||||
@@ -2572,21 +2696,33 @@ fn wire_drag_select(ui: &Rc<Ui>) {
|
|||||||
let state = Rc::new(RefCell::new(DragSelect::default()));
|
let state = Rc::new(RefCell::new(DragSelect::default()));
|
||||||
|
|
||||||
let state_begin = state.clone();
|
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();
|
let mut drag = state_begin.borrow_mut();
|
||||||
if let Some(source) = drag.scroll.take() {
|
if let Some(source) = drag.scroll.take() {
|
||||||
source.remove();
|
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 {
|
*drag = DragSelect {
|
||||||
start: (x, y),
|
start: (x, y),
|
||||||
pointer: (x, y),
|
pointer: (x, y),
|
||||||
|
out,
|
||||||
..DragSelect::default()
|
..DragSelect::default()
|
||||||
};
|
};
|
||||||
|
// Left to the tile's own drag source.
|
||||||
|
if out {
|
||||||
|
gesture.set_state(gtk4::EventSequenceState::Denied);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_update = ui.clone();
|
let ui_update = ui.clone();
|
||||||
let state_update = state.clone();
|
let state_update = state.clone();
|
||||||
drag.connect_drag_update(move |gesture, dx, dy| {
|
drag.connect_drag_update(move |gesture, dx, dy| {
|
||||||
|
if state_update.borrow().out {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let (sx, sy) = state_update.borrow().start;
|
let (sx, sy) = state_update.borrow().start;
|
||||||
state_update.borrow_mut().pointer = (sx + dx, sy + dy);
|
state_update.borrow_mut().pointer = (sx + dx, sy + dy);
|
||||||
if !state_update.borrow().active {
|
if !state_update.borrow().active {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub(crate) mod crumb;
|
|||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
pub(crate) mod file_list;
|
pub(crate) mod file_list;
|
||||||
pub(crate) mod menu;
|
pub(crate) mod menu;
|
||||||
|
pub(crate) mod photo_drag;
|
||||||
pub(crate) mod scrubber;
|
pub(crate) mod scrubber;
|
||||||
pub(crate) mod share_dialog;
|
pub(crate) mod share_dialog;
|
||||||
pub(crate) mod thumbnails;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,13 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
|
|||||||
slideshow.
|
slideshow.
|
||||||
- The line under the date names the town the photo was taken in, when it is known.
|
- 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.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
|
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
|
||||||
|
|||||||
+223
-217
File diff suppressed because it is too large.
Load diff
+223
-217
File diff suppressed because it is too large.
Load diff
+223
-217
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user