feat: split settings into Identity, Server, Backup and Advanced tabs

This commit is contained in:
randogoth 2026-10-08 20:46:17 +03:00
parent 2892ea3fa7
commit 743c388cfb
4 changed files with 365 additions and 288 deletions

View file

@ -159,10 +159,6 @@ pub enum Message {
SignupRegister,
SignupRecall,
// Pin offer (signup and settings share it)
PinAccept,
PinDiscard,
// Mail
GotoMail(Folder),
Goto(Page),
@ -202,19 +198,17 @@ pub enum Message {
ContactAccept(String, String),
ContactBlock(String),
ContactUnblock(String),
CopyExport,
// Settings
SettingsLearnKey,
SecretRevealToggled,
CopySecret,
KeepMailChanged(bool),
TimeoutChanged(String),
RotatePressed,
RotateConfirmed,
RotateCancelled,
UnpinPressed(String),
ExportPressed,
ImportBodyChanged(String),
ImportPressed,
ExportBackupTo(PathBuf),
ImportBackupFrom(String),
ResetPressed,
ResetConfirmed,
ResetCancelled,
@ -229,8 +223,6 @@ pub enum OpResult {
/// The key a server presented with no pin in place (sec 4 tofu).
PinOffered(Result<Option<(Address, [u8; KEY_LEN])>, String>),
Pinned(Result<(), String>),
/// (note, backup text) — the text is for the clipboard and the view.
Exported(Result<(String, String), String>),
}
/// One background operation to run on the blocking pool, carrying every
@ -280,11 +272,8 @@ pub enum PendingOp {
uri: String,
},
Rotate,
Unpin {
label: String,
},
Export {
dir: PathBuf,
path: PathBuf,
},
ImportBackup {
text: String,
@ -322,8 +311,8 @@ pub struct Carrier {
pub drafts: Vec<Draft>,
pub snapshot: Option<Snapshot>,
pub pin_offer: Option<(Address, [u8; KEY_LEN])>,
pub backup_text: Option<String>,
pub import_body: String,
/// The Identity tab's recovery secret is masked until this is toggled.
pub secret_revealed: bool,
pub contact_import: String,
pub dialog: Option<Dialog>,
/// The view syncs entry text from the model once after a prefill
@ -389,8 +378,7 @@ impl Carrier {
drafts,
snapshot: None,
pin_offer: None,
backup_text: None,
import_body: String::new(),
secret_revealed: false,
contact_import: String::new(),
dialog: None,
compose_fresh: Cell::new(false),
@ -480,7 +468,7 @@ impl Carrier {
}
let (app, _) = Carrier::boot(self.dir.clone());
*self = app;
self.note("factory reset done. Create a new identity or restore a seed.".into());
self.note("logged out. Create a new identity or restore a seed.".into());
}
/// Cancelling out of a not-yet-registered identity removes the key
@ -577,11 +565,6 @@ impl Carrier {
})
}
/// The account address, for the settings page's learn-key action.
fn account_address(&self) -> Option<Address> {
self.store.account().ok().flatten()
}
/// `user@host` as typed on the signup page, defaulting the port.
fn signup_address(&self) -> Option<Address> {
let user = self.signup.user.trim();
@ -656,9 +639,6 @@ impl Carrier {
}
}
}
Message::ImportBodyChanged(text) => {
self.import_body = text;
}
Message::ContactSelected(address) => {
self.stash_draft();
self.selected_own = false;
@ -709,6 +689,9 @@ impl Carrier {
Message::KeepMailChanged(keep) => {
self.keep_mail = keep;
}
Message::SecretRevealToggled => {
self.secret_revealed = !self.secret_revealed;
}
Message::TimeoutChanged(text) => {
if let Ok(timeout) = text.trim().parse::<u64>() {
self.timeout = timeout;
@ -790,21 +773,6 @@ impl Carrier {
};
return vec![PendingOp::LearnKey { addr }];
}
Message::SettingsLearnKey => {
let Some(addr) = self.account_address() else {
return Vec::new();
};
return vec![PendingOp::LearnKey { addr }];
}
Message::PinDiscard => {
self.pin_offer = None;
}
Message::PinAccept => {
let Some((addr, key)) = self.pin_offer.take() else {
return Vec::new();
};
return vec![PendingOp::Pin { addr, key }];
}
Message::SignupRegister => {
let Some(addr) = self.signup_address() else {
self.fail("enter user and host".into());
@ -1102,11 +1070,10 @@ impl Carrier {
};
return vec![PendingOp::Accept { address, identity }];
}
Message::CopyExport => {
if let Some(text) = &self.backup_text {
if let Some(display) = gtk::gdk::Display::default() {
display.clipboard().set_text(text);
}
Message::CopySecret => {
let text = ops::seed_text(&self.master());
if let Some(display) = gtk::gdk::Display::default() {
display.clipboard().set_text(&text);
}
}
Message::RotatePressed => {
@ -1129,20 +1096,14 @@ impl Carrier {
self.dialog = None;
return vec![PendingOp::Rotate];
}
Message::UnpinPressed(label) => {
return vec![PendingOp::Unpin { label }];
Message::ExportBackupTo(path) => {
return vec![PendingOp::Export { path }];
}
Message::ExportPressed => {
let dir = self.dir.clone();
return vec![PendingOp::Export { dir }];
}
Message::ImportPressed => {
if self.import_body.trim().is_empty() {
Message::ImportBackupFrom(text) => {
if text.trim().is_empty() {
return Vec::new();
}
return vec![PendingOp::ImportBackup {
text: self.import_body.clone(),
}];
return vec![PendingOp::ImportBackup { text }];
}
}
Vec::new()
@ -1211,17 +1172,6 @@ impl Carrier {
self.fail(e);
None
}
OpResult::Exported(Ok((note, text))) => {
self.done();
self.backup_text = Some(text);
self.note(note);
None
}
OpResult::Exported(Err(e)) => {
self.done();
self.fail(e);
None
}
}
}
}
@ -1280,6 +1230,33 @@ mod tests {
assert!(!app.keep_mail);
}
#[test]
fn secret_reveal_toggles() {
let mut app = app();
assert!(!app.secret_revealed);
app.handle(Message::SecretRevealToggled);
assert!(app.secret_revealed);
app.handle(Message::SecretRevealToggled);
assert!(!app.secret_revealed);
}
/// Export/Import now go through a native file dialog rather than a
/// paste box, so the model only ever sees the chosen path/text —
/// an empty import (e.g. an empty file) is a no-op, like before.
#[test]
fn export_and_import_dispatch_on_the_chosen_path() {
let mut app = app();
let path = std::path::PathBuf::from("/tmp/wherever.gsmol");
let ops = app.handle(Message::ExportBackupTo(path.clone()));
assert!(matches!(ops.as_slice(), [PendingOp::Export { path: p }] if *p == path));
assert!(app
.handle(Message::ImportBackupFrom(" ".into()))
.is_empty());
let ops = app.handle(Message::ImportBackupFrom("some backup text".into()));
assert!(matches!(ops.as_slice(), [PendingOp::ImportBackup { .. }]));
}
/// A factory reset asks first, then erases every file in the config
/// directory and returns the model to onboarding, as if the app had
/// never run.