wired new locations into gui, fuse acceptance with new permission based mounts

This commit is contained in:
nvrl committed 2026-07-31 19:37:12 +02:00
1 parent d78ddeab8e
commit d988236bfa
19 files changed
+1241 -263

No files matched your search

+26 -7
View File
@@ -6,6 +6,7 @@ pub(crate) mod widgets;
use pages::activity::*;
use pages::browser::*;
use pages::devices::*;
use pages::locations::*;
use pages::login::*;
use pages::photo_viewer::*;
use pages::photos::*;
@@ -49,6 +50,8 @@ use pdfs_core::control::{
send,
};
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
use pdfs_core::service;
const APP_ID: &str = "io.narl.proton-drive-linux";
@@ -113,6 +116,7 @@ struct Ui {
pub(crate) shared: SharedState,
pub(crate) shared_by_me: SharedByMeState,
pub(crate) devices: DevicesState,
pub(crate) locations: LocationsState,
pub(crate) activity: ActivityState,
}
@@ -333,6 +337,7 @@ fn build_window(app: &adw::Application) {
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();
stack.add_named(&login_page, Some("login"));
@@ -342,6 +347,7 @@ fn build_window(app: &adw::Application) {
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"));
@@ -513,6 +519,15 @@ fn build_window(app: &adw::Application) {
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),
},
activity: ActivityState {
content: activity_widgets.content.clone(),
status: activity_widgets.status.clone(),
@@ -546,12 +561,8 @@ fn build_window(app: &adw::Application) {
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
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,
&devices_widgets.add_folder,
&devices_widgets.restore,
);
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
wire_activity(&ui, &activity_widgets.retry);
wire_refresh(
&ui,
@@ -562,6 +573,7 @@ fn build_window(app: &adw::Application) {
&shared_widgets.refresh,
&shared_by_me_widgets.refresh,
&devices_widgets.refresh,
&locations_widgets.refresh,
&activity_widgets.refresh,
],
);
@@ -584,6 +596,8 @@ fn build_window(app: &adw::Application) {
Some("shared") => load_shared(&ui_nav),
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
Some("devices") => load_devices(&ui_nav),
Some("locations") if page_fresh(&ui_nav.locations.loaded_at) => {}
Some("locations") => 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.
@@ -626,10 +640,11 @@ fn build_window(app: &adw::Application) {
/// The sidebar destinations, in order: the row index is the index into this table,
/// and each entry is `(stack page name, label, icon)`.
const DESTINATIONS: [(&str, &str, &str); 8] = [
const DESTINATIONS: [(&str, &str, &str); 9] = [
("browser", "My files", "folder-symbolic"),
("sharedbyme", "Shared", "emblem-shared-symbolic"),
("shared", "Shared with me", "system-users-symbolic"),
("locations", "Locations", "drive-harddisk-symbolic"),
("devices", "Computers", "computer-symbolic"),
("gallery", "Photos", "image-x-generic-symbolic"),
("activity", "Activity", "document-open-recent-symbolic"),
@@ -748,6 +763,10 @@ fn reload_current_page(ui: &Rc<Ui>) {
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),
_ => {}
}
+1
View File
@@ -1442,6 +1442,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
cached: false,
uid: h.uid.clone(),
path: h.path.clone(),
role: String::new(),
})
.collect();
entries.sort_by(|a, b| {
+99 -183
View File
@@ -9,6 +9,8 @@ pub(crate) struct DevicesState {
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) sync_group: adw::PreferencesGroup,
/// The single "This computer" summary row. The per-folder rows moved to the
/// Locations page, which owns every local path.
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
/// "Rename" action in the "This computer" header. Insensitive until the
/// device list identifies this machine's own device.
@@ -25,7 +27,8 @@ pub(crate) struct DevicesState {
pub(crate) struct DevicesWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
/// "This computer" — the local folders synced to this machine's device.
/// "This computer" — this machine's device identity and a pointer to its
/// folders on the Locations page.
pub(crate) sync_group: adw::PreferencesGroup,
/// "Rename" in the "This computer" header — renames this machine's device.
pub(crate) rename_this: gtk4::Button,
@@ -33,7 +36,6 @@ pub(crate) struct DevicesWidgets {
/// machine's own device is deliberately not among them; see
/// [`repaint_devices`].
pub(crate) group: adw::PreferencesGroup,
pub(crate) add_folder: gtk4::Button,
/// "Restore Folders" — re-attach this device's remote folders to local
/// directories after adopting it on a new machine.
pub(crate) restore: gtk4::Button,
@@ -54,11 +56,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
titles.set_hexpand(true);
titles.append(&title);
let add_folder = gtk4::Button::builder()
.label("Add Folder")
.valign(gtk4::Align::Center)
.build();
add_folder.add_css_class("flat");
let restore = gtk4::Button::builder()
.label("Restore Folders")
.tooltip_text("Sync this computer's Drive folders back to local directories")
@@ -71,18 +68,14 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
header.append(&titles);
header.append(&refresh);
header.append(&restore);
header.append(&add_folder);
// The description spells out what the per-row On-demand switch does to the
// local copy, because the switch itself can't: turning it on *deletes* the
// files from this disk, which is not something to discover afterwards.
// This page is about device *identity* — which computer this is, which other
// computers back up to the account, and how to adopt one. The folders
// themselves are local paths, so they live on the Locations page; keeping a
// second copy of that list here would be two places to change the same mode.
let sync_group = adw::PreferencesGroup::builder()
.title("This computer")
.description(
"Local folders backed up to this device. Synced folders keep a full copy on \
this disk; on-demand folders keep the files in Proton Drive only and fetch \
them as you open them.",
)
.description("The device this machine backs up to.")
.build();
// Rename control for *this* machine's device, in the section header. The
// current device is filtered out of "Other computers", so this is the only
@@ -145,7 +138,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
sync_group,
rename_this,
group,
add_folder,
restore,
retry,
refresh,
@@ -153,20 +145,13 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
)
}
/// Install the Devices page's retry button and the "Add Folder" action.
pub(crate) fn wire_devices(
ui: &Rc<Ui>,
retry: &gtk4::Button,
add_folder: &gtk4::Button,
restore: &gtk4::Button,
) {
/// Install the Devices page's retry button and the "Restore Folders" action.
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button, restore: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_devices(&ui_retry);
});
let ui_add = ui.clone();
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
let ui_restore = ui.clone();
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
let ui_ren = ui.clone();
@@ -224,9 +209,9 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
return;
}
};
// Daemon reachable: show the list and paint the synced-folders section.
// Daemon reachable: show the list and paint the "This computer" section.
ui.devices.content.set_visible_child_name("list");
repaint_sync_folders(&ui, &folders);
repaint_this_computer(&ui, &folders);
let devices = match devices_reply {
Ok(Ok(Response::Devices { items })) => items,
@@ -239,35 +224,6 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
});
}
/// Refresh just the synced-folders section, with no status flash, no spinner and
/// no devices round-trip. Driven by the periodic tick while the Devices page is
/// on screen: a folder's sync progress is only live if something re-reads it.
///
/// A hiccup leaves the page as it is — the next tick tries again, and a real
/// outage is reported by [`load_devices`] on the next visit.
pub(crate) fn refresh_sync_folders(ui: &Rc<Ui>) {
if ui.devices.inflight.get() {
return;
}
ui.devices.inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.devices.inflight.set(false);
let Ok(Ok(Response::SyncFolders { items })) = result else {
return;
};
// The page may have been navigated away from, or collapsed to a status
// view, while the request was in flight.
if ui.stack.visible_child_name().as_deref() == Some("devices")
&& ui.devices.content.visible_child_name().as_deref() == Some("list")
{
repaint_sync_folders(&ui, &items);
}
});
}
/// The daemon didn't answer the Devices page.
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
@@ -398,137 +354,53 @@ pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
}
}
/// Rebuild the "This computer" section from a fresh synced-folders listing.
pub(crate) fn repaint_sync_folders(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
/// Rebuild the "This computer" section: one row naming the device this machine
/// backs up to, and how many folders it carries.
///
/// The per-folder rows (mode switch, sync now, remove) live on the Locations
/// page. They are local paths, and Locations is the one page that owns those;
/// two lists of the same folders would be two places to flip the same switch.
pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
ui.devices.sync_group.remove(&row);
}
let row = adw::ActionRow::builder()
.title("This computer")
.subtitle(this_computer_subtitle(folders))
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let manage = gtk4::Button::builder()
.label("Locations")
.tooltip_text("Manage this computer's folders and mountpoint")
.valign(gtk4::Align::Center)
.build();
manage.add_css_class("flat");
let ui_go = ui.clone();
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
row.add_suffix(&manage);
ui.devices.sync_group.add(&row);
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
}
/// How many folders this machine backs up, and whether any of them needs
/// attention — the one fact worth surfacing away from the folder list itself.
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
if folders.is_empty() {
let row = adw::ActionRow::builder()
.title("No synced folders")
.subtitle("Add a folder to back it up to this device.")
.build();
row.add_prefix(&gtk4::Image::from_icon_name("folder-symbolic"));
ui.devices.sync_group.add(&row);
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
return;
return "No folders backed up yet — add one under Locations.".to_string();
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for f in folders {
// A running pass describes itself ("Uploading photo.jpg — 12 of 40");
// otherwise fall back to the folder's resting state.
let status = match &f.progress {
Some(p) => sync_progress_label(p),
None => sync_state_label(&f.state).to_string(),
};
// A queued switch is where the folder is *going*, which is what the user
// just asked for and is waiting to see happen — so it leads the subtitle
// instead of the mode the folder is still technically in.
let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) {
(Some("ondemand"), _) => format!("Going on-demand · {status}"),
(Some(_), _) => format!("Switching to synced · {status}"),
(None, "ondemand") => format!("On-demand · {status}"),
(None, _) => format!("Synced · {status}"),
};
let row = adw::ActionRow::builder()
.title(&f.local_path)
.subtitle(&subtitle)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("folder-symbolic"));
let id = f.id;
// A pass that knows how far it has got shows it: the subtitle alone
// ("syncing photo.jpg — 12 of 40") never conveys that the end is near. This
// holds for scanning as much as applying — a big folder spends minutes there
// — but a folder syncing for the first time has no estimate to draw against
// (`total == 0`), and this row is repainted on the 2s tick, too slow for a
// pulse to read as motion. So it stays text-only until real counts exist.
if let Some(p) = &f.progress
&& p.total > 0
{
let bar = gtk4::ProgressBar::builder()
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
.valign(gtk4::Align::Center)
.width_request(120)
.build();
row.add_suffix(&bar);
}
// Sync now — only meaningful for mirror folders (on-demand has no local
// copy to reconcile). The engine also syncs on file changes and on a
// 120s poll; this is the "don't wait" button.
if f.mode != "ondemand" {
let sync_now = gtk4::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text("Sync this folder now")
.valign(gtk4::Align::Center)
.build();
sync_now.add_css_class("flat");
let ui_sync = ui.clone();
sync_now.connect_clicked(move |_| {
let rx = spawn_request(
ui_sync.dirs.control_socket(),
Request::SyncNow { id: Some(id) },
);
let ui_sync = ui_sync.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast(&ui_sync, "Syncing folder…"),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui_sync, "Couldn't sync", &message, kind)
}
_ => toast_error(
&ui_sync,
"Couldn't sync",
"The mount service didn't respond.",
),
}
});
});
row.add_suffix(&sync_now);
}
// On-demand toggle: off = full local copy (mirror), on = FUSE mount that
// frees the disk. Set the state before wiring the handler so painting the
// current mode doesn't fire a spurious request.
//
// A queued switch paints as already flipped: the daemon accepted the
// request and will act on it, so snapping the switch back to the current
// mode would read as "it didn't take" and invite the user to toggle again.
let target = f.pending_mode.as_deref().unwrap_or(&f.mode);
let ondemand = gtk4::Switch::builder()
.tooltip_text(
"On-demand: free this disk by keeping the files in Proton Drive only, \
fetching each as you open it. Turn off to download them back and keep a \
full local copy.",
)
.valign(gtk4::Align::Center)
.active(target == "ondemand")
.build();
let ui_mode = ui.clone();
ondemand.connect_state_set(move |_, on| {
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
glib::Propagation::Proceed
});
row.add_suffix(&ondemand);
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Stop syncing this folder")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let ui_rm = ui.clone();
let path = f.local_path.clone();
// The folder's *current* mode, not a queued one: a switch that hasn't
// landed yet has not moved the files anywhere.
let ondemand = f.mode == "ondemand";
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, ondemand));
row.add_suffix(&remove);
ui.devices.sync_group.add(&row);
rows.push(row.upcast());
let count = match folders.len() {
1 => "1 folder backed up".to_string(),
n => format!("{n} folders backed up"),
};
let attention = folders
.iter()
.filter(|f| f.state == "error" || f.state == "conflict")
.count();
match attention {
0 => format!("{count} · manage them under Locations"),
1 => format!("{count} · 1 needs attention"),
n => format!("{count} · {n} need attention"),
}
*ui.devices.sync_rows.borrow_mut() = rows;
}
/// Human label for a synced folder's `state` column.
@@ -592,7 +464,7 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
// device and creating the remote folder is off-socket network
// work. A fixed delay here either fires too early (empty list)
// or too late (row flashes in). Instead let the periodic
// `refresh_sync_folders` tick pick the row up whenever it
// `refresh_locations` tick pick the row up whenever it
// actually lands, which is what keeps a running pass live too.
toast(&ui, "Syncing folder…");
}
@@ -942,3 +814,47 @@ pub(crate) fn run_devices_mutation(
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(state: &str) -> SyncFolderInfo {
SyncFolderInfo {
id: 1,
local_path: "/home/u/Documents".into(),
remote_uid: "vol~link".into(),
mode: "mirror".into(),
state: state.into(),
last_sync: 0,
pending_mode: None,
progress: None,
}
}
#[test]
fn a_machine_with_no_folders_is_told_where_to_add_one() {
assert_eq!(
this_computer_subtitle(&[]),
"No folders backed up yet — add one under Locations."
);
}
#[test]
fn folders_needing_attention_are_counted_not_hidden() {
// The per-folder rows moved to Locations, so this row is the only place
// the Computers page can surface that something is wrong.
assert_eq!(
this_computer_subtitle(&[folder("idle"), folder("idle")]),
"2 folders backed up · manage them under Locations"
);
assert_eq!(
this_computer_subtitle(&[folder("idle"), folder("error")]),
"2 folders backed up · 1 needs attention"
);
assert_eq!(
this_computer_subtitle(&[folder("conflict"), folder("error")]),
"2 folders backed up · 2 need attention"
);
}
}
+536
View File
@@ -0,0 +1,536 @@
//! The Locations page: every local place Proton Drive occupies on this machine.
//!
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
//! a mirrored local directory or an on-demand FUSE session. The page is called
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
//! with no FUSE session behind it (mount-architecture.md §4).
//!
//! The device rows drive the same control requests the Computers page used to:
//! mode switch, sync now, remove. What is new here is that the primary mount is
//! listed alongside them, with the mountpoint chooser that used to live in
//! Settings.
use crate::*;
pub(crate) struct LocationsState {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>,
}
/// Widgets the Locations page's load/repaint touch.
pub(crate) struct LocationsWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) group: adw::PreferencesGroup,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
pub(crate) add_folder: gtk4::Button,
}
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
let title = gtk4::Label::builder()
.label("Locations")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let add_folder = gtk4::Button::builder()
.label("Add Folder")
.tooltip_text("Back a local folder up to this computer's Proton Drive device")
.valign(gtk4::Align::Center)
.build();
add_folder.add_css_class("flat");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
header.append(&titles);
header.append(&refresh);
header.append(&add_folder);
// Clamped to the same width as the rows below, or the page title floats off
// to the left of the list it names.
let header_clamp = adw::Clamp::builder().child(&header).build();
// Same warning the Computers page carried, for the same reason: the
// on-demand switch removes the local copy, which is not a thing to discover
// after the fact.
let group = adw::PreferencesGroup::builder()
.title("On this computer")
.description(
"Where Proton Drive lives on this machine. Synced folders keep a full copy on \
this disk; on-demand folders keep the files in Proton Drive only and fetch \
them as you open them.",
)
.build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&group);
let clamp = adw::Clamp::builder().child(&groups).build();
// Never scroll sideways: a location's title is a full path, and letting the
// row grow to fit one pushes the controls at its end off screen. Constrained,
// the path ellipsizes and the switch/remove/open stay reachable.
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("drive-harddisk-symbolic")
.title("Loading…")
.child(&retry)
.build();
// A bare `StatusPage` asks for more width than a half-screen window has, and
// a `Stack` is as wide as its widest child — so an unwrapped status page
// silently widens the *list* page too, pushing each row's controls off the
// right edge. Scrolling it (never horizontally) caps that demand.
let status_scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&status)
.build();
let content = gtk4::Stack::new();
content.add_named(&scroll, Some("list"));
content.add_named(&status_scroll, Some("status"));
let page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
page.set_margin_top(18);
page.set_margin_bottom(18);
page.set_margin_start(18);
page.set_margin_end(18);
page.append(&header_clamp);
page.append(&content);
let widgets = LocationsWidgets {
content: content.clone(),
status: status.clone(),
group: group.clone(),
retry: retry.clone(),
refresh,
add_folder,
};
(page.upcast(), widgets)
}
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
ui_retry.locations.loaded_at.set(None);
load_locations(&ui_retry);
});
let ui_add = ui.clone();
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
}
/// Show a status page in place of the locations list.
pub(crate) fn locations_status(
ui: &Rc<Ui>,
icon: &str,
title: &str,
description: &str,
retry: bool,
) {
ui.locations.status.set_icon_name(Some(icon));
ui.locations.status.set_title(title);
ui.locations.status.set_description(Some(description));
ui.locations.retry.set_visible(retry);
ui.locations.content.set_visible_child_name("status");
}
/// Fetch every local location and repaint the list.
pub(crate) fn load_locations(ui: &Rc<Ui>) {
if ui.locations.inflight.get() {
return;
}
ui.locations.inflight.set(true);
locations_status(
ui,
"drive-harddisk-symbolic",
"Loading…",
"Reading this computer's Proton Drive locations.",
false,
);
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.locations.inflight.set(false);
match result {
Ok(Ok(Response::Locations { items })) => {
ui.locations.content.set_visible_child_name("list");
repaint_locations(&ui, &items);
ui.locations.loaded_at.set(Some(Instant::now()));
}
Ok(Ok(Response::Error { message, .. })) => {
ui.locations.loaded_at.set(None);
locations_status(
&ui,
"dialog-warning-symbolic",
"Unavailable",
&message,
true,
);
}
_ => {
ui.locations.loaded_at.set(None);
locations_unreachable(&ui);
}
}
});
}
/// Refresh the rows with no status flash and no spinner, from the periodic tick:
/// a sync pass's progress is only live if something re-reads it.
pub(crate) fn refresh_locations(ui: &Rc<Ui>) {
if ui.locations.inflight.get() {
return;
}
ui.locations.inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.locations.inflight.set(false);
let Ok(Ok(Response::Locations { items })) = result else {
return;
};
// The page may have been navigated away from, or collapsed to a status
// view, while the request was in flight.
if ui.stack.visible_child_name().as_deref() == Some("locations")
&& ui.locations.content.visible_child_name().as_deref() == Some("list")
{
repaint_locations(&ui, &items);
}
});
}
/// The daemon didn't answer the Locations page.
pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
locations_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
locations_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("locations") {
load_locations(&ui);
}
});
}
pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
for row in ui.locations.rows.borrow_mut().drain(..) {
ui.locations.group.remove(&row);
}
if items.is_empty() {
let row = adw::ActionRow::builder()
.title("No locations")
.subtitle("The mount service hasn't reported a mountpoint yet.")
.build();
row.add_prefix(&gtk4::Image::from_icon_name("drive-harddisk-symbolic"));
ui.locations.group.add(&row);
*ui.locations.rows.borrow_mut() = vec![row.upcast()];
return;
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for spec in items {
let row = adw::ActionRow::builder()
.title(&spec.local_path)
.subtitle(location_subtitle(spec))
// One line each: a wrapped path would reflow the whole list every
// time a sync state changed under it.
.title_lines(1)
.subtitle_lines(1)
.build();
row.add_prefix(&gtk4::Image::from_icon_name(location_icon(&spec.kind)));
// A read-only location cannot be written through even where the files are
// visible; saying so on the row is cheaper than letting the user find out
// from a save dialog.
if spec.access == MountAccess::Ro {
let badge = gtk4::Label::new(Some("Read-only"));
badge.add_css_class("dim-label");
badge.add_css_class("caption");
badge.set_valign(gtk4::Align::Center);
row.add_suffix(&badge);
}
// Same rule as the Computers page: a first pass has no estimate to draw
// against, so the bar appears only once real counts exist.
if let Some(p) = &spec.progress
&& p.total > 0
{
let bar = gtk4::ProgressBar::builder()
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
.valign(gtk4::Align::Center)
.width_request(120)
.build();
row.add_suffix(&bar);
}
match &spec.kind {
MountKind::MyFiles => add_my_files_controls(ui, &row),
MountKind::Device { sync_folder_id } => {
add_device_controls(ui, &row, spec, *sync_folder_id)
}
// A standalone shared mount has no local mode to switch and is not
// this device's to remove.
MountKind::Shared { .. } => {}
}
let open = gtk4::Button::builder()
.icon_name("folder-open-symbolic")
.tooltip_text("Open this folder")
.valign(gtk4::Align::Center)
.build();
open.add_css_class("flat");
let path = spec.local_path.clone();
open.connect_clicked(move |_| open_path(&path));
row.add_suffix(&open);
ui.locations.group.add(&row);
rows.push(row.upcast());
}
*ui.locations.rows.borrow_mut() = rows;
}
/// The primary mount's only control: where it lives. Changing it rewrites config
/// and offers a service restart, which is why it stays a single prompt shared
/// with the (now removed) Settings row.
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
let change = gtk4::Button::builder()
.label("Change")
.tooltip_text("Choose a different folder for the Proton Drive mount")
.valign(gtk4::Align::Center)
.build();
change.add_css_class("flat");
let ui_mp = ui.clone();
change.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
row.add_suffix(&change);
}
/// Sync now (mirror only), the on-demand switch, and Remove — the same three
/// controls, and the same handlers, the Computers page used to carry.
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
if spec.mode != MountMode::OnDemand {
let sync_now = gtk4::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text("Sync this folder now")
.valign(gtk4::Align::Center)
.build();
sync_now.add_css_class("flat");
let ui_sync = ui.clone();
sync_now.connect_clicked(move |_| sync_folder_now(&ui_sync, id));
row.add_suffix(&sync_now);
}
// A queued switch paints as already flipped: the daemon accepted it and will
// act on it, so snapping back would read as "it didn't take". The state is
// set before the handler is wired so painting the current mode doesn't fire
// a spurious request.
let target = spec.pending_mode.unwrap_or(spec.mode);
let ondemand = gtk4::Switch::builder()
.tooltip_text(
"On-demand: free this disk by keeping the files in Proton Drive only, \
fetching each as you open it. Turn off to download them back and keep a \
full local copy.",
)
.valign(gtk4::Align::Center)
.active(target == MountMode::OnDemand)
.build();
let ui_mode = ui.clone();
ondemand.connect_state_set(move |_, on| {
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
glib::Propagation::Proceed
});
row.add_suffix(&ondemand);
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Stop syncing this folder")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let ui_rm = ui.clone();
let path = spec.local_path.clone();
// The folder's *current* mode, not a queued one: a switch that hasn't landed
// yet has not moved the files anywhere.
let is_ondemand = spec.mode == MountMode::OnDemand;
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, is_ondemand));
row.add_suffix(&remove);
}
/// Ask the daemon for an immediate pass over one folder.
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Syncing folder…"),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't sync", &message, kind)
}
_ => toast_error(&ui, "Couldn't sync", "The mount service didn't respond."),
}
});
}
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
match kind {
MountKind::MyFiles => "folder-remote-symbolic",
MountKind::Device { .. } => "folder-symbolic",
MountKind::Shared { .. } => "system-users-symbolic",
}
}
/// One line describing what a location *is* and what it is doing.
///
/// Ordered by how likely it is to be what the user came to check: a queued mode
/// switch leads (they just asked for it), then the resting mode, then the sync
/// state, then — only when it is surprising — the fact that no session owns the
/// path. A mirror folder is a plain directory with no FUSE session, so "not
/// mounted" is its normal state and saying it would be noise.
pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
let mut parts: Vec<String> = Vec::new();
match &spec.kind {
MountKind::MyFiles => parts.push("My files".to_string()),
MountKind::Shared { .. } => parts.push("Shared folder".to_string()),
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
(Some(MountMode::OnDemand), _) => parts.push("Going on-demand".to_string()),
(Some(MountMode::Mirror), _) => parts.push("Switching to synced".to_string()),
(Some(MountMode::Unknown) | None, MountMode::OnDemand) => {
parts.push("On-demand".to_string())
}
(Some(MountMode::Unknown) | None, _) => parts.push("Synced".to_string()),
},
}
if matches!(spec.kind, MountKind::Device { .. }) {
parts.push(match &spec.progress {
Some(p) => sync_progress_label(p),
None => sync_state_label(&spec.state).to_string(),
});
}
// Only the locations that are supposed to have a session report its absence:
// a mirror folder never has one.
let expects_session = !matches!(spec.kind, MountKind::Device { .. })
|| spec.mode == MountMode::OnDemand
|| spec.pending_mode == Some(MountMode::OnDemand);
if expects_session && !spec.mounted {
parts.push("not mounted".to_string());
}
parts.join(" · ")
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(kind: MountKind, mode: MountMode, mounted: bool) -> MountSpec {
MountSpec {
id: 1,
kind,
local_path: "/home/u/ProtonDrive".into(),
root_uid: "vol~link".into(),
root_share_id: "share".into(),
mode,
access: MountAccess::Rw,
state: "idle".into(),
last_sync: 0,
pending_mode: None,
mounted,
progress: None,
}
}
#[test]
fn a_mirror_folder_is_never_reported_as_unmounted() {
// It is a plain local directory: no FUSE session is expected, so saying
// "not mounted" would describe every healthy mirror folder as broken.
let spec = spec(
MountKind::Device { sync_folder_id: 7 },
MountMode::Mirror,
false,
);
assert_eq!(location_subtitle(&spec), "Synced · up to date");
}
#[test]
fn an_on_demand_folder_without_a_session_says_so() {
let spec = spec(
MountKind::Device { sync_folder_id: 7 },
MountMode::OnDemand,
false,
);
assert_eq!(
location_subtitle(&spec),
"On-demand · up to date · not mounted"
);
}
#[test]
fn a_queued_switch_leads_the_subtitle() {
let mut spec = spec(
MountKind::Device { sync_folder_id: 7 },
MountMode::Mirror,
true,
);
spec.pending_mode = Some(MountMode::OnDemand);
assert!(location_subtitle(&spec).starts_with("Going on-demand"));
spec.mode = MountMode::OnDemand;
spec.pending_mode = Some(MountMode::Mirror);
assert!(location_subtitle(&spec).starts_with("Switching to synced"));
}
/// An unrecognised persisted mode must not silently read as "Synced" *and*
/// must not lose the rest of the line.
#[test]
fn the_primary_mount_reports_only_its_own_facts() {
let mut spec = spec(MountKind::MyFiles, MountMode::OnDemand, true);
assert_eq!(location_subtitle(&spec), "My files");
spec.mounted = false;
assert_eq!(location_subtitle(&spec), "My files · not mounted");
}
#[test]
fn sync_state_reaches_the_subtitle() {
let mut spec = spec(
MountKind::Device { sync_folder_id: 2 },
MountMode::Mirror,
true,
);
spec.state = "conflict".into();
assert_eq!(location_subtitle(&spec), "Synced · needs attention");
}
}
+1
View File
@@ -1,6 +1,7 @@
pub(crate) mod activity;
pub(crate) mod browser;
pub(crate) mod devices;
pub(crate) mod locations;
pub(crate) mod login;
pub(crate) mod photo_viewer;
pub(crate) mod photos;
+95 -2
View File
@@ -298,8 +298,15 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
.title(&entry.name)
.activatable(true)
.build();
if !entry.is_dir {
row.set_subtitle(&human_bytes(entry.size));
let subtitle = shared_row_subtitle(entry);
if !subtitle.is_empty() {
row.set_subtitle(&subtitle);
}
// What I am allowed to do with someone else's file is the thing this page
// cannot leave implicit: a viewer share is browsable and unwritable, and
// that is only visible if it is said.
if let Some(badge) = role_badge(&entry.role) {
row.add_suffix(&badge);
}
row.add_prefix(&gtk4::Image::from_icon_name(if entry.is_dir {
"folder-symbolic"
@@ -325,6 +332,43 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
row
}
/// A shared row's subtitle: its size (files only), and where it can be reached
/// in the local mount once the synthetic `Shared with me/` directory has interned
/// it. An unlisted share has no local path yet, and says nothing rather than
/// pointing at a directory that is not there.
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
let mut parts: Vec<String> = Vec::new();
if !entry.is_dir {
parts.push(human_bytes(entry.size));
}
if !entry.path.is_empty() {
parts.push(format!("in {}", entry.path));
}
parts.join(" · ")
}
/// The display name for a wire role, or `None` when there is nothing to show:
/// owned content carries no role, and a role the API did not report is not
/// guessed at (the SDK's `from_permissions_exact` returns `None` for an
/// unrecognised mask rather than degrading it to viewer).
pub(crate) fn role_label(role: &str) -> Option<&'static str> {
match role {
"viewer" => Some("Viewer"),
"editor" => Some("Editor"),
"admin" => Some("Admin"),
_ => None,
}
}
/// A dim role pill for a share I did not create.
pub(crate) fn role_badge(role: &str) -> Option<gtk4::Label> {
let badge = gtk4::Label::new(Some(role_label(role)?));
badge.add_css_class("dim-label");
badge.add_css_class("caption");
badge.set_valign(gtk4::Align::Center);
Some(badge)
}
/// Download a file shared with me into the daemon's cache and hand it to the
/// user's default application — the shared-item twin of the browser's
/// download-and-open, addressed by uid because the file lives outside the mount.
@@ -663,3 +707,52 @@ pub(crate) fn run_shared_mutation(
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(is_dir: bool, path: &str, role: &str) -> DirEntry {
DirEntry {
name: "Budget.ods".into(),
is_dir,
size: 2048,
modified: 0,
pinned: false,
cached: false,
uid: "vol~link".into(),
path: path.into(),
role: role.into(),
}
}
#[test]
fn a_share_with_no_local_path_says_nothing_about_one() {
// Before the synthetic directory has been listed, the daemon cannot
// resolve a path — pointing at one that isn't there would be worse than
// silence.
assert_eq!(shared_row_subtitle(&entry(true, "", "viewer")), "");
assert_eq!(
shared_row_subtitle(&entry(false, "", "viewer")),
human_bytes(2048)
);
}
#[test]
fn a_resident_share_reports_where_it_can_be_reached() {
let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor"));
assert_eq!(subtitle, "in Shared with me/Team Budget");
}
#[test]
fn only_a_known_role_becomes_a_badge() {
// "" is owned content (no role applies) and an unrecognised mask is
// deliberately not guessed at — `from_permissions_exact` returns None
// rather than degrading to viewer, and this must not undo that.
for role in ["viewer", "editor", "admin"] {
assert!(role_label(role).is_some(), "{role} should have a label");
}
assert!(role_label("").is_none());
assert!(role_label("inherited").is_none());
}
}
+14 -13
View File
@@ -236,19 +236,18 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
row.add_suffix(&open);
}
// Manage opens the per-node Share dialog — only when the node's path is
// known (it has been browsed to this session); the dialog is path-keyed.
if !item.path.is_empty() {
let manage = gtk4::Button::builder()
.label("Manage")
.valign(gtk4::Align::Center)
.build();
manage.add_css_class("flat");
let ui_manage = ui.clone();
let entry = shared_item_as_entry(item);
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
row.add_suffix(&manage);
}
// Manage opens the per-node Share dialog. It used to be offered only for
// an item whose path the daemon could resolve; the dialog now addresses a
// pathless node by uid, so every shared item can be managed from here.
let manage = gtk4::Button::builder()
.label("Manage")
.valign(gtk4::Align::Center)
.build();
manage.add_css_class("flat");
let ui_manage = ui.clone();
let entry = shared_item_as_entry(item);
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
row.add_suffix(&manage);
ui.shared_by_me.group.add(&row);
rows.push(row.upcast());
@@ -295,5 +294,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
cached: false,
uid: item.uid.clone(),
path: item.path.clone(),
// Shared *by* me: I own it, so there is no role of mine to report.
role: String::new(),
}
}
+20 -8
View File
@@ -31,8 +31,8 @@ pub(crate) struct StatusState {
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
pub(crate) budget_row: adw::SpinRow,
/// Shows the active mountpoint in its subtitle; updated when the user picks a
/// new folder.
/// Shows where the primary mount lives; the folder itself is managed on the
/// Locations page, which owns every local path.
pub(crate) mountpoint_row: adw::ActionRow,
/// Set while a settings widget is being populated programmatically, so its
/// change handler skips the IPC/systemd side effect.
@@ -100,7 +100,8 @@ pub(crate) struct MainWidgets {
pub(crate) budget_row: adw::SpinRow,
/// Purges all unpinned cached content.
pub(crate) purge_button: gtk4::Button,
/// Shows the active mountpoint; its suffix button opens a folder chooser.
/// Shows the active mountpoint; its suffix button opens the Locations page,
/// which is where the folder is changed.
pub(crate) mountpoint_row: adw::ActionRow,
pub(crate) mountpoint_button: gtk4::Button,
}
@@ -215,12 +216,17 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
.subtitle("Mount Proton Drive automatically when you log in.")
.build();
system_group.add(&autostart_row);
// The mountpoint is a *location*, and every other local path this client
// owns is managed on the Locations page. Keeping a second chooser here would
// be a second source of truth for the same setting, so this row reports the
// path and hands the change over.
let mountpoint_row = adw::ActionRow::builder()
.title("Mountpoint")
.subtitle("—")
.build();
let mountpoint_button = gtk4::Button::builder()
.label("Change")
.label("Locations")
.tooltip_text("Manage this computer's Proton Drive locations")
.valign(gtk4::Align::Center)
.build();
mountpoint_button.add_css_class("flat");
@@ -372,10 +378,10 @@ pub(crate) fn wire_settings(
}
});
// Mountpoint: pick a folder, persist it, and offer to restart the mount so
// the change takes effect (the daemon reads the path on mount).
// Mountpoint: the chooser lives on the Locations page, next to every other
// local path; this button is the way there.
let ui_mp = ui.clone();
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
}
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
@@ -423,6 +429,12 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
return;
}
ui.status.mountpoint_row.set_subtitle(&path_str);
// The Locations page shows the same path as a row title; its cached rows
// are now stale whether or not it is the visible page.
ui.locations.loaded_at.set(None);
if ui.stack.visible_child_name().as_deref() == Some("locations") {
load_locations(&ui);
}
// The daemon only reads the mountpoint at mount time, so offer a restart.
let confirm = adw::AlertDialog::builder()
@@ -503,7 +515,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
// they are on screen. Every other page loads on navigation only.
match ui.stack.visible_child_name().as_deref() {
Some("main") => refresh_quota(ui),
Some("devices") => refresh_sync_folders(ui),
Some("locations") => refresh_locations(ui),
Some("activity") => refresh_activity(ui),
_ => {}
}
+123 -36
View File
@@ -21,13 +21,117 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 {
}
}
/// How an open Share dialog addresses its node.
///
/// A node the browser is showing has a mountpoint-relative path. A node reached
/// from Shared or Shared-with-me may not: it can live under a device folder's
/// own inode space, or on someone else's volume, neither of which the primary
/// mount's path space can name. Those carry a uid instead, and the daemon
/// resolves it with `Core::resolve_anywhere` (mount-architecture.md P4).
///
/// The two are separate request variants rather than one request with an
/// optional field on purpose: an older daemon that ignored `uid` would resolve
/// the empty path to the *mount root* and act on the wrong node.
pub(crate) enum ShareTarget {
Path(String),
Uid(String),
}
impl ShareTarget {
fn list(&self) -> Request {
match self {
ShareTarget::Path(path) => Request::ListShare { path: path.clone() },
ShareTarget::Uid(uid) => Request::ListShareByUid { uid: uid.clone() },
}
}
fn invite(&self, emails: Vec<String>, role: String, message: Option<String>) -> Request {
match self {
ShareTarget::Path(path) => Request::ShareNode {
path: path.clone(),
emails,
role,
message,
},
ShareTarget::Uid(uid) => Request::ShareNodeByUid {
uid: uid.clone(),
emails,
role,
message,
},
}
}
fn update_role(&self, id: String, kind: ShareEntryKind, role: String) -> Request {
match self {
ShareTarget::Path(path) => Request::UpdateShareRole {
path: path.clone(),
id,
kind,
role,
},
ShareTarget::Uid(uid) => Request::UpdateShareRoleByUid {
uid: uid.clone(),
id,
kind,
role,
},
}
}
fn remove_entry(&self, id: String, kind: ShareEntryKind) -> Request {
match self {
ShareTarget::Path(path) => Request::RemoveShareEntry {
path: path.clone(),
id,
kind,
},
ShareTarget::Uid(uid) => Request::RemoveShareEntryByUid {
uid: uid.clone(),
id,
kind,
},
}
}
fn create_link(&self, role: String, password: Option<String>) -> Request {
match self {
ShareTarget::Path(path) => Request::CreatePublicLink {
path: path.clone(),
role,
password,
expires: None,
},
ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid {
uid: uid.clone(),
role,
password,
expires: None,
},
}
}
fn remove_link(&self, id: String) -> Request {
match self {
ShareTarget::Path(path) => Request::RemovePublicLink {
path: path.clone(),
id,
},
ShareTarget::Uid(uid) => Request::RemovePublicLinkByUid {
uid: uid.clone(),
id,
},
}
}
}
/// The mutable state behind an open Share dialog, so the invite/role/link
/// handlers can rebuild the people and link sections after each change without
/// tearing the whole dialog down.
pub(crate) struct ShareDialog {
pub(crate) ui: Rc<Ui>,
/// The node's mountpoint-relative path — how every request addresses it.
pub(crate) rel: String,
/// How every request from this dialog addresses the node.
pub(crate) target: ShareTarget,
pub(crate) people: adw::PreferencesGroup,
pub(crate) link_group: adw::PreferencesGroup,
pub(crate) people_rows: RefCell<Vec<gtk4::Widget>>,
@@ -41,7 +145,13 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
return;
}
let rel = entry_rel(ui, entry);
// A node with no path is not a bug here: Shared and Shared-with-me list
// nodes that the primary mount never interned. Address those by uid.
let target = if entry.path.is_empty() {
ShareTarget::Uid(entry.uid.clone())
} else {
ShareTarget::Path(entry_rel(ui, entry))
};
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
@@ -110,7 +220,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
let state = Rc::new(ShareDialog {
ui: ui.clone(),
rel,
target,
people,
link_group,
people_rows: RefCell::new(Vec::new()),
@@ -144,12 +254,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
let msg_clear = message_row.clone();
share_dialog_op(
&state_inv,
Request::ShareNode {
path: state_inv.rel.clone(),
emails,
role,
message,
},
state_inv.target.invite(emails, role, message),
"Invitations sent",
"Couldn't send invitations",
Some(Box::new(move || {
@@ -165,12 +270,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
/// Re-fetch the node's share and rebuild the people + public-link sections.
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
let rx = spawn_request(
state.ui.dirs.control_socket(),
Request::ListShare {
path: state.rel.clone(),
},
);
let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
@@ -232,12 +332,11 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
drop.connect_selected_notify(move |d| {
share_dialog_op(
&state_role,
Request::UpdateShareRole {
path: state_role.rel.clone(),
id: id.clone(),
state_role.target.update_role(
id.clone(),
kind,
role: role_index_to_wire(d.selected()).to_string(),
},
role_index_to_wire(d.selected()).to_string(),
),
"Role updated",
"Couldn't update the role",
None,
@@ -265,11 +364,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
remove.connect_clicked(move |_| {
share_dialog_op(
&state_rm,
Request::RemoveShareEntry {
path: state_rm.rel.clone(),
id: id.clone(),
kind,
},
state_rm.target.remove_entry(id.clone(), kind),
"Access removed",
"Couldn't remove access",
None,
@@ -332,10 +427,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
remove.connect_clicked(move |_| {
share_dialog_op(
&state_rm,
Request::RemovePublicLink {
path: state_rm.rel.clone(),
id: id.clone(),
},
state_rm.target.remove_link(id.clone()),
"Public link removed",
"Couldn't remove the link",
None,
@@ -407,12 +499,7 @@ pub(crate) fn share_dialog_create_link(
) {
let rx = spawn_request(
state.ui.dirs.control_socket(),
Request::CreatePublicLink {
path: state.rel.clone(),
role,
password,
expires: None,
},
state.target.create_link(role, password),
);
let state = state.clone();
glib::spawn_future_local(async move {