feat(sharing,gui,cli): change public links and show folder sizes

The share dialog gets Link settings under an existing link: role, password
and expiry apply at once and keep the URL. The daemon fills in the options a
change keeps from the link as it stands, and refuses to keep a password it
cannot read rather than dropping it. pdfs public-link update does the same
from a terminal, and pdfs members shows the expiry.

Details shows a folder's size and item count as Proton Drive counts it,
trash included, and pdfs du prints it. Both are refused offline and for a
folder that is not on Drive yet.

Needs proton-drive-rs with update_public_link, get_folder_size and
PublicLink::custom_password.
This commit is contained in:
nvrl committed 2026-10-25 22:18:11 +01:00
1 parent ee59e338b6
commit 4edcff3f87
21 files changed
+1824 -686

No files matched your search

+92 -2
View File
@@ -13,7 +13,7 @@ use pdfs_core::auth;
use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
ConflictKeep, ErrorKind, LinkChange, MountKind, RefreshScope, Request as CtlRequest,
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
daemon_outdated, pending_summary,
};
@@ -368,6 +368,14 @@ enum Command {
#[arg(required = true, num_args = 2.., value_name = "PATH")]
paths: Vec<PathBuf>,
},
/// Show how much a folder holds on Proton Drive.
///
/// Proton Drive adds it up, so nothing is downloaded. The size is the
/// encrypted bytes on storage and includes items in the trash.
Du {
/// Folder path, inside My files or relative to the mountpoint.
path: PathBuf,
},
/// Trash a file or folder via the running daemon.
Rm {
/// File/folder path, inside My files or an on-demand synced folder, or
@@ -789,6 +797,29 @@ enum PublicLinkCmd {
#[arg(long)]
expires: Option<i64>,
},
/// Change a node's public link. The URL stays the same; an option not
/// given is kept.
Update {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
/// Public-link id, from `pdfs members`.
id: String,
/// New role: viewer or editor.
#[arg(long)]
role: Option<String>,
/// New custom password protecting the link.
#[arg(long, conflicts_with = "no_password")]
password: Option<String>,
/// Remove the custom password.
#[arg(long)]
no_password: bool,
/// New expiry, Unix epoch seconds.
#[arg(long, conflicts_with = "no_expiry")]
expires: Option<i64>,
/// Remove the expiry.
#[arg(long)]
no_expiry: bool,
},
/// Remove a node's public link.
Remove {
/// File/folder path, inside the mountpoint or relative to it.
@@ -935,6 +966,7 @@ fn main() -> Result<()> {
let new_parent = paths.pop().expect("clap requires at least two paths");
cmd_copy(paths, new_parent)
}
Command::Du { path } => cmd_du(path),
Command::Rm { path } => cmd_rm(path),
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
Command::Upload { sources, parent } => cmd_upload(sources, parent),
@@ -1528,7 +1560,11 @@ fn cmd_members(path: PathBuf) -> Result<()> {
Some(l) => {
let url = l.url.as_deref().unwrap_or("(url hidden)");
let pw = if l.has_password { " +password" } else { "" };
println!("public link ({}{pw}): {url} [{}]", l.role, l.id);
let expiry = l
.expires
.map(|at| format!(", expires {}", format_epoch_utc(at)))
.unwrap_or_default();
println!("public link ({}{pw}{expiry}): {url} [{}]", l.role, l.id);
}
None => println!("public link: none"),
}
@@ -1580,6 +1616,27 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
PublicLinkCmd::Update {
path,
id,
role,
password,
no_password,
expires,
no_expiry,
} => match control_request(CtlRequest::UpdatePublicLink {
path: path_arg(&path)?,
id,
role,
password: link_change(password, no_password),
expires: link_change(expires, no_expiry),
})? {
CtlResponse::PublicLink { link } => {
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
PublicLinkCmd::Remove { path, id } => {
ok_or_bail(control_request(CtlRequest::RemovePublicLink {
path: path_arg(&path)?,
@@ -1590,6 +1647,15 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
Ok(())
}
/// A `--x VALUE` / `--no-x` flag pair as one link option change.
fn link_change<T>(value: Option<T>, remove: bool) -> LinkChange<T> {
match (value, remove) {
(Some(value), _) => LinkChange::Set(value),
(None, true) => LinkChange::Remove,
(None, false) => LinkChange::Keep,
}
}
fn cmd_shared() -> Result<()> {
match control_request(CtlRequest::ListSharedByMe)? {
CtlResponse::SharedByMe { items } if items.is_empty() => {
@@ -2966,6 +3032,23 @@ fn cmd_copy(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
Ok(())
}
fn cmd_du(path: PathBuf) -> Result<()> {
match control_request(CtlRequest::FolderSize {
path: path_arg(&path)?,
})? {
CtlResponse::FolderSize { size, items } => {
let noun = if items == 1 { "item" } else { "items" };
println!(
"{} in {items} {noun} (on Proton Drive, trash included)",
human_bytes(size)
);
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_rm(path: PathBuf) -> Result<()> {
match control_request(CtlRequest::Delete {
path: path_arg(&path)?,
@@ -3952,6 +4035,13 @@ mod login_wait_tests {
assert_eq!(slept, 0);
}
#[test]
fn a_link_flag_pair_keeps_removes_or_sets() {
assert_eq!(link_change::<i64>(None, false), LinkChange::Keep);
assert_eq!(link_change::<i64>(None, true), LinkChange::Remove);
assert_eq!(link_change(Some(5), false), LinkChange::Set(5));
}
#[test]
fn epoch_seconds_format_as_utc_calendar_dates() {
assert_eq!(format_epoch_utc(0), "1970-01-01");
+90 -2
View File
@@ -350,6 +350,11 @@ pub enum Request {
/// taken in `new_parent` gets the next free ` (n)` suffix. Replies with
/// [`Response::Ok`].
Copy { path: String, new_parent: String },
/// Ask Proton Drive how much the folder at mountpoint-relative `path`
/// holds. Replies with [`Response::FolderSize`].
FolderSize { path: String },
/// The by-uid twin of [`Request::FolderSize`].
FolderSizeByUid { uid: String },
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
/// [`Response::Ok`].
Delete { path: String },
@@ -602,6 +607,24 @@ pub enum Request {
RemovePublicLink { path: String, id: String },
/// The by-uid twin of [`Request::RemovePublicLink`].
RemovePublicLinkByUid { uid: String, id: String },
/// Change the public link `id` on the node at `path`, keeping its URL.
/// `role` is "viewer" or "editor", or `None` to keep the current one.
/// Replies with [`Response::PublicLink`].
UpdatePublicLink {
path: String,
id: String,
role: Option<String>,
password: LinkChange<String>,
expires: LinkChange<i64>,
},
/// The by-uid twin of [`Request::UpdatePublicLink`].
UpdatePublicLinkByUid {
uid: String,
id: String,
role: Option<String>,
password: LinkChange<String>,
expires: LinkChange<i64>,
},
/// Stop sharing the node at `path`: remove its public link, every pending
/// invitation and every member. Each removal is attempted even if an
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
@@ -1081,6 +1104,18 @@ pub struct PublicLinkInfo {
pub created: i64,
}
/// What [`Request::UpdatePublicLink`] does with one option of a link: its
/// custom password or its expiry.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum LinkChange<T> {
/// Leave the option as it is.
Keep,
/// Remove the option: no custom password, or no expiry.
Remove,
/// Set the option to this value.
Set(T),
}
/// One invitation addressed to me (in [`Response::Invitations`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct InvitationInfo {
@@ -2192,9 +2227,14 @@ pub enum Response {
entries: Vec<ShareEntry>,
link: Option<PublicLinkInfo>,
},
/// A freshly created public link, carrying the shareable URL (reply to
/// [`Request::CreatePublicLink`]).
/// A freshly created or changed public link, carrying the shareable URL
/// (reply to [`Request::CreatePublicLink`] and
/// [`Request::UpdatePublicLink`]).
PublicLink { link: PublicLinkInfo },
/// What a folder holds on Proton Drive (reply to [`Request::FolderSize`]):
/// the bytes on storage and the number of items below it, folders and
/// trashed items included.
FolderSize { size: u64, items: u64 },
/// Invitations addressed to me (reply to [`Request::ListInvitations`]).
Invitations { items: Vec<InvitationInfo> },
/// Saved public links (reply to [`Request::ListBookmarks`]).
@@ -3258,6 +3298,24 @@ mod tests {
uid: "vol~link".into(),
id: "url-1".into(),
},
Request::UpdatePublicLink {
path: "a/b".into(),
id: "url-1".into(),
role: Some("editor".into()),
password: LinkChange::Set("pw".into()),
expires: LinkChange::Remove,
},
Request::UpdatePublicLinkByUid {
uid: "vol~link".into(),
id: "url-1".into(),
role: None,
password: LinkChange::Keep,
expires: LinkChange::Set(1_700_000_000),
},
Request::FolderSize { path: "a".into() },
Request::FolderSizeByUid {
uid: "vol~link".into(),
},
Request::ListSharedByMe,
Request::ListActivity { limit: 100 },
Request::ListSharedWithMe,
@@ -3632,6 +3690,16 @@ mod tests {
},
"RemovePublicLinkByUid",
),
(
Request::UpdatePublicLinkByUid {
uid: "vol~link".into(),
id: "link".into(),
role: None,
password: LinkChange::Keep,
expires: LinkChange::Keep,
},
"UpdatePublicLinkByUid",
),
(
Request::StopSharingByUid {
uid: "vol~link".into(),
@@ -3657,6 +3725,7 @@ mod tests {
| Request::RemoveShareEntryByUid { uid, .. }
| Request::CreatePublicLinkByUid { uid, .. }
| Request::RemovePublicLinkByUid { uid, .. }
| Request::UpdatePublicLinkByUid { uid, .. }
| Request::StopSharingByUid { uid } => uid,
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
};
@@ -3665,6 +3734,25 @@ mod tests {
}
}
#[test]
fn a_link_update_names_what_it_keeps_removes_and_sets() {
let request = Request::UpdatePublicLink {
path: "a/b".into(),
id: "url-1".into(),
role: None,
password: LinkChange::Keep,
expires: LinkChange::Set(1_700_000_000),
};
assert_eq!(
serde_json::to_string(&request).unwrap(),
r#"{"UpdatePublicLink":{"path":"a/b","id":"url-1","role":null,"password":"Keep","expires":{"Set":1700000000}}}"#
);
assert_eq!(
serde_json::to_string(&LinkChange::<String>::Remove).unwrap(),
r#""Remove""#
);
}
#[test]
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
+43
View File
@@ -29,6 +29,17 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// Answer a [`CtlRequest::FolderSize`].
fn folder_size_reply(size: CoreResult<proton_drive_rs::FolderSize>) -> CtlResponse {
match size {
Ok(size) => CtlResponse::FolderSize {
size: size.size,
items: size.descendants,
},
Err(e) => CtlResponse::error(e),
}
}
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
/// into an error that names what is still shared.
fn stop_sharing_reply(
@@ -392,6 +403,8 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
| R::CreatePublicLinkByUid { .. }
| R::RemovePublicLink { .. }
| R::RemovePublicLinkByUid { .. }
| R::UpdatePublicLink { .. }
| R::UpdatePublicLinkByUid { .. }
| R::StopSharing { .. }
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
R::LeaveShared { .. }
@@ -1088,6 +1101,11 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
}
}
Ok(CtlRequest::FolderSize { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => folder_size_reply(core.folder_size(&rel)),
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::FolderSizeByUid { uid }) => folder_size_reply(core.folder_size_by_uid(&uid)),
Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
Ok((core, rel)) => match core.delete(&rel) {
Ok(name) => {
@@ -1655,6 +1673,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
CtlResponse::error(e)
}
},
Ok(CtlRequest::UpdatePublicLink {
path,
id,
role,
password,
expires,
}) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => {
match core.update_public_link(&rel, &id, role.as_deref(), password, expires) {
Ok(link) => CtlResponse::PublicLink { link },
Err(e) => CtlResponse::error(e),
}
}
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UpdatePublicLinkByUid {
uid,
id,
role,
password,
expires,
}) => match core.update_public_link_by_uid(&uid, &id, role.as_deref(), password, expires) {
Ok(link) => CtlResponse::PublicLink { link },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_public_link(&rel, &id) {
Ok(()) => {
+37
View File
@@ -5288,6 +5288,43 @@ impl Core {
Ok(copy_name)
}
/// What the folder at `rel` holds on Proton Drive: bytes on storage and the
/// number of items below it. Drive adds it up, so nothing is listed here;
/// trashed items count, and the bytes are encrypted ones.
pub(crate) fn folder_size(&self, rel: &Path) -> CoreResult<proton_drive_rs::FolderSize> {
let (ino, uid) = self.resolve(rel)?;
let is_folder = self
.state()
.entries
.get(&ino)
.is_some_and(|entry| entry.node.is_folder());
if !is_folder {
return Err(CoreError::invalid(format!(
"{} is not a folder",
rel.display()
)));
}
self.folder_size_for_uid(&uid)
}
/// [`Core::folder_size`] for a folder addressed through any daemon location.
pub(crate) fn folder_size_by_uid(&self, uid: &str) -> CoreResult<proton_drive_rs::FolderSize> {
let uid = self.resolve_anywhere(uid)?;
self.folder_size_for_uid(&uid)
}
fn folder_size_for_uid(&self, uid: &NodeUid) -> CoreResult<proton_drive_rs::FolderSize> {
if is_local_uid(uid) {
return Err(CoreError::conflict("the folder is not on Proton Drive yet"));
}
if !self.is_online() {
return Err(CoreError::offline());
}
self.rt
.block_on(self.client.get_folder_size(uid))
.map_err(|e| CoreError::from_api(&e, "folder size"))
}
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
/// evicts any cached content, and invalidates the parent listing.
fn delete(&self, rel: &Path) -> CoreResult<String> {
+85 -1
View File
@@ -9,7 +9,8 @@
use std::path::Path;
use pdfs_core::control::{
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
BookmarkInfo, DirEntry, InvitationInfo, LinkChange, PublicLinkInfo, ShareEntry, ShareEntryKind,
SharedItem,
};
use pdfs_core::{CoreError, CoreResult};
use tracing::debug;
@@ -367,6 +368,89 @@ impl Core {
Ok(public_link_info(link))
}
/// Change the public link `id` on the node at `rel`, keeping its URL.
pub(crate) fn update_public_link(
&self,
rel: &Path,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let (_ino, uid) = self.resolve(rel)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
/// [`Core::update_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn update_public_link_by_uid(
&self,
uid: &str,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
/// The SDK sets all three options on every update, so each `Keep` is
/// filled in from the link as it stands.
fn update_public_link_for_uid(
&self,
uid: &NodeUid,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let link = self
.rt
.block_on(self.client.get_public_link(uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))?
.filter(|l| l.public_link_id == id)
.ok_or_else(|| CoreError::not_found("public link not found"))?;
let role = match role {
Some(role) => role_from_str(role)?,
None => link.role,
};
let unchanged = role == link.role
&& matches!(password, LinkChange::Keep)
&& matches!(expires, LinkChange::Keep);
if unchanged {
return Ok(public_link_info(link));
}
let password = match password {
LinkChange::Keep if link.has_custom_password => {
// Sending `None` here would quietly remove the password.
Some(link.custom_password.clone().ok_or_else(|| {
CoreError::conflict(
"the link's password could not be read; set a new one or remove it",
)
})?)
}
LinkChange::Keep | LinkChange::Remove => None,
LinkChange::Set(password) if password.is_empty() => {
return Err(CoreError::invalid("the new password is empty"));
}
LinkChange::Set(password) => Some(password),
};
let expires = match expires {
LinkChange::Keep => link.expiration_time,
LinkChange::Remove => None,
LinkChange::Set(at) => Some(at),
};
let link = self
.rt
.block_on(
self.client
.update_public_link(uid, role, password.as_deref(), expires),
)
.map_err(|e| CoreError::from_api(&e, "update public link"))?;
Ok(public_link_info(link))
}
/// Remove the public link `id` from the node at `rel`.
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
+5 -5
View File
@@ -75,11 +75,11 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
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, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
TransferDirection, TransferItem, send,
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
};
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
+50 -7
View File
@@ -368,12 +368,19 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
} else {
gettext("File")
});
d.size_row.set_subtitle(&if entry.is_dir {
"—".to_string()
// A folder's size is Proton Drive's count, which takes a request; it lands
// in `load_details_extras`.
if entry.is_dir {
d.size_row.set_title(&gettext("Size on Proton Drive"));
d.size_row.set_subtitle("—");
d.size_row.set_tooltip_text(Some(&gettext(
"Counted by Proton Drive, including items in the trash.",
)));
} else {
human_bytes(entry.size)
});
d.size_row.set_visible(!entry.is_dir);
d.size_row.set_title(&pgettext("column", "Size"));
d.size_row.set_subtitle(&human_bytes(entry.size));
d.size_row.set_tooltip_text(None);
}
d.modified_row
.set_subtitle(&dates::short_date(entry.modified));
let rel = entry_rel(ui, entry);
@@ -426,8 +433,8 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
d.pages.set_visible_child_name("details");
}
/// Fill the Sharing and Versions rows, which need the network. The answers
/// land only if the pane still shows the same entry.
/// Fill the Sharing and Versions rows, and a folder's size, which need the
/// network. The answers land only if the pane still shows the same entry.
fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
let by_uid = entry.path.is_empty() && !entry.uid.is_empty();
let rel = entry_rel(ui, entry);
@@ -453,6 +460,24 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
});
if entry.is_dir {
let req = if by_uid {
Request::FolderSizeByUid {
uid: entry.uid.clone(),
}
} else {
Request::FolderSize { path: rel }
};
let rx = spawn_request(ui.dirs.control_socket(), req);
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
glib::spawn_future_local(async move {
let text = match rx.recv().await {
Ok(Ok(Response::FolderSize { size, items })) => folder_size_summary(size, items),
_ => gettext("Couldn't load the size"),
};
if details_showing(&ui_c, &uid) {
ui_c.details.details.size_row.set_subtitle(&text);
}
});
return;
}
let req = if by_uid {
@@ -499,6 +524,18 @@ pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
}
}
/// "1.2 GiB · 340 items": what a folder holds, as Proton Drive counts it.
pub(crate) fn folder_size_summary(size: u64, items: u64) -> String {
// Translators: {size} is a size such as "1.2 GiB"; {n} counts the files
// and folders inside a folder.
ngettext_f(
"{size} · {n} item",
"{size} · {n} items",
items,
&[("size", &human_bytes(size))],
)
}
/// "3 versions · latest 2 Sep 2026".
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
let latest = items.iter().map(|r| r.created).max();
@@ -547,6 +584,12 @@ pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
mod tests {
use super::*;
#[test]
fn folder_size_summary_reads_size_and_item_count() {
assert_eq!(folder_size_summary(0, 0), "0 bytes · 0 items");
assert_eq!(folder_size_summary(2048, 1), "2.0 KiB · 1 item");
}
#[test]
fn sharing_summary_names_people_and_link() {
assert_eq!(sharing_summary(0, false), "Only you");
+233 -19
View File
@@ -51,6 +51,48 @@ pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option<i64> {
.map(|days| now + days * 86_400)
}
/// The labels of the Expires dropdown on an existing link. When the link has
/// an expiry, the first entry is that expiry, so the dropdown shows what is
/// set and choosing it again changes nothing.
pub(crate) fn link_expiry_choices(current: Option<i64>, now: i64) -> Vec<String> {
let current = current.map(|at| capitalize(&link_expiry_label(at, now)));
current
.into_iter()
.chain(LINK_EXPIRY.iter().map(|(name, _)| gettext(name)))
.collect()
}
/// What choosing entry `idx` of [`link_expiry_choices`] does to the expiry.
pub(crate) fn link_expiry_change(idx: u32, current: Option<i64>, now: i64) -> LinkChange<i64> {
let idx = match (current, idx) {
(Some(_), 0) => return LinkChange::Keep,
(Some(_), idx) => idx - 1,
(None, idx) => idx,
};
match link_expiry_at(idx, now) {
Some(at) => LinkChange::Set(at),
None if current.is_some() => LinkChange::Remove,
None => LinkChange::Keep,
}
}
/// A row with a title and a dropdown of `labels` as its suffix.
fn dropdown_row(title: &str, labels: &[String], selected: u32) -> (adw::ActionRow, gtk4::DropDown) {
let drop = gtk4::DropDown::builder()
.model(&string_list(labels))
.selected(selected)
.valign(gtk4::Align::Center)
.build();
let row = adw::ActionRow::builder().title(title).build();
row.add_suffix(&drop);
(row, drop)
}
/// The roles a public link can grant, in dropdown order.
fn link_roles() -> [String; 2] {
[pgettext("role", "Viewer"), pgettext("role", "Editor")]
}
/// A loose shape check for an email address: one `@` with something before
/// it and a dotted domain after it. The server does the real validation; this
/// only catches typos before the Invite button is pressed.
@@ -237,6 +279,31 @@ impl ShareTarget {
}
}
fn update_link(
&self,
id: String,
role: Option<String>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> Request {
match self {
ShareTarget::Path(path) => Request::UpdatePublicLink {
path: path.clone(),
id,
role,
password,
expires,
},
ShareTarget::Uid(uid) => Request::UpdatePublicLinkByUid {
uid: uid.clone(),
id,
role,
password,
expires,
},
}
}
fn remove_link(&self, id: String) -> Request {
match self {
ShareTarget::Path(path) => Request::RemovePublicLink {
@@ -585,8 +652,8 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
*state.people_rows.borrow_mut() = rows;
}
/// Rebuild the public-link section: an existing link (copy / remove) or a
/// create control.
/// Rebuild the public-link section: an existing link (copy / remove, and its
/// settings) or a create control.
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
for row in state.link_rows.borrow_mut().drain(..) {
state.link_group.remove(&row);
@@ -670,30 +737,19 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
state.link_group.add(&row);
rows.push(row.upcast());
let settings = link_settings_row(state, link);
state.link_group.add(&settings);
rows.push(settings.upcast());
}
None => {
let role_model = string_list(&[pgettext("role", "Viewer"), pgettext("role", "Editor")]);
let role_drop = gtk4::DropDown::builder()
.model(&role_model)
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_row = adw::ActionRow::builder()
.title(gettext("Link role"))
.build();
role_row.add_suffix(&role_drop);
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), 0);
let pw_row = adw::PasswordEntryRow::builder()
.title(gettext("Password (optional)"))
.build();
let expiry_names: Vec<String> =
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
let expiry_drop = gtk4::DropDown::builder()
.model(&string_list(&expiry_names))
.selected(0)
.valign(gtk4::Align::Center)
.build();
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
expiry_row.add_suffix(&expiry_drop);
let (expiry_row, expiry_drop) = dropdown_row(&gettext("Expires"), &expiry_names, 0);
let create = group_button(&gettext("Create Public Link"));
// Enter in the password field creates the link.
@@ -734,6 +790,142 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
*state.link_rows.borrow_mut() = rows;
}
/// The settings of an existing link, collapsed under one row. Each control
/// applies at once, the way a member's role does. While a change is on its way
/// the whole row is insensitive: the daemon fills in what a change keeps from
/// the link as it stands, so two changes in flight could undo each other.
fn link_settings_row(state: &Rc<ShareDialog>, link: &PublicLinkInfo) -> adw::ExpanderRow {
let now = glib::real_time() / 1_000_000;
let expander = adw::ExpanderRow::builder()
.title(gettext("Link settings"))
.subtitle(gettext(
"Change who can edit, the password or the expiry. The link stays the same.",
))
.build();
let selected_role = u32::from(link.role == "editor");
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), selected_role);
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
role_drop.connect_selected_notify(move |d| {
let Some(state) = state_w.upgrade() else {
return;
};
let role = if d.selected() == 1 {
"editor"
} else {
"viewer"
};
let request = state.target.update_link(
id.clone(),
Some(role.into()),
LinkChange::Keep,
LinkChange::Keep,
);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&role_row);
let current = link.expires;
let (expiry_row, expiry_drop) =
dropdown_row(&gettext("Expires"), &link_expiry_choices(current, now), 0);
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
expiry_drop.connect_selected_notify(move |d| {
let Some(state) = state_w.upgrade() else {
return;
};
let expires = link_expiry_change(d.selected(), current, glib::real_time() / 1_000_000);
if expires == LinkChange::Keep {
return;
}
let request = state
.target
.update_link(id.clone(), None, LinkChange::Keep, expires);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&expiry_row);
let pw_row = adw::PasswordEntryRow::builder()
.title(if link.has_password {
gettext("New password")
} else {
gettext("Add a password")
})
.show_apply_button(true)
.build();
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
pw_row.connect_apply(move |row| {
let Some(state) = state_w.upgrade() else {
return;
};
let password = row.text().to_string();
if password.is_empty() {
return;
}
let request = state.target.update_link(
id.clone(),
None,
LinkChange::Set(password),
LinkChange::Keep,
);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&pw_row);
if link.has_password {
let remove = gtk4::Button::builder()
.label(gettext("Remove"))
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let row = adw::ActionRow::builder()
.title(gettext("Remove the password"))
.subtitle(gettext(
"Anyone with the link can open it without a password.",
))
.build();
row.add_suffix(&remove);
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
remove.connect_clicked(move |_| {
let Some(state) = state_w.upgrade() else {
return;
};
let request =
state
.target
.update_link(id.clone(), None, LinkChange::Remove, LinkChange::Keep);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&row);
}
expander
}
/// Send a link change, then reload the dialog: on success to show the link as
/// it now is, on failure to put the controls back to what the link still is.
fn share_dialog_update_link(
state: &Rc<ShareDialog>,
request: Request,
settings: &adw::ExpanderRow,
) {
settings.set_sensitive(false);
let rx = spawn_request(state.ui.dirs.control_socket(), request);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::PublicLink { .. })) => toast(&state.ui, &gettext("Link updated")),
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, &gettext("Couldn't change the link"), &message)
}
_ => toast_error(
&state.ui,
&gettext("Couldn't change the link"),
&gettext("The Proton Drive service didn't respond."),
),
}
share_dialog_reload(&state);
});
}
/// Create a public link, then reload the dialog so the copy/remove controls
/// replace the create form (and the freshly minted URL is shown).
pub(crate) fn share_dialog_create_link(
@@ -863,6 +1055,28 @@ mod tests {
assert_eq!(link_expiry_at(99, 0), None);
}
#[test]
fn choosing_an_expiry_keeps_removes_or_sets_it() {
let now = 1_000;
// No expiry yet: the choices are just Never and the lifetimes.
assert_eq!(link_expiry_choices(None, now).len(), LINK_EXPIRY.len());
assert_eq!(link_expiry_change(0, None, now), LinkChange::Keep);
assert_eq!(
link_expiry_change(2, None, now),
LinkChange::Set(now + 7 * 86_400)
);
// An expiry set: it comes first, then Never and the lifetimes.
let at = Some(2_000_000_000);
assert_eq!(link_expiry_choices(at, now).len(), LINK_EXPIRY.len() + 1);
assert_eq!(link_expiry_change(0, at, now), LinkChange::Keep);
assert_eq!(link_expiry_change(1, at, now), LinkChange::Remove);
assert_eq!(
link_expiry_change(2, at, now),
LinkChange::Set(now + 86_400)
);
}
#[test]
fn past_expiry_reads_expired() {
assert_eq!(link_expiry_label(10, 20), "expired");