Add server editing and deletion
This commit is contained in:
19
TESTING.md
19
TESTING.md
@@ -117,6 +117,25 @@ answer before uploading it to App Store Connect.
|
|||||||
still work without re-entry.
|
still work without re-entry.
|
||||||
- [ ] Open the server picker from a repository list and switch between at least
|
- [ ] Open the server picker from a repository list and switch between at least
|
||||||
two configured servers; every data tab changes to the selected server.
|
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
|
## Native interaction and navigation
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ impl GotchaCore {
|
|||||||
.map(|server| server.name.clone())
|
.map(|server| server.name.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn server_editor(&self, index: u32) -> Result<ServerEditor, GotchaError> {
|
||||||
|
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> {
|
pub fn select_server(&self, index: u32) -> Result<(), GotchaError> {
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
let index = index as usize;
|
let index = index as usize;
|
||||||
@@ -88,16 +102,121 @@ impl GotchaCore {
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
server.provider = client.provider();
|
server.provider = client.provider();
|
||||||
save_server_token(&server)?;
|
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.preferences.servers.push(server);
|
assign_server_credential_account(&mut server, &state.preferences.servers);
|
||||||
let index = state.preferences.servers.len() - 1;
|
save_server_token(&server)?;
|
||||||
state.preferences.last_server = Some(index);
|
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);
|
state.active_server = Some(index);
|
||||||
save_preferences(&state.preferences)?;
|
|
||||||
Ok(index as u32)
|
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 {
|
pub fn settings(&self) -> Settings {
|
||||||
let state = self.state.lock().unwrap();
|
let state = self.state.lock().unwrap();
|
||||||
Settings {
|
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<usize>,
|
||||||
|
removed: usize,
|
||||||
|
remaining: usize,
|
||||||
|
) -> Option<usize> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ pub struct Server {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub provider: gotcha_gitea::Provider,
|
pub provider: gotcha_gitea::Provider,
|
||||||
|
#[serde(default)]
|
||||||
|
pub credential_account: String,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
pub struct Preferences {
|
pub struct Preferences {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub servers: Vec<Server>,
|
pub servers: Vec<Server>,
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ impl From<ServerProvider> for gotcha_gitea::Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<gotcha_gitea::Provider> 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 {
|
impl RepositoryPane {
|
||||||
const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones];
|
const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones];
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ pub struct ServerRow {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct ServerEditor {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub provider: crate::ServerProvider,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, uniffi::Record)]
|
#[derive(Clone, uniffi::Record)]
|
||||||
pub struct RepositoryRow {
|
pub struct RepositoryRow {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
@@ -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 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::{
|
use crate::{
|
||||||
RepositoryPane,
|
RepositoryPane,
|
||||||
@@ -40,6 +47,7 @@ pub fn validate_server(
|
|||||||
name: name.into(),
|
name: name.into(),
|
||||||
url: url.into(),
|
url: url.into(),
|
||||||
provider,
|
provider,
|
||||||
|
credential_account: String::new(),
|
||||||
token: token.into(),
|
token: token.into(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -59,14 +67,19 @@ pub fn load_preferences() -> Result<Preferences, String> {
|
|||||||
if !matches!(preferences.pull_status.as_str(), "open" | "closed") {
|
if !matches!(preferences.pull_status.as_str(), "open" | "closed") {
|
||||||
preferences.pull_status = open_status();
|
preferences.pull_status = open_status();
|
||||||
}
|
}
|
||||||
|
let mut credentials_migrated = false;
|
||||||
for server in &mut preferences.servers {
|
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(
|
server.token = String::from_utf8(
|
||||||
get_generic_password("de.rfc1437.gotcha", &keychain_account(server))
|
get_generic_password("de.rfc1437.gotcha", &keychain_account(server))
|
||||||
.map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?,
|
.map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?,
|
||||||
)
|
)
|
||||||
.map_err(|_| format!("The token for {} is not valid text.", 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)?;
|
save_preferences(&preferences)?;
|
||||||
}
|
}
|
||||||
Ok(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))
|
.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<PathBuf, String> {
|
fn preferences_path() -> Result<PathBuf, String> {
|
||||||
let home = env::var_os("HOME").ok_or("Cannot find the app data directory.")?;
|
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"))
|
Ok(PathBuf::from(home).join("Library/Application Support/Gotcha/preferences.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keychain_account(server: &Server) -> String {
|
fn keychain_account(server: &Server) -> String {
|
||||||
|
if server.credential_account.is_empty() {
|
||||||
format!("{}|{}", server.name, server.url)
|
format!("{}|{}", server.name, server.url)
|
||||||
|
} else {
|
||||||
|
server.credential_account.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -182,6 +236,15 @@ mod tests {
|
|||||||
let legacy_server: Server =
|
let legacy_server: Server =
|
||||||
serde_json::from_str(r#"{"name":"Work","url":"https://gitea.example.com"}"#).unwrap();
|
serde_json::from_str(r#"{"name":"Work","url":"https://gitea.example.com"}"#).unwrap();
|
||||||
assert_eq!(legacy_server.provider, Provider::Gitea);
|
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!(
|
assert_eq!(
|
||||||
crate::domain::AppearanceMode::from_index(1),
|
crate::domain::AppearanceMode::from_index(1),
|
||||||
Some(crate::domain::AppearanceMode::Light)
|
Some(crate::domain::AppearanceMode::Light)
|
||||||
|
|||||||
@@ -671,10 +671,14 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func addServer(name: String, url: String, token: String, provider: ServerProvider) async throws -> UInt32
|
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 home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage
|
||||||
|
|
||||||
func selectServer(index: UInt32) throws
|
func selectServer(index: UInt32) throws
|
||||||
|
|
||||||
|
func serverEditor(index: UInt32) throws -> ServerEditor
|
||||||
|
|
||||||
func servers() -> [ServerRow]
|
func servers() -> [ServerRow]
|
||||||
|
|
||||||
func setAppearance(index: UInt32) throws
|
func setAppearance(index: UInt32) throws
|
||||||
@@ -687,6 +691,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func startupError() -> String?
|
func startupError() -> String?
|
||||||
|
|
||||||
|
func updateServer(index: UInt32, name: String, url: String, token: String, provider: ServerProvider) async throws
|
||||||
|
|
||||||
}
|
}
|
||||||
open class GotchaCore: GotchaCoreProtocol, @unchecked Sendable {
|
open class GotchaCore: GotchaCoreProtocol, @unchecked Sendable {
|
||||||
fileprivate let handle: UInt64
|
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 {
|
open func home(page: UInt32, filter: HomeActivityFilter)async throws -> HomePage {
|
||||||
return
|
return
|
||||||
try await uniffiRustCallAsync(
|
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] {
|
open func servers() -> [ServerRow] {
|
||||||
return try! FfiConverterSequenceTypeServerRow.lift(try! rustCall() {
|
return try! FfiConverterSequenceTypeServerRow.lift(try! rustCall() {
|
||||||
uniffiCallStatus in
|
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 struct ServerRow: Equatable, Hashable {
|
||||||
public var name: String
|
public var name: String
|
||||||
public var url: String
|
public var url: String
|
||||||
@@ -5012,12 +5112,18 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_add_server() != 51168) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_add_server() != 51168) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 37602) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 10204) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 10204) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_servers() != 16732) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -5036,6 +5142,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_startup_error() != 58843) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_startup_error() != 58843) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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) {
|
if (uniffi_gotcha_core_checksum_constructor_gotchacore_new() != 35044) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_add_server(uint64_t ptr, RustBuffer name, RustBuffer url, RustBuffer token, RustBuffer provider
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
||||||
#define 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
|
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
|
void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVERS
|
||||||
#define 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
|
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
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_startup_error(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC
|
||||||
#define 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
|
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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ADD_SERVER
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_add_server(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_select_server(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVERS
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_STARTUP_ERROR
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_startup_error(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_CONSTRUCTOR_GOTCHACORE_NEW
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_CONSTRUCTOR_GOTCHACORE_NEW
|
||||||
|
|||||||
@@ -47,12 +47,16 @@ final class AppContext {
|
|||||||
|
|
||||||
func selectServer(index: UInt32) throws {
|
func selectServer(index: UInt32) throws {
|
||||||
try core.selectServer(index: index)
|
try core.selectServer(index: index)
|
||||||
|
reloadAfterServerChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadAfterServerChange() {
|
||||||
let replacements: [(Int, UIViewController)] = [
|
let replacements: [(Int, UIViewController)] = [
|
||||||
(0, HomeViewController(context: self)),
|
(0, HomeViewController(context: self)),
|
||||||
(1, RepositoriesViewController(context: self, mode: .issues)),
|
(1, repositoryRoot(mode: .issues)),
|
||||||
(2, RepositoriesViewController(context: self, mode: .commits)),
|
(2, repositoryRoot(mode: .commits)),
|
||||||
(3, PullsViewController(context: self)),
|
(3, PullsViewController(context: self)),
|
||||||
(4, RepositoriesViewController(context: self, mode: .milestones)),
|
(4, repositoryRoot(mode: .milestones)),
|
||||||
]
|
]
|
||||||
for (index, root) in replacements {
|
for (index, root) in replacements {
|
||||||
navigationControllers[index].setViewControllers([root], animated: false)
|
navigationControllers[index].setViewControllers([root], animated: false)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import UIKit
|
|||||||
final class ServersViewController: UITableViewController {
|
final class ServersViewController: UITableViewController {
|
||||||
private let context: AppContext
|
private let context: AppContext
|
||||||
private var servers: [ServerRow] = []
|
private var servers: [ServerRow] = []
|
||||||
|
private var mutationTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(context: AppContext) {
|
init(context: AppContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -16,16 +17,16 @@ final class ServersViewController: UITableViewController {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
mutationTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
servers = context.core.servers()
|
reloadServers()
|
||||||
tableView.reloadData()
|
|
||||||
tableView.backgroundView = servers.isEmpty
|
|
||||||
? EmptyBackgroundView(title: "No servers", detail: "Add a code hosting server to get started.")
|
|
||||||
: nil
|
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
systemItem: .add,
|
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() {
|
override func tableView(
|
||||||
let controller = AddServerViewController(context: context) { [weak self] in
|
_ tableView: UITableView,
|
||||||
guard let self else { return }
|
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||||
self.servers = self.context.core.servers()
|
) -> UISwipeActionsConfiguration? {
|
||||||
self.tableView.reloadData()
|
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
|
@MainActor
|
||||||
final class AddServerViewController: UITableViewController, UITextFieldDelegate {
|
final class ServerEditorViewController: UITableViewController, UITextFieldDelegate {
|
||||||
private let context: AppContext
|
private let context: AppContext
|
||||||
|
private let index: UInt32?
|
||||||
|
private let editor: ServerEditor?
|
||||||
private let completion: () -> Void
|
private let completion: () -> Void
|
||||||
private let nameField = UITextField()
|
private let nameField = UITextField()
|
||||||
private let urlField = UITextField()
|
private let urlField = UITextField()
|
||||||
@@ -74,11 +158,19 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
|
|||||||
private var provider = ServerProvider.gitea
|
private var provider = ServerProvider.gitea
|
||||||
private var saveButton: UIBarButtonItem!
|
private var saveButton: UIBarButtonItem!
|
||||||
|
|
||||||
init(context: AppContext, completion: @escaping () -> Void) {
|
init(
|
||||||
|
context: AppContext,
|
||||||
|
index: UInt32?,
|
||||||
|
editor: ServerEditor?,
|
||||||
|
completion: @escaping () -> Void
|
||||||
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
|
self.index = index
|
||||||
|
self.editor = editor
|
||||||
self.completion = completion
|
self.completion = completion
|
||||||
|
provider = editor?.provider ?? .gitea
|
||||||
super.init(style: .insetGrouped)
|
super.init(style: .insetGrouped)
|
||||||
title = "Add Server"
|
title = index == nil ? "Add Server" : "Edit Server"
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -91,7 +183,7 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
|
|||||||
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
|
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
|
||||||
)
|
)
|
||||||
saveButton = UIBarButtonItem(
|
saveButton = UIBarButtonItem(
|
||||||
title: "Add",
|
title: index == nil ? "Add" : "Save",
|
||||||
style: .done,
|
style: .done,
|
||||||
target: self,
|
target: self,
|
||||||
action: #selector(save)
|
action: #selector(save)
|
||||||
@@ -106,6 +198,11 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
|
|||||||
tokenField.autocapitalizationType = .none
|
tokenField.autocapitalizationType = .none
|
||||||
tokenField.returnKeyType = .done
|
tokenField.returnKeyType = .done
|
||||||
configureProviderButton()
|
configureProviderButton()
|
||||||
|
nameField.text = editor?.name
|
||||||
|
urlField.text = editor?.url
|
||||||
|
if editor != nil {
|
||||||
|
tokenField.placeholder = "Leave unchanged"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func numberOfSections(in tableView: UITableView) -> Int { 4 }
|
override func numberOfSections(in tableView: UITableView) -> Int { 4 }
|
||||||
@@ -150,6 +247,16 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
|
|||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
|
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(
|
let index = try await context.core.addServer(
|
||||||
name: nameField.text ?? "",
|
name: nameField.text ?? "",
|
||||||
url: urlField.text ?? "",
|
url: urlField.text ?? "",
|
||||||
@@ -157,6 +264,7 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
|
|||||||
provider: provider
|
provider: provider
|
||||||
)
|
)
|
||||||
try context.didAddServer(index: index)
|
try context.didAddServer(index: index)
|
||||||
|
}
|
||||||
completion()
|
completion()
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -186,14 +294,14 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate
|
|||||||
providerButton.showsMenuAsPrimaryAction = true
|
providerButton.showsMenuAsPrimaryAction = true
|
||||||
providerButton.changesSelectionAsPrimaryAction = true
|
providerButton.changesSelectionAsPrimaryAction = true
|
||||||
providerButton.accessibilityLabel = "API provider"
|
providerButton.accessibilityLabel = "API provider"
|
||||||
providerButton.accessibilityValue = "Gitea"
|
providerButton.accessibilityValue = provider == .gitea ? "Gitea" : "Forgejo"
|
||||||
providerButton.menu = UIMenu(options: .singleSelection, children: [
|
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?.provider = .gitea
|
||||||
self?.urlField.placeholder = "https://gitea.example.com"
|
self?.urlField.placeholder = "https://gitea.example.com"
|
||||||
self?.providerButton.accessibilityValue = "Gitea"
|
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?.provider = .forgejo
|
||||||
self?.urlField.placeholder = "https://forgejo.example.com"
|
self?.urlField.placeholder = "https://forgejo.example.com"
|
||||||
self?.providerButton.accessibilityValue = "Forgejo"
|
self?.providerButton.accessibilityValue = "Forgejo"
|
||||||
|
|||||||
Reference in New Issue
Block a user