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

+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 "));
}
}