feat(core,fuse,gui,cli): stop sharing and share dialog polish

StopSharing / StopSharingByUid remove the public link, external
invitations, invitations and members, trying every step and reporting
what is still shared. Shared by Me gets a Stop Sharing… item and
pdfs unshare <path> works without an id.

The Share dialog validates addresses inline, shows spinners on buttons
while requests run, offers a link expiry and shows an existing link's
expiry.
This commit is contained in:
nvrl committed 2026-09-23 04:09:32 +02:00
1 parent dfc48375e2
commit 15985f8684
8 files changed
+497 -37

No files matched your search

+51 -9
View File
@@ -403,14 +403,18 @@ enum Command {
/// New role: viewer, editor or admin.
role: String,
},
/// Remove a member or pending invitation from a node's share.
/// Stop sharing a node, or remove one member or pending invitation.
///
/// With only a path, removes the public link, every invitation and every
/// member. With an id and kind (from `pdfs members`), removes that entry.
Unshare {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
/// Entry id (membership or invitation id), from `pdfs members`.
id: String,
#[arg(requires = "kind")]
id: Option<String>,
/// Entry kind: member, proton or external.
kind: ShareKindArg,
kind: Option<ShareKindArg>,
},
/// Inspect and restore a file's earlier versions.
Versions {
@@ -1261,12 +1265,17 @@ fn cmd_share_role(path: PathBuf, id: String, kind: ShareKindArg, role: String) -
})?)
}
fn cmd_unshare(path: PathBuf, id: String, kind: ShareKindArg) -> Result<()> {
ok_or_bail(control_request(CtlRequest::RemoveShareEntry {
path: path_arg(&path)?,
id,
kind: kind.to_kind(),
})?)
fn cmd_unshare(path: PathBuf, id: Option<String>, kind: Option<ShareKindArg>) -> Result<()> {
let path = path_arg(&path)?;
let request = match (id, kind) {
(Some(id), Some(kind)) => CtlRequest::RemoveShareEntry {
path,
id,
kind: kind.to_kind(),
},
_ => CtlRequest::StopSharing { path },
};
ok_or_bail(control_request(request)?)
}
fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
@@ -3353,3 +3362,36 @@ mod login_wait_tests {
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
}
}
#[cfg(test)]
mod unshare_tests {
use super::*;
use clap::Parser;
#[test]
fn unshare_with_only_a_path_stops_sharing() {
let cli = Cli::try_parse_from(["pdfs", "unshare", "docs/report.pdf"]).unwrap();
assert!(matches!(
cli.command,
Command::Unshare {
id: None,
kind: None,
..
}
));
}
#[test]
fn unshare_with_an_id_needs_its_kind() {
assert!(Cli::try_parse_from(["pdfs", "unshare", "docs", "abc"]).is_err());
let cli = Cli::try_parse_from(["pdfs", "unshare", "docs", "abc", "member"]).unwrap();
assert!(matches!(
cli.command,
Command::Unshare {
id: Some(_),
kind: Some(_),
..
}
));
}
}
+15 -1
View File
@@ -518,6 +518,13 @@ pub enum Request {
RemovePublicLink { path: String, id: String },
/// The by-uid twin of [`Request::RemovePublicLink`].
RemovePublicLinkByUid { uid: String, id: String },
/// 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
/// what is still shared.
StopSharing { path: String },
/// The by-uid twin of [`Request::StopSharing`].
StopSharingByUid { uid: String },
// ---- revisions --------------------------------------------------------
/// List the version history of the file at mountpoint-relative `path`,
@@ -2885,6 +2892,12 @@ mod tests {
},
"RemovePublicLinkByUid",
),
(
Request::StopSharingByUid {
uid: "vol~link".into(),
},
"StopSharingByUid",
),
];
for (request, variant) in cases {
@@ -2903,7 +2916,8 @@ mod tests {
| Request::UpdateShareRoleByUid { uid, .. }
| Request::RemoveShareEntryByUid { uid, .. }
| Request::CreatePublicLinkByUid { uid, .. }
| Request::RemovePublicLinkByUid { uid, .. } => uid,
| Request::RemovePublicLinkByUid { uid, .. }
| Request::StopSharingByUid { uid } => uid,
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
};
assert_eq!(decoded_uid, "vol~link");
+27
View File
@@ -29,6 +29,26 @@ 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::StopSharing`]: log it, and turn a partial removal
/// into an error that names what is still shared.
fn stop_sharing_reply(
core: &Core,
target: &str,
result: CoreResult<super::sharing::StopSharingOutcome>,
) -> CtlResponse {
match result.map(|outcome| outcome.into_result()) {
Ok(Ok(message)) => {
core.log_activity(ActivityKind::Unshare, target, "stopped sharing", true);
CtlResponse::Ok { message }
}
Ok(Err(left)) => {
core.log_activity(ActivityKind::Unshare, target, &left, false);
CtlResponse::error(CoreError::remote(left))
}
Err(e) => CtlResponse::error(e),
}
}
/// A client that connects, sends a request and then never reads its reply used
/// to park a handler thread forever in `write_all`; 64 of those exhaust
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
@@ -1310,6 +1330,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::StopSharing { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => stop_sharing_reply(core, &path, core.stop_sharing(&rel)),
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::StopSharingByUid { uid }) => {
stop_sharing_reply(core, &uid, core.stop_sharing_by_uid(&uid))
}
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_revisions(&rel) {
Ok(items) => CtlResponse::Revisions { items },
+135
View File
@@ -388,6 +388,66 @@ impl Core {
.map_err(|e| CoreError::from_api(&e, "remove public link"))
}
/// Stop sharing the node at `rel`: remove its public link, then every
/// external invitation, Proton invitation and member. The SDK has no call
/// that deletes a share outright, so this composes the per-entry ones.
pub(crate) fn stop_sharing(&self, rel: &Path) -> CoreResult<StopSharingOutcome> {
let (_ino, uid) = self.resolve(rel)?;
self.stop_sharing_for_uid(&uid)
}
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
let uid = self.resolve_anywhere(uid)?;
self.stop_sharing_for_uid(&uid)
}
/// Each step runs even when an earlier one failed, so a single stuck
/// member does not leave the public link up. The link goes first: it is the
/// widest exposure. Only a failure to list what is there fails the whole
/// request; a failed removal is counted in the outcome.
fn stop_sharing_for_uid(&self, uid: &NodeUid) -> CoreResult<StopSharingOutcome> {
let mut outcome = StopSharingOutcome::default();
let link = self
.rt
.block_on(self.client.get_public_link(uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))?;
if let Some(link) = link {
let result = self.rt.block_on(self.client.remove_public_link(&link));
outcome.record("the public link", result);
}
let external = self
.rt
.block_on(self.client.list_external_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?;
for ext in external {
let result = self
.rt
.block_on(self.client.delete_external_invitation(&ext));
outcome.record(&ext.invitee_email, result);
}
let invitations = self
.rt
.block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
for inv in invitations {
let result = self.rt.block_on(self.client.delete_invitation(&inv));
outcome.record(&inv.invitee_email, result);
}
let members = self
.rt
.block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?;
for member in members {
let result = self.rt.block_on(self.client.remove_member(&member));
outcome.record(&member.email, result);
}
if !outcome.failed.is_empty() {
debug!(%uid, failed = ?outcome.failed, "stop sharing: some entries remain");
}
Ok(outcome)
}
// ---- shared with me ---------------------------------------------------
/// List nodes shared with me that I have accepted.
@@ -657,6 +717,47 @@ impl Core {
}
}
/// What [`Core::stop_sharing`] managed to remove.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct StopSharingOutcome {
/// Entries removed (the link, invitations and members alike).
pub(crate) removed: usize,
/// What could not be removed, by email (or "the public link"), with why.
pub(crate) failed: Vec<(String, String)>,
}
impl StopSharingOutcome {
fn record<E: std::fmt::Display>(&mut self, what: &str, result: Result<(), E>) {
match result {
Ok(()) => self.removed += 1,
Err(e) => self.failed.push((what.to_string(), e.to_string())),
}
}
/// The reply for the control socket: a sentence for a full success, an
/// error naming what is left otherwise.
pub(crate) fn into_result(self) -> Result<String, String> {
if self.failed.is_empty() {
return Ok(match self.removed {
0 => "it was not shared".to_string(),
1 => "stopped sharing: removed 1 entry".to_string(),
n => format!("stopped sharing: removed {n} entries"),
});
}
let left = self
.failed
.iter()
.map(|(what, why)| format!("{what} ({why})"))
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"removed {} of {}; still shared: {left}",
self.removed,
self.removed + self.failed.len()
))
}
}
/// Walk one inode space from `uid` up to its root, building the path. `None` if
/// that mount has never interned the node, or if the chain breaks partway —
/// a broken chain is a missing path, never a partial one.
@@ -671,3 +772,37 @@ fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
parts.reverse();
Some(parts.join("/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stopping_a_share_that_is_gone_says_so() {
let outcome = StopSharingOutcome::default();
assert_eq!(outcome.into_result().unwrap(), "it was not shared");
}
#[test]
fn a_failed_removal_names_what_is_still_shared() {
let mut outcome = StopSharingOutcome::default();
outcome.record::<String>("the public link", Ok(()));
outcome.record("bob@example.com", Err("forbidden"));
outcome.record::<String>("carol@example.com", Ok(()));
assert_eq!(
outcome.into_result().unwrap_err(),
"removed 2 of 3; still shared: bob@example.com (forbidden)"
);
}
#[test]
fn a_full_removal_counts_the_entries() {
let mut outcome = StopSharingOutcome::default();
outcome.record::<String>("the public link", Ok(()));
outcome.record::<String>("bob@example.com", Ok(()));
assert_eq!(
outcome.into_result().unwrap(),
"stopped sharing: removed 2 entries"
);
}
}
+1
View File
@@ -2117,6 +2117,7 @@ pub(crate) fn reload_listing(ui: &Rc<Ui>) {
match ui.stack.visible_child_name().as_deref() {
Some("browser") => load_browser(ui),
Some("trash") => load_trash(ui),
Some("sharedbyme") => load_shared_by_me(ui),
_ => {}
}
}
+36 -1
View File
@@ -231,7 +231,8 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
*ui.shared_by_me.rows.borrow_mut() = rows;
}
/// The ⋮ menu on a shared item: open it, the link, and the Share dialog.
/// The ⋮ menu on a shared item: open it, the link, the Share dialog, and
/// Stop Sharing.
///
/// Manage access opens the per-node Share dialog, which addresses a pathless
/// node by uid, so every shared item can be managed from here.
@@ -254,9 +255,43 @@ fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4
"Manage Access…",
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
));
let (ui_c, entry_c) = (ui.clone(), entry.clone());
items.push((
"Stop Sharing…",
Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)),
));
more_menu_button(items)
}
/// Confirm, then remove every member, invitation and the public link.
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
let dialog = adw::AlertDialog::builder()
.heading("Stop sharing?")
.body(format!(
"Everyone you invited loses access to “{}”, pending invitations are \
withdrawn and its public link stops working.",
entry.name
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("stop", "Stop Sharing");
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui_c = ui.clone();
let uid = entry.uid.clone();
let name = entry.name.clone();
dialog.connect_response(Some("stop"), move |_, _| {
run_mutation(
&ui_c,
Request::StopSharingByUid { uid: uid.clone() },
format!("Stopped sharing {name}"),
"Couldn't stop sharing",
);
});
dialog.present(ui_window(ui).as_ref());
}
/// Open a shared item: a folder in My Files, a file the way My Files would.
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
if entry.is_dir {
+224 -26
View File
@@ -21,6 +21,99 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 {
}
}
/// The expiry choices offered when creating a public link, in dropdown order,
/// with their lifetime in days (`None` never expires).
pub(crate) const LINK_EXPIRY: [(&str, Option<i64>); 4] = [
("Never", None),
("1 day", Some(1)),
("7 days", Some(7)),
("30 days", Some(30)),
];
/// The Unix expiry for a [`LINK_EXPIRY`] choice, counted from `now`.
pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option<i64> {
LINK_EXPIRY
.get(idx as usize)
.and_then(|(_, days)| *days)
.map(|days| now + days * 86_400)
}
/// 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.
pub(crate) fn looks_like_email(s: &str) -> bool {
let Some((local, domain)) = s.split_once('@') else {
return false;
};
!local.is_empty()
&& !domain.contains('@')
&& !s.chars().any(char::is_whitespace)
&& domain
.split_once('.')
.is_some_and(|(host, rest)| !host.is_empty() && !rest.is_empty())
&& !domain.ends_with('.')
}
/// Split the invite field into addresses. Returns `Err` with the first entry
/// that doesn't look like an email, and `Ok` with an empty list when the field
/// is blank.
pub(crate) fn parse_emails(raw: &str) -> Result<Vec<String>, String> {
raw.split([',', ' ', ';'])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
if looks_like_email(s) {
Ok(s.to_string())
} else {
Err(s.to_string())
}
})
.collect()
}
/// A button showing a spinner while its request runs. It goes insensitive
/// on [`Busy::start`] and gets its label or icon back when dropped, so moving
/// the guard into the request's future ends the busy state on every path.
pub(crate) struct Busy {
button: gtk4::Button,
label: Option<glib::GString>,
icon: Option<glib::GString>,
}
impl Busy {
pub(crate) fn start(button: &gtk4::Button) -> Self {
let label = button.label();
let icon = button.icon_name();
let spinner = gtk4::Spinner::builder().spinning(true).build();
match &label {
Some(text) => {
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
content.append(&spinner);
content.append(&gtk4::Label::new(Some(text)));
button.set_child(Some(&content));
}
None => button.set_child(Some(&spinner)),
}
button.set_sensitive(false);
Self {
button: button.clone(),
label,
icon,
}
}
}
impl Drop for Busy {
fn drop(&mut self) {
if let Some(label) = &self.label {
self.button.set_label(label);
} else if let Some(icon) = &self.icon {
self.button.set_icon_name(icon);
}
self.button.set_sensitive(true);
}
}
/// How an open Share dialog addresses its node.
///
/// A node the browser is showing has a mountpoint-relative path. A node reached
@@ -94,19 +187,19 @@ impl ShareTarget {
}
}
fn create_link(&self, role: String, password: Option<String>) -> Request {
fn create_link(&self, role: String, password: Option<String>, expires: Option<i64>) -> Request {
match self {
ShareTarget::Path(path) => Request::CreatePublicLink {
path: path.clone(),
role,
password,
expires: None,
expires,
},
ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid {
uid: uid.clone(),
role,
password,
expires: None,
expires,
},
}
}
@@ -179,6 +272,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
.margin_top(6)
.build();
invite_btn.add_css_class("suggested-action");
invite_btn.set_sensitive(false);
let invite_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&invite_btn)
@@ -227,6 +321,25 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
link_rows: RefCell::new(Vec::new()),
});
// Check the addresses as they are typed: a malformed one marks the row as
// an error and keeps the Invite button off, so a typo never reaches the
// server as a failed invitation.
let btn = invite_btn.clone();
email_row.connect_changed(move |row| {
let parsed = parse_emails(&row.text());
match &parsed {
Err(bad) => {
row.add_css_class("error");
row.set_tooltip_text(Some(&format!("“{bad}” isn't an email address")));
}
Ok(_) => {
row.remove_css_class("error");
row.set_tooltip_text(None);
}
}
btn.set_sensitive(parsed.is_ok_and(|emails| !emails.is_empty()));
});
// Enter in either free-text field sends the invitations, no mouse needed.
let btn = invite_btn.clone();
email_row.connect_entry_activated(move |_| btn.emit_clicked());
@@ -235,16 +348,15 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
// Invite button.
let state_inv = state.clone();
invite_btn.connect_clicked(move |_| {
let raw = email_row.text();
let emails: Vec<String> = raw
.split([',', ' ', ';'])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
invite_btn.connect_clicked(move |btn| {
// Enter in a field reaches here even while the button is off.
if !btn.is_sensitive() {
return;
}
let Ok(emails) = parse_emails(&email_row.text()) else {
return;
};
if emails.is_empty() {
toast_error(&state_inv.ui, "Couldn't share", "Enter at least one email.");
return;
}
let role = role_index_to_wire(role_drop.selected()).to_string();
@@ -257,6 +369,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
state_inv.target.invite(emails, role, message),
"Invitations sent",
"Couldn't send invitations",
Some(Busy::start(btn)),
Some(Box::new(move || {
email_clear.set_text("");
msg_clear.set_text("");
@@ -340,6 +453,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
"Role updated",
"Couldn't update the role",
None,
None,
);
});
row.add_suffix(&drop);
@@ -365,6 +479,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
remove.connect_clicked(move |btn| {
let state = state_rm.clone();
let id = id.clone();
let button = btn.clone();
confirm_destructive(
btn,
"Remove Access?",
@@ -376,6 +491,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
state.target.remove_entry(id.clone(), kind),
"Access removed",
"Couldn't remove access",
Some(Busy::start(&button)),
None,
);
},
@@ -400,14 +516,14 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
match link {
Some(link) => {
let url = link.url.clone().unwrap_or_default();
let subtitle = if link.has_password {
format!(
"Anyone with the link ({}) · password-protected",
capitalize(&link.role)
)
} else {
format!("Anyone with the link ({})", capitalize(&link.role))
};
let mut subtitle = format!("Anyone with the link ({})", capitalize(&link.role));
if link.has_password {
subtitle.push_str(" · password-protected");
}
if let Some(expires) = link.expires {
subtitle.push_str(" · ");
subtitle.push_str(&link_expiry_label(expires, glib::real_time() / 1_000_000));
}
let row = adw::ActionRow::builder()
.title(if url.is_empty() { "Public link" } else { &url })
.subtitle(&subtitle)
@@ -441,6 +557,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
remove.connect_clicked(move |btn| {
let state = state_rm.clone();
let id = id.clone();
let button = btn.clone();
confirm_destructive(
btn,
"Remove Public Link?",
@@ -453,6 +570,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
state.target.remove_link(id.clone()),
"Public link removed",
"Couldn't remove the link",
Some(Busy::start(&button)),
None,
);
},
@@ -475,6 +593,14 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
let pw_row = adw::PasswordEntryRow::builder()
.title("Password (optional)")
.build();
let expiry_names: Vec<&str> = LINK_EXPIRY.iter().map(|(name, _)| *name).collect();
let expiry_drop = gtk4::DropDown::builder()
.model(&gtk4::StringList::new(&expiry_names))
.selected(0)
.valign(gtk4::Align::Center)
.build();
let expiry_row = adw::ActionRow::builder().title("Expires").build();
expiry_row.add_suffix(&expiry_drop);
let create = gtk4::Button::builder()
.label("Create Public Link")
.halign(gtk4::Align::End)
@@ -492,7 +618,10 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
let state_c = state.clone();
let pw_for = pw_row.clone();
create.connect_clicked(move |_| {
create.connect_clicked(move |btn| {
if !btn.is_sensitive() {
return;
}
let role = if role_drop.selected() == 1 {
"editor"
} else {
@@ -501,14 +630,17 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
.to_string();
let pw = pw_for.text().to_string();
let password = if pw.is_empty() { None } else { Some(pw) };
share_dialog_create_link(&state_c, role, password);
let expires = link_expiry_at(expiry_drop.selected(), glib::real_time() / 1_000_000);
share_dialog_create_link(&state_c, role, password, expires, Busy::start(btn));
});
state.link_group.add(&role_row);
state.link_group.add(&pw_row);
state.link_group.add(&expiry_row);
state.link_group.add(&create_wrap);
rows.push(role_row.upcast());
rows.push(pw_row.upcast());
rows.push(expiry_row.upcast());
rows.push(create_wrap.upcast());
}
}
@@ -521,14 +653,18 @@ pub(crate) fn share_dialog_create_link(
state: &Rc<ShareDialog>,
role: String,
password: Option<String>,
expires: Option<i64>,
busy: Busy,
) {
let rx = spawn_request(
state.ui.dirs.control_socket(),
state.target.create_link(role, password),
state.target.create_link(role, password, expires),
);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
let reply = rx.recv().await;
drop(busy);
match reply {
Ok(Ok(Response::PublicLink { .. })) => {
toast(&state.ui, "Public link created");
share_dialog_reload(&state);
@@ -546,18 +682,22 @@ pub(crate) fn share_dialog_create_link(
}
/// Run a Share-dialog mutation, then reload the dialog on success. `on_success`
/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload.
/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload;
/// `busy` keeps the button that started it spinning until the reply arrives.
pub(crate) fn share_dialog_op(
state: &Rc<ShareDialog>,
req: Request,
done: &'static str,
failed: &'static str,
busy: Option<Busy>,
on_success: Option<Box<dyn Fn()>>,
) {
let rx = spawn_request(state.ui.dirs.control_socket(), req);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
let reply = rx.recv().await;
drop(busy);
match reply {
Ok(Ok(Response::Ok { .. })) => {
if let Some(cb) = on_success {
cb();
@@ -575,3 +715,61 @@ pub(crate) fn share_dialog_op(
}
});
}
/// How an existing link's expiry reads in its subtitle: the date it stops
/// working, or that it already has.
pub(crate) fn link_expiry_label(expires: i64, now: i64) -> String {
if expires <= now {
return "expired".to_string();
}
let date = glib::DateTime::from_unix_local(expires)
.ok()
.and_then(|at| at.format("%x").ok())
.map(|s| s.to_string())
.unwrap_or_default();
format!("expires {date}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn email_check_accepts_plain_addresses() {
assert!(looks_like_email("a@b.co"));
assert!(looks_like_email("first.last+tag@mail.example.org"));
}
#[test]
fn email_check_rejects_typos() {
for bad in [
"a", "a@", "@b.co", "a@b", "a@b.", "a@.co", "a@@b.co", "a@b@c.co",
] {
assert!(!looks_like_email(bad), "{bad}");
}
}
#[test]
fn parse_emails_splits_and_reports_first_bad_entry() {
assert_eq!(
parse_emails("a@b.co, c@d.org;e@f.net"),
Ok(vec!["a@b.co".into(), "c@d.org".into(), "e@f.net".into()])
);
assert_eq!(parse_emails(" , "), Ok(vec![]));
assert_eq!(parse_emails("a@b.co oops"), Err("oops".into()));
}
#[test]
fn link_expiry_counts_days_from_now() {
assert_eq!(link_expiry_at(0, 1_000), None);
assert_eq!(link_expiry_at(1, 1_000), Some(1_000 + 86_400));
assert_eq!(link_expiry_at(3, 0), Some(30 * 86_400));
assert_eq!(link_expiry_at(99, 0), None);
}
#[test]
fn past_expiry_reads_expired() {
assert_eq!(link_expiry_label(10, 20), "expired");
assert!(link_expiry_label(2_000_000_000, 0).starts_with("expires "));
}
}
+8
View File
@@ -43,6 +43,11 @@ Schema: **31** (unchanged).
→ Show tray icon brings the icon back. "Stop Proton Drive…" asks for confirmation in the app,
then stops the service and the tray. `pdfs-app` accepts `--page <name>` and `--confirm-stop`.
A second launch raises the running window instead of opening another one.
- **Stop sharing.** Shared by Me → item menu → "Stop Sharing…" and `pdfs unshare <path>` (with no
id) remove the public link, every invitation and every member in one go. Each step is tried even
if an earlier one fails, and a partial result names what is still shared.
- **Public link expiry.** The Share dialog's create-link form has an "Expires" choice (Never,
1 day, 7 days, 30 days), and an existing link shows when it expires or that it has expired.
### Changed
- **New app shell.** Every page has its own header bar; the sidebar ends in a footer with sync
@@ -116,6 +121,9 @@ Schema: **31** (unchanged).
daemon logs again on every run now shows once, with a count. A "Needs attention" section on top
lists the conflicts still waiting for a decision. Each logged conflict has a Resolve… button that
opens the same dialog as the Sync page, and reads as resolved once the copy is gone.
- **Share dialog.** The Invite button stays off until every address looks like an email; a
malformed one marks the field red and names the entry in a tooltip. Buttons show a spinner
while their request runs, so a slow invite or link removal can't be sent twice.
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
`pdfs favorite` command.