Replace Slint UI with UIKit and add milestones

Fixes #2.\nFixes #9.
This commit is contained in:
Georg Bauer
2026-07-31 09:52:24 +02:00
parent 8f9a4dfc00
commit f201814d54
33 changed files with 7455 additions and 7878 deletions

355
crates/app/src/lib.rs Normal file
View File

@@ -0,0 +1,355 @@
use std::sync::{Arc, Mutex};
use gotcha_gitea::Client;
use thiserror::Error;
mod activity;
mod api;
mod diff;
mod domain;
mod presentation;
mod storage;
use api::*;
use domain::*;
pub use presentation::*;
use storage::*;
uniffi::setup_scaffolding!();
#[derive(Debug, Error, uniffi::Error)]
pub enum GotchaError {
#[error("{message}")]
Message { message: String },
}
impl From<String> for GotchaError {
fn from(message: String) -> Self {
Self::Message { message }
}
}
impl From<&str> for GotchaError {
fn from(message: &str) -> Self {
message.to_string().into()
}
}
#[derive(Clone, uniffi::Record)]
pub struct Settings {
pub issue_status: String,
pub pull_status: String,
pub appearance: u32,
}
#[derive(uniffi::Object)]
pub struct GotchaCore {
state: Mutex<State>,
startup_error: Option<String>,
}
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
#[uniffi::constructor]
pub fn new() -> Arc<Self> {
let (preferences, startup_error) = match load_preferences() {
Ok(preferences) => (preferences, None),
Err(error) => (Preferences::default(), Some(error)),
};
let active_server = preferences
.last_server
.filter(|index| *index < preferences.servers.len())
.or_else(|| (preferences.servers.len() == 1).then_some(0));
Arc::new(Self {
state: Mutex::new(State {
preferences,
active_server,
..Default::default()
}),
startup_error,
})
}
pub fn startup_error(&self) -> Option<String> {
self.startup_error.clone()
}
pub fn servers(&self) -> Vec<ServerRow> {
self.state
.lock()
.unwrap()
.preferences
.servers
.iter()
.map(|server| ServerRow {
name: server.name.clone(),
url: server.url.clone(),
})
.collect()
}
pub fn active_server_index(&self) -> Option<u32> {
self.state
.lock()
.unwrap()
.active_server
.map(|index| index as u32)
}
pub fn active_server_name(&self) -> Option<String> {
let state = self.state.lock().unwrap();
state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.map(|server| server.name.clone())
}
pub fn select_server(&self, index: u32) -> Result<(), GotchaError> {
let mut state = self.state.lock().unwrap();
let index = index as usize;
if index >= state.preferences.servers.len() {
return Err("That server no longer exists.".into());
}
state.active_server = Some(index);
state.preferences.last_server = Some(index);
state.repositories.clear();
save_preferences(&state.preferences).map_err(Into::into)
}
pub async fn add_server(
&self,
name: String,
url: String,
token: String,
) -> Result<u32, GotchaError> {
let server = validate_server(&name, &url, &token)?;
Client::new(&server.url, Some(&server.token))
.map_err(|error| error.to_string())?
.current_user()
.await
.map_err(|error| error.to_string())?;
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);
state.active_server = Some(index);
save_preferences(&state.preferences)?;
Ok(index as u32)
}
pub fn settings(&self) -> Settings {
let state = self.state.lock().unwrap();
Settings {
issue_status: state.preferences.issue_status.clone(),
pull_status: state.preferences.pull_status.clone(),
appearance: state.preferences.appearance.index() as u32,
}
}
pub fn set_issue_status(&self, status: String) -> Result<(), GotchaError> {
if !matches!(status.as_str(), "open" | "closed") {
return Err("Unsupported issue status.".into());
}
let mut state = self.state.lock().unwrap();
state.preferences.issue_status = status;
save_preferences(&state.preferences).map_err(Into::into)
}
pub fn set_pull_status(&self, status: String) -> Result<(), GotchaError> {
if !matches!(status.as_str(), "open" | "closed") {
return Err("Unsupported pull-request status.".into());
}
let mut state = self.state.lock().unwrap();
state.preferences.pull_status = status;
save_preferences(&state.preferences).map_err(Into::into)
}
pub fn set_appearance(&self, index: u32) -> Result<(), GotchaError> {
let appearance =
AppearanceMode::from_index(index as i32).ok_or("Unsupported appearance.")?;
let mut state = self.state.lock().unwrap();
state.preferences.appearance = appearance;
save_preferences(&state.preferences).map_err(Into::into)
}
pub async fn home(&self) -> Result<HomePage, GotchaError> {
let server = self.server()?;
let name = server.name.clone();
Ok(home_page(name, load_home(&server).await?))
}
pub async fn repositories(&self) -> Result<Vec<RepositoryRow>, GotchaError> {
let server = self.server()?;
let repositories = load_repositories(&server).await?;
let mut state = self.state.lock().unwrap();
state.repositories = repositories;
Ok(self.repository_rows(&state))
}
pub fn toggle_favorite(
&self,
owner: String,
repository: String,
) -> Result<Vec<RepositoryRow>, GotchaError> {
let mut state = self.state.lock().unwrap();
let server = state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.ok_or("Select a server first.")?;
let key = favorite_key(&server.url, &owner, &repository);
if !state.preferences.favorites.remove(&key) {
state.preferences.favorites.insert(key);
}
save_preferences(&state.preferences)?;
Ok(self.repository_rows(&state))
}
pub async fn issues(
&self,
owner: String,
repository: String,
) -> Result<Vec<IssueRow>, GotchaError> {
let server = self.server()?;
let status = self.state.lock().unwrap().preferences.issue_status.clone();
Ok(issue_rows(
&load_issues(&server, &owner, &repository, &status).await?,
))
}
pub async fn issue(
&self,
owner: String,
repository: String,
number: i64,
) -> Result<IssuePage, GotchaError> {
Ok(issue_page(
load_issue(&self.server()?, &owner, &repository, number).await?,
))
}
pub async fn milestones(
&self,
owner: String,
repository: String,
) -> Result<Vec<MilestoneRow>, GotchaError> {
Ok(milestone_rows(
&load_milestones(&self.server()?, &owner, &repository).await?,
))
}
pub async fn milestone(
&self,
owner: String,
repository: String,
id: i64,
) -> Result<MilestonePage, GotchaError> {
Ok(milestone_page(
load_milestone(&self.server()?, &owner, &repository, id).await?,
))
}
pub async fn pulls(&self) -> Result<Vec<PullRow>, GotchaError> {
let server = self.server()?;
let status = self.state.lock().unwrap().preferences.pull_status.clone();
Ok(pull_rows(&load_pulls(&server, &status).await?))
}
pub async fn pull(
&self,
owner: String,
repository: String,
number: i64,
) -> Result<PullPage, GotchaError> {
Ok(pull_page(
load_pull(&self.server()?, &owner, &repository, number).await?,
))
}
pub async fn commits(
&self,
owner: String,
repository: String,
branch: Option<String>,
) -> Result<CommitPage, GotchaError> {
let server = self.server()?;
let default_branch = self
.state
.lock()
.unwrap()
.repositories
.iter()
.find(|candidate| candidate.owner == owner && candidate.name == repository)
.map(|candidate| candidate.default_branch.clone())
.unwrap_or_else(|| "main".into());
let branches = load_branches(&server, &owner, &repository, &default_branch).await?;
let commits = match branch.as_deref() {
Some(branch) => load_branch_commits(&server, &owner, &repository, branch).await?,
None => load_all_commits(&server, &owner, &repository, &branches).await?,
};
Ok(commit_page(branches, &commits, branch.is_none()))
}
pub async fn commit_files(
&self,
owner: String,
repository: String,
sha: String,
) -> Result<Vec<FileRow>, GotchaError> {
Ok(commit_file_rows(
load_commit_files(&self.server()?, &owner, &repository, &sha).await?,
))
}
pub async fn commit_diff(
&self,
owner: String,
repository: String,
sha: String,
path: String,
) -> Result<DiffPage, GotchaError> {
Ok(diff_page(
&path,
load_commit_diff(&self.server()?, &owner, &repository, &sha, &path).await?,
))
}
pub async fn pull_diff(
&self,
owner: String,
repository: String,
number: i64,
path: String,
) -> Result<DiffPage, GotchaError> {
Ok(diff_page(
&path,
load_pull_diff(&self.server()?, &owner, &repository, number, &path).await?,
))
}
}
impl GotchaCore {
fn server(&self) -> Result<Server, GotchaError> {
let state = self.state.lock().unwrap();
state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.cloned()
.ok_or_else(|| "Select a server first.".to_string().into())
}
fn repository_rows(&self, state: &State) -> Vec<RepositoryRow> {
let server_url = state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.map(|server| server.url.as_str())
.unwrap_or_default();
repository_rows(&state.repositories, |repository| {
state.preferences.favorites.contains(&favorite_key(
server_url,
&repository.owner,
&repository.name,
))
})
}
}