diff --git a/TESTING.md b/TESTING.md index fb11f55..a1a28fc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -117,6 +117,25 @@ answer before uploading it to App Store Connect. still work without re-entry. - [ ] Open the server picker from a repository list and switch between at least two configured servers; every data tab changes to the selected server. +- [ ] Swipe a server row from the trailing edge; Delete and Edit use native + contextual actions with symbols. A full swipe invokes Delete, and both a + full swipe and a Delete tap still require the same destructive alert. +- [ ] Cancel the delete alert; the row, selected server, saved token, favorites, + and filters remain unchanged. +- [ ] Tap Edit; API provider, name, and URL show the saved values. The secure + token field is empty and says Leave unchanged so the stored token is not + exposed to the UI. +- [ ] Save edits with the token field empty; the existing token still works. + Then change the name, URL, provider, and token and save again; the server + is revalidated, provider discovery is applied, URL-scoped favorites and + filters follow the server, and all data tabs use the edited connection. +- [ ] Enter invalid edited credentials or an invalid URL; the editor stays open, + shows the error, and preserves the previously working configuration. +- [ ] Confirm Delete; the row and Keychain token are removed. If it was selected, + the next server is selected (or the previous last row); deleting the last + server returns server-dependent tabs to their empty Servers screen. +- [ ] Terminate and relaunch after editing and deleting; edited details persist, + deleted servers do not return, and remaining servers still authenticate. ## Native interaction and navigation diff --git a/crates/app/src/core/servers.rs b/crates/app/src/core/servers.rs index 059a2cd..00889eb 100644 --- a/crates/app/src/core/servers.rs +++ b/crates/app/src/core/servers.rs @@ -60,6 +60,20 @@ impl GotchaCore { .map(|server| server.name.clone()) } + pub fn server_editor(&self, index: u32) -> Result { + let state = self.state.lock().unwrap(); + let server = state + .preferences + .servers + .get(index as usize) + .ok_or("That server no longer exists.")?; + Ok(ServerEditor { + name: server.name.clone(), + url: server.url.clone(), + provider: server.provider.into(), + }) + } + pub fn select_server(&self, index: u32) -> Result<(), GotchaError> { let mut state = self.state.lock().unwrap(); let index = index as usize; @@ -88,16 +102,121 @@ impl GotchaCore { .await .map_err(|error| error.to_string())?; server.provider = client.provider(); - save_server_token(&server)?; let mut state = self.state.lock().unwrap(); - state.preferences.servers.push(server); - let index = state.preferences.servers.len() - 1; - state.preferences.last_server = Some(index); + assign_server_credential_account(&mut server, &state.preferences.servers); + save_server_token(&server)?; + let mut preferences = state.preferences.clone(); + preferences.servers.push(server.clone()); + let index = preferences.servers.len() - 1; + preferences.last_server = Some(index); + if let Err(error) = save_preferences(&preferences) { + return Err(rollback_added_server_token(&server, error).into()); + } + state.preferences = preferences; state.active_server = Some(index); - save_preferences(&state.preferences)?; Ok(index as u32) } + pub async fn update_server( + &self, + index: u32, + name: String, + url: String, + token: String, + provider: ServerProvider, + ) -> Result<(), GotchaError> { + let index = index as usize; + let old_server = self + .state + .lock() + .unwrap() + .preferences + .servers + .get(index) + .cloned() + .ok_or("That server no longer exists.")?; + let token = if token.trim().is_empty() { + old_server.token.clone() + } else { + token + }; + let mut server = validate_server(&name, &url, &token, provider.into())?; + server.credential_account = old_server.credential_account.clone(); + let client = Client::discover(&server.url, Some(&server.token), server.provider) + .await + .map_err(|error| error.to_string())?; + client + .current_user() + .await + .map_err(|error| error.to_string())?; + server.provider = client.provider(); + + let mut state = self.state.lock().unwrap(); + let current = state + .preferences + .servers + .get(index) + .ok_or("That server no longer exists.")?; + if current.credential_account != old_server.credential_account { + return Err("That server changed while it was being verified.".into()); + } + save_server_token(&server)?; + let mut preferences = state.preferences.clone(); + preferences.servers[index] = server; + if old_server.url != preferences.servers[index].url + && preferences + .servers + .iter() + .all(|saved| saved.url != old_server.url) + { + let new_url = preferences.servers[index].url.clone(); + migrate_server_settings(&mut preferences, &old_server.url, &new_url); + } + if let Err(error) = save_preferences(&preferences) { + return Err(rollback_updated_server_token(&old_server, error).into()); + } + state.preferences = preferences; + state.repositories.clear(); + Ok(()) + } + + pub async fn delete_server(&self, index: u32) -> Result<(), GotchaError> { + let index = index as usize; + let mut state = self.state.lock().unwrap(); + let server = state + .preferences + .servers + .get(index) + .cloned() + .ok_or("That server no longer exists.")?; + let mut preferences = state.preferences.clone(); + preferences.servers.remove(index); + let active_server = + active_server_after_removal(state.active_server, index, preferences.servers.len()); + preferences.last_server = active_server; + if preferences + .servers + .iter() + .all(|saved| saved.url != server.url) + { + remove_server_settings(&mut preferences, &server.url); + } + save_preferences(&preferences)?; + if let Err(error) = delete_server_token(&server) { + if let Err(rollback) = save_preferences(&state.preferences) { + return Err(format!( + "{error} Restoring the server configuration also failed: {rollback}" + ) + .into()); + } + return Err(error.into()); + } + state.preferences = preferences; + state.active_server = active_server; + state.repositories.clear(); + Ok(()) + } + pub fn settings(&self) -> Settings { let state = self.state.lock().unwrap(); Settings { @@ -146,3 +265,154 @@ impl GotchaCore { )) } } + +fn rollback_added_server_token(server: &Server, error: String) -> String { + match delete_server_token(server) { + Ok(()) => error, + Err(rollback) => format!("{error} Removing the unused token also failed: {rollback}"), + } +} + +fn rollback_updated_server_token(server: &Server, error: String) -> String { + match save_server_token(server) { + Ok(()) => error, + Err(rollback) => format!("{error} Restoring the previous token also failed: {rollback}"), + } +} + +fn active_server_after_removal( + active: Option, + removed: usize, + remaining: usize, +) -> Option { + match active { + _ if remaining == 0 => None, + Some(active) if active > removed => Some(active - 1), + Some(active) if active == removed => Some(removed.min(remaining - 1)), + active => active, + } +} + +fn migrate_server_settings(preferences: &mut Preferences, old_url: &str, new_url: &str) { + for pane in RepositoryPane::ALL { + let old_prefix = format!("{}|{old_url}|", pane.key()); + let new_prefix = format!("{}|{new_url}|", pane.key()); + let moved: Vec<_> = preferences + .favorites + .iter() + .filter_map(|favorite| { + favorite + .strip_prefix(&old_prefix) + .map(|suffix| (favorite.clone(), format!("{new_prefix}{suffix}"))) + }) + .collect(); + for (old, new) in moved { + preferences.favorites.remove(&old); + preferences.favorites.insert(new); + } + } + + let old_prefix = format!("{old_url}|"); + let new_prefix = format!("{new_url}|"); + let issue_filters: Vec<_> = preferences + .issue_filters + .iter() + .filter_map(|(key, filter)| { + key.strip_prefix(&old_prefix) + .map(|suffix| (key.clone(), format!("{new_prefix}{suffix}"), filter.clone())) + }) + .collect(); + for (old, new, filter) in issue_filters { + preferences.issue_filters.remove(&old); + preferences.issue_filters.entry(new).or_insert(filter); + } + if let Some(filter) = preferences.pull_filters.remove(old_url) { + preferences + .pull_filters + .entry(new_url.into()) + .or_insert(filter); + } +} + +fn remove_server_settings(preferences: &mut Preferences, url: &str) { + preferences.favorites.retain(|favorite| { + RepositoryPane::ALL + .iter() + .all(|pane| !favorite.starts_with(&format!("{}|{url}|", pane.key()))) + }); + let prefix = format!("{url}|"); + preferences + .issue_filters + .retain(|key, _| !key.starts_with(&prefix)); + preferences.pull_filters.remove(url); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server(name: &str, url: &str) -> Server { + Server { + name: name.into(), + url: url.into(), + provider: gotcha_gitea::Provider::Gitea, + credential_account: name.into(), + token: "secret".into(), + } + } + + #[test] + fn adjusts_selection_when_servers_are_removed() { + assert_eq!(active_server_after_removal(Some(0), 0, 0), None); + assert_eq!(active_server_after_removal(Some(0), 0, 2), Some(0)); + assert_eq!(active_server_after_removal(Some(2), 1, 2), Some(1)); + assert_eq!(active_server_after_removal(Some(0), 2, 2), Some(0)); + assert_eq!(active_server_after_removal(None, 0, 1), None); + } + + #[test] + fn migrates_and_removes_server_scoped_settings() { + let old = "https://old.example.com"; + let new = "https://new.example.com"; + let mut preferences = Preferences { + servers: vec![server("Work", old)], + favorites: [format!("issues|{old}|octo/demo")].into_iter().collect(), + issue_filters: [( + format!("{old}|octo/demo"), + IssueFilter { + milestone: "v1".into(), + ..Default::default() + }, + )] + .into_iter() + .collect(), + pull_filters: [( + old.into(), + PullFilter { + milestone: "v2".into(), + ..Default::default() + }, + )] + .into_iter() + .collect(), + ..Default::default() + }; + + migrate_server_settings(&mut preferences, old, new); + assert!( + preferences + .favorites + .contains(&format!("issues|{new}|octo/demo")) + ); + assert_eq!( + preferences.issue_filters[&format!("{new}|octo/demo")].milestone, + "v1" + ); + assert_eq!(preferences.pull_filters[new].milestone, "v2"); + + remove_server_settings(&mut preferences, new); + assert!(preferences.favorites.is_empty()); + assert!(preferences.issue_filters.is_empty()); + assert!(preferences.pull_filters.is_empty()); + } +} diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index b3b7196..6dcc24a 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -41,11 +41,13 @@ pub struct Server { pub url: String, #[serde(default)] pub provider: gotcha_gitea::Provider, + #[serde(default)] + pub credential_account: String, #[serde(skip)] pub token: String, } -#[derive(Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] pub struct Preferences { #[serde(default)] pub servers: Vec, diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 0bc5670..46bf915 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -36,6 +36,15 @@ impl From for gotcha_gitea::Provider { } } +impl From for ServerProvider { + fn from(provider: gotcha_gitea::Provider) -> Self { + match provider { + gotcha_gitea::Provider::Gitea => Self::Gitea, + gotcha_gitea::Provider::Forgejo => Self::Forgejo, + } + } +} + impl RepositoryPane { const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones]; diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index fbd4d72..215990f 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -20,6 +20,13 @@ pub struct ServerRow { pub url: String, } +#[derive(Clone, uniffi::Record)] +pub struct ServerEditor { + pub name: String, + pub url: String, + pub provider: crate::ServerProvider, +} + #[derive(Clone, uniffi::Record)] pub struct RepositoryRow { pub name: String, diff --git a/crates/app/src/storage.rs b/crates/app/src/storage.rs index 0244e41..3b6460e 100644 --- a/crates/app/src/storage.rs +++ b/crates/app/src/storage.rs @@ -1,7 +1,14 @@ -use std::{env, fs, path::PathBuf}; +use std::{ + env, fs, + path::PathBuf, + process, + time::{SystemTime, UNIX_EPOCH}, +}; use gotcha_gitea::{Client, Provider}; -use security_framework::passwords::{get_generic_password, set_generic_password}; +use security_framework::passwords::{ + delete_generic_password, get_generic_password, set_generic_password, +}; use crate::{ RepositoryPane, @@ -40,6 +47,7 @@ pub fn validate_server( name: name.into(), url: url.into(), provider, + credential_account: String::new(), token: token.into(), }) } @@ -59,14 +67,19 @@ pub fn load_preferences() -> Result { if !matches!(preferences.pull_status.as_str(), "open" | "closed") { preferences.pull_status = open_status(); } + let mut credentials_migrated = false; for server in &mut preferences.servers { + if server.credential_account.is_empty() { + server.credential_account = format!("{}|{}", server.name, server.url); + credentials_migrated = true; + } server.token = String::from_utf8( get_generic_password("de.rfc1437.gotcha", &keychain_account(server)) .map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?, ) .map_err(|_| format!("The token for {} is not valid text.", server.name))?; } - if favorites_migrated { + if favorites_migrated || credentials_migrated { save_preferences(&preferences)?; } Ok(preferences) @@ -111,13 +124,54 @@ pub fn save_server_token(server: &Server) -> Result<(), String> { .map_err(|error| format!("Cannot save the token for {}: {error}", server.name)) } +pub fn delete_server_token(server: &Server) -> Result<(), String> { + match delete_generic_password("de.rfc1437.gotcha", &keychain_account(server)) { + Ok(()) => Ok(()), + // A missing Keychain item must not make an otherwise valid profile undeletable. + Err(error) if error.code() == -25300 => Ok(()), // errSecItemNotFound + Err(error) => Err(format!( + "Cannot delete the token for {}: {error}", + server.name + )), + } +} + +pub fn assign_server_credential_account(server: &mut Server, existing: &[Server]) { + if !server.credential_account.is_empty() { + return; + } + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let prefix = format!("server-{}-{timestamp}", process::id()); + server.credential_account = (0_u32..) + .map(|suffix| { + if suffix == 0 { + prefix.clone() + } else { + format!("{prefix}-{suffix}") + } + }) + .find(|candidate| { + existing + .iter() + .all(|saved| saved.credential_account != *candidate) + }) + .expect("a unique Keychain account must exist"); +} + fn preferences_path() -> Result { let home = env::var_os("HOME").ok_or("Cannot find the app data directory.")?; Ok(PathBuf::from(home).join("Library/Application Support/Gotcha/preferences.json")) } fn keychain_account(server: &Server) -> String { - format!("{}|{}", server.name, server.url) + if server.credential_account.is_empty() { + format!("{}|{}", server.name, server.url) + } else { + server.credential_account.clone() + } } #[cfg(test)] @@ -182,6 +236,15 @@ mod tests { let legacy_server: Server = serde_json::from_str(r#"{"name":"Work","url":"https://gitea.example.com"}"#).unwrap(); assert_eq!(legacy_server.provider, Provider::Gitea); + assert!(legacy_server.credential_account.is_empty()); + assert_eq!( + keychain_account(&legacy_server), + "Work|https://gitea.example.com" + ); + let mut new_server = legacy_server.clone(); + assign_server_credential_account(&mut new_server, &[]); + assert!(new_server.credential_account.starts_with("server-")); + assert_eq!(keychain_account(&new_server), new_server.credential_account); assert_eq!( crate::domain::AppearanceMode::from_index(1), Some(crate::domain::AppearanceMode::Light) diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 23e248a..b0e415b 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -671,10 +671,14 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func addServer(name: String, url: String, token: String, provider: ServerProvider) async throws -> UInt32 + func deleteServer(index: UInt32) async throws + func home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage func selectServer(index: UInt32) throws + func serverEditor(index: UInt32) throws -> ServerEditor + func servers() -> [ServerRow] func setAppearance(index: UInt32) throws @@ -687,6 +691,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func startupError() -> String? + func updateServer(index: UInt32, name: String, url: String, token: String, provider: ServerProvider) async throws + } open class GotchaCore: GotchaCoreProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -1240,6 +1246,22 @@ open func addServer(name: String, url: String, token: String, provider: ServerPr ) } +open func deleteServer(index: UInt32)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_delete_server( + self.uniffiCloneHandle(),FfiConverterUInt32.lower(index) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func home(page: UInt32, filter: HomeActivityFilter)async throws -> HomePage { return try await uniffiRustCallAsync( @@ -1265,6 +1287,16 @@ open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverte } } +open func serverEditor(index: UInt32)throws -> ServerEditor { + return try FfiConverterTypeServerEditor_lift(try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_server_editor( + self.uniffiCloneHandle(), + FfiConverterUInt32.lower(index),uniffiCallStatus + ) +}) +} + open func servers() -> [ServerRow] { return try! FfiConverterSequenceTypeServerRow.lift(try! rustCall() { uniffiCallStatus in @@ -1319,6 +1351,22 @@ open func startupError() -> String? { }) } +open func updateServer(index: UInt32, name: String, url: String, token: String, provider: ServerProvider)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_update_server( + self.uniffiCloneHandle(),FfiConverterUInt32.lower(index),FfiConverterString.lower(name),FfiConverterString.lower(url),FfiConverterString.lower(token),FfiConverterTypeServerProvider_lower(provider) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + } @@ -3415,6 +3463,58 @@ public func FfiConverterTypeRepositoryRow_lower(_ value: RepositoryRow) -> RustB } +public struct ServerEditor: Equatable, Hashable { + public var name: String + public var url: String + public var provider: ServerProvider + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(name: String, url: String, provider: ServerProvider) { + self.name = name + self.url = url + self.provider = provider + } +} + +#if compiler(>=6) +extension ServerEditor: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeServerEditor: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ServerEditor { + return + try ServerEditor( + name: FfiConverterString.read(from: &buf), + url: FfiConverterString.read(from: &buf), + provider: FfiConverterTypeServerProvider.read(from: &buf) + ) + } + + public static func write(_ value: ServerEditor, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterString.write(value.url, into: &buf) + FfiConverterTypeServerProvider.write(value.provider, into: &buf) + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeServerEditor_lift(_ buf: RustBuffer) throws -> ServerEditor { + return try FfiConverterTypeServerEditor.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeServerEditor_lower(_ value: ServerEditor) -> RustBuffer { + return FfiConverterTypeServerEditor.lower(value) +} + public struct ServerRow: Equatable, Hashable { public var name: String public var url: String @@ -5012,12 +5112,18 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_add_server() != 51168) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_delete_server() != 5208) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 37602) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 10204) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_server_editor() != 36193) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_servers() != 16732) { return InitializationResult.apiChecksumMismatch } @@ -5036,6 +5142,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_startup_error() != 58843) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_update_server() != 31001) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_constructor_gotchacore_new() != 35044) { return InitializationResult.apiChecksumMismatch } @@ -5056,4 +5165,4 @@ public func uniffiEnsureGotchaCoreInitialized() { } } -// swiftlint:enable all \ No newline at end of file +// swiftlint:enable all diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 922ca76..619b81d 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -429,6 +429,11 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_name(uint64_t p uint64_t uniffi_gotcha_core_fn_method_gotchacore_add_server(uint64_t ptr, RustBuffer name, RustBuffer url, RustBuffer token, RustBuffer provider ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_SERVER +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_SERVER +uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_server(uint64_t ptr, uint32_t index +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page, RustBuffer filter @@ -439,6 +444,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t pag void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVER_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVER_EDITOR +RustBuffer uniffi_gotcha_core_fn_method_gotchacore_server_editor(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVERS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVERS RustBuffer uniffi_gotcha_core_fn_method_gotchacore_servers(uint64_t ptr, RustCallStatus *_Nonnull out_status @@ -469,6 +479,11 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_settings(uint64_t ptr, RustCa RustBuffer uniffi_gotcha_core_fn_method_gotchacore_startup_error(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_UPDATE_SERVER +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_UPDATE_SERVER +uint64_t uniffi_gotcha_core_fn_method_gotchacore_update_server(uint64_t ptr, uint32_t index, RustBuffer name, RustBuffer url, RustBuffer token, RustBuffer provider +); +#endif #ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC #define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC RustBuffer ffi_gotcha_core_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status @@ -931,6 +946,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_name(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ADD_SERVER uint16_t uniffi_gotcha_core_checksum_method_gotchacore_add_server(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_SERVER +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_SERVER +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_server(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME @@ -943,6 +964,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER uint16_t uniffi_gotcha_core_checksum_method_gotchacore_select_server(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVER_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVER_EDITOR +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_server_editor(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVERS @@ -979,6 +1006,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_settings(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_STARTUP_ERROR uint16_t uniffi_gotcha_core_checksum_method_gotchacore_startup_error(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_UPDATE_SERVER +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_UPDATE_SERVER +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_update_server(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_CONSTRUCTOR_GOTCHACORE_NEW diff --git a/ios/Sources/AppContext.swift b/ios/Sources/AppContext.swift index ad33b20..7c3644e 100644 --- a/ios/Sources/AppContext.swift +++ b/ios/Sources/AppContext.swift @@ -47,12 +47,16 @@ final class AppContext { func selectServer(index: UInt32) throws { try core.selectServer(index: index) + reloadAfterServerChange() + } + + func reloadAfterServerChange() { let replacements: [(Int, UIViewController)] = [ (0, HomeViewController(context: self)), - (1, RepositoriesViewController(context: self, mode: .issues)), - (2, RepositoriesViewController(context: self, mode: .commits)), + (1, repositoryRoot(mode: .issues)), + (2, repositoryRoot(mode: .commits)), (3, PullsViewController(context: self)), - (4, RepositoriesViewController(context: self, mode: .milestones)), + (4, repositoryRoot(mode: .milestones)), ] for (index, root) in replacements { navigationControllers[index].setViewControllers([root], animated: false) diff --git a/ios/Sources/ServerScreens.swift b/ios/Sources/ServerScreens.swift index e2ad005..41adb74 100644 --- a/ios/Sources/ServerScreens.swift +++ b/ios/Sources/ServerScreens.swift @@ -4,6 +4,7 @@ import UIKit final class ServersViewController: UITableViewController { private let context: AppContext private var servers: [ServerRow] = [] + private var mutationTask: Task? init(context: AppContext) { self.context = context @@ -16,16 +17,16 @@ final class ServersViewController: UITableViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + deinit { + mutationTask?.cancel() + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - servers = context.core.servers() - tableView.reloadData() - tableView.backgroundView = servers.isEmpty - ? EmptyBackgroundView(title: "No servers", detail: "Add a code hosting server to get started.") - : nil + reloadServers() navigationItem.rightBarButtonItem = UIBarButtonItem( systemItem: .add, - primaryAction: UIAction { [weak self] _ in self?.showAddServer() } + primaryAction: UIAction { [weak self] _ in self?.showServerEditor() } ) } @@ -54,18 +55,101 @@ final class ServersViewController: UITableViewController { } } - private func showAddServer() { - let controller = AddServerViewController(context: context) { [weak self] in - guard let self else { return } - self.servers = self.context.core.servers() - self.tableView.reloadData() + override func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + let server = servers[indexPath.row] + let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { + [weak self] _, _, completion in + self?.confirmDelete(server, index: indexPath.row, completion: completion) + ?? completion(false) } - present(UINavigationController(rootViewController: controller), animated: true) + deleteAction.image = context.symbol("trash") + + let editAction = UIContextualAction(style: .normal, title: "Edit") { + [weak self] _, _, completion in + self?.showServerEditor(index: indexPath.row, completion: completion) + ?? completion(false) + } + editAction.image = context.symbol("pencil") + + let configuration = UISwipeActionsConfiguration(actions: [deleteAction, editAction]) + configuration.performsFirstActionWithFullSwipe = true + return configuration + } + + private func reloadServers() { + servers = context.core.servers() + tableView.reloadData() + tableView.backgroundView = servers.isEmpty + ? EmptyBackgroundView(title: "No servers", detail: "Add a code hosting server to get started.") + : nil + } + + private func showServerEditor( + index: Int? = nil, + completion swipeCompletion: ((Bool) -> Void)? = nil + ) { + do { + let editor = try index.map { try context.core.serverEditor(index: UInt32($0)) } + let controller = ServerEditorViewController( + context: context, + index: index.map(UInt32.init), + editor: editor + ) { [weak self] in + self?.reloadServers() + } + present(UINavigationController(rootViewController: controller), animated: true) { + swipeCompletion?(true) + } + } catch { + swipeCompletion?(false) + show(error: error) + } + } + + private func confirmDelete( + _ server: ServerRow, + index: Int, + completion: @escaping (Bool) -> Void + ) { + let alert = UIAlertController( + title: "Delete “\(server.name)”?", + message: "This removes the server configuration and access token from this device. It doesn’t change anything on the server.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) }) + alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in + guard let self else { + completion(false) + return + } + self.mutationTask?.cancel() + self.mutationTask = Task { + do { + try await self.context.core.deleteServer(index: UInt32(index)) + guard !Task.isCancelled else { + completion(false) + return + } + completion(true) + self.reloadServers() + self.context.reloadAfterServerChange() + } catch { + completion(false) + if !Task.isCancelled { self.show(error: error) } + } + } + }) + present(alert, animated: true) } } @MainActor -final class AddServerViewController: UITableViewController, UITextFieldDelegate { +final class ServerEditorViewController: UITableViewController, UITextFieldDelegate { private let context: AppContext + private let index: UInt32? + private let editor: ServerEditor? private let completion: () -> Void private let nameField = UITextField() private let urlField = UITextField() @@ -74,11 +158,19 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate private var provider = ServerProvider.gitea private var saveButton: UIBarButtonItem! - init(context: AppContext, completion: @escaping () -> Void) { + init( + context: AppContext, + index: UInt32?, + editor: ServerEditor?, + completion: @escaping () -> Void + ) { self.context = context + self.index = index + self.editor = editor self.completion = completion + provider = editor?.provider ?? .gitea super.init(style: .insetGrouped) - title = "Add Server" + title = index == nil ? "Add Server" : "Edit Server" } @available(*, unavailable) @@ -91,7 +183,7 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) } ) saveButton = UIBarButtonItem( - title: "Add", + title: index == nil ? "Add" : "Save", style: .done, target: self, action: #selector(save) @@ -106,6 +198,11 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate tokenField.autocapitalizationType = .none tokenField.returnKeyType = .done configureProviderButton() + nameField.text = editor?.name + urlField.text = editor?.url + if editor != nil { + tokenField.placeholder = "Leave unchanged" + } } override func numberOfSections(in tableView: UITableView) -> Int { 4 } @@ -150,13 +247,24 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner) Task { do { - let index = try await context.core.addServer( - name: nameField.text ?? "", - url: urlField.text ?? "", - token: tokenField.text ?? "", - provider: provider - ) - try context.didAddServer(index: index) + if let index { + try await context.core.updateServer( + index: index, + name: nameField.text ?? "", + url: urlField.text ?? "", + token: tokenField.text ?? "", + provider: provider + ) + context.reloadAfterServerChange() + } else { + let index = try await context.core.addServer( + name: nameField.text ?? "", + url: urlField.text ?? "", + token: tokenField.text ?? "", + provider: provider + ) + try context.didAddServer(index: index) + } completion() dismiss(animated: true) } catch { @@ -186,14 +294,14 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate providerButton.showsMenuAsPrimaryAction = true providerButton.changesSelectionAsPrimaryAction = true providerButton.accessibilityLabel = "API provider" - providerButton.accessibilityValue = "Gitea" + providerButton.accessibilityValue = provider == .gitea ? "Gitea" : "Forgejo" providerButton.menu = UIMenu(options: .singleSelection, children: [ - UIAction(title: "Gitea", state: .on) { [weak self] _ in + UIAction(title: "Gitea", state: provider == .gitea ? .on : .off) { [weak self] _ in self?.provider = .gitea self?.urlField.placeholder = "https://gitea.example.com" self?.providerButton.accessibilityValue = "Gitea" }, - UIAction(title: "Forgejo") { [weak self] _ in + UIAction(title: "Forgejo", state: provider == .forgejo ? .on : .off) { [weak self] _ in self?.provider = .forgejo self?.urlField.placeholder = "https://forgejo.example.com" self?.providerButton.accessibilityValue = "Forgejo"