Files
proton-drive-linux/crates/pdfs-gui/src/app/main.rs
T
nvrl bd4f797d1f feat(gui): polish pass over wording, icons and responsiveness
The Sync page gives each folder one card with its state, a Synced /
Online only switch and a menu for the rest; switching either way asks
first and the download prompt shows the free space on the disk.

Enabling, disabling and restarting the Proton Drive service ran
systemctl on GTK's main thread and froze the window. They now run in
the background, and every Retry button goes through one shared restart
helper.

Ten icons are bundled under resources/icons for glyphs no stock theme
has or that read ambiguously (sync, share, people, link, menu, map and
so on). A new test fails when the app or search prompt asks for a
symbolic icon that is neither bundled nor on a list checked against
Adwaita 50, libadwaita and GTK, or when a bundled icon is missing from
the resource manifest. The tray's sync icon, which Adwaita 50 no longer
ships, is now view-refresh.

Wording follows one glossary, recorded in docs/TRANSLATING.md: "Proton
Drive service" instead of "mount service" or "daemon", "Online only"
instead of "On-demand", "Proton Drive folder" instead of "mountpoint".
Dialog headings and buttons use title case and page titles match the
sidebar. Dates go through one formatter (app/dates.rs) and sizes
through i18n::human_bytes, and the cache label is translated.

The status tick stops while the window is hidden or minimized and runs
every fifth tick while it is unfocused, refreshing as soon as it gains
focus. "Sync complete" is announced only for large batches and only
when the window isn't focused. Smaller fixes: Empty Trash hides when
there is nothing to empty, copy buttons confirm with a check mark, the
sidebar status spins until the first report, and Photos moves Import
and Find Duplicates into a menu beside a labelled Select toggle.

All catalogs are updated and complete.
2026-09-28 13:26:59 +02:00

2020 lines
82 KiB
Rust

#[path = "../activation.rs"]
pub(crate) mod activation;
pub(crate) mod dates;
#[path = "../i18n.rs"]
pub(crate) mod i18n;
pub(crate) mod pages;
pub(crate) mod widgets;
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::*;
use pages::photos::*;
use pages::places::*;
use pages::places_map::*;
use pages::shared::*;
use pages::shared_by_me::*;
use pages::status::*;
use pages::takeout::*;
use pages::trash::*;
use pages::verify::*;
use widgets::details::*;
use widgets::menu::*;
use widgets::share_dialog::*;
use widgets::thumbnails::*;
use widgets::versions_dialog::*;
pub(crate) use i18n::{
country_name, gettext, gettext_f, gettext_noop, human_bytes, ngettext_f, pgettext,
};
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::rc::Rc;
use std::time::{Duration, Instant};
use adw::prelude::*;
use gtk4::gio;
use gtk4::glib;
use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth;
use pdfs_core::config::{AppDirs, FileSort, FilesView};
use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
send,
};
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
use pdfs_core::service;
const APP_ID: &str = "io.narl.proton-drive-linux";
/// How often the window re-reads mount status, cache usage and the pin list.
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
/// While the window is open but not focused, only every this-many ticks
/// refreshes (every 10 s). A minimised or hidden window skips them all, and
/// focusing the window refreshes at once.
const BACKGROUND_REFRESH_EVERY: u32 = 5;
/// Backoff between auto-retries of a Files/Photos load while the mount service
/// is still coming up (see [`load_browser`] / [`load_gallery`]).
const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2);
/// How long a network-backed page (Shared, Shared-by-me, Devices, Activity) is
/// considered fresh. Re-navigating to it within this window reuses the rows
/// already on screen instead of re-fetching and flashing the "Loading…"
/// placeholder. The Retry button and every mutation still force an immediate
/// reload by clearing the page's timestamp.
const PAGE_TTL: Duration = Duration::from_secs(30);
/// How often the gallery asks whether the timeline refresh it started has
/// finished.
const PHOTOS_REFRESH_POLL: Duration = Duration::from_secs(2);
/// How long the gallery keeps following one timeline refresh. A library of tens
/// of thousands of photos is re-read in batches over several minutes; past this
/// the daemon carries on and the page catches up on its next load.
const PHOTOS_REFRESH_FOLLOW_LIMIT: Duration = Duration::from_secs(15 * 60);
/// All widgets the periodic refresh and the action handlers mutate, plus the
/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout
/// closure share one instance.
struct Ui {
dirs: AppDirs,
stack: adw::ViewStack,
/// Wraps the whole window content; every non-blocking outcome (a completed
/// rename, a failed upload, a purge) is reported here rather than in a modal,
/// so an action never interrupts what the user is doing next.
toasts: adw::ToastOverlay,
/// How many open/load round-trips are in flight. While any is, every page
/// header shows its spinner (see [`page_frame`]); ref-counted so concurrent
/// operations don't hide it early.
busy: Cell<u32>,
/// Keys (relative path / photo uid) of open requests currently in flight, so
/// a double-click on the same entry is a no-op instead of a second download.
opening: RefCell<HashSet<String>>,
/// Resolved login identity, cached so the periodic [`refresh`] never hits the
/// keyring (a DBus round-trip). Populated at startup and updated only on
/// login / logout. `None` = signed out.
session: RefCell<Option<auth::StoredSession>>,
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
/// (which need the daemon to evict + re-hydrate) and every mutating action.
mounted: RefCell<bool>,
/// Sidebar destination list (My files, Photos, …, Sync). Selecting a row swaps
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
sidebar: gtk4::ListBox,
/// The sidebar/content split. Collapsed while signed out, so the login page
/// owns the whole window and no destination is reachable without a session.
nav: adw::NavigationSplitView,
/// Shared thumbnails for ordinary image files outside the Photos gallery.
/// One cache and request queue serves Files, search, Shared and Trash, so
/// the same image is downloaded and decoded only once.
pub(crate) file_thumbs: FileThumbnailState,
// Per-page state. Each page module owns its own struct; `Ui` keeps only
// what more than one page genuinely shares.
pub(crate) login: LoginState,
pub(crate) status: StatusState,
pub(crate) browser: BrowserState,
pub(crate) details: DetailsState,
pub(crate) trash: TrashState,
pub(crate) gallery: GalleryState,
pub(crate) shared: SharedState,
pub(crate) shared_by_me: SharedByMeState,
pub(crate) devices: DevicesState,
pub(crate) locations: LocationsState,
pub(crate) activity: ActivityState,
pub(crate) takeout: TakeoutState,
}
impl Ui {
/// Begin a unit of background work: show + spin the header spinner.
fn busy_begin(&self) {
self.busy.set(self.busy.get() + 1);
set_busy_spinners(true);
}
/// End a unit of background work: stop the spinner once the last one is done.
fn busy_end(&self) {
let remaining = self.busy.get().saturating_sub(1);
self.busy.set(remaining);
if remaining == 0 {
set_busy_spinners(false);
}
}
/// Remember a decoded thumbnail, evicting the oldest once the cache is full.
/// Scrolling back over a day then repaints from memory rather than decoding
/// the same JPEGs off disk again.
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) {
let mut cache = self.gallery.photo_tex.borrow_mut();
let mut order = self.gallery.photo_tex_order.borrow_mut();
if cache.insert(uid.to_string(), texture).is_none() {
order.push_back(uid.to_string());
}
while order.len() > TEXTURE_CACHE_MAX {
if let Some(old) = order.pop_front() {
cache.remove(&old);
}
}
}
/// Mark a cached thumbnail as freshly used, so eviction takes the tiles
/// nobody has looked at rather than the oldest ones.
///
/// Insertion order alone evicts the top of the timeline first — exactly what
/// a scroll back up then asks for again.
fn touch_texture(&self, uid: &str) {
let mut order = self.gallery.photo_tex_order.borrow_mut();
if order.back().is_some_and(|back| back == uid) {
return;
}
if let Some(at) = order.iter().position(|held| held == uid) {
order.remove(at);
order.push_back(uid.to_string());
}
}
}
fn main() -> glib::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
i18n::init();
// The tray launches the app with arguments ("--page locations",
// "--confirm-stop"), which a running instance must receive too.
let app = adw::Application::builder()
.application_id(APP_ID)
.flags(gio::ApplicationFlags::HANDLES_COMMAND_LINE)
.build();
app.connect_startup(|_| {
load_proton_theme();
if let Ok(dirs) = AppDirs::new() {
set_proton_theme(dirs.load_config().proton_theme.unwrap_or(false));
}
// Refresh the file manager's right-click pin/unpin scripts, so they always
// match the installed `pdfs`.
pdfs_core::shell::install_file_manager_scripts();
if !AppDirs::new().is_ok_and(|dirs| dirs.load_config().tray_hidden) {
spawn_tray();
}
});
app.connect_activate(present_window);
app.connect_command_line(|app, command| {
let args: Vec<String> = command
.arguments()
.iter()
.skip(1)
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
app.activate();
match parse_launch(&args) {
Launch::Window => {}
Launch::Page(page) => app.activate_action("show-page", Some(&page.to_variant())),
Launch::ConfirmStop => app.activate_action("confirm-stop", None),
}
glib::ExitCode::SUCCESS
});
app.run()
}
/// What a launch of `pdfs-app` asks for, beyond showing the window.
#[derive(Debug, PartialEq, Eq)]
enum Launch {
Window,
/// `--page <name>`: open on a stack page, such as "locations" for Sync.
Page(String),
/// `--confirm-stop`: ask whether to stop Proton Drive (from the tray).
ConfirmStop,
}
fn parse_launch(args: &[String]) -> Launch {
let mut args = args.iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--confirm-stop" => return Launch::ConfirmStop,
"--page" => {
if let Some(page) = args.next() {
return Launch::Page(page.clone());
}
}
other => {
if let Some(page) = other.strip_prefix("--page=") {
return Launch::Page(page.to_string());
}
}
}
}
Launch::Window
}
/// Raise the window if there is one, else build it. A second launch reaches
/// the running instance, which must not open a second window.
fn present_window(app: &adw::Application) {
match app.windows().first() {
Some(window) => window.present(),
None => build_window(app),
}
}
/// Spawn the tray icon process in the background.
pub(crate) fn spawn_tray() {
match Command::new("pdfs-tray").spawn() {
Ok(_) => tracing::info!("spawned `pdfs-tray`"),
Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"),
}
}
/// Register the bundled GResources (custom icons, the stylesheet) and load the
/// app stylesheet. The Proton theme is a separate provider, applied by
/// [`set_proton_theme`] from the saved preference.
fn load_proton_theme() {
let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource"));
let resource_data = glib::Bytes::from_static(bytes);
if let Ok(resource) = gio::Resource::from_data(&resource_data) {
gio::resources_register(&resource);
} else {
tracing::error!("failed to load gresource bundle");
}
let Some(display) = gtk4::gdk::Display::default() else {
return;
};
let provider = gtk4::CssProvider::new();
provider.load_from_resource("/de/nils/protondrivelinux/style.css");
// Above the user's own gtk.css, which often imports a whole theme: its
// `button { padding: 6px 10px; min-width: 16px }` would otherwise pad every
// photo tile and push the grid past the window. Every rule in the sheet is
// scoped to the app's own classes, so nothing else the user styled changes.
// One above the Proton theme too, so the app's rules refine it.
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_USER + 2,
);
gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons");
}
thread_local! {
/// The Proton theme provider while it is installed. Kept so turning the
/// preference off can remove exactly what turning it on added.
static PROTON_THEME: RefCell<Option<gtk4::CssProvider>> = const { RefCell::new(None) };
}
/// Whether the Proton theme is on.
fn proton_theme_active() -> bool {
PROTON_THEME.with(|slot| slot.borrow().is_some())
}
/// libadwaita's own stylesheet, bundled in the library.
const ADWAITA_STYLESHEET: &str = "/org/gnome/Adwaita/styles/gtk.css";
/// The Proton palette laid over it, from our own bundle.
const PROTON_PALETTE: &str = "/de/nils/protondrivelinux/proton-theme.css";
/// Make the theme provider's `@media (prefers-color-scheme)` and
/// `(prefers-contrast)` rules match what libadwaita shows, now and whenever it
/// changes. A provider evaluates those itself, and a new one assumes light.
///
/// The properties are set by name because they only exist since GTK 4.20, and
/// the app still runs on older GTK, whose libadwaita has no such rules anyway.
fn follow_style_manager(provider: &gtk4::CssProvider) {
fn set_enum(provider: &gtk4::CssProvider, property: &str, nick: &str) {
let Some(pspec) = provider.find_property(property) else {
return;
};
if let Some(value) = glib::EnumClass::with_type(pspec.value_type())
.and_then(|class| class.to_value_by_nick(nick))
{
provider.set_property_from_value(property, &value);
}
}
fn apply(provider: &gtk4::CssProvider, manager: &adw::StyleManager) {
let scheme = if manager.is_dark() { "dark" } else { "light" };
set_enum(provider, "prefers-color-scheme", scheme);
let contrast = if manager.is_high_contrast() {
"more"
} else {
"no-preference"
};
set_enum(provider, "prefers-contrast", contrast);
}
let manager = adw::StyleManager::default();
apply(provider, &manager);
// Weak, so a provider the preference removed isn't kept alive; its
// handlers then do nothing.
let weak = provider.downgrade();
let weak_dark = weak.clone();
manager.connect_dark_notify(move |manager| {
if let Some(provider) = weak_dark.upgrade() {
apply(&provider, manager);
}
});
manager.connect_high_contrast_notify(move |manager| {
if let Some(provider) = weak.upgrade() {
apply(&provider, manager);
}
});
}
/// Paint the app in the Proton web apps' colours (`on`) or follow the system
/// theme and accent colour.
///
/// Setting libadwaita's colour variables alone isn't enough: a GTK theme the
/// user's `gtk.css` imports (Catppuccin, Fluent, ...) sits above the app and
/// hard-codes its own accent into hundreds of rules. So the theme provider,
/// one above the user's CSS, first resets every property, then re-applies
/// libadwaita's whole stylesheet and finally the Proton palette, which leaves
/// nothing of the user's theme inside the app.
fn set_proton_theme(on: bool) {
let Some(display) = gtk4::gdk::Display::default() else {
return;
};
PROTON_THEME.with(|slot| {
let mut slot = slot.borrow_mut();
match (on, slot.as_ref()) {
(true, None) => {
let stylesheet = |path| {
gio::resources_lookup_data(path, gio::ResourceLookupFlags::NONE)
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
};
let palette = stylesheet(PROTON_PALETTE).unwrap_or_default();
let css = match stylesheet(ADWAITA_STYLESHEET) {
// Its images are named relative to the sheet, which a
// string loaded here doesn't have.
Ok(adwaita) => format!(
"* {{ all: unset; }}\n{}\n{palette}",
adwaita.replace(
"url(\"assets/",
"url(\"resource:///org/gnome/Adwaita/styles/assets/"
)
),
// Still recolours everything that follows libadwaita's
// variables; only a custom theme's own rules show through.
Err(e) => {
tracing::warn!("libadwaita stylesheet not found, only recolouring: {e}");
palette
}
};
let provider = gtk4::CssProvider::new();
follow_style_manager(&provider);
provider.load_from_string(&css);
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_USER + 1,
);
*slot = Some(provider);
}
(false, Some(provider)) => {
gtk4::style_context_remove_provider_for_display(&display, provider);
*slot = None;
}
_ => {}
}
});
}
/// Build the application window, wire the two pages, kick off the refresh loop,
/// and present it.
fn build_window(app: &adw::Application) {
let dirs = match AppDirs::new() {
Ok(d) => d,
Err(e) => {
tracing::error!("cannot resolve app dirs: {e}");
return;
}
};
let stack = adw::ViewStack::new();
let (login_page, login_widgets) = build_login_page();
let main_widgets = build_main_page();
let (browser_page, browser_widgets) = build_browser_page();
let (gallery_page, gallery_widgets) = build_gallery_page();
let (shared_page, shared_widgets) = build_shared_page();
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
let (devices_page, devices_widgets) = build_devices_page();
let (locations_page, locations_widgets) = build_locations_page();
let (activity_page, activity_widgets) = build_activity_page();
let (trash_page, trash_widgets) = build_trash_page();
let (takeout_page, takeout_widgets) = build_takeout_page();
// The login page has no title and no actions, but it still needs a header
// bar: it is the only thing carrying the window controls.
let login_frame = adw::ToolbarView::new();
login_frame.add_top_bar(&{
let header = adw::HeaderBar::new();
header.set_show_title(false);
header
});
login_frame.set_content(Some(&login_page));
stack.add_named(&login_frame, Some("login"));
stack.add_named(&browser_page, Some("browser"));
stack.add_named(&gallery_page, Some("gallery"));
stack.add_named(&shared_by_me_page, Some("sharedbyme"));
stack.add_named(&shared_page, Some("shared"));
stack.add_named(&devices_page, Some("devices"));
stack.add_named(&locations_page, Some("locations"));
stack.add_named(&activity_page, Some("activity"));
stack.add_named(&trash_page, Some("trash"));
stack.add_named(&takeout_page, Some("takeout"));
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
// login lands on Files).
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer);
// Every page brings its own header bar (see `page_frame`), so the content
// side is the bare stack.
let content_page = adw::NavigationPage::builder()
.title("Proton Drive")
.child(&stack)
.build();
let split = adw::NavigationSplitView::builder()
.sidebar(&sidebar_page)
.content(&content_page)
.min_sidebar_width(200.0)
.max_sidebar_width(240.0)
.build();
// Toasts float over everything, so a report from a background action reaches
// the user whichever page they're on.
let toasts = adw::ToastOverlay::new();
toasts.set_child(Some(&split));
let ui = Rc::new(Ui {
dirs,
stack: stack.clone(),
toasts: toasts.clone(),
busy: Cell::new(0),
opening: RefCell::new(HashSet::new()),
session: RefCell::new(auth::load().ok()),
mounted: RefCell::new(false),
sidebar: sidebar_list.clone(),
nav: split.clone(),
file_thumbs: FileThumbnailState::new(),
login: login_widgets,
status: StatusState {
status_inflight: Cell::new(false),
transfers_group: locations_widgets.transfers_group.clone(),
prefs: main_widgets.prefs.clone(),
account_name: main_widgets.account_name.clone(),
avatar: main_widgets.avatar.clone(),
status_icon: main_widgets.status_icon.clone(),
status_spinner: main_widgets.status_spinner.clone(),
status_title: main_widgets.status_title.clone(),
status_detail: main_widgets.status_detail.clone(),
transfer_rows: RefCell::new(Vec::new()),
transfers_inflight: Cell::new(false),
cache_bar: main_widgets.cache_bar.clone(),
cache_label: main_widgets.cache_label.clone(),
quota_box: main_widgets.quota_box.clone(),
quota_bar: main_widgets.quota_bar.clone(),
quota_label: main_widgets.quota_label.clone(),
quota_inflight: Cell::new(false),
quota_checked_at: Cell::new(None),
autostart_row: main_widgets.autostart_row.clone(),
budget_row: main_widgets.budget_row.clone(),
upload_limit_row: main_widgets.upload_limit_row.clone(),
download_limit_row: main_widgets.download_limit_row.clone(),
mountpoint_row: main_widgets.mountpoint_row.clone(),
theme_row: main_widgets.theme_row.clone(),
online_map_row: main_widgets.online_map_row.clone(),
tray_row: main_widgets.tray_row.clone(),
language_row: main_widgets.language_row.clone(),
settings_suppress: Cell::new(false),
budget_source: RefCell::new(None),
limit_source: RefCell::new(None),
pins_expanded: Cell::new(false),
pins_group: main_widgets.pins_group.clone(),
pin_rows: RefCell::new(Vec::new()),
pins_state: RefCell::new(None),
notified_mounted: Cell::new(None),
active_transfers: Cell::new(0),
transfer_batch: RefCell::new(None),
},
browser: BrowserState {
model: browser_widgets.model.clone(),
history: RefCell::new(Vec::new()),
future: RefCell::new(Vec::new()),
crumb: browser_widgets.crumb.clone(),
content: browser_widgets.content.clone(),
status: browser_widgets.status.clone(),
retry: browser_widgets.retry.clone(),
split: browser_widgets.split.clone(),
path: RefCell::new(String::new()),
search: browser_widgets.search.clone(),
search_scope: browser_widgets.search_scope.clone(),
actions: browser_widgets.actions.clone(),
view_button: browser_widgets.view_button.clone(),
view: Cell::new(FilesView::default()),
listing: RefCell::new(Vec::new()),
build_thumbnails: browser_widgets.build_thumbnails.clone(),
thumbnail_build_row: browser_widgets.thumbnail_build_row.clone(),
thumbnail_progress: browser_widgets.thumbnail_progress.clone(),
thumbnail_status: browser_widgets.thumbnail_status.clone(),
thumbnail_poll: RefCell::new(None),
thumbnail_build_running: Cell::new(false),
thumbnail_cancel_pending: Cell::new(false),
search_source: RefCell::new(None),
load_generation: Cell::new(0),
views: browser_widgets.views.clone(),
column_view: browser_widgets.column_view.clone(),
bulk: browser_widgets.bulk.clone(),
bulk_label: browser_widgets.bulk_label.clone(),
bulk_trash: browser_widgets.bulk_trash.clone(),
bulk_pin: browser_widgets.bulk_pin.clone(),
bulk_unpin: browser_widgets.bulk_unpin.clone(),
bulk_move: browser_widgets.bulk_move.clone(),
empty_actions: browser_widgets.empty_actions.clone(),
summary: browser_widgets.summary.clone(),
zoom: browser_widgets.zoom.clone(),
grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT),
grid_tiles: RefCell::new(Vec::new()),
},
details: DetailsState {
details: browser_widgets.details,
details_entry: RefCell::new(None),
details_suppress: Cell::new(false),
selection: browser_widgets.selection.clone(),
},
trash: TrashState {
model: trash_widgets.model.clone(),
content: trash_widgets.content.clone(),
status: trash_widgets.status.clone(),
retry: trash_widgets.retry.clone(),
empty: trash_widgets.empty.clone(),
subtitle: trash_widgets.subtitle.clone(),
selection: trash_widgets.selection.clone(),
selection_bar: trash_widgets.selection_bar.clone(),
selection_label: trash_widgets.selection_label.clone(),
},
gallery: GalleryState {
model: gallery_widgets.model.clone(),
groups: gallery_widgets.groups.clone(),
row_height: Cell::new(ROW_DEFAULT),
scrolling_down: Cell::new(true),
scroll_offset: Cell::new(0.0),
content: gallery_widgets.content.clone(),
status: gallery_widgets.status.clone(),
retry: gallery_widgets.retry.clone(),
pager: gallery_widgets.pager.clone(),
scrubber: gallery_widgets.scrubber.clone(),
months: RefCell::new(Vec::new()),
scrubbing: Cell::new(false),
scrub_source: RefCell::new(None),
jump: Cell::new(None),
burst: Cell::new(false),
import_banner: gallery_widgets.import_banner.clone(),
upload: gallery_widgets.upload.clone(),
empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(),
albums: gallery_widgets.albums.clone(),
albums_stack: gallery_widgets.albums_stack.clone(),
albums_status: gallery_widgets.albums_status.clone(),
photos_btn: gallery_widgets.photos_btn.clone(),
albums_btn: gallery_widgets.albums_btn.clone(),
view_switch: gallery_widgets.view_switch.clone(),
albums_loading: Cell::new(false),
places: gallery_widgets.places.clone(),
places_stack: gallery_widgets.places_stack.clone(),
places_status: gallery_widgets.places_status.clone(),
places_btn: gallery_widgets.places_btn.clone(),
places_loading: Cell::new(false),
places_mapping: gallery_widgets.places_mapping.clone(),
places_mapping_label: gallery_widgets.places_mapping_label.clone(),
places_mapping_bar: gallery_widgets.places_mapping_bar.clone(),
places_poll: RefCell::new(None),
places_map: gallery_widgets.places_map.clone(),
places_map_btn: gallery_widgets.places_map_btn.clone(),
place_count: Cell::new(0),
place: RefCell::new(None),
album: RefCell::new(None),
timeline_stale: Cell::new(false),
album_list: RefCell::new(Vec::new()),
back: gallery_widgets.back.clone(),
filters: gallery_widgets.filters.clone(),
kind: Cell::new(None),
tabs: gallery_widgets.tabs.clone(),
counts: Cell::new(None),
dates: gallery_widgets.dates.clone(),
filter_btn: gallery_widgets.filter_btn.clone(),
filter_count: gallery_widgets.filter_count.clone(),
clear_filters: gallery_widgets.clear_filters.clone(),
date_ranges: RefCell::new(vec![None]),
range: Cell::new(None),
favorites_row: gallery_widgets.favorites_row.clone(),
favorites: Cell::new(false),
not_in_album_row: gallery_widgets.not_in_album_row.clone(),
not_in_album: Cell::new(false),
memories: gallery_widgets.memories.clone(),
memory_cards: gallery_widgets.memory_cards.clone(),
has_memories: Cell::new(false),
date_suppress: Cell::new(false),
reload_pending: Cell::new(false),
loading: Cell::new(false),
has_more: Cell::new(false),
page_waiters: RefCell::new(Vec::new()),
width: Cell::new(0),
photo_tex: RefCell::new(HashMap::new()),
photo_tex_order: RefCell::new(VecDeque::new()),
photo_nothumb: RefCell::new(HashSet::new()),
thumb_wanted: RefCell::new(HashMap::new()),
thumb_queue: RefCell::new(VecDeque::new()),
thumb_inflight: Cell::new(false),
decode_queue: RefCell::new(VecDeque::new()),
decode_idle: Cell::new(false),
thumb_source: RefCell::new(None),
relayout_source: RefCell::new(None),
bound: RefCell::new(BTreeMap::new()),
list: gallery_widgets.list.clone(),
selecting: Cell::new(false),
selected: RefCell::new(HashSet::new()),
select_anchor: RefCell::new(None),
select_btn: gallery_widgets.select_btn.clone(),
select_bar: gallery_widgets.select_bar.clone(),
select_label: gallery_widgets.select_label.clone(),
select_trash: gallery_widgets.select_trash.clone(),
select_album: gallery_widgets.select_album.clone(),
},
shared: SharedState {
content: shared_widgets.content.clone(),
status: shared_widgets.status.clone(),
retry: shared_widgets.retry.clone(),
with_me_group: shared_widgets.shared_with_me.clone(),
invitations_group: shared_widgets.invitations.clone(),
bookmarks_group: shared_widgets.bookmarks.clone(),
title: shared_widgets.title.clone(),
back: shared_widgets.back.clone(),
add_bookmark: shared_widgets.add_bookmark.clone(),
nav: RefCell::new(Vec::new()),
rows: RefCell::new(Vec::new()),
inflight: Cell::new(false),
loaded_at: Cell::new(None),
},
shared_by_me: SharedByMeState {
content: shared_by_me_widgets.content.clone(),
status: shared_by_me_widgets.status.clone(),
retry: shared_by_me_widgets.retry.clone(),
group: shared_by_me_widgets.group.clone(),
rows: RefCell::new(Vec::new()),
inflight: Cell::new(false),
loaded_at: Cell::new(None),
},
devices: DevicesState {
content: devices_widgets.content.clone(),
status: devices_widgets.status.clone(),
retry: devices_widgets.retry.clone(),
group: devices_widgets.group.clone(),
rows: RefCell::new(Vec::new()),
sync_group: devices_widgets.sync_group.clone(),
sync_rows: RefCell::new(Vec::new()),
rename_this: devices_widgets.rename_this.clone(),
this_device: RefCell::new(None),
inflight: Cell::new(false),
loaded_at: Cell::new(None),
},
locations: LocationsState {
content: locations_widgets.content.clone(),
status: locations_widgets.status.clone(),
retry: locations_widgets.retry.clone(),
group: locations_widgets.group.clone(),
rows: RefCell::new(Vec::new()),
inflight: Cell::new(false),
loaded_at: Cell::new(None),
card: SyncCard {
icon: locations_widgets.card_icon.clone(),
row: locations_widgets.card_row.clone(),
pause: locations_widgets.pause.clone(),
paused: Cell::new(false),
},
queue: QueueState {
group: locations_widgets.queue_group.clone(),
retry_all: locations_widgets.retry_all.clone(),
rows: RefCell::new(Vec::new()),
painted: RefCell::new(Vec::new()),
inflight: Cell::new(false),
},
conflicts: ConflictsState {
group: locations_widgets.conflicts_group.clone(),
rows: RefCell::new(Vec::new()),
painted: RefCell::new(Vec::new()),
inflight: Cell::new(false),
fetched_at: Cell::new(None),
},
},
activity: ActivityState {
content: activity_widgets.content.clone(),
status: activity_widgets.status.clone(),
retry: activity_widgets.retry.clone(),
attention: activity_widgets.attention.clone(),
attention_rows: RefCell::new(Vec::new()),
days: activity_widgets.days.clone(),
inflight: Cell::new(false),
key: RefCell::new(None),
items: RefCell::new(Vec::new()),
filter: Cell::new(ActivityFilter::All),
conflicts: RefCell::new(None),
conflicts_inflight: Cell::new(false),
conflicts_at: Cell::new(None),
},
takeout: TakeoutState {
archives: RefCell::new(Vec::new()),
list_group: takeout_widgets.list_group.clone(),
rows: RefCell::new(Vec::new()),
dropzone: takeout_widgets.dropzone.clone(),
scan_button: takeout_widgets.scan_button.clone(),
import_button: takeout_widgets.import_button.clone(),
cancel_button: takeout_widgets.cancel_button.clone(),
clear_button: takeout_widgets.clear_button.clone(),
progress_group: takeout_widgets.progress_group.clone(),
progress_label: takeout_widgets.progress_label.clone(),
progress_bar: takeout_widgets.progress_bar.clone(),
summary_group: takeout_widgets.summary_group.clone(),
summary_rows: RefCell::new(Vec::new()),
inflight: Cell::new(false),
running: Cell::new(false),
dry_run: Cell::new(false),
},
});
wire_login(&ui);
let ui_status = ui.clone();
main_widgets
.status_button
.connect_clicked(move |_| ui_status.stack.set_visible_child_name("locations"));
wire_settings(
&ui,
&main_widgets.purge_button,
&main_widgets.mountpoint_button,
);
wire_sidebar(&ui);
wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view);
wire_bulk(
&ui,
&browser_widgets.bulk_clear,
&browser_widgets.empty_upload,
&browser_widgets.empty_new_folder,
);
wire_browser_actions(&ui, &browser_widgets.build_thumbnails);
wire_details(&ui);
wire_search(&ui);
wire_gallery(
&ui,
&gallery_widgets.list,
&gallery_widgets.scroll,
&gallery_widgets.select_done,
);
wire_gallery_empty(
&ui,
&gallery_widgets.empty_upload,
&gallery_widgets.empty_import,
);
wire_albums(&ui);
wire_places(&ui);
wire_trash(&ui, &trash_widgets);
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
wire_devices(&ui, &devices_widgets.retry);
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
wire_activity(&ui, &activity_widgets.retry, &activity_widgets.filters);
wire_takeout(&ui, &takeout_widgets);
wire_refresh(
&ui,
&[
&browser_widgets.refresh,
&gallery_widgets.refresh,
&trash_widgets.refresh,
&shared_widgets.refresh,
&shared_by_me_widgets.refresh,
&devices_widgets.refresh,
&locations_widgets.refresh,
&activity_widgets.refresh,
],
);
wire_retry(&ui);
// Lazily load the Files / Photos pages the first time they're shown, so the
// network round-trip only happens on demand rather than on every refresh.
let ui_nav = ui.clone();
stack.connect_visible_child_name_notify(move |st| {
// Rows from the page being left must not keep full-size image downloads
// alive in the daemon. The page being entered establishes a fresh
// thumbnail generation as it paints.
cancel_file_thumbnails(&ui_nav);
sync_sidebar(&ui_nav);
match st.visible_child_name().as_deref() {
Some("browser") => load_browser(&ui_nav),
Some("gallery") => load_gallery(&ui_nav, false),
// Network-backed pages skip the fetch (and the "Loading…" flash) when
// the rows on screen are still fresh; the Retry button and mutations
// invalidate the timestamp to force a reload.
Some("sharedbyme") if page_fresh(&ui_nav.shared_by_me.loaded_at) => {}
Some("sharedbyme") => load_shared_by_me(&ui_nav),
Some("shared") if page_fresh(&ui_nav.shared.loaded_at) => {}
Some("shared") => load_shared(&ui_nav),
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
Some("devices") => load_devices(&ui_nav),
Some("locations") => {
refresh_conflicts(&ui_nav, true);
if !page_fresh(&ui_nav.locations.loaded_at) {
load_locations(&ui_nav);
}
}
// Activity is intentionally not TTL-cached: it changes out from under
// the page as background uploads and edits complete, so it reloads on
// every visit to stay live.
Some("activity") => load_activity(&ui_nav),
Some("trash") => load_trash(&ui_nav),
// The import runs in the daemon and outlives this page, so arriving
// here asks straight away whether one is in flight.
Some("takeout") => refresh_takeout(&ui_nav),
_ => {}
}
});
let window = adw::ApplicationWindow::builder()
.application(app)
.title("Proton Drive")
.default_width(980)
.default_height(680)
.content(&toasts)
.build();
// A wide window docks the details pane beside the files instead of laying it
// over them.
let wide = adw::Breakpoint::new(
adw::BreakpointCondition::parse("min-width: 1000sp").expect("static breakpoint"),
);
wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value()));
window.add_breakpoint(wide);
install_shortcuts(&ui, &window);
install_window_actions(&ui, &window);
install_launch_actions(&ui, app, &window);
app.set_accels_for_action("win.preferences", &["<Control>comma"]);
app.set_accels_for_action("win.shortcuts", &["<Control>question"]);
refresh(&ui);
// Periodic refresh while the window lives. The closure holds a strong `Rc`;
// it is dropped when the source is removed on window close. A window in the
// background refreshes less often, and one nobody can see not at all.
let ui_tick = ui.clone();
let window_tick = window.downgrade();
let skipped = Cell::new(0u32);
let source = glib::timeout_add_local(REFRESH_INTERVAL, move || {
let Some(window) = window_tick.upgrade() else {
return glib::ControlFlow::Continue;
};
if window_hidden(&window) {
return glib::ControlFlow::Continue;
}
if !window.is_active() && skipped.get() + 1 < BACKGROUND_REFRESH_EVERY {
skipped.set(skipped.get() + 1);
return glib::ControlFlow::Continue;
}
skipped.set(0);
refresh(&ui_tick);
glib::ControlFlow::Continue
});
// Catch up as soon as the user comes back to the window.
let ui_focus = ui.clone();
window.connect_is_active_notify(move |window| {
if window.is_active() {
refresh(&ui_focus);
}
});
let cell = RefCell::new(Some(source));
window.connect_close_request(move |_| {
if let Some(id) = cell.borrow_mut().take() {
id.remove();
}
glib::Propagation::Proceed
});
window.present();
}
/// Whether `window` is minimised or otherwise out of sight (another workspace,
/// fully covered), as far as the compositor tells us.
fn window_hidden(window: &adw::ApplicationWindow) -> bool {
if !window.is_visible() {
return true;
}
window
.surface()
.and_downcast::<gtk4::gdk::Toplevel>()
.is_some_and(|toplevel| {
toplevel.state().intersects(
gtk4::gdk::ToplevelState::MINIMIZED | gtk4::gdk::ToplevelState::SUSPENDED,
)
})
}
/// The sidebar destinations, in order: the row index is the index into this table,
/// and each entry is `(stack page name, label, icon)`. The places in the account
/// come first; the rows from [`SIDEBAR_SYNC_SECTION`] on are about this computer's
/// sync, set off by a separator.
const DESTINATIONS: [(&str, &str, &str); 8] = [
("browser", gettext_noop("My files"), "folder-symbolic"),
(
"gallery",
gettext_noop("Photos"),
"image-x-generic-symbolic",
),
(
"shared",
gettext_noop("Shared with me"),
"pdfs-people-symbolic",
),
(
"sharedbyme",
gettext_noop("Shared by me"),
"pdfs-share-symbolic",
),
("devices", gettext_noop("Computers"), "computer-symbolic"),
("trash", gettext_noop("Trash"), "user-trash-symbolic"),
// Translators: sidebar entry for the page listing this computer's synced folders.
("locations", gettext_noop("Sync"), "pdfs-sync-symbolic"),
(
"activity",
gettext_noop("Activity"),
"document-open-recent-symbolic",
),
];
/// Index of the first row in the sidebar's sync section.
const SIDEBAR_SYNC_SECTION: i32 = 6;
/// The navigation sidebar: the destinations over a footer carrying the sync
/// status, the account quota and the account menu. Returns the page (for the
/// split view) and the list (to drive + reflect the current page).
fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
let list = gtk4::ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single);
list.add_css_class("navigation-sidebar");
for (_, label, icon) in DESTINATIONS {
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
row_box.append(&gtk4::Image::from_icon_name(icon));
row_box.append(&gtk4::Label::new(Some(&gettext(label))));
let row = gtk4::ListBoxRow::builder().child(&row_box).build();
list.append(&row);
}
list.set_header_func(|row, _| {
if row.index() == SIDEBAR_SYNC_SECTION {
let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal);
separator.set_margin_top(6);
separator.set_margin_bottom(6);
row.set_header(Some(&separator));
} else {
row.set_header(gtk4::Widget::NONE);
}
});
let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let icon = gtk4::Image::from_icon_name(APP_ID);
icon.set_pixel_size(24);
brand.append(&icon);
let name = gtk4::Label::new(Some("Proton Drive"));
name.add_css_class("heading");
brand.append(&name);
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&brand));
header.pack_end(&build_primary_menu());
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vexpand(true)
.child(&list)
.build();
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scroll));
toolbar.add_bottom_bar(footer);
let page = adw::NavigationPage::builder()
.title("Proton Drive")
.child(&toolbar)
.build();
(page, list)
}
thread_local! {
/// Every page header's busy spinner. There is one per page because each
/// page has its own header bar; [`Ui::busy_begin`] shows them all, so the one
/// on screen is always the right one.
static BUSY_SPINNERS: RefCell<Vec<gtk4::Spinner>> = const { RefCell::new(Vec::new()) };
}
fn set_busy_spinners(visible: bool) {
BUSY_SPINNERS.with(|spinners| {
for spinner in spinners.borrow().iter() {
spinner.set_visible(visible);
spinner.set_spinning(visible);
}
});
}
/// A destination page's frame: its own header bar over the page content, the
/// way GNOME apps lay a split view out. The title sits in the middle with the
/// busy spinner beside it; the page packs its actions into the returned header
/// bar and may give the returned title a subtitle.
fn page_frame(
title: &str,
content: &impl IsA<gtk4::Widget>,
) -> (adw::ToolbarView, adw::HeaderBar, adw::WindowTitle) {
let window_title = adw::WindowTitle::new(title, "");
let spinner = gtk4::Spinner::new();
spinner.set_visible(false);
BUSY_SPINNERS.with(|spinners| spinners.borrow_mut().push(spinner.clone()));
let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
title_box.append(&window_title);
title_box.append(&spinner);
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&title_box));
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(content));
(toolbar, header, window_title)
}
/// The sidebar destination a page belongs under. Most pages are their own
/// destination; a sub-page reached from one (Import, off Photos) has no row of
/// its own and answers with its parent, so the sidebar highlights where the user
/// came from rather than clearing — which would read as "nowhere".
fn destination_of(page: &str) -> &str {
match page {
"takeout" => "gallery",
other => other,
}
}
/// Selecting a sidebar row navigates the page stack. The reverse direction (stack
/// → sidebar highlight) is [`sync_sidebar`], so the two can't fight: this handler
/// only ever writes the stack.
fn wire_sidebar(ui: &Rc<Ui>) {
let ui_row = ui.clone();
ui.sidebar.connect_row_selected(move |_, row| {
let Some(row) = row else { return };
let Some((page, _, _)) = DESTINATIONS.get(row.index() as usize) else {
return;
};
// Compared by *destination*, not by page: while a sub-page is open its
// parent's row is the selected one, and re-asserting it here would kick
// the user off the sub-page the moment they arrived.
let current = ui_row.stack.visible_child_name();
if current.as_deref().map(destination_of) != Some(*page) {
ui_row.stack.set_visible_child_name(page);
}
});
}
/// Whether a network-backed page painted good data within [`PAGE_TTL`] and so
/// can be reused without re-fetching. A `None` timestamp (never loaded, or
/// invalidated by a mutation) is always stale.
fn page_fresh(loaded_at: &Cell<Option<Instant>>) -> bool {
loaded_at.get().is_some_and(|t| t.elapsed() < PAGE_TTL)
}
/// A page header's Refresh button. Every page that can show stale rows carries
/// one, so the user never has to guess whether what they're looking at is
/// current or wait out a TTL they can't see.
fn refresh_button() -> gtk4::Button {
let button = gtk4::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text(gettext("Refresh (F5)"))
.valign(gtk4::Align::Center)
.build();
button.add_css_class("flat");
button
}
/// Point every page's Refresh button at the current page. One handler for all of
/// them: the button acts on whatever is on screen, so it can't refresh a page the
/// user has since navigated away from.
fn wire_refresh(ui: &Rc<Ui>, buttons: &[&gtk4::Button]) {
for button in buttons {
let ui = ui.clone();
button.connect_clicked(move |_| reload_current_page(&ui));
}
}
/// Re-fetch the visible page from the server, bypassing every layer of cache
/// between it and the account.
///
/// The two layers are separate: the daemon's own persisted listings (folders,
/// trash, photos) are dropped with [`Request::Refresh`] before re-asking, while
/// the pages the daemon always fetches live (sharing, devices, activity) only
/// need this front-end's [`PAGE_TTL`] stamp cleared.
fn reload_current_page(ui: &Rc<Ui>) {
match ui.stack.visible_child_name().as_deref() {
Some("browser") => {
let path = ui.browser.path.borrow().clone();
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
}
// One scope covers the whole photos view, so Refresh reloads whichever of
// the views — a grid, or the timeline, an album or a place — is on screen.
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
match ui.gallery.content.visible_child_name().as_deref() {
Some("albums") => load_albums(ui),
Some("places") => load_places(ui),
_ => load_gallery(ui, false),
}
}),
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
Some("shared") => {
ui.shared.loaded_at.set(None);
load_shared(ui);
}
Some("sharedbyme") => {
ui.shared_by_me.loaded_at.set(None);
load_shared_by_me(ui);
}
Some("devices") => {
ui.devices.loaded_at.set(None);
load_devices(ui);
}
Some("locations") => {
ui.locations.loaded_at.set(None);
load_locations(ui);
}
Some("activity") => load_activity(ui),
_ => {}
}
}
/// Drop a daemon-side cached listing, then run the page's loader to re-fetch it.
///
/// The loader runs even when the invalidation failed: it is the loader that knows
/// how to report an unreachable daemon on its own page, and a refresh that fails
/// silently would read as a dead button.
fn refresh_then(ui: &Rc<Ui>, scope: RefreshScope, load: fn(&Rc<Ui>)) {
// A photos refresh answers as soon as it has started, because re-reading a
// large library takes minutes. The page is therefore loaded twice: once from
// what the daemon already holds, and again when the refresh has landed.
let follow = matches!(scope, RefreshScope::Photos { .. });
let rx = spawn_request(ui.dirs.control_socket(), Request::Refresh { scope });
let ui = ui.clone();
glib::spawn_future_local(async move {
let _ = rx.recv().await;
load(&ui);
if follow {
follow_photos_refresh(&ui, load);
}
});
}
/// Watch the timeline refresh the daemon just started, and load the page again
/// when it finishes.
///
/// Giving up after [`PHOTOS_REFRESH_FOLLOW_LIMIT`] is not a failure: the refresh
/// keeps running in the daemon and the next visit to the page shows its result.
/// It only stops this front-end from polling a daemon forever.
fn follow_photos_refresh(ui: &Rc<Ui>, load: fn(&Rc<Ui>)) {
let ui = ui.clone();
let give_up_at = Instant::now() + PHOTOS_REFRESH_FOLLOW_LIMIT;
glib::spawn_future_local(async move {
loop {
glib::timeout_future(PHOTOS_REFRESH_POLL).await;
// The person may have left the gallery in the meantime; reloading a
// page nobody is looking at would only cost the daemon work.
if ui.stack.visible_child_name().as_deref() != Some("gallery") {
return;
}
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotosRefreshStatus);
match rx.recv().await {
Ok(Ok(Response::PhotosRefresh { running: true })) => {
if Instant::now() >= give_up_at {
return;
}
}
Ok(Ok(Response::PhotosRefresh { running: false })) => {
load(&ui);
return;
}
// An older daemon does not know the request, and anything else
// means we cannot tell — either way, stop asking.
_ => return,
}
}
});
}
/// Highlight the sidebar row for whichever page the stack is showing, so
/// navigation that doesn't start in the sidebar (login landing on Files, the tray
/// raising the window) still moves the selection.
fn sync_sidebar(ui: &Rc<Ui>) {
let Some(current) = ui.stack.visible_child_name() else {
return;
};
let current = destination_of(&current);
let index = DESTINATIONS
.iter()
.position(|(page, _, _)| *page == current);
match index.and_then(|i| ui.sidebar.row_at_index(i as i32)) {
Some(row) => {
if ui.sidebar.selected_row().as_ref() != Some(&row) {
ui.sidebar.select_row(Some(&row));
}
}
// The login page has no destination row.
None => ui.sidebar.unselect_all(),
}
}
/// Show a transient toast. Non-blocking by design: an action's outcome is
/// reported without stealing focus or forcing a click, so the user can keep
/// working while a slow upload lands.
fn toast(ui: &Rc<Ui>, message: &str) {
ui.toasts.add_toast(adw::Toast::new(message));
}
/// Show a toast that offers one follow-up action — an Undo, or a way to the page
/// the outcome landed on.
///
/// Given a longer timeout than a plain toast: an action the user has to notice,
/// read and reach is not the same ask as a line of confirmation they can ignore.
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
let toast = adw::Toast::builder()
.title(message)
.button_label(label)
.timeout(8)
.build();
let ui_action = ui.clone();
toast.connect_button_clicked(move |_| action(&ui_action));
ui.toasts.add_toast(toast);
}
/// Show a toast for a failure. Same surface as [`toast`], but the message is
/// prefixed with what was being attempted, since a bare daemon error ("no such
/// file") reads as noise without it.
fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
let detail = detail.trim();
let message = if detail.is_empty() {
what.to_string()
} else {
// Translators: a failed action's toast. {what} says what failed, such as
// "Couldn't rename"; {detail} is the reason, usually untranslated text
// from the mount service.
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
};
tracing::warn!("{message}");
let toast = adw::Toast::builder().title(&message).timeout(6).build();
ui.toasts.add_toast(toast);
}
/// Headline for a failed request, chosen from its [`ErrorKind`] rather than from
/// the text the daemon happened to assemble.
///
/// The daemon's prose names the layer that failed (`"enumerate nodes: …"`), which
/// is right for a log and wrong for a user. `kind` is the part that says what the
/// person in front of the screen should understand, so the headline comes from it
/// and the prose is demoted to the detail line.
///
/// `fallback` is the caller's own description of the action ("Couldn't rename"),
/// used where the class carries no better wording than the caller already has.
fn error_headline(kind: ErrorKind, fallback: &str) -> &str {
match kind {
ErrorKind::Offline => static_gettext(gettext_noop("You're offline")),
ErrorKind::NotFound => static_gettext(gettext_noop("That's not there any more")),
ErrorKind::Denied => static_gettext(gettext_noop("You don't have access to that")),
ErrorKind::Conflict => static_gettext(gettext_noop("Something changed this first")),
ErrorKind::Quota => static_gettext(gettext_noop("Your Proton Drive is full")),
ErrorKind::Invalid | ErrorKind::Remote | ErrorKind::Internal => fallback,
}
}
thread_local! {
/// Translations handed out by [`static_gettext`], one per msgid.
static STATIC_TRANSLATIONS: RefCell<HashMap<&'static str, &'static str>> =
RefCell::new(HashMap::new());
}
/// [`gettext`] for a helper that has to return `&'static str`. The language is
/// fixed for the life of the process, so each msgid is translated once and the
/// result kept; the set of msgids is a handful of literals, so the kept strings
/// are bounded.
fn static_gettext(msgid: &'static str) -> &'static str {
STATIC_TRANSLATIONS.with(|cache| {
*cache
.borrow_mut()
.entry(msgid)
.or_insert_with(|| Box::leak(gettext(msgid).into_boxed_str()))
})
}
/// Report a failed request, letting its class pick the wording.
///
/// Prefer this to [`toast_error`] anywhere a [`Response::Error`] is being shown:
/// being offline is the common case and deserves to read as a state of the
/// network rather than as a fault in whatever the user just did.
fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
match kind {
// The detail here is always some inner layer's EIO. Nothing in it helps.
ErrorKind::Offline => toast_error(ui, error_headline(kind, what), ""),
_ => toast_error(ui, error_headline(kind, what), message),
}
}
/// The sidebar's primary (hamburger) menu: the app-level entries that don't
/// belong on any one page.
fn build_primary_menu() -> gtk4::MenuButton {
let menu = gio::Menu::new();
menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
menu.append(
Some(&gettext("About Proton Drive for Linux")),
Some("win.about"),
);
gtk4::MenuButton::builder()
.icon_name("pdfs-menu-symbolic")
.tooltip_text(gettext("Main menu"))
.primary(true)
.menu_model(&menu)
.build()
}
/// Back the primary and account menus' entries with window actions.
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let preferences = gio::SimpleAction::new("preferences", None);
let ui_prefs = ui.clone();
let win = window.clone();
preferences.connect_activate(move |_, _| {
// Signed out there is nothing to configure: every page needs a session.
if ui_prefs.session.borrow().is_some() {
ui_prefs.status.prefs.present(Some(&win));
}
});
window.add_action(&preferences);
let sign_out_action = gio::SimpleAction::new("sign-out", None);
let ui_out = ui.clone();
sign_out_action.connect_activate(move |_, _| sign_out(&ui_out));
window.add_action(&sign_out_action);
// The Photos page's import banner and page menu lead here. The import is a
// staged, hours-long migration, so it gets its own page rather than a file
// chooser: the archives need reviewing before anything is sent, and the run
// needs somewhere to report.
let show_import = gio::SimpleAction::new("show-import", None);
let ui_import = ui.clone();
show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout"));
window.add_action(&show_import);
// The Photos page menu's Find Duplicates. The finder opens over the page:
// a review, not a view of it.
let find_duplicates = gio::SimpleAction::new("find-duplicates", None);
let ui_duplicates = ui.clone();
find_duplicates.connect_activate(move |_, _| show_duplicates(&ui_duplicates));
window.add_action(&find_duplicates);
// The Albums view's New Album button.
let new_album = gio::SimpleAction::new("new-album", None);
let ui_album = ui.clone();
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
window.add_action(&new_album);
let shortcuts = gio::SimpleAction::new("shortcuts", None);
let win = window.clone();
shortcuts.connect_activate(move |_, _| show_shortcuts(&win));
window.add_action(&shortcuts);
let about = gio::SimpleAction::new("about", None);
let win = window.clone();
let ui_about = ui.clone();
about.connect_activate(move |_, _| {
let dialog = adw::AboutDialog::builder()
.application_name(gettext("Proton Drive for Linux"))
.application_icon("io.narl.proton-drive-linux")
.version(pdfs_core::config::APP_VERSION)
.developer_name("Nils Pukropp")
.website("https://github.com/narrrl/proton-drive-linux")
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
.copyright("© 2026 Nils Pukropp (contact@narl.io)")
.license_type(gtk4::License::MitX11)
.comments(gettext("Files-on-demand Proton Drive for the Linux desktop.\n\nUnofficial client — not affiliated with, endorsed by, or supported by Proton AG."))
.debug_info(debug_info(&ui_about))
.debug_info_filename("proton-drive-linux-debug.txt")
.build();
dialog.add_link(
&gettext("Source Code"),
"https://github.com/narrrl/proton-drive-linux",
);
dialog.add_link(
&gettext("Proton Terms of Service"),
"https://proton.me/legal/terms",
);
dialog.add_link(
&gettext("Proton Privacy Policy"),
"https://proton.me/legal/privacy",
);
// The text is Pango markup: an ampersand must be written as "&amp;".
dialog.add_legal_section(
&gettext("Disclaimer"),
None,
gtk4::License::Custom,
Some(&gettext(
"This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.",
)),
);
dialog.add_legal_section(
"GeoNames",
None,
gtk4::License::Custom,
Some(&gettext(
"Places are named with data from <a href=\"https://www.geonames.org/\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/licenses/by/4.0/\">CC BY 4.0</a>.",
)),
);
dialog.add_legal_section(
"Natural Earth",
None,
gtk4::License::Custom,
Some(&gettext(
"The places map is drawn from <a href=\"https://www.naturalearthdata.com/\">Natural Earth</a> land outlines and country borders, which are in the public domain.",
)),
);
dialog.add_legal_section(
"OpenStreetMap",
None,
gtk4::License::Custom,
Some(&gettext(
"The street map, when turned on, comes from <a href=\"https://openfreemap.org/\">OpenFreeMap</a> in the <a href=\"https://www.openmaptiles.org/\">OpenMapTiles</a> schema. Map data © <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors, available under the Open Database License.",
)),
);
dialog.add_legal_section(
"proton-sdk-rs",
Some("© 2026 Nils Pukropp (contact@narl.io)"),
gtk4::License::MitX11,
None,
);
// Translators: replace with your name(s) and email address(es), one
// per line, to be credited in the About dialog.
let credits = gettext("translator-credits");
if credits != "translator-credits" {
dialog.set_translator_credits(&credits);
}
dialog.present(Some(&win));
});
window.add_action(&about);
}
/// Back the launch arguments (see [`Launch`]) with app actions, so a running
/// instance can act on them.
fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::ApplicationWindow) {
let show_page = gio::SimpleAction::new("show-page", Some(glib::VariantTy::STRING));
let ui_page = ui.clone();
show_page.connect_activate(move |_, param| {
let Some(page) = param.and_then(|p| p.str()) else {
return;
};
// Signed out, the login page owns the window; unknown names are ignored.
if ui_page.session.borrow().is_some() && ui_page.stack.child_by_name(page).is_some() {
ui_page.stack.set_visible_child_name(page);
}
});
app.add_action(&show_page);
let confirm_stop = gio::SimpleAction::new("confirm-stop", None);
let win = window.clone();
let app_stop = app.clone();
let ui_stop = ui.clone();
confirm_stop.connect_activate(move |_, _| {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Stop Proton Drive?"))
.body(gettext("My files goes offline and nothing syncs until you connect again. Proton Drive starts again at your next login."))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("stop", &pgettext("verb", "Stop"));
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let app = app_stop.clone();
let ui = ui_stop.clone();
dialog.connect_response(Some("stop"), move |_, _| {
let app = app.clone();
let ui = ui.clone();
glib::spawn_future_local(async move {
let stopped = gio::spawn_blocking(service::stop).await.unwrap_or(false);
if stopped {
pdfs_core::tray::quit(&ui.dirs);
app.quit();
} else {
toast_error(
&ui,
&gettext("Couldn't stop Proton Drive"),
&gettext("The Proton Drive service did not stop."),
);
}
});
});
dialog.present(Some(&win));
});
app.add_action(&confirm_stop);
}
/// The About dialog's Troubleshooting text: what a bug report needs and what
/// used to sit in a "Developer" group on the Settings page.
fn debug_info(ui: &Rc<Ui>) -> String {
let config = ui.dirs.load_config();
format!(
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
pdfs_core::config::APP_VERSION,
pdfs_core::config::USER_AGENT,
adw::major_version(),
adw::minor_version(),
adw::micro_version(),
gtk4::major_version(),
gtk4::minor_version(),
gtk4::micro_version(),
ui.dirs.resolved_mountpoint(&config).display(),
ui.dirs.control_socket().display(),
if *ui.mounted.borrow() { "yes" } else { "no" },
)
}
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
fn show_shortcuts(window: &adw::ApplicationWindow) {
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
(
gettext_noop("General"),
&[
("<Control>f", gettext_noop("Search Drive")),
("F5", gettext_noop("Refresh")),
("<Control>comma", gettext_noop("Preferences")),
("<Control>question", gettext_noop("Keyboard shortcuts")),
],
),
(
gettext_noop("Files"),
&[
("<Control>n", gettext_noop("New folder")),
("<Control>u", gettext_noop("Upload files")),
("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
("<Alt>Up", gettext_noop("Parent folder")),
("<Control>1 <Control>2", gettext_noop("Grid / list")),
("F2", gettext_noop("Rename")),
("Delete", gettext_noop("Move to Trash")),
("Escape", gettext_noop("Clear the selection")),
],
),
(
gettext_noop("Photos"),
&[
("<Control>plus", gettext_noop("Larger thumbnails")),
("<Control>minus", gettext_noop("Smaller thumbnails")),
("<Control>0", gettext_noop("Reset thumbnail size")),
("<Control>a", gettext_noop("Select all")),
],
),
(
gettext_noop("Photo Viewer"),
&[
("Left Right", gettext_noop("Previous / next photo")),
("Home End", gettext_noop("First / last photo")),
("i", gettext_noop("Show details")),
("f", gettext_noop("Fullscreen")),
("Delete", gettext_noop("Move to Trash")),
("Escape", gettext_noop("Close")),
],
),
];
let page = adw::PreferencesPage::new();
for (title, keys) in GROUPS {
let group = adw::PreferencesGroup::builder()
.title(gettext(title))
.build();
for (accel, action) in keys {
let row = adw::ActionRow::builder().title(gettext(action)).build();
row.add_suffix(
&gtk4::ShortcutLabel::builder()
.accelerator(*accel)
.valign(gtk4::Align::Center)
.build(),
);
group.add(&row);
}
page.add(&group);
}
let dialog = adw::Dialog::builder()
.title(gettext("Keyboard Shortcuts"))
.content_width(460)
.content_height(620)
.child(&{
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&adw::HeaderBar::new());
toolbar.set_content(Some(&page));
toolbar
})
.build();
dialog.present(Some(window));
}
/// Window-level keyboard shortcuts, so the browser is usable without the mouse:
/// Ctrl+F focuses search, Ctrl+N makes a folder, Ctrl+U uploads, F2 renames and
/// Delete trashes the selected entry, Escape closes the details pane.
fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let controller = gtk4::EventControllerKey::new();
let ui = ui.clone();
controller.connect_key_pressed(move |_, key, _, state| {
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
match key {
// Refresh works on every page, so it is matched before the
// browser-only bindings.
gtk4::gdk::Key::F5 => reload_current_page(&ui),
gtk4::gdk::Key::r | gtk4::gdk::Key::R if ctrl => reload_current_page(&ui),
// Search lives on Files, but the shortcut works from anywhere:
// wanting to find a file is not a reason to first have to remember
// which page owns the search box.
gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl => {
if !on_browser {
ui.stack.set_visible_child_name("browser");
}
ui.browser.search.grab_focus();
}
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
gtk4::gdk::Key::_1 if ctrl && on_browser => set_files_view(
&ui,
FilesView {
list: false,
..ui.browser.view.get()
},
),
gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view(
&ui,
FilesView {
list: true,
..ui.browser.view.get()
},
),
gtk4::gdk::Key::Left if alt && on_browser => {
ui.browser.actions.activate_action("back", None)
}
gtk4::gdk::Key::Right if alt && on_browser => {
ui.browser.actions.activate_action("forward", None)
}
gtk4::gdk::Key::Up if alt && on_browser => {
ui.browser.actions.activate_action("up", None)
}
// A focused text field takes Backspace first, so this only fires
// with the focus on the files, as in a file manager.
gtk4::gdk::Key::BackSpace if on_browser => {
ui.browser.actions.activate_action("back", None)
}
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
gtk4::gdk::Key::Menu if on_browser => popup_keyboard_context_menu(&ui),
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter if alt && on_browser => {
toggle_details(&ui)
}
gtk4::gdk::Key::F10
if on_browser && state.contains(gtk4::gdk::ModifierType::SHIFT_MASK) =>
{
popup_keyboard_context_menu(&ui)
}
gtk4::gdk::Key::F2 if on_browser => {
// Renaming is one name at a time; saying so beats a key that
// silently does nothing with several items highlighted.
if selected_entries(&ui).len() > 1 {
toast(&ui, &gettext("Select a single item to rename it"));
} else if let Some(entry) = selected_entry(&ui) {
prompt_rename(&ui, &entry);
}
}
// Acts on the whole selection: Delete with five files highlighted
// means those five, not whichever one the details pane happens to
// be describing.
gtk4::gdk::Key::Delete if on_browser => {
let entries = selected_entries(&ui);
if !entries.is_empty() {
trash_entries(&ui, entries);
}
}
gtk4::gdk::Key::Escape
if on_browser
&& (ui.browser.split.shows_sidebar() || ui.browser.bulk.reveals_child()) =>
{
clear_selection(&ui);
sync_bulk_bar(&ui);
clear_details(&ui);
ui.details.details.toggle.set_active(false);
}
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
});
window.add_controller(controller);
}
/// Send a desktop notification through the app's GIO channel. `id` replaces any
/// earlier notification with the same id, so a flapping mount updates one
/// notification instead of stacking a column of them.
fn notify(id: &str, title: &str, body: &str) {
let Some(app) = gio::Application::default() else {
return;
};
let notification = gio::Notification::new(title);
notification.set_body(Some(body));
notification.set_priority(gio::NotificationPriority::Low);
app.send_notification(Some(id), &notification);
}
/// Run one blocking control-socket round-trip on a worker thread, returning a
/// channel that yields the [`Response`] once. Browser/gallery requests reach the
/// network through the daemon, so they must not block the GTK main loop the way
/// the cheap [`Request::Status`] poll in [`refresh`] can.
fn spawn_request(
socket: PathBuf,
req: Request,
) -> async_channel::Receiver<Result<Response, String>> {
let (tx, rx) = async_channel::bounded(1);
std::thread::spawn(move || {
let result = send(&socket, &req).map_err(|e| e.to_string());
let _ = tx.send_blocking(result);
});
rx
}
/// The top-level window, for parenting dialogs.
fn ui_window(ui: &Rc<Ui>) -> Option<gtk4::Window> {
ui.stack.root().and_downcast::<gtk4::Window>()
}
/// Ask before an action that has no Undo. `on_confirm` runs only when the user
/// picks `action`; Cancel is the default, so Enter never destroys anything.
fn confirm_destructive(
parent: &impl IsA<gtk4::Widget>,
heading: &str,
body: &str,
action: &str,
on_confirm: impl Fn() + 'static,
) {
let dialog = adw::AlertDialog::builder()
.heading(heading)
.body(body)
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("confirm", action);
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
dialog.connect_response(Some("confirm"), move |_, _| on_confirm());
dialog.present(Some(parent));
}
/// A dim, non-interactive placeholder row for an empty section.
fn dim_row(text: &str) -> adw::ActionRow {
let row = adw::ActionRow::builder().title(text).build();
row.add_css_class("dim-label");
row.set_activatable(false);
row
}
/// Open a URL in the user's default browser.
fn open_uri(url: &str) {
if let Err(e) = gio::AppInfo::launch_default_for_uri(url, None::<&gio::AppLaunchContext>) {
tracing::warn!("open uri {url}: {e}");
}
}
/// Uppercase the first character of a role word for read-only display.
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Whether the Proton Drive service is down (failed, or not running at all).
/// Asks systemd on a worker thread: `systemctl` can take seconds to answer,
/// and the window must not freeze while it does.
pub(crate) async fn service_down() -> bool {
gio::spawn_blocking(|| service::is_failed() || !service::is_active())
.await
.unwrap_or(true)
}
/// Restart the Proton Drive service off the main thread, then run `then`.
pub(crate) fn restart_service_then(ui: &Rc<Ui>, then: impl FnOnce(&Rc<Ui>) + 'static) {
let ui = ui.clone();
glib::spawn_future_local(async move {
let _ = gio::spawn_blocking(service::restart).await;
then(&ui);
});
}
/// A page's answer to "the daemon didn't reply". It tells *still starting*
/// (poll again, no button) from *down* (an error with Retry, which restarts the
/// service), so a cold start heals itself but a real failure stays visible.
/// `status` paints the page's status view; `reload` loads the page again, and
/// runs only while `page` is still the one on screen.
pub(crate) fn service_unreachable(
ui: &Rc<Ui>,
page: &'static str,
status: impl Fn(&Rc<Ui>, &str, &str, &str, bool) + 'static,
reload: impl Fn(&Rc<Ui>) + 'static,
) {
let ui = ui.clone();
glib::spawn_future_local(async move {
if service_down().await {
status(
&ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive service isn't running."),
true,
);
return;
}
status(
&ui,
"network-idle-symbolic",
&gettext("Connecting…"),
&gettext("Waiting for the Proton Drive service to start."),
false,
);
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some(page) {
reload(&ui);
}
});
});
}
/// How long a copy button shows its check after copying.
const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
/// Confirm a copy on the button itself: its icon turns into a check for a
/// moment, then back to `icon`.
pub(crate) fn flash_copied(button: &gtk4::Button, icon: &'static str) {
button.set_icon_name("object-select-symbolic");
let button = button.downgrade();
glib::timeout_add_local_once(COPIED_FEEDBACK, move || {
if let Some(button) = button.upgrade() {
button.set_icon_name(icon);
}
});
}
/// Open a local path with the handler the user configured — `xdg-open` unless
/// `open_with` in `config.json` overrides it for this kind of file.
fn open_path(path: &str) {
let path = Path::new(path);
pdfs_core::opener::open_default(path, path.is_dir());
}
/// [`open_path`] for a file the daemon materialised into the content cache,
/// where the on-disk name is a content hash and carries no extension for the
/// rules (or `xdg-open`) to key off. `name` is the Drive name it was opened as.
pub(crate) fn open_named_path(path: &str, name: &str) {
pdfs_core::opener::open_default_named(Path::new(path), name, false);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_tray_can_launch_the_app_on_a_page_or_at_the_stop_prompt() {
let args = |list: &[&str]| list.iter().map(|a| a.to_string()).collect::<Vec<_>>();
assert_eq!(parse_launch(&args(&[])), Launch::Window);
assert_eq!(
parse_launch(&args(&["--page", "locations"])),
Launch::Page("locations".into())
);
assert_eq!(
parse_launch(&args(&["--page=activity"])),
Launch::Page("activity".into())
);
assert_eq!(
parse_launch(&args(&["--confirm-stop"])),
Launch::ConfirmStop
);
// A trailing --page with no name is no request at all.
assert_eq!(parse_launch(&args(&["--page"])), Launch::Window);
}
/// One file of a shot, for the group-switch tests.
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
PhotoItem {
uid: uid.into(),
capture_time: 0,
thumb_path: None,
name: name.map(str::to_string),
ratio: None,
no_thumb: false,
kind,
favorite: false,
group_size: 2,
has_raw: kind == PhotoKind::Raw,
}
}
/// The lightbox steps through a shot's files and comes back round, so a
/// burst of three is as reachable as a RAW+JPEG pair.
#[test]
fn the_group_switch_cycles_through_every_file() {
let members = [
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
member("clip", None, PhotoKind::Video),
];
let next = |uid| next_group_member(&members, uid).map(|item| item.uid.as_str());
assert_eq!(next("jpeg"), Some("raw"));
assert_eq!(next("raw"), Some("clip"));
assert_eq!(next("clip"), Some("jpeg"));
// A photo that is not in the group leaves the button alone rather than
// jumping somewhere arbitrary.
assert_eq!(next("elsewhere"), None);
assert!(next_group_member(&[], "jpeg").is_none());
}
/// The switch names what it will show; a file whose name the daemon has not
/// resolved still gets a sentence rather than a blank.
#[test]
fn a_group_member_without_a_name_is_still_described() {
assert_eq!(
member_label(&member("raw", Some("IMG_1.CR2"), PhotoKind::Raw)),
"IMG_1.CR2"
);
assert_eq!(
member_label(&member("raw", None, PhotoKind::Raw)),
"the raw file"
);
assert_eq!(
member_label(&member("clip", Some(""), PhotoKind::Video)),
"the video"
);
}
}