diff --git a/API_COVERAGE.md b/API_COVERAGE.md index d375f50..ab8b6b6 100644 --- a/API_COVERAGE.md +++ b/API_COVERAGE.md @@ -1,15 +1,20 @@ -# Gitea API coverage +# Gitea-compatible API coverage Source: the OpenAPI contract served by the configured Gitea 1.25.4 instance. It contains 299 paths, 467 operations, 243 read/query operations, and 216 schema definitions. `gotcha_gitea` encapsulates the generated Gitea 1.25 client. Its concrete -`Client` methods own the supported Gitea workflows and relationships, while +`Client` methods own the supported Gitea and Forgejo workflows and +relationships, while `gotcha_gitea::models` exposes generated response records for presentation. Generated API modules and client configuration are deliberately private so the CLI and app cannot duplicate Gitea behavior. +Forgejo uses the common generated client for its documented Gitea-compatible +`/api/v1` surface. Provider discovery and provider-specific endpoints, such as +Forgejo's dedicated version API, are routed inside `gotcha_gitea::Client`. + | Domain | Queries | Other actions | Generated client | CLI queries | CLI writes | Capabilities | | --- | ---: | ---: | --- | ---: | ---: | --- | | activitypub | 1 | 1 | Complete | 0 | 0 | Person actors and inbox federation | diff --git a/README.md b/README.md index 1567500..2b1b714 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Gotcha -Gotcha is a lightweight Gitea client with a reusable Rust core, a CLI, and a -native iOS application. The iOS interface is UIKit/Swift; UniFFI exposes the -Rust application logic to Swift. +Gotcha is a lightweight Gitea and Forgejo client with a reusable Rust core, a +CLI, and a native iOS application. The iOS interface is UIKit/Swift; UniFFI +exposes the Rust application logic to Swift. On the icon: git in a tea cup. it is obvious, isn't it? @@ -17,8 +17,8 @@ not necessarily the app to use when you manage or frequent a large instance. ## Workspace -- `gotcha_gitea`: reusable asynchronous Gitea API client with the complete - typed Gitea 1.25 API and model surface +- `gotcha_gitea`: reusable asynchronous Gitea/Forgejo API client with the + complete typed Gitea 1.25-compatible API and model surface - `gotcha`: CLI for typed common operations and arbitrary API requests - `gotcha-app`: Rust application core and UniFFI API used by iOS - `ios`: native UIKit application for activity, repositories, favorites, @@ -41,6 +41,7 @@ Store each server profile in `~/.config/gotcha/config`: ```sh cargo run -p gotcha-cli -- auth login gitea.example.com +cargo run -p gotcha-cli -- auth login forgejo.example.com --provider forgejo cargo run -p gotcha-cli -- server version cargo run -p gotcha-cli -- user show @@ -56,15 +57,18 @@ cargo run -p gotcha-cli -- api request GET repos/owner/project/issues cargo run -p gotcha-cli -- api request POST user/repos '{"name":"demo"}' ``` -`auth login` derives `https://gitea.example.com` from the server name and reads -the token from standard input with echo disabled. The resulting plain YAML file -at `~/.config/gotcha/config` has mode `0600` and one entry per server: +`auth login` derives `https://gitea.example.com` from the server name, discovers +Gitea or Forgejo, and reads the token from standard input with echo disabled. +The optional provider requires the selected API when automatic discovery is +not sufficient. The resulting plain YAML file at `~/.config/gotcha/config` has +mode `0600` and one entry per server: ```yaml servers: gitea.example.com: url: https://gitea.example.com token: your-token + provider: gitea ``` Inside a Git repository, Gotcha matches its remotes to these server URLs and @@ -74,14 +78,14 @@ accepted as command-line arguments or environment variables. ## Architecture -`gotcha_gitea` owns Gitea access, authentication, validation, mutations, and -relationships between Gitea objects. The CLI is a terminal presentation layer: +`gotcha_gitea` owns Gitea/Forgejo access, authentication, validation, +mutations, and relationships between server objects. The CLI is a terminal presentation layer: it parses arguments and YAML, invokes shared client operations, and formats the results. `gotcha-app` owns application state, preferences, favorites, Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native navigation, controls, layout, and other platform presentation behavior. -New Gitea workflows belong in `gotcha_gitea::Client` first, then receive CLI or +New server workflows belong in `gotcha_gitea::Client` first, then receive CLI or app presentation as needed. Future work includes iOS integrations such as sharing, notifications, and background refresh. diff --git a/TESTING.md b/TESTING.md index 12f4fe7..fb11f55 100644 --- a/TESTING.md +++ b/TESTING.md @@ -94,6 +94,9 @@ answer before uploading it to App Store Connect. - [ ] With no configured server, Issues and Repos show the Servers screen and its empty state. - [ ] The add button presents Add Server; Cancel dismisses it. +- [ ] API provider is a native single-selection menu with Gitea selected by + default and Forgejo available; selecting either updates the displayed + value, VoiceOver value, and URL example. - [ ] Name, Server URL, and Access token use native text fields and suitable keyboards; Next advances between fields and Done submits. - [ ] Long-press in every field shows the native loupe, insertion point, and @@ -104,7 +107,12 @@ answer before uploading it to App Store Connect. - [ ] The token is obscured, remains editable, and does not trigger a password save prompt. - [ ] Empty or invalid values show an error without adding a server. -- [ ] Valid credentials add and select the server; all data tabs load. +- [ ] Valid Gitea credentials add and select the server; all data tabs load. +- [ ] Valid Forgejo credentials add and select the server; repositories, + issues, milestones, pulls, activity, commits, files, and mutations use + the same native screens and load successfully. +- [ ] Leave Gitea selected while adding a modern Forgejo server; provider + discovery identifies Forgejo and the server works after relaunch. - [ ] Terminate and relaunch the app; the selected server and Keychain token still work without re-entry. - [ ] Open the server picker from a repository list and switch between at least diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 22d6007..78ecbab 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -424,7 +424,7 @@ pub async fn load_home( } fn client(server: &Server) -> Result { - Client::new(&server.url, Some(&server.token)).map_err(message) + Client::with_provider(&server.url, Some(&server.token), server.provider).map_err(message) } fn scope(owner: &str, repository: &str) -> Result { diff --git a/crates/app/src/core/servers.rs b/crates/app/src/core/servers.rs index 7a08ebd..059a2cd 100644 --- a/crates/app/src/core/servers.rs +++ b/crates/app/src/core/servers.rs @@ -77,13 +77,17 @@ impl GotchaCore { name: String, url: String, token: String, + provider: ServerProvider, ) -> Result { - let server = validate_server(&name, &url, &token)?; - Client::new(&server.url, Some(&server.token)) - .map_err(|error| error.to_string())? + let mut server = validate_server(&name, &url, &token, provider.into())?; + 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(); save_server_token(&server)?; let mut state = self.state.lock().unwrap(); state.preferences.servers.push(server); diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index 679175c..b3b7196 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -39,6 +39,8 @@ impl AppearanceMode { pub struct Server { pub name: String, pub url: String, + #[serde(default)] + pub provider: gotcha_gitea::Provider, #[serde(skip)] pub token: String, } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 13a6d94..0bc5670 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -21,6 +21,21 @@ pub enum RepositoryPane { Milestones, } +#[derive(Clone, Copy, Debug, uniffi::Enum)] +pub enum ServerProvider { + Gitea, + Forgejo, +} + +impl From for gotcha_gitea::Provider { + fn from(provider: ServerProvider) -> Self { + match provider { + ServerProvider::Gitea => Self::Gitea, + ServerProvider::Forgejo => Self::Forgejo, + } + } +} + impl RepositoryPane { const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones]; diff --git a/crates/app/src/storage.rs b/crates/app/src/storage.rs index 26e7134..0244e41 100644 --- a/crates/app/src/storage.rs +++ b/crates/app/src/storage.rs @@ -1,6 +1,6 @@ use std::{env, fs, path::PathBuf}; -use gotcha_gitea::Client; +use gotcha_gitea::{Client, Provider}; use security_framework::passwords::{get_generic_password, set_generic_password}; use crate::{ @@ -20,7 +20,12 @@ pub fn repository_key(server: &str, owner: &str, repository: &str) -> String { format!("{server}|{owner}/{repository}") } -pub fn validate_server(name: &str, url: &str, token: &str) -> Result { +pub fn validate_server( + name: &str, + url: &str, + token: &str, + provider: Provider, +) -> Result { let name = name.trim(); let url = url.trim().trim_end_matches('/'); let token = token.trim(); @@ -30,10 +35,11 @@ pub fn validate_server(name: &str, url: &str, token: &str) -> Result = std::result::Result; @@ -19,6 +19,8 @@ type Result = std::result::Result; pub struct Server { pub url: String, pub token: String, + #[serde(default)] + pub provider: Provider, } #[derive(Default, Deserialize, Serialize)] @@ -33,6 +35,7 @@ pub struct Selection { pub name: Option, pub url: String, pub token: Option, + pub provider: Provider, pub repository: Option, } @@ -59,20 +62,21 @@ impl Config { config.path = path; for (name, server) in &config.servers { validate_name(name)?; - Client::new(&server.url, Some(&server.token)) + Client::with_provider(&server.url, Some(&server.token), server.provider) .map_err(|error| format!("invalid server {name}: {error}"))?; } Ok(config) } - pub fn login(&mut self, name: &str, token: &str) -> Result<()> { + pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> { let url = server_url(name)?; - Client::new(&url, Some(token)).map_err(|error| error.to_string())?; + Client::with_provider(&url, Some(token), provider).map_err(|error| error.to_string())?; self.servers.insert( name.into(), Server { url, token: token.into(), + provider, }, ); self.save() @@ -111,6 +115,7 @@ impl Config { &Server { url: url.into(), token: String::new(), + provider: Provider::Gitea, }, &remotes, )), @@ -138,6 +143,7 @@ impl Config { name: Some((*name).clone()), url: server.url.clone(), token: Some(server.token.clone()), + provider: server.provider, repository: Some(scope.clone()), }), _ => Err("multiple server profiles match this Git repository; use --server".into()), @@ -183,6 +189,7 @@ fn selection(name: Option<&str>, server: &Server, remotes: &[String]) -> Selecti name: name.map(str::to_owned), url: server.url.clone(), token: (!server.token.is_empty()).then(|| server.token.clone()), + provider: server.provider, repository: remotes .iter() .find_map(|remote| repository_scope(&server.url, remote)), @@ -300,15 +307,25 @@ mod tests { config.select(None, None).err().unwrap(), "no servers configured; run `gotcha auth login SERVER`" ); - config.login("code.example", "secret").unwrap(); + config + .login("code.example", "secret", Provider::Forgejo) + .unwrap(); let loaded = Config::load_from(path).unwrap(); assert_eq!(loaded.servers["code.example"].token, "secret"); + assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo); + assert_eq!( + loaded.select(Some("code.example"), None).unwrap().provider, + Provider::Forgejo + ); assert!( fs::read_to_string(&loaded.path) .unwrap() - .contains("token: secret") + .contains("provider: forgejo") ); + let legacy: Server = + serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap(); + assert_eq!(legacy.provider, Provider::Gitea); let scope = repository_scope( "https://code.example/gitea", "https://code.example/gitea/alice/project.git", diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 732904c..37800a1 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -4,7 +4,7 @@ mod work_items; use std::{env, error::Error, process}; use config::{Config, RepositoryScope, Selection, server_url}; -use gotcha_gitea::{Client, Method, Repository, ServerVersion, User}; +use gotcha_gitea::{Client, Method, Provider, Repository, ServerVersion, User}; use serde_json::Value; const ROOT_HELP: &str = "\ @@ -15,13 +15,13 @@ Usage: Commands: auth Manage server authentication - server Inspect the selected Gitea server + server Inspect the selected Gitea or Forgejo server user Work with the authenticated user repo Work with repositories issue Work with issues and comments milestone Work with repository milestones pull Work with pull requests - api Send a raw Gitea API request + api Send a raw server API request Run `gotcha COMMAND` to list that command's subcommands. @@ -33,7 +33,8 @@ const AUTH_HELP: &str = "\ Usage: gotcha auth SUBCOMMAND Subcommands: - login SERVER Verify and store a token read from standard input + login SERVER [--provider gitea|forgejo] + Verify and store a token read from standard input list List configured servers without showing tokens status Show the selected server and repository scope logout SERVER Remove a stored server and token"; @@ -42,7 +43,7 @@ const SERVER_HELP: &str = "\ Usage: gotcha server SUBCOMMAND Subcommands: - version Show the selected Gitea server version"; + version Show the selected server version"; const USER_HELP: &str = "\ Usage: gotcha user SUBCOMMAND @@ -82,24 +83,26 @@ async fn run() -> Result<(), Box> { } let mut config = Config::load()?; - match args.command.as_slice() { - [domain, command, name] if domain == "auth" && command == "login" => { - let url = server_url(name)?; - let token = rpassword::prompt_password("Token: ")?; - if token.is_empty() { - return Err("token must not be empty".into()); - } - let user = Client::new(&url, Some(&token))?.current_user().await?; - config.login(name, &token)?; - println!( - "Logged in to {url} as {} ({name}).", - user.login.as_deref().unwrap_or("unknown") - ); - return Ok(()); + if let Some((name, fallback)) = login_command(&args.command)? { + let url = server_url(name)?; + let token = rpassword::prompt_password("Token: ")?; + if token.is_empty() { + return Err("token must not be empty".into()); } + let client = Client::discover(&url, Some(&token), fallback).await?; + let user = client.current_user().await?; + config.login(name, &token, client.provider())?; + println!( + "Logged in to {url} as {} ({name}, {}).", + user.login.as_deref().unwrap_or("unknown"), + client.provider() + ); + return Ok(()); + } + match args.command.as_slice() { [domain, command] if domain == "auth" && command == "list" => { for (name, server) in &config.servers { - println!("{name}\t{}", server.url); + println!("{name}\t{}\t{}", server.provider, server.url); } return Ok(()); } @@ -118,7 +121,11 @@ async fn run() -> Result<(), Box> { return Ok(()); } - let client = Client::new(&selection.url, selection.token.as_deref())?; + let client = Client::with_provider( + &selection.url, + selection.token.as_deref(), + selection.provider, + )?; if work_items::run(&args.command, &selection, &client).await? { return Ok(()); } @@ -160,6 +167,20 @@ fn print_version(version: &ServerVersion) { println!("{}", version.version.as_deref().unwrap_or("unknown")); } +fn login_command(command: &[String]) -> Result, Box> { + match command { + [domain, action, name] if domain == "auth" && action == "login" => { + Ok(Some((name, Provider::Gitea))) + } + [domain, action, name, option, provider] + if domain == "auth" && action == "login" && option == "--provider" => + { + Ok(Some((name, provider.parse()?))) + } + _ => Ok(None), + } +} + fn print_user(user: &User) { field("login", user.login.as_deref()); field("name", user.full_name.as_deref()); @@ -354,7 +375,7 @@ fn domain_help(domain: &str) -> Option<&'static str> { fn subcommand_help(domain: &str, subcommand: &str) -> Option<&'static str> { match (domain, subcommand) { ("auth", "login") => Some( - "Usage: gotcha auth login SERVER\n\nPrompts for a token, verifies it, and stores the server in the YAML config.", + "Usage: gotcha auth login SERVER [--provider gitea|forgejo]\n\nPrompts for a token, discovers and verifies the provider, and stores the server in the YAML config.", ), ("auth", "list") => { Some("Usage: gotcha auth list\n\nLists configured servers without tokens.") @@ -365,7 +386,7 @@ fn subcommand_help(domain: &str, subcommand: &str) -> Option<&'static str> { ("auth", "logout") => Some( "Usage: gotcha auth logout SERVER\n\nRemoves the server and its token from the YAML config.", ), - ("server", "version") => Some("Usage: gotcha server version\n\nShows the Gitea version."), + ("server", "version") => Some("Usage: gotcha server version\n\nShows the server version."), ("user", "show") => Some("Usage: gotcha user show\n\nShows the authenticated user."), ("repo", "list") => Some( "Usage: gotcha repo list\n\nLists repositories belonging to the authenticated user.", @@ -402,6 +423,7 @@ fn print_status(selection: &Selection) { "no" } ); + println!("provider: {}", selection.provider); if let Some(scope) = &selection.repository { println!("repository: {}/{}", scope.owner, scope.repository); } diff --git a/crates/cli/src/tests.rs b/crates/cli/src/tests.rs index 2bd8843..7ea5a77 100644 --- a/crates/cli/src/tests.rs +++ b/crates/cli/src/tests.rs @@ -23,6 +23,26 @@ fn parses_server_selection_and_repository_scope() { .unwrap() .starts_with("Usage: gotcha repo show") ); + assert_eq!( + login_command(&["auth".into(), "login".into(), "code.example".into()]) + .unwrap() + .unwrap() + .1, + Provider::Gitea + ); + assert_eq!( + login_command(&[ + "auth".into(), + "login".into(), + "code.example".into(), + "--provider".into(), + "forgejo".into(), + ]) + .unwrap() + .unwrap() + .1, + Provider::Forgejo + ); } #[test] diff --git a/crates/gitea/src/lib.rs b/crates/gitea/src/lib.rs index 9a1c09a..9ae021c 100644 --- a/crates/gitea/src/lib.rs +++ b/crates/gitea/src/lib.rs @@ -1,11 +1,11 @@ -use std::{error, fmt}; +use std::{error, fmt, str::FromStr}; use reqwest::{ Client as HttpClient, header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue}, }; pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use gitea_openapi::apis; pub use gitea_openapi::models; @@ -30,6 +30,37 @@ pub use pulls::{PullFileSource, pull_file_source, pull_state}; pub type Result = std::result::Result; +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Provider { + #[default] + Gitea, + Forgejo, +} + +impl fmt::Display for Provider { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Gitea => "gitea", + Self::Forgejo => "forgejo", + }) + } +} + +impl FromStr for Provider { + type Err = Error; + + fn from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "gitea" => Ok(Self::Gitea), + "forgejo" => Ok(Self::Forgejo), + _ => Err(Error::InvalidInput( + "provider must be gitea or forgejo".into(), + )), + } + } +} + pub(crate) fn validate_page(page: i32, limit: i32) -> Result<()> { if page < 1 || limit < 1 { return Err(Error::InvalidInput( @@ -67,7 +98,7 @@ impl fmt::Display for Error { Self::Transport(error) => error.fmt(formatter), Self::Generated(message) => formatter.write_str(message), Self::Api { status, message } => { - write!(formatter, "Gitea returned {status}: {message}") + write!(formatter, "Server returned {status}: {message}") } } } @@ -97,22 +128,32 @@ impl Error { #[derive(Clone, Debug)] pub struct Client { api_url: Url, + forgejo_api_url: Url, http: HttpClient, + provider: Provider, } impl Client { pub fn new(instance_url: &str, token: Option<&str>) -> Result { + Self::with_provider(instance_url, token, Provider::Gitea) + } + + pub fn with_provider( + instance_url: &str, + token: Option<&str>, + provider: Provider, + ) -> Result { let mut instance_url = Url::parse(instance_url) - .map_err(|error| Error::Configuration(format!("invalid Gitea URL: {error}")))?; + .map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?; if !matches!(instance_url.scheme(), "http" | "https") { return Err(Error::Configuration( - "Gitea URL must use http or https".into(), + "Server URL must use http or https".into(), )); } if !instance_url.username().is_empty() || instance_url.password().is_some() { return Err(Error::Configuration( - "Gitea URL must not contain credentials".into(), + "Server URL must not contain credentials".into(), )); } if !instance_url.path().ends_with('/') { @@ -121,25 +162,61 @@ impl Client { let api_url = instance_url .join("api/v1/") - .map_err(|error| Error::Configuration(format!("invalid Gitea URL: {error}")))?; + .map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?; + let forgejo_api_url = instance_url + .join("api/forgejo/v1/") + .map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?; let mut headers = HeaderMap::new(); headers.insert(ACCEPT, HeaderValue::from_static("application/json")); if let Some(token) = token { let value = HeaderValue::from_str(&format!("token {token}")) - .map_err(|_| Error::Configuration("invalid Gitea token".into()))?; + .map_err(|_| Error::Configuration("invalid server token".into()))?; headers.insert(AUTHORIZATION, value); } Ok(Self { api_url, + forgejo_api_url, http: HttpClient::builder().default_headers(headers).build()?, + provider, }) } + pub async fn discover( + instance_url: &str, + token: Option<&str>, + fallback: Provider, + ) -> Result { + let mut client = Self::with_provider(instance_url, token, fallback)?; + let version = client.gitea_version().await?; + if is_forgejo_version(&version) { + client.provider = Provider::Forgejo; + return Ok(client); + } + + match client.forgejo_version().await { + Ok(_) => client.provider = Provider::Forgejo, + Err(Error::Api { status, .. }) if status == StatusCode::NOT_FOUND => { + if fallback == Provider::Forgejo { + return Err(Error::Configuration( + "Server does not expose the Forgejo API".into(), + )); + } + client.provider = Provider::Gitea; + } + Err(error) => return Err(error), + } + Ok(client) + } + pub fn api_url(&self) -> &Url { &self.api_url } + pub fn provider(&self) -> Provider { + self.provider + } + /// Returns an authenticated configuration for every generated typed API. pub(crate) fn configuration(&self) -> apis::configuration::Configuration { apis::configuration::Configuration { @@ -188,11 +265,27 @@ impl Client { } pub async fn version(&self) -> Result { + match self.provider { + Provider::Gitea => self.gitea_version().await, + Provider::Forgejo => self.forgejo_version().await, + } + } + + async fn gitea_version(&self) -> Result { apis::miscellaneous_api::get_version(&self.configuration()) .await .map_err(|error| Error::Generated(error.to_string())) } + async fn forgejo_version(&self) -> Result { + let request = + self.http + .get(self.forgejo_api_url.join("version").map_err(|error| { + Error::Configuration(format!("invalid Forgejo endpoint: {error}")) + })?); + Ok(self.execute(request).await?.json().await?) + } + pub async fn current_user(&self) -> Result { apis::user_api::user_get_current(&self.configuration()) .await @@ -218,6 +311,13 @@ impl Client { } } +fn is_forgejo_version(version: &models::ServerVersion) -> bool { + version + .version + .as_deref() + .is_some_and(|version| version.contains("+gitea-")) +} + #[derive(Debug, Deserialize)] struct ApiError { #[serde(default)] @@ -230,7 +330,12 @@ mod tests { #[test] fn keeps_instance_subpaths_and_rejects_endpoint_escapes() { - let client = Client::new("https://example.com/gitea", Some("secret")).unwrap(); + let client = Client::with_provider( + "https://example.com/gitea", + Some("secret"), + Provider::Forgejo, + ) + .unwrap(); assert_eq!( client.api_url().as_str(), @@ -247,6 +352,20 @@ mod tests { client.configuration().base_path, "https://example.com/gitea/api/v1" ); + assert_eq!(client.provider(), Provider::Forgejo); + assert_eq!( + client.forgejo_api_url.as_str(), + "https://example.com/gitea/api/forgejo/v1/" + ); + assert_eq!("GITEA".parse::().unwrap(), Provider::Gitea); + assert_eq!(Provider::Forgejo.to_string(), "forgejo"); + assert!("github".parse::().is_err()); + assert!(is_forgejo_version(&models::ServerVersion { + version: Some("16.0.1+gitea-1.24.6".into()), + })); + assert!(!is_forgejo_version(&models::ServerVersion { + version: Some("1.25.2".into()), + })); let _typed_model = models::Issue::default(); } } diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 745c4b8..23e248a 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -669,7 +669,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func activeServerName() -> String? - func addServer(name: String, url: String, token: String) async throws -> UInt32 + func addServer(name: String, url: String, token: String, provider: ServerProvider) async throws -> UInt32 func home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage @@ -1224,12 +1224,12 @@ open func activeServerName() -> String? { }) } -open func addServer(name: String, url: String, token: String)async throws -> UInt32 { +open func addServer(name: String, url: String, token: String, provider: ServerProvider)async throws -> UInt32 { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_add_server( - self.uniffiCloneHandle(),FfiConverterString.lower(name),FfiConverterString.lower(url),FfiConverterString.lower(token) + self.uniffiCloneHandle(),FfiConverterString.lower(name),FfiConverterString.lower(url),FfiConverterString.lower(token),FfiConverterTypeServerProvider_lower(provider) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_u32, @@ -4163,6 +4163,72 @@ public func FfiConverterTypeRepositoryPane_lower(_ value: RepositoryPane) -> Rus +public enum ServerProvider: Equatable, Hashable { + + case gitea + case forgejo + + + + + +} + +#if compiler(>=6) +extension ServerProvider: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeServerProvider: FfiConverterRustBuffer { + typealias SwiftType = ServerProvider + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ServerProvider { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .gitea + + case 2: return .forgejo + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ServerProvider, into buf: inout [UInt8]) { + switch value { + + + case .gitea: + writeInt(&buf, Int32(1)) + + + case .forgejo: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeServerProvider_lift(_ buf: RustBuffer) throws -> ServerProvider { + return try FfiConverterTypeServerProvider.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeServerProvider_lower(_ value: ServerProvider) -> RustBuffer { + return FfiConverterTypeServerProvider.lower(value) +} + + + + public enum WorkItemState: Equatable, Hashable { case `open` @@ -4943,7 +5009,7 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_active_server_name() != 45045) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_add_server() != 8514) { + if (uniffi_gotcha_core_checksum_method_gotchacore_add_server() != 51168) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 37602) { diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 7b99368..922ca76 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -426,7 +426,7 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_name(uint64_t p #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER -uint64_t uniffi_gotcha_core_fn_method_gotchacore_add_server(uint64_t ptr, RustBuffer name, RustBuffer url, RustBuffer token +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_HOME diff --git a/ios/Sources/ServerScreens.swift b/ios/Sources/ServerScreens.swift index 2b5b339..e2ad005 100644 --- a/ios/Sources/ServerScreens.swift +++ b/ios/Sources/ServerScreens.swift @@ -21,7 +21,7 @@ final class ServersViewController: UITableViewController { servers = context.core.servers() tableView.reloadData() tableView.backgroundView = servers.isEmpty - ? EmptyBackgroundView(title: "No servers", detail: "Add a Gitea server to get started.") + ? EmptyBackgroundView(title: "No servers", detail: "Add a code hosting server to get started.") : nil navigationItem.rightBarButtonItem = UIBarButtonItem( systemItem: .add, @@ -70,6 +70,8 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate private let nameField = UITextField() private let urlField = UITextField() private let tokenField = UITextField() + private let providerButton = UIButton(type: .system) + private var provider = ServerProvider.gitea private var saveButton: UIBarButtonItem! init(context: AppContext, completion: @escaping () -> Void) { @@ -103,13 +105,14 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate tokenField.isSecureTextEntry = true tokenField.autocapitalizationType = .none tokenField.returnKeyType = .done + configureProviderButton() } - override func numberOfSections(in tableView: UITableView) -> Int { 3 } + override func numberOfSections(in tableView: UITableView) -> Int { 4 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - ["Name", "Server URL", "Access token"][section] + ["API provider", "Name", "Server URL", "Access token"][section] } override func tableView( @@ -117,14 +120,16 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = UITableViewCell(style: .default, reuseIdentifier: nil) - let field = [nameField, urlField, tokenField][indexPath.section] - field.translatesAutoresizingMaskIntoConstraints = false - cell.contentView.addSubview(field) + let control: UIView = indexPath.section == 0 + ? providerButton + : [nameField, urlField, tokenField][indexPath.section - 1] + control.translatesAutoresizingMaskIntoConstraints = false + cell.contentView.addSubview(control) NSLayoutConstraint.activate([ - field.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16), - field.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16), - field.topAnchor.constraint(equalTo: cell.contentView.topAnchor), - field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor), + control.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16), + control.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16), + control.topAnchor.constraint(equalTo: cell.contentView.topAnchor), + control.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor), cell.contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48), ]) return cell @@ -148,7 +153,8 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate let index = try await context.core.addServer( name: nameField.text ?? "", url: urlField.text ?? "", - token: tokenField.text ?? "" + token: tokenField.text ?? "", + provider: provider ) try context.didAddServer(index: index) completion() @@ -174,4 +180,24 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate field.adjustsFontForContentSizeCategory = true field.font = .preferredFont(forTextStyle: .body) } + + private func configureProviderButton() { + providerButton.contentHorizontalAlignment = .leading + providerButton.showsMenuAsPrimaryAction = true + providerButton.changesSelectionAsPrimaryAction = true + providerButton.accessibilityLabel = "API provider" + providerButton.accessibilityValue = "Gitea" + providerButton.menu = UIMenu(options: .singleSelection, children: [ + UIAction(title: "Gitea", state: .on) { [weak self] _ in + self?.provider = .gitea + self?.urlField.placeholder = "https://gitea.example.com" + self?.providerButton.accessibilityValue = "Gitea" + }, + UIAction(title: "Forgejo") { [weak self] _ in + self?.provider = .forgejo + self?.urlField.placeholder = "https://forgejo.example.com" + self?.providerButton.accessibilityValue = "Forgejo" + }, + ]) + } }