Prepare Gotcha 1.0 for release

This commit is contained in:
Georg Bauer
2026-08-03 17:54:07 +02:00
parent 1c4a6efd13
commit 46cca8fdd2
60 changed files with 7916 additions and 7173 deletions

View File

@@ -1,9 +1,8 @@
use std::sync::{Arc, Mutex};
use gotcha_gitea::Client;
use std::sync::Mutex;
use thiserror::Error;
mod api;
mod core;
mod domain;
mod presentation;
mod storage;
@@ -65,766 +64,6 @@ pub struct GotchaCore {
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,
page: u32,
filter: HomeActivityFilter,
) -> Result<HomePage, GotchaError> {
let server = self.server()?;
let name = server.name.clone();
Ok(home_page(
name,
load_home(&server, valid_page(page)?, filter.into()).await?,
))
}
pub async fn repositories(
&self,
page: u32,
pane: RepositoryPane,
) -> Result<RepositoryListPage, GotchaError> {
let server = self.server()?;
let page_number = valid_page(page)?;
let repositories = load_repositories(&server, page_number).await?;
let mut state = self.state.lock().unwrap();
if page_number == 1 {
state.repositories.clear();
}
for repository in repositories.items {
if let Some(existing) = state.repositories.iter_mut().find(|candidate| {
candidate.owner == repository.owner && candidate.name == repository.name
}) {
*existing = repository;
} else {
state.repositories.push(repository);
}
}
Ok(RepositoryListPage {
rows: self.repository_rows(&state, pane),
has_more: repositories.has_more,
})
}
pub fn toggle_favorite(
&self,
owner: String,
repository: String,
pane: RepositoryPane,
) -> 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(pane, &server.url, &owner, &repository);
if !state.preferences.favorites.remove(&key) {
state.preferences.favorites.insert(key);
}
save_preferences(&state.preferences)?;
Ok(self.repository_rows(&state, pane))
}
pub async fn issues(
&self,
owner: String,
repository: String,
page: u32,
) -> Result<IssueListPage, GotchaError> {
let server = self.server()?;
let (status, filter) = {
let state = self.state.lock().unwrap();
(
state.preferences.issue_status.clone(),
state
.preferences
.issue_filters
.get(&repository_key(&server.url, &owner, &repository))
.cloned()
.unwrap_or_default(),
)
};
let page = load_issues(
&server,
&owner,
&repository,
&status,
&filter,
valid_page(page)?,
)
.await?;
Ok(IssueListPage {
rows: issue_rows(&page.items),
has_more: page.has_more,
})
}
pub async fn issue_filters(
&self,
owner: String,
repository: String,
) -> Result<IssueFilterOptions, GotchaError> {
let server = self.server()?;
let filter = self
.state
.lock()
.unwrap()
.preferences
.issue_filters
.get(&repository_key(&server.url, &owner, &repository))
.cloned()
.unwrap_or_default();
let (labels, milestones) = tokio::try_join!(
load_labels(&server, &owner, &repository),
load_milestones(&server, &owner, &repository)
)?;
Ok(issue_filter_options(&labels, &milestones, &filter))
}
pub fn issue_filters_active(
&self,
owner: String,
repository: String,
) -> Result<bool, GotchaError> {
let server = self.server()?;
let state = self.state.lock().unwrap();
let filter = state
.preferences
.issue_filters
.get(&repository_key(
&server.url,
owner.trim(),
repository.trim(),
))
.cloned()
.unwrap_or_default();
Ok(filter.is_active(&state.preferences.issue_status))
}
pub fn set_issue_filters(
&self,
owner: String,
repository: String,
milestone: String,
labels: Vec<String>,
search_text: String,
) -> Result<(), GotchaError> {
let owner = owner.trim();
let repository = repository.trim();
if owner.is_empty() || repository.is_empty() {
return Err("Select a repository first.".into());
}
let filter = IssueFilter {
milestone,
labels: labels
.into_iter()
.filter(|label| !label.is_empty())
.collect(),
search_text: search_text.trim().into(),
};
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 = repository_key(&server.url, owner, repository);
if filter == IssueFilter::default() {
state.preferences.issue_filters.remove(&key);
} else {
state.preferences.issue_filters.insert(key, filter);
}
save_preferences(&state.preferences).map_err(Into::into)
}
pub fn clear_issue_filters(
&self,
owner: String,
repository: String,
) -> Result<(), GotchaError> {
let owner = owner.trim();
let repository = repository.trim();
if owner.is_empty() || repository.is_empty() {
return Err("Select a repository first.".into());
}
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 = repository_key(&server.url, owner, repository);
state.preferences.issue_status = "open".into();
state.preferences.issue_filters.remove(&key);
save_preferences(&state.preferences).map_err(Into::into)
}
pub async fn issue(
&self,
owner: String,
repository: String,
number: i64,
page: u32,
) -> Result<IssuePage, GotchaError> {
Ok(issue_page(
load_issue(
&self.server()?,
&owner,
&repository,
number,
valid_page(page)?,
)
.await?,
))
}
pub async fn save_issue_comment(
&self,
owner: String,
repository: String,
number: i64,
comment_id: Option<i64>,
body: String,
) -> Result<(), GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
if number <= 0 || comment_id.is_some_and(|id| id <= 0) {
return Err("Invalid issue comment selection.".into());
}
if body.trim().is_empty() {
return Err("Enter a comment.".into());
}
save_issue_comment(&self.server()?, owner, repository, number, comment_id, body).await?;
Ok(())
}
pub async fn issue_editor(
&self,
owner: String,
repository: String,
number: Option<i64>,
) -> Result<IssueEditorPage, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
if number.is_some_and(|number| number <= 0) {
return Err("Invalid issue number.".into());
}
Ok(issue_editor_page(
load_issue_editor(&self.server()?, owner, repository, number).await?,
))
}
#[allow(clippy::too_many_arguments)]
pub async fn save_issue(
&self,
owner: String,
repository: String,
number: Option<i64>,
title: String,
body: String,
label_ids: Vec<i64>,
milestone_id: Option<i64>,
due_date: Option<i64>,
closed: bool,
) -> Result<i64, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
let title = title.trim();
if title.is_empty() {
return Err("Enter an issue title.".into());
}
if number.is_some_and(|number| number <= 0)
|| milestone_id.is_some_and(|id| id <= 0)
|| label_ids.iter().any(|id| *id <= 0)
{
return Err("Invalid issue editor selection.".into());
}
let label_ids: Vec<_> = label_ids
.into_iter()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let draft = IssueDraft {
title: title.into(),
body,
label_ids,
milestone_id,
due_date,
closed,
};
let server = self.server()?;
let issue = match number {
Some(number) => edit_issue(&server, owner, repository, number, draft).await?,
None => create_issue(&server, owner, repository, draft).await?,
};
issue
.number
.ok_or_else(|| "The saved issue has no number.".into())
}
pub async fn set_issue_closed(
&self,
owner: String,
repository: String,
number: i64,
closed: bool,
) -> Result<(), GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
if number <= 0 {
return Err("Invalid issue number.".into());
}
set_issue_closed(&self.server()?, owner, repository, number, closed).await?;
Ok(())
}
pub async fn delete_issue(
&self,
owner: String,
repository: String,
number: i64,
) -> Result<(), GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
if number <= 0 {
return Err("Invalid issue number.".into());
}
delete_issue(&self.server()?, owner, repository, number).await?;
Ok(())
}
pub async fn milestones(
&self,
owner: String,
repository: String,
page: u32,
) -> Result<MilestoneListPage, GotchaError> {
let page =
load_milestones_page(&self.server()?, &owner, &repository, valid_page(page)?).await?;
Ok(MilestoneListPage {
rows: milestone_rows(&page.items),
has_more: page.has_more,
})
}
pub async fn milestone(
&self,
owner: String,
repository: String,
id: i64,
page: u32,
) -> Result<MilestonePage, GotchaError> {
Ok(milestone_page(
load_milestone(&self.server()?, &owner, &repository, id, valid_page(page)?).await?,
))
}
pub async fn milestone_editor(
&self,
owner: String,
repository: String,
id: Option<i64>,
) -> Result<MilestoneEditorPage, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
if id.is_some_and(|id| id <= 0) {
return Err("Invalid milestone selection.".into());
}
let milestone = match id {
Some(id) => Some(load_milestone_editor(&self.server()?, owner, repository, id).await?),
None => None,
};
Ok(milestone_editor_page(milestone))
}
pub async fn save_milestone(
&self,
owner: String,
repository: String,
id: Option<i64>,
draft: MilestoneEditorPage,
) -> Result<i64, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
let title = draft.title.trim();
if title.is_empty() {
return Err("Enter a milestone title.".into());
}
if id.is_some_and(|id| id <= 0) {
return Err("Invalid milestone selection.".into());
}
let milestone = save_milestone(
&self.server()?,
owner,
repository,
id,
MilestoneDraft {
title: title.into(),
description: draft.description,
due_date: draft.due_date,
closed: draft.closed,
},
)
.await?;
milestone
.id
.ok_or_else(|| "The saved milestone has no ID.".into())
}
pub async fn set_milestone_closed(
&self,
owner: String,
repository: String,
id: i64,
closed: bool,
) -> Result<(), GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
if id <= 0 {
return Err("Invalid milestone selection.".into());
}
set_milestone_closed(&self.server()?, owner, repository, id, closed).await?;
Ok(())
}
pub async fn delete_milestone(
&self,
owner: String,
repository: String,
id: i64,
) -> Result<(), GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
if id <= 0 {
return Err("Invalid milestone selection.".into());
}
delete_milestone(&self.server()?, owner, repository, id).await?;
Ok(())
}
pub async fn pull_filters(&self) -> Result<PullFilterOptions, GotchaError> {
let server = self.server()?;
let filter = self
.state
.lock()
.unwrap()
.preferences
.pull_filters
.get(&server.url)
.cloned()
.unwrap_or_default();
Ok(pull_filter_options(
&load_pull_milestones(&server).await?,
&filter,
))
}
pub fn pull_filters_active(&self) -> Result<bool, GotchaError> {
let server = self.server()?;
let state = self.state.lock().unwrap();
let filter = state
.preferences
.pull_filters
.get(&server.url)
.cloned()
.unwrap_or_default();
Ok(filter.is_active(&state.preferences.pull_status))
}
pub fn set_pull_filters(
&self,
milestone: String,
search_text: String,
) -> Result<(), GotchaError> {
let filter = PullFilter {
milestone,
search_text: search_text.trim().into(),
};
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 = server.url.clone();
if filter == PullFilter::default() {
state.preferences.pull_filters.remove(&key);
} else {
state.preferences.pull_filters.insert(key, filter);
}
save_preferences(&state.preferences).map_err(Into::into)
}
pub fn clear_pull_filters(&self) -> Result<(), 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 = server.url.clone();
state.preferences.pull_status = "open".into();
state.preferences.pull_filters.remove(&key);
save_preferences(&state.preferences).map_err(Into::into)
}
pub async fn pulls(&self, page: u32) -> Result<PullListPage, GotchaError> {
let server = self.server()?;
let (status, filter) = {
let state = self.state.lock().unwrap();
(
state.preferences.pull_status.clone(),
state
.preferences
.pull_filters
.get(&server.url)
.cloned()
.unwrap_or_default(),
)
};
let page = load_pulls(
&server,
&status,
&filter.milestone,
&filter.search_text,
valid_page(page)?,
)
.await?;
Ok(PullListPage {
rows: pull_rows(&page.items),
has_more: page.has_more,
})
}
pub async fn pull(
&self,
owner: String,
repository: String,
number: i64,
page: u32,
) -> Result<PullPage, GotchaError> {
Ok(pull_page(
load_pull(
&self.server()?,
&owner,
&repository,
number,
valid_page(page)?,
)
.await?,
))
}
pub async fn commits(
&self,
owner: String,
repository: String,
branch: Option<String>,
path: String,
pages: u32,
) -> 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 path = (!path.is_empty()).then_some(path.as_str());
let commits = match branch.as_deref() {
Some(branch) => {
load_branch_commits(&server, &owner, &repository, branch, path, pages.max(1))
.await?
}
None => {
load_all_commits(&server, &owner, &repository, &branches, path, pages.max(1))
.await?
}
};
Ok(commit_page(
branches,
&commits.items,
branch.is_none(),
commits.has_more,
))
}
pub async fn repository_contents(
&self,
owner: String,
repository: String,
path: String,
) -> Result<Vec<RepositoryContentRow>, GotchaError> {
Ok(repository_content_rows(
load_repository_contents(&self.server()?, &owner, &repository, &path).await?,
))
}
pub async fn repository_file(
&self,
owner: String,
repository: String,
path: String,
) -> Result<RepositoryFilePage, GotchaError> {
let data = load_repository_file(&self.server()?, &owner, &repository, &path).await?;
Ok(repository_file_page(&path, data))
}
pub async fn commit_details(
&self,
owner: String,
repository: String,
sha: String,
branch: Option<String>,
) -> Result<CommitDetailsPage, GotchaError> {
Ok(commit_details_page(
load_commit(&self.server()?, &owner, &repository, &sha).await?,
branch,
))
}
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?,
))
}
}
fn validate_repository<'a>(
owner: &'a str,
repository: &'a str,