feat(core,app,android,ios): rename a share

ShareStore::rename changes only the name; the id that the catalog, watch
state and offline files are keyed by stays the same. Sync carries the
change as it does any other change to the share's register. The desktop
renames in the share's row. The phones rename from the share's menu.
Before, the name given at add time was permanent short of removing the
share.
This commit is contained in:
nvrl committed 2026-10-13 16:01:11 +02:00
1 parent 5b931111b3
commit abb3e3aaf8
10 files changed
+185

No files matched your search

@@ -423,6 +423,15 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/** Call a share something else. The library, history and downloads stay. */
fun renameShare(id: String, name: String) {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().renameShare(id, name) } }
.onFailure(::reportError)
reload()
}
}
fun removeShare(id: String) { fun removeShare(id: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
@@ -310,6 +310,7 @@ fun ProtonStreamApp(
addFolder = model::addAccountFolder, addFolder = model::addAccountFolder,
onError = model::reportError, onError = model::reportError,
), ),
onRename = model::renameShare,
) )
Destination.History -> HistoryScreen( Destination.History -> HistoryScreen(
state, state,
@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.DangerButton import io.narl.protonstream.ui.theme.DangerButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import androidx.compose.material.icons.filled.Edit
import uniffi.pstr_android.AccountState import uniffi.pstr_android.AccountState
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -70,8 +71,10 @@ internal fun SharesScreen(
padding: PaddingValues, padding: PaddingValues,
account: AccountUiState = AccountUiState(), account: AccountUiState = AccountUiState(),
accountActions: AccountActions? = null, accountActions: AccountActions? = null,
onRename: (String, String) -> Unit = { _, _ -> },
) { ) {
var showAdd by remember { mutableStateOf(false) } var showAdd by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<ShareRecord?>(null) }
var signingIn by remember { mutableStateOf(false) } var signingIn by remember { mutableStateOf(false) }
var browsing by remember { mutableStateOf(false) } var browsing by remember { mutableStateOf(false) }
var repairing by remember { mutableStateOf<ShareRecord?>(null) } var repairing by remember { mutableStateOf<ShareRecord?>(null) }
@@ -108,6 +111,7 @@ internal fun SharesScreen(
onRefresh = { onRefresh(share.id) }, onRefresh = { onRefresh(share.id) },
onRepair = { repairing = share }, onRepair = { repairing = share },
onRemove = { removing = share }, onRemove = { removing = share },
onRename = { renaming = share },
) )
} }
} }
@@ -146,6 +150,21 @@ internal fun SharesScreen(
onRepair = { url, password -> onRepair(share.id, url, password) }, onRepair = { url, password -> onRepair(share.id, url, password) },
) )
} }
renaming?.let { share ->
var name by remember(share.id) { mutableStateOf(share.name) }
AlertDialog(
onDismissRequest = { renaming = null },
title = { Text("Rename ${share.name}") },
text = { OutlinedTextField(name, { name = it }, label = { Text("Name") }, singleLine = true) },
confirmButton = {
AccentButton(enabled = name.isNotBlank(), onClick = {
renaming = null
onRename(share.id, name.trim())
}) { Text("Save") }
},
dismissButton = { TonalButton(onClick = { renaming = null }) { Text("Cancel") } },
)
}
removing?.let { share -> removing?.let { share ->
// Asked, because it cannot be undone: the link's secret and its // Asked, because it cannot be undone: the link's secret and its
// offline files go. Watch positions stay (`Catalog::remove_share`). // offline files go. Watch positions stay (`Catalog::remove_share`).
@@ -197,6 +216,7 @@ private fun ShareRow(
onRefresh: () -> Unit, onRefresh: () -> Unit,
onRepair: () -> Unit, onRepair: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
onRename: () -> Unit = {},
) { ) {
var menu by remember { mutableStateOf(false) } var menu by remember { mutableStateOf(false) }
ListItem( ListItem(
@@ -244,6 +264,11 @@ private fun ShareRow(
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
onClick = { menu = false; onRefresh() }, onClick = { menu = false; onRefresh() },
) )
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
onClick = { menu = false; onRename() },
)
// Not a remove: a share whose secret has become unreadable // Not a remove: a share whose secret has become unreadable
// cannot be removed either, since removal deletes a secret // cannot be removed either, since removal deletes a secret
// the store can no longer touch. // the store can no longer touch.
+8
View File
@@ -1386,6 +1386,14 @@ impl AndroidEngine {
Ok(share_record(share)) Ok(share_record(share))
} }
/// Call a share something else; see `ShareStore::rename`.
pub fn rename_share(&self, share_id: String, name: String) -> Result<ShareRecord, BridgeError> {
self.store
.rename(&share_id, &name)
.map(share_record)
.map_err(BridgeError::from_display)
}
pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> { pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> {
let _publication = self.share_publication.lock(); let _publication = self.share_publication.lock();
let catalog = self.catalog.lock(); let catalog = self.catalog.lock();
+12
View File
@@ -146,6 +146,11 @@ pub enum Action {
OpenShareForm, OpenShareForm,
/// Stop offering posters and synopses on the library page. /// Stop offering posters and synopses on the library page.
DismissMetadataOffer, DismissMetadataOffer,
/// Call a share something else.
RenameShare {
id: String,
name: String,
},
/// Store a new link, and password, for a share already in the library. /// Store a new link, and password, for a share already in the library.
RepairShare { RepairShare {
id: String, id: String,
@@ -563,6 +568,9 @@ pub struct ShareForm {
pub repairing: Option<String>, pub repairing: Option<String>,
pub repair_url: String, pub repair_url: String,
pub repair_password: String, pub repair_password: String,
/// The share being renamed in its own row, if any, and the name so far.
pub renaming: Option<String>,
pub rename_to: String,
} }
pub struct App { pub struct App {
@@ -1671,6 +1679,10 @@ impl App {
self.form.error = None; self.form.error = None;
self.note(ctx, "share link pasted — add it, or name it first", false); self.note(ctx, "share link pasted — add it, or name it first", false);
} }
Action::RenameShare { id, name } => {
self.form.renaming = None;
self.engine.rename_share(id, name);
}
Action::RepairShare { id, url, password } => { Action::RepairShare { id, url, password } => {
self.crawling = true; self.crawling = true;
self.form.repairing = None; self.form.repairing = None;
+10
View File
@@ -696,6 +696,16 @@ impl Engine {
}); });
} }
/// Call a share something else. See [`ShareStore::rename`].
pub fn rename_share(&self, id: String, name: String) {
let engine = self.clone();
self.runtime
.spawn_blocking(move || match engine.store.rename(&id, &name) {
Ok(_) => engine.load_shares(),
Err(error) => engine.fail("rename the share", error),
});
}
/// Re-supply the link behind a share whose stored secret is unreadable — /// Re-supply the link behind a share whose stored secret is unreadable —
/// a keyring that was reset — or whose custom password changed, keeping /// a keyring that was reset — or whose custom password changed, keeping
/// its catalog, watch state and downloads. See /// its catalog, watch state and downloads. See
+35
View File
@@ -108,6 +108,10 @@ fn share_row(
if ui.button("Crawl").clicked() { if ui.button("Crawl").clicked() {
actions.push(Action::Crawl(Some(share.id.clone()))); actions.push(Action::Crawl(Some(share.id.clone())));
} }
if ui.button("Rename").clicked() {
form.renaming = Some(share.id.clone());
form.rename_to = share.name.clone();
}
// Not for a folder of the account, which has no link. // Not for a folder of the account, which has no link.
if share.folder.is_none() if share.folder.is_none()
&& ui && ui
@@ -127,10 +131,41 @@ fn share_row(
if form.repairing.as_deref() == Some(share.id.as_str()) { if form.repairing.as_deref() == Some(share.id.as_str()) {
repair_form(ui, share, form, actions); repair_form(ui, share, form, actions);
} }
if form.renaming.as_deref() == Some(share.id.as_str()) {
rename_form(ui, share, form, actions);
}
}); });
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
} }
/// A new name for a share, in its own row. Enter saves, as everywhere else.
fn rename_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
ui.add_space(theme::space::M);
ui.horizontal(|ui| {
let field = ui.add(
egui::TextEdit::singleline(&mut form.rename_to)
.hint_text("Name")
.desired_width(320.0),
);
let ready = !form.rename_to.trim().is_empty();
let entered = field.lost_focus() && ui_enter_pressed(ui.ctx());
if ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
.inner
.clicked()
|| (ready && entered)
{
actions.push(Action::RenameShare {
id: share.id.clone(),
name: form.rename_to.trim().to_owned(),
});
}
if ui.button("Cancel").clicked() {
form.renaming = None;
}
});
}
/// The same link again, and a password if it has one, for a share already in /// The same link again, and a password if it has one, for a share already in
/// the library. /// the library.
fn repair_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) { fn repair_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
+38
View File
@@ -567,6 +567,25 @@ impl ShareStore {
Ok(updated) Ok(updated)
} }
/// Call a share something else. Only the name changes: the id, which the
/// catalog, watch state and offline files are keyed by, stays. Sync sees
/// the new name as a change to the share's register, like any other.
pub fn rename(&self, id: &str, name: &str) -> Result<Share> {
let name = name.trim();
if name.is_empty() {
return Err(Error::Config("a share needs a name".to_owned()));
}
let mut shares = self.list()?;
let share = shares
.iter_mut()
.find(|share| share.id == id)
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
share.name = name.to_owned();
let renamed = share.clone();
write_json(&self.dirs.shares_file(), &shares)?;
Ok(renamed)
}
/// Forget a share and its secrets. /// Forget a share and its secrets.
/// ///
/// The config entry goes first here, for the mirror-image reason: if the /// The config entry goes first here, for the mirror-image reason: if the
@@ -1257,6 +1276,25 @@ mod tests {
); );
} }
#[test]
fn a_renamed_share_keeps_its_id_and_refuses_an_empty_name() {
let (store, _) = store_in("rename");
let added = store
.add(
"Library",
"https://drive.proton.me/urls/ABC123#s3cr3t",
None,
)
.expect("add");
let renamed = store.rename(&added.id, " Anime ").expect("rename");
assert_eq!(renamed.id, added.id);
assert_eq!(renamed.name, "Anime");
assert_eq!(store.list().expect("list")[0].name, "Anime");
assert!(store.rename(&added.id, " ").is_err());
assert!(store.rename("share-missing", "Anime").is_err());
}
/// A different link is a different share. Repointing this one at it would /// A different link is a different share. Repointing this one at it would
/// leave every catalog row and offline file describing files that are not /// leave every catalog row and offline file describing files that are not
/// there. /// there.
+12
View File
@@ -316,6 +316,18 @@ final class AppModel {
} }
} }
/// Call a share something else. The library, history and downloads stay.
func renameShare(id: String, name: String) {
Task {
do {
_ = try await run { try $0.renameShare(shareId: id, name: name) }
} catch {
reportError(error)
}
reload()
}
}
func removeShare(_ id: String) { func removeShare(_ id: String) {
Task { Task {
let name = shares.first { $0.id == id }?.name ?? "the share" let name = shares.first { $0.id == id }?.name ?? "the share"
+35
View File
@@ -13,6 +13,7 @@ struct SharesView: View {
@State private var browsing = false @State private var browsing = false
@State private var repairing: ShareRecord? @State private var repairing: ShareRecord?
@State private var removing: ShareRecord? @State private var removing: ShareRecord?
@State private var renaming: ShareRecord?
var body: some View { var body: some View {
TabPage("Shares") { TabPage("Shares") {
@@ -34,6 +35,7 @@ struct SharesView: View {
share: share, share: share,
onRefresh: { model.refreshShare(share.id) }, onRefresh: { model.refreshShare(share.id) },
onRepair: { repairing = share }, onRepair: { repairing = share },
onRename: { renaming = share },
onRemove: { removing = share } onRemove: { removing = share }
) )
} }
@@ -78,6 +80,9 @@ struct SharesView: View {
.fullScreenCover(isPresented: $browsing) { .fullScreenCover(isPresented: $browsing) {
DriveBrowser() DriveBrowser()
} }
.sheet(item: $renaming) { share in
RenameShareSheet(share: share) { model.renameShare(id: share.id, name: $0) }
}
.sheet(item: $repairing) { share in .sheet(item: $repairing) { share in
RepairShareSheet(share: share) { url, password in RepairShareSheet(share: share) { url, password in
model.repairShare(id: share.id, url: url, password: password) model.repairShare(id: share.id, url: url, password: password)
@@ -124,6 +129,7 @@ private struct ShareRow: View {
let share: ShareRecord let share: ShareRecord
let onRefresh: () -> Void let onRefresh: () -> Void
let onRepair: () -> Void let onRepair: () -> Void
let onRename: () -> Void
let onRemove: () -> Void let onRemove: () -> Void
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
@@ -147,6 +153,7 @@ private struct ShareRow: View {
// One share, not the library: a link that has just had files // One share, not the library: a link that has just had files
// added should not cost a walk of every other one. // added should not cost a walk of every other one.
Button(action: onRefresh) { Label("Refresh", systemImage: "arrow.clockwise") } Button(action: onRefresh) { Label("Refresh", systemImage: "arrow.clockwise") }
Button(action: onRename) { Label("Rename", systemImage: "pencil") }
// Not a remove: a share whose secret has become unreadable // Not a remove: a share whose secret has become unreadable
// cannot be removed either. // cannot be removed either.
if !share.fromAccount { if !share.fromAccount {
@@ -165,6 +172,34 @@ private struct ShareRow: View {
} }
} }
/// A new name for a share. The library, history and downloads stay.
private struct RenameShareSheet: View {
let share: ShareRecord
let onRename: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
init(share: ShareRecord, onRename: @escaping (String) -> Void) {
self.share = share
self.onRename = onRename
_name = State(initialValue: share.name)
}
var body: some View {
DialogSheet("Rename \(share.name)") {
Field(label: "Name", text: $name)
} buttons: {
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
Button("Save") {
onRename(name.trimmingCharacters(in: .whitespaces))
dismiss()
}
.buttonStyle(.accent)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
/// Re-enter the link for a share the app can no longer decrypt its secret for. /// Re-enter the link for a share the app can no longer decrypt its secret for.
/// ///
/// It has to be the *same* link — a different token is a different share. /// It has to be the *same* link — a different token is a different share.