mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 17:32:00 +01:00
Merge pull request #90 from narrrl/feat/photos-drag-out
feat(gui): drag photos out of the timeline, upload progress bar
This commit is contained in:
14 files changed
+2447
-1968
No files matched your search
@@ -40,6 +40,7 @@ use widgets::crumb::*;
|
||||
use widgets::details::*;
|
||||
use widgets::file_list::*;
|
||||
use widgets::menu::*;
|
||||
use widgets::photo_drag::*;
|
||||
use widgets::scrubber::*;
|
||||
use widgets::share_dialog::*;
|
||||
use widgets::thumbnails::*;
|
||||
@@ -525,6 +526,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(),
|
||||
@@ -2036,6 +2038,13 @@ 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]
|
||||
|
||||
@@ -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
|
||||
@@ -478,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,
|
||||
@@ -903,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);
|
||||
@@ -913,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
|
||||
@@ -943,6 +952,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
pager,
|
||||
scrubber,
|
||||
import_banner,
|
||||
upload_bar,
|
||||
list,
|
||||
scroll,
|
||||
retry,
|
||||
@@ -1384,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);
|
||||
|
||||
@@ -1513,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()
|
||||
@@ -1913,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>) {
|
||||
@@ -1927,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 {
|
||||
@@ -1973,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 {
|
||||
@@ -2381,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.
|
||||
@@ -2478,6 +2589,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
|
||||
/// rebuilt.
|
||||
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
|
||||
@@ -2556,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
|
||||
@@ -2572,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 {
|
||||
@@ -3954,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);
|
||||
@@ -4358,10 +4516,10 @@ pub(crate) fn play_external(path: &str) {
|
||||
#[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};
|
||||
|
||||
@@ -4484,6 +4642,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);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 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);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,13 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
|
||||
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.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`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