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

6
Cargo.lock generated
View File

@@ -444,7 +444,7 @@ dependencies = [
[[package]]
name = "gotcha-app"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"gotcha_gitea",
"security-framework",
@@ -457,7 +457,7 @@ dependencies = [
[[package]]
name = "gotcha-cli"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"gotcha_gitea",
"rpassword",
@@ -469,7 +469,7 @@ dependencies = [
[[package]]
name = "gotcha_gitea"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"gitea-client",
"reqwest",

View File

@@ -70,6 +70,25 @@ xcrun simctl install booted "$gotcha_build_dir/Gotcha.app"
xcrun simctl launch booted de.rfc1437.gotcha
```
Before App Store submission, create a signed Release archive and inspect the
packaged application rather than relying on Debug build settings:
```sh
cd ios
xcodebuild \
-project Gotcha.xcodeproj \
-scheme Gotcha \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath /private/tmp/Gotcha.xcarchive \
archive
```
Verify the archived app reports version `1.0` and a positive build number, is
signed for distribution with the expected bundle identifier and entitlements,
contains `PrivacyInfo.xcprivacy`, and declares the correct export-compliance
answer before uploading it to App Store Connect.
## Add Server and native text editing
- [ ] With no configured server, Issues and Repos show the Servers screen and
@@ -358,6 +377,8 @@ xcrun simctl launch booted de.rfc1437.gotcha
## Release sign-off
- [ ] All build gates passed.
- [ ] A signed Release archive passed the version, identity, entitlement,
privacy-manifest, and export-compliance checks above.
- [ ] Fresh-install and upgrade passes completed.
- [ ] Every supported iOS version and required device class completed.
- [ ] Every scenario passed; any discovered failures were resolved and retested.

View File

@@ -1,7 +1,7 @@
[package]
name = "gotcha-app"
version = "0.1.0"
description = "Lightweight Slint client for Gitea on iOS"
version = "1.0.0"
description = "Rust application core for the Gotcha iOS client"
edition.workspace = true
license.workspace = true
rust-version.workspace = true

View File

@@ -0,0 +1,102 @@
use crate::*;
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
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?,
))
}
}

View File

@@ -0,0 +1,265 @@
use crate::*;
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
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(())
}
}

View File

@@ -0,0 +1,109 @@
use crate::*;
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
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(())
}
}

View File

@@ -0,0 +1,6 @@
mod content;
mod issues;
mod milestones;
mod pulls;
mod repositories;
mod servers;

View File

@@ -0,0 +1,115 @@
use crate::*;
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
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?,
))
}
}

View File

@@ -0,0 +1,50 @@
use crate::*;
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
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))
}
}

View File

@@ -0,0 +1,144 @@
use std::sync::Arc;
use gotcha_gitea::Client;
use crate::*;
#[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?,
))
}
}

View File

@@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet};
pub use gotcha_gitea::{
HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails,
parse_api_date,
civil_from_days, days_from_civil, parse_api_date,
};
use serde::{Deserialize, Serialize};
@@ -146,31 +146,6 @@ pub fn open_status() -> String {
"open".into()
}
pub fn civil_from_days(days: i64) -> (i64, i64, i64) {
let days = days + 719_468;
let era = days.div_euclid(146_097);
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += i64::from(month <= 2);
(year, month, day)
}
pub fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 {
year -= i64::from(month <= 2);
let era = year.div_euclid(400);
let year_of_era = year - era * 400;
let month_prime = month + if month > 2 { -3 } else { 9 };
let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
#[cfg(test)]
mod tests {
use super::*;

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,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,256 @@
use super::{helpers::*, *};
pub fn issue_page(details: IssueDetails) -> IssuePage {
IssuePage {
title: details
.issue
.title
.clone()
.unwrap_or_else(|| "Untitled issue".into()),
state: work_item_state(details.issue.state.as_deref()),
meta: issue_meta(&details.issue),
milestone: issue_milestone(&details.issue),
labels: details
.issue
.labels
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(label_row)
.collect(),
body: details
.issue
.body
.filter(|body| !body.is_empty())
.unwrap_or_else(|| "No description provided.".into()),
comments: details
.comments
.iter()
.map(|comment| comment_row(comment, details.viewer_id))
.collect(),
has_more: details.has_more,
}
}
pub fn issue_editor_page(data: IssueEditorData) -> IssueEditorPage {
let selected_labels: std::collections::BTreeSet<_> = data
.issue
.as_ref()
.and_then(|issue| issue.labels.as_deref())
.unwrap_or_default()
.iter()
.filter_map(|label| label.id)
.collect();
let selected_milestone = data
.issue
.as_ref()
.and_then(|issue| issue.milestone.as_ref())
.and_then(|milestone| milestone.id);
let mut labels: Vec<_> = data
.labels
.into_iter()
.filter_map(|label| {
let id = label.id?;
Some(IssueEditorLabel {
id,
name: label.name?,
selected: selected_labels.contains(&id),
})
})
.collect();
labels.sort_by_key(|label| label.name.to_lowercase());
let mut milestones: Vec<_> = data
.milestones
.into_iter()
.filter(|milestone| {
milestone.state.as_deref() != Some("closed") || milestone.id == selected_milestone
})
.filter_map(|milestone| {
let id = milestone.id?;
Some(IssueEditorMilestone {
id,
title: milestone.title?,
selected: Some(id) == selected_milestone,
})
})
.collect();
milestones.sort_by_key(|milestone| milestone.title.to_lowercase());
IssueEditorPage {
title: data
.issue
.as_ref()
.and_then(|issue| issue.title.clone())
.unwrap_or_default(),
body: data
.issue
.as_ref()
.and_then(|issue| issue.body.clone())
.unwrap_or_default(),
due_date: data
.issue
.as_ref()
.and_then(|issue| issue.due_date.as_deref())
.and_then(parse_api_date),
closed: data.issue.as_ref().and_then(|issue| issue.state.as_deref()) == Some("closed"),
labels,
milestones,
}
}
pub fn pull_page(details: PullDetails) -> PullPage {
let pull = &details.pull;
let author = pull
.user
.as_ref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown");
let head = pull
.head
.as_ref()
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
.unwrap_or("head");
let base = pull
.base
.as_ref()
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
.unwrap_or("base");
let state = pull_state(pull);
PullPage {
title: pull
.title
.clone()
.unwrap_or_else(|| "Untitled pull request".into()),
state: work_item_state(pull.state.as_deref()),
meta: format!(
"#{} · {state} · {author} · {head} → {base}\n{} files · +{} {} · {} comments",
pull.number.unwrap_or_default(),
pull.changed_files.unwrap_or_default(),
pull.additions.unwrap_or_default(),
pull.deletions.unwrap_or_default(),
pull.comments.unwrap_or_default(),
),
body: pull
.body
.clone()
.filter(|body| !body.is_empty())
.unwrap_or_else(|| "No description provided.".into()),
files_ref: pull_files_ref(pull),
files: details.files.iter().filter_map(file_row).collect(),
comments: details
.comments
.iter()
.map(|comment| comment_row(comment, None))
.collect(),
has_more: details.has_more,
}
}
pub fn file_rows(files: Vec<models::ChangedFile>) -> Vec<FileRow> {
files.iter().filter_map(file_row).collect()
}
pub fn commit_file_rows(files: Vec<models::CommitAffectedFiles>) -> Vec<FileRow> {
files
.into_iter()
.filter_map(|file| {
Some(FileRow {
path: file.filename?,
status: file.status.unwrap_or_else(|| "modified".into()),
})
})
.collect()
}
pub fn commit_details_page(commit: models::Commit, branch: Option<String>) -> CommitDetailsPage {
let details = commit.commit.as_deref();
let message = details
.and_then(|details| details.message.as_deref())
.unwrap_or("Commit");
let mut lines = message.lines();
let title = lines.next().unwrap_or("Commit").to_string();
let description = lines.collect::<Vec<_>>().join("\n").trim().to_string();
let author = details
.and_then(|details| details.author.as_deref())
.and_then(commit_user)
.or_else(|| {
commit
.author
.as_deref()
.and_then(|author| author.login.clone())
});
let committer = details
.and_then(|details| details.committer.as_deref())
.and_then(commit_user)
.or_else(|| {
commit
.committer
.as_deref()
.and_then(|committer| committer.login.clone())
});
let date = details
.and_then(|details| details.committer.as_deref())
.and_then(|committer| committer.date.as_deref())
.or_else(|| {
details
.and_then(|details| details.author.as_deref())
.and_then(|author| author.date.as_deref())
})
.or(commit.created.as_deref());
let files = commit.files.unwrap_or_default();
let mut metadata = Vec::new();
if let Some(author) = author {
metadata.push(metadata_row("Author", author, false));
}
if let Some(committer) = committer {
metadata.push(metadata_row("Committer", committer, false));
}
if date.is_some() {
metadata.push(metadata_row("Committed", compact_date(date), false));
}
if let Some(branch) = branch.filter(|branch| !branch.is_empty()) {
metadata.push(metadata_row("Branch", branch, false));
}
metadata.push(metadata_row(
"Commit",
commit.sha.clone().unwrap_or_else(|| "unknown".into()),
true,
));
let verification = details.and_then(|details| details.verification.as_deref());
metadata.push(metadata_row(
"Signature",
match verification.filter(|verification| {
verification
.signature
.as_deref()
.is_some_and(|signature| !signature.is_empty())
}) {
Some(verification) if verification.verified.unwrap_or(false) => "Verified".into(),
Some(_) => "Unverified".into(),
None => "Unsigned".into(),
},
false,
));
CommitDetailsPage {
title,
description,
metadata,
files: commit_file_rows(files),
}
}
fn commit_user(user: &models::CommitUser) -> Option<String> {
match (user.name.as_deref(), user.email.as_deref()) {
(Some(name), Some(email)) => Some(format!("{name} <{email}>")),
(Some(name), None) => Some(name.into()),
(None, Some(email)) => Some(email.into()),
(None, None) => None,
}
}
fn metadata_row(label: &str, value: String, monospaced: bool) -> CommitMetadataRow {
CommitMetadataRow {
label: label.into(),
value,
monospaced,
}
}

View File

@@ -0,0 +1,115 @@
use super::{helpers::*, *};
pub fn repository_content_rows(
contents: Vec<models::ContentsResponse>,
) -> Vec<RepositoryContentRow> {
let mut rows: Vec<_> = contents
.into_iter()
.filter_map(|content| {
Some(RepositoryContentRow {
name: content.name?,
path: content.path?,
kind: if content.r#type.as_deref() == Some("dir") {
RepositoryContentKind::Directory
} else {
RepositoryContentKind::File
},
size: content.size.unwrap_or_default(),
})
})
.collect();
rows.sort_by_key(|row| {
(
row.kind != RepositoryContentKind::Directory,
row.name.to_lowercase(),
)
});
rows
}
pub fn repository_file_page(path: &str, data: Vec<u8>) -> RepositoryFilePage {
let name = path.rsplit('/').next().unwrap_or("Preview").to_string();
let extension = path.rsplit('.').next().unwrap_or_default().to_lowercase();
let language = source_language(path).unwrap_or_default().to_string();
let markdown = matches!(extension.as_str(), "md" | "markdown" | "mdown" | "mkd");
let media = matches!(
extension.as_str(),
"apng"
| "avi"
| "bmp"
| "flac"
| "gif"
| "heic"
| "heif"
| "jpeg"
| "jpg"
| "m4a"
| "m4v"
| "mkv"
| "mov"
| "mp3"
| "mp4"
| "mpeg"
| "mpg"
| "ogg"
| "pdf"
| "png"
| "tif"
| "tiff"
| "wav"
| "webm"
| "webp"
);
if media {
return RepositoryFilePage {
name,
kind: RepositoryFileKind::Preview,
language,
text: String::new(),
data,
};
}
match String::from_utf8(data) {
Ok(text) => RepositoryFilePage {
name,
kind: if markdown {
RepositoryFileKind::Markdown
} else {
RepositoryFileKind::Source
},
language,
text,
data: Vec::new(),
},
Err(error) => RepositoryFilePage {
name,
kind: RepositoryFileKind::Preview,
language,
text: String::new(),
data: error.into_bytes(),
},
}
}
pub fn diff_page(path: &str, diff: diff::Parsed) -> DiffPage {
DiffPage {
title: path.rsplit('/').next().unwrap_or(path).into(),
columns: diff.columns.min(u32::MAX as usize) as u32,
lines: diff
.lines
.into_iter()
.map(|line| DiffLine {
old_number: line.old_number,
new_number: line.new_number,
text: line.text,
kind: match line.kind {
"addition" => DiffLineKind::Addition,
"removal" => DiffLineKind::Removal,
"hunk" => DiffLineKind::Hunk,
"header" => DiffLineKind::Header,
_ => DiffLineKind::Context,
},
})
.collect(),
}
}

View File

@@ -0,0 +1,198 @@
use super::*;
pub(super) fn label_row(label: &models::Label) -> Option<LabelRow> {
let name = label.name.as_deref()?.trim();
let color = label.color.as_deref()?.trim().trim_start_matches('#');
if name.is_empty() || color.len() != 6 {
return None;
}
let value = u32::from_str_radix(color, 16).ok()?;
let red = (value >> 16) & 0xff;
let green = (value >> 8) & 0xff;
let blue = value & 0xff;
Some(LabelRow {
name: name.into(),
color: format!("#{color}"),
light: red * 299 + green * 587 + blue * 114 > 150_000,
})
}
pub(super) fn comment_row(comment: &models::Comment, viewer_id: Option<i64>) -> CommentRow {
CommentRow {
id: comment.id.unwrap_or_default(),
author: comment
.user
.as_ref()
.and_then(|user| user.login.as_deref())
.or(comment.original_author.as_deref())
.unwrap_or("unknown")
.into(),
body: comment
.body
.clone()
.filter(|body| !body.is_empty())
.unwrap_or_else(|| "No comment text.".into()),
meta: compact_date(
comment
.updated_at
.as_deref()
.or(comment.created_at.as_deref()),
),
can_edit: comment_can_edit(comment, viewer_id),
}
}
pub(super) fn file_row(file: &models::ChangedFile) -> Option<FileRow> {
Some(FileRow {
path: file.filename.clone()?,
status: file.status.clone().unwrap_or_else(|| "modified".into()),
})
}
pub(super) fn pull_files_ref(pull: &models::PullRequest) -> String {
match pull_file_source(pull) {
PullFileSource::Branch(branch) => format!("Files on {branch}"),
PullFileSource::Commit(sha) => format!("Files at merge commit {sha}"),
PullFileSource::Request => "Files from pull request".into(),
}
}
pub(super) fn issue_meta(issue: &models::Issue) -> String {
let author = issue
.user
.as_ref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown");
format!(
"#{} · {} · updated {} · {} comments",
issue.number.unwrap_or_default(),
author,
compact_date(issue.updated_at.as_deref()),
issue.comments.unwrap_or_default()
)
}
pub(super) fn issue_milestone(issue: &models::Issue) -> String {
issue
.milestone
.as_ref()
.and_then(|milestone| milestone.title.as_deref())
.unwrap_or_default()
.into()
}
pub(super) fn milestone_row(milestone: &models::Milestone) -> MilestoneRow {
let open = milestone.open_issues.unwrap_or_default();
let closed = milestone.closed_issues.unwrap_or_default();
let total = open + closed;
let due = milestone
.due_on
.as_deref()
.map(|date| format!("due {}", compact_date(Some(date))))
.unwrap_or_else(|| "no due date".into());
MilestoneRow {
id: milestone.id.unwrap_or_default(),
state: work_item_state(milestone.state.as_deref()),
title: milestone
.title
.clone()
.unwrap_or_else(|| "Untitled milestone".into()),
description: milestone
.description
.clone()
.filter(|description| !description.is_empty())
.unwrap_or_else(|| "No description".into()),
meta: format!(
"{} · {closed} of {} closed · {due}",
milestone.state.as_deref().unwrap_or("unknown"),
open + closed
),
has_issues: total > 0,
progress: if total == 0 {
0.0
} else {
closed as f64 / total as f64
},
progress_accessibility: format!("{closed} closed, {open} open"),
}
}
pub(super) fn work_item_state(state: Option<&str>) -> WorkItemState {
match state {
Some("open") => WorkItemState::Open,
Some("closed") => WorkItemState::Closed,
Some(_) | None => WorkItemState::Unknown,
}
}
pub(super) fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(8)]
}
pub(super) fn source_language(path: &str) -> Option<&'static str> {
let filename = path.rsplit('/').next()?.to_lowercase();
match filename.as_str() {
"cmakelists.txt" => return Some("cmake"),
"dockerfile" => return Some("dockerfile"),
"gemfile" | "podfile" => return Some("ruby"),
"makefile" => return Some("makefile"),
_ => {}
}
match filename.rsplit('.').next()? {
"asm" => Some("x86asm"),
"c" => Some("c"),
"cc" | "cpp" | "h" | "hpp" => Some("cpp"),
"clj" => Some("clojure"),
"cs" => Some("csharp"),
"css" => Some("css"),
"dart" => Some("dart"),
"ex" | "exs" => Some("elixir"),
"fs" => Some("fsharp"),
"go" => Some("go"),
"groovy" => Some("groovy"),
"hs" => Some("haskell"),
"html" | "plist" | "xml" => Some("xml"),
"java" => Some("java"),
"jl" => Some("julia"),
"js" | "jsx" => Some("javascript"),
"json" => Some("json"),
"kt" | "kts" => Some("kotlin"),
"lua" => Some("lua"),
"m" | "mm" => Some("objectivec"),
"md" | "markdown" | "mdown" | "mkd" => Some("markdown"),
"php" => Some("php"),
"pl" => Some("perl"),
"ps1" => Some("powershell"),
"py" => Some("python"),
"r" => Some("r"),
"rb" => Some("ruby"),
"rs" => Some("rust"),
"scala" => Some("scala"),
"scss" => Some("scss"),
"sh" => Some("bash"),
"sql" => Some("sql"),
"swift" => Some("swift"),
"toml" => Some("ini"),
"ts" | "tsx" => Some("typescript"),
"txt" => Some("plaintext"),
"yaml" | "yml" => Some("yaml"),
_ => None,
}
}
pub(super) fn summary(body: &str) -> String {
body.split_whitespace()
.take(24)
.collect::<Vec<_>>()
.join(" ")
}
pub(super) fn indices(values: &[usize]) -> Vec<u32> {
values.iter().map(|value| *value as u32).collect()
}
pub fn compact_date(date: Option<&str>) -> String {
date.and_then(|date| date.get(..10))
.unwrap_or("unknown")
.to_string()
}

View File

@@ -0,0 +1,229 @@
use super::{helpers::*, *};
pub fn home_page(server_name: String, home: HomeData) -> HomePage {
let (heat_cells, contribution_count) = heat_cells(&home.heatmap);
HomePage {
server_name,
activities: home.activities.iter().map(activity_row).collect(),
heat_cells,
contribution_count,
next_page: home.next_page.map(|page| page as u32),
}
}
fn activity_row(activity: &models::Activity) -> ActivityRow {
use models::activity::OpType;
let repository = activity
.repo
.as_ref()
.and_then(|repo| repo.full_name.as_deref())
.unwrap_or("repository");
let branch = activity
.ref_name
.as_deref()
.and_then(|name| name.strip_prefix("refs/heads/").or(Some(name)))
.unwrap_or("default branch");
let (icon, title) = match activity.op_type {
Some(OpType::CommitRepo) if activity.content.as_deref().unwrap_or("").is_empty() => (
ActivityIcon::Branch,
format!("Created branch {branch} in {repository}"),
),
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => (
ActivityIcon::Push,
format!("Pushed to {branch} in {repository}"),
),
Some(OpType::CreateRepo) => (ActivityIcon::Repository, format!("Created {repository}")),
Some(OpType::RenameRepo) => (ActivityIcon::Repository, format!("Renamed {repository}")),
Some(OpType::StarRepo) => (ActivityIcon::Repository, format!("Starred {repository}")),
Some(OpType::WatchRepo) => (
ActivityIcon::Repository,
format!("Started watching {repository}"),
),
Some(OpType::CreateIssue) => (
ActivityIcon::Issue,
format!("Opened an issue in {repository}"),
),
Some(OpType::CloseIssue) => (
ActivityIcon::Issue,
format!("Closed an issue in {repository}"),
),
Some(OpType::ReopenIssue) => (
ActivityIcon::Issue,
format!("Reopened an issue in {repository}"),
),
Some(OpType::CommentIssue) => (
ActivityIcon::Issue,
format!("Commented on an issue in {repository}"),
),
Some(OpType::CreatePullRequest) => (
ActivityIcon::PullRequest,
format!("Opened a pull request in {repository}"),
),
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => (
ActivityIcon::PullRequest,
format!("Merged a pull request in {repository}"),
),
Some(OpType::ClosePullRequest) => (
ActivityIcon::PullRequest,
format!("Closed a pull request in {repository}"),
),
Some(OpType::ReopenPullRequest) => (
ActivityIcon::PullRequest,
format!("Reopened a pull request in {repository}"),
),
Some(OpType::CommentPull) => (
ActivityIcon::PullRequest,
format!("Commented on a pull request in {repository}"),
),
Some(OpType::ApprovePullRequest) => (
ActivityIcon::PullRequest,
format!("Approved a pull request in {repository}"),
),
Some(OpType::RejectPullRequest) => (
ActivityIcon::PullRequest,
format!("Requested changes in {repository}"),
),
Some(OpType::PushTag) => (
ActivityIcon::Tag,
format!("Pushed tag {branch} in {repository}"),
),
Some(OpType::DeleteTag) => (
ActivityIcon::Tag,
format!("Deleted tag {branch} in {repository}"),
),
Some(OpType::DeleteBranch) => (
ActivityIcon::Branch,
format!("Deleted branch {branch} in {repository}"),
),
Some(OpType::PublishRelease) => (
ActivityIcon::Release,
format!("Published a release in {repository}"),
),
Some(_) | None => (ActivityIcon::Repository, format!("Updated {repository}")),
};
let target = activity::target(activity);
let (target, owner, repository, number, sha) = match target {
Some(activity::Target::Repository { owner, repository }) => (
ActivityTargetKind::Repository,
owner,
repository,
0,
String::new(),
),
Some(activity::Target::Issue {
owner,
repository,
number,
}) => (
ActivityTargetKind::Issue,
owner,
repository,
number,
String::new(),
),
Some(activity::Target::Pull {
owner,
repository,
number,
}) => (
ActivityTargetKind::PullRequest,
owner,
repository,
number,
String::new(),
),
Some(activity::Target::Commit {
owner,
repository,
sha,
}) => (ActivityTargetKind::Commit, owner, repository, 0, sha),
None => (
ActivityTargetKind::None,
String::new(),
String::new(),
0,
String::new(),
),
};
ActivityRow {
icon,
title,
detail: activity_detail(activity),
meta: compact_date(activity.created.as_deref()),
target,
owner,
repository,
number,
sha,
}
}
fn activity_detail(activity: &models::Activity) -> String {
let text = activity
.comment
.as_ref()
.and_then(|comment| comment.body.as_deref())
.or(activity.content.as_deref())
.unwrap_or("");
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(text) {
let commits = payload.get("Len").and_then(|value| value.as_i64());
let message = payload
.get("HeadCommit")
.and_then(|commit| commit.get("Message"))
.and_then(|message| message.as_str())
.map(summary)
.unwrap_or_default();
if !message.is_empty() {
return match commits {
Some(1) => format!("1 commit · {message}"),
Some(count) => format!("{count} commits · {message}"),
None => message,
};
}
}
let text = summary(text);
if text.is_empty() {
"Server activity".into()
} else {
text
}
}
pub(super) fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
let latest = data
.iter()
.filter_map(|entry| entry.timestamp)
.max()
.unwrap_or(0)
/ 86_400;
let (year, month, _) = civil_from_days(latest);
let first_month = year * 12 + month - 1 - 8;
let start = days_from_civil(
first_month.div_euclid(12),
first_month.rem_euclid(12) + 1,
1,
);
let mut counts = vec![0_i64; (latest - start + 1) as usize];
for entry in data {
let day = entry.timestamp.unwrap_or_default() / 86_400;
if (start..=latest).contains(&day) {
counts[(day - start) as usize] += entry.contributions.unwrap_or_default();
}
}
let maximum = counts.iter().copied().max().unwrap_or_default();
let contribution_count = counts.iter().sum();
let cells = counts
.into_iter()
.enumerate()
.map(|(index, count)| HeatCell {
level: if count == 0 || maximum == 0 {
0
} else {
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32
},
timestamp: (start + index as i64) * 86_400,
})
.collect();
(cells, contribution_count)
}

View File

@@ -0,0 +1,272 @@
use super::{helpers::*, *};
pub fn repository_rows(
repositories: &[RepositoryData],
is_favorite: impl Fn(&RepositoryData) -> bool,
) -> Vec<RepositoryRow> {
let mut repositories = repositories.to_vec();
repositories
.sort_by_key(|repository| (!is_favorite(repository), repository.name.to_lowercase()));
repositories
.into_iter()
.map(|repository| RepositoryRow {
favorite: is_favorite(&repository),
meta: format!(
"{} · {} open · {}",
repository.language, repository.open_issues, repository.updated
),
name: repository.name,
owner: repository.owner,
description: repository.description,
})
.collect()
}
pub fn issue_rows(issues: &[models::Issue]) -> Vec<IssueRow> {
issues
.iter()
.map(|issue| IssueRow {
number: issue.number.unwrap_or_default(),
state: work_item_state(issue.state.as_deref()),
title: issue
.title
.clone()
.unwrap_or_else(|| "Untitled issue".into()),
summary: issue
.body
.as_deref()
.map(summary)
.unwrap_or_else(|| "No description".into()),
meta: issue_meta(issue),
milestone: issue_milestone(issue),
labels: issue
.labels
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(label_row)
.collect(),
})
.collect()
}
pub fn issue_filter_options(
labels: &[models::Label],
milestones: &[models::Milestone],
filter: &IssueFilter,
) -> IssueFilterOptions {
let mut labels: Vec<_> = labels
.iter()
.filter_map(|label| label.name.clone())
.collect();
let unavailable_labels = filter
.labels
.iter()
.filter(|label| !labels.contains(label))
.cloned()
.collect();
labels.extend(filter.labels.iter().cloned());
labels.sort_by_key(|label| label.to_lowercase());
labels.dedup();
let mut milestones: Vec<_> = milestones
.iter()
.filter_map(|milestone| milestone.title.clone())
.collect();
if !filter.milestone.is_empty() {
milestones.push(filter.milestone.clone());
}
milestones.sort_by_key(|milestone| milestone.to_lowercase());
milestones.dedup();
IssueFilterOptions {
milestones,
labels,
unavailable_labels,
selected_milestone: filter.milestone.clone(),
selected_labels: filter.labels.iter().cloned().collect(),
search_text: filter.search_text.clone(),
}
}
pub fn milestone_rows(milestones: &[models::Milestone]) -> Vec<MilestoneRow> {
let mut milestones = milestones.to_vec();
milestones.sort_by_key(|milestone| {
(
milestone.state.as_deref() == Some("closed"),
milestone
.title
.as_deref()
.unwrap_or_default()
.to_lowercase(),
)
});
milestones.iter().map(milestone_row).collect()
}
pub fn milestone_page(details: MilestoneDetails) -> MilestonePage {
MilestonePage {
milestone: milestone_row(&details.milestone),
issues: issue_rows(&details.issues),
pulls: pull_rows(&details.pulls),
has_more: details.has_more,
}
}
pub fn milestone_editor_page(milestone: Option<models::Milestone>) -> MilestoneEditorPage {
MilestoneEditorPage {
title: milestone
.as_ref()
.and_then(|milestone| milestone.title.clone())
.unwrap_or_default(),
description: milestone
.as_ref()
.and_then(|milestone| milestone.description.clone())
.unwrap_or_default(),
due_date: milestone
.as_ref()
.and_then(|milestone| milestone.due_on.as_deref())
.and_then(parse_api_date),
closed: milestone
.as_ref()
.and_then(|milestone| milestone.state.as_deref())
== Some("closed"),
}
}
pub fn pull_filter_options(milestones: &[String], filter: &PullFilter) -> PullFilterOptions {
let mut milestones = milestones.to_vec();
if !filter.milestone.is_empty() {
milestones.push(filter.milestone.clone());
}
milestones.sort_by_key(|milestone| milestone.to_lowercase());
milestones.dedup();
PullFilterOptions {
milestones,
selected_milestone: filter.milestone.clone(),
search_text: filter.search_text.clone(),
}
}
pub fn pull_rows(pulls: &[models::Issue]) -> Vec<PullRow> {
pulls
.iter()
.filter_map(|pull| {
let repository = pull.repository.as_ref()?;
let author = pull
.user
.as_ref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown");
let kind = if pull
.pull_request
.as_ref()
.and_then(|pull| pull.draft)
.unwrap_or(false)
{
"Draft"
} else {
pull.state.as_deref().unwrap_or("unknown")
};
Some(PullRow {
number: pull.number.unwrap_or_default(),
owner: repository.owner.clone()?,
repository: repository.name.clone()?,
state: work_item_state(pull.state.as_deref()),
title: format!(
"{} #{}\n{}",
repository.name.as_deref()?,
pull.number.unwrap_or_default(),
pull.title.as_deref().unwrap_or("Untitled pull request")
),
summary: pull
.body
.as_deref()
.map(summary)
.filter(|body| !body.is_empty())
.unwrap_or_else(|| "No description".into()),
meta: format!(
"{kind} · {author} · {} · {} comments",
compact_date(pull.updated_at.as_deref()),
pull.comments.unwrap_or_default()
),
})
})
.collect()
}
pub fn commit_page(
branches: Vec<String>,
commits: &[HistoryCommit],
show_graph: bool,
has_more: bool,
) -> CommitPage {
let lane_count = if show_graph {
commits
.iter()
.flat_map(|commit| {
commit
.top_lanes
.iter()
.chain(commit.bottom_lanes.iter())
.chain(commit.top_connections.iter())
.chain(commit.bottom_connections.iter())
.chain(commit.node_lane.iter())
})
.max()
.map_or(0, |lane| lane + 1)
} else {
0
};
let commits = commits
.iter()
.filter_map(|row| {
let commit = &row.commit;
let sha = commit.sha.as_deref()?;
let details = commit.commit.as_ref();
let author = details
.and_then(|commit| commit.author.as_ref())
.and_then(|author| author.name.as_deref())
.or_else(|| {
commit
.author
.as_ref()
.and_then(|author| author.login.as_deref())
})
.unwrap_or("unknown");
let date = details
.and_then(|commit| commit.author.as_ref())
.and_then(|author| author.date.as_deref())
.or(commit.created.as_deref());
let mut branch_labels = row.refs.clone();
for label in &row.branch_starts {
if !branch_labels.contains(label) {
branch_labels.push(label.clone());
}
}
Some(CommitRow {
sha: sha.into(),
title: details
.and_then(|commit| commit.message.as_deref())
.map(|message| message.lines().next().unwrap_or(message))
.unwrap_or("Commit")
.into(),
detail: if !branch_labels.is_empty() {
format!("{author} · {} · {}", compact_date(date), short_sha(sha))
} else {
format!("{author} · {}\n{}", compact_date(date), short_sha(sha))
},
branch_label: (!branch_labels.is_empty()).then(|| branch_labels.join(" · ")),
top_lanes: indices(&row.top_lanes),
bottom_lanes: indices(&row.bottom_lanes),
node_lane: row.node_lane.map(|lane| lane as u32),
top_connections: indices(&row.top_connections),
bottom_connections: indices(&row.bottom_connections),
})
})
.collect();
CommitPage {
branches,
commits,
lane_count: lane_count as u32,
has_more,
}
}

View File

@@ -0,0 +1,387 @@
use super::{helpers::*, home::heat_cells, *};
#[test]
fn repository_directories_sort_before_files() {
let contents = [
("z.swift", "file"),
("Sources", "dir"),
("README.md", "file"),
]
.into_iter()
.map(|(name, kind)| models::ContentsResponse {
name: Some(name.into()),
path: Some(name.into()),
r#type: Some(kind.into()),
..Default::default()
})
.collect();
assert_eq!(
repository_content_rows(contents)
.into_iter()
.map(|row| row.name)
.collect::<Vec<_>>(),
["Sources", "README.md", "z.swift"]
);
}
#[test]
fn presents_complete_commit_details() {
let page = commit_details_page(
models::Commit {
sha: Some("0123456789abcdef".into()),
commit: Some(Box::new(models::RepoCommit {
message: Some("Add details\n\nExplain the change.".into()),
author: Some(Box::new(models::CommitUser {
name: Some("Ada".into()),
email: Some("ada@example.com".into()),
date: Some("2026-08-01T10:00:00Z".into()),
})),
committer: Some(Box::new(models::CommitUser {
name: Some("Grace".into()),
date: Some("2026-08-02T11:00:00Z".into()),
..Default::default()
})),
verification: Some(Box::new(models::PayloadCommitVerification {
verified: Some(true),
signature: Some("signed".into()),
..Default::default()
})),
..Default::default()
})),
files: Some(vec![models::CommitAffectedFiles {
filename: Some("README.md".into()),
status: Some("modified".into()),
}]),
..Default::default()
},
Some("main".into()),
);
assert_eq!(page.title, "Add details");
assert_eq!(page.description, "Explain the change.");
assert_eq!(page.files[0].path, "README.md");
assert_eq!(
page.metadata
.iter()
.map(|row| (row.label.as_str(), row.value.as_str()))
.collect::<Vec<_>>(),
[
("Author", "Ada <ada@example.com>"),
("Committer", "Grace"),
("Committed", "2026-08-02"),
("Branch", "main"),
("Commit", "0123456789abcdef"),
("Signature", "Verified"),
]
);
assert_eq!(
commit_details_page(models::Commit::default(), None)
.metadata
.last()
.unwrap()
.value,
"Unsigned"
);
}
#[test]
fn maps_heat_levels_and_labels() {
let heatmap = vec![
models::UserHeatmapData {
timestamp: Some(20_393 * 86_400),
contributions: Some(1),
},
models::UserHeatmapData {
timestamp: Some(20_665 * 86_400),
contributions: Some(4),
},
];
let (cells, total) = heat_cells(&heatmap);
assert_eq!((cells.len(), total), (273, 5));
assert_eq!(
(cells[0].level, cells[0].timestamp, cells[272].level,),
(1, 20_393 * 86_400, 4)
);
let label = models::Label {
name: Some("bug".into()),
color: Some("d73a4a".into()),
..Default::default()
};
assert!(label_row(&label).is_some());
assert!(label_row(&models::Label::default()).is_none());
let milestone = models::Milestone {
id: Some(1),
title: Some("Version 1".into()),
open_issues: Some(3),
closed_issues: Some(2),
state: Some("open".into()),
..Default::default()
};
let milestone_row = milestone_rows(std::slice::from_ref(&milestone)).remove(0);
assert_eq!(milestone_row.progress, 0.4);
assert_eq!(milestone_row.state, WorkItemState::Open);
assert_eq!(milestone_row.progress_accessibility, "2 closed, 3 open");
let issue = models::Issue {
state: Some("closed".into()),
milestone: Some(Box::new(milestone)),
..Default::default()
};
let row = &issue_rows(&[issue])[0];
assert_eq!(row.state, WorkItemState::Closed);
assert_eq!(row.milestone, "Version 1");
}
#[test]
fn presents_filtered_home_activity_and_continuation() {
use models::activity::OpType;
let page = home_page(
"Gitea".into(),
HomeData {
activities: [OpType::CreatePullRequest, OpType::ClosePullRequest]
.into_iter()
.map(|op_type| models::Activity {
op_type: Some(op_type),
..Default::default()
})
.collect(),
heatmap: Vec::new(),
next_page: Some(4),
},
);
assert_eq!(page.activities.len(), 2);
assert_eq!(page.next_page, Some(4));
}
#[test]
fn issue_page_exposes_state_and_owned_comment_editing() {
let page = issue_page(IssueDetails {
issue: models::Issue {
state: Some("closed".into()),
labels: Some(vec![models::Label {
name: Some("bug".into()),
color: Some("ff0000".into()),
..Default::default()
}]),
..Default::default()
},
comments: vec![
models::Comment {
id: Some(7),
user: Some(Box::new(models::User {
id: Some(3),
login: Some("viewer".into()),
..Default::default()
})),
body: Some("Comment".into()),
..Default::default()
},
models::Comment {
id: Some(8),
user: Some(Box::new(models::User {
id: Some(4),
login: Some("someone-else".into()),
..Default::default()
})),
..Default::default()
},
],
viewer_id: Some(3),
has_more: false,
});
assert_eq!(page.state, WorkItemState::Closed);
assert_eq!(page.labels[0].name, "bug");
assert_eq!(page.comments[0].id, 7);
assert!(page.comments[0].can_edit);
assert!(!page.comments[1].can_edit);
}
#[test]
fn issue_editor_exposes_selected_metadata_and_due_date() {
let selected_label = models::Label {
id: Some(2),
name: Some("bug".into()),
..Default::default()
};
let page = issue_editor_page(IssueEditorData {
issue: Some(models::Issue {
title: Some("Title".into()),
body: Some("Body".into()),
due_date: Some("2024-02-29T18:00:00Z".into()),
state: Some("closed".into()),
labels: Some(vec![selected_label.clone()]),
milestone: Some(Box::new(models::Milestone {
id: Some(4),
title: Some("Current".into()),
state: Some("closed".into()),
..Default::default()
})),
..Default::default()
}),
labels: vec![selected_label],
milestones: vec![models::Milestone {
id: Some(4),
title: Some("Current".into()),
state: Some("closed".into()),
..Default::default()
}],
});
assert_eq!((page.title.as_str(), page.body.as_str()), ("Title", "Body"));
assert_eq!(page.due_date, Some(1_709_164_800));
assert!(page.closed);
assert!(page.labels[0].selected);
assert!(page.milestones[0].selected);
let new = issue_editor_page(IssueEditorData {
issue: None,
labels: Vec::new(),
milestones: Vec::new(),
});
assert!(!new.closed);
}
#[test]
fn builds_sorted_issue_filter_options() {
let filter = IssueFilter {
milestone: "v3".into(),
labels: ["bug".into(), "retired".into()].into(),
search_text: "login".into(),
};
let options = issue_filter_options(
&[
models::Label {
name: Some("critical".into()),
..Default::default()
},
models::Label {
name: Some("bug".into()),
..Default::default()
},
],
&[
models::Milestone {
title: Some("v2".into()),
..Default::default()
},
models::Milestone {
title: Some("v1".into()),
..Default::default()
},
],
&filter,
);
assert_eq!(options.labels, ["bug", "critical", "retired"]);
assert_eq!(options.unavailable_labels, ["retired"]);
assert_eq!(options.milestones, ["v1", "v2", "v3"]);
assert_eq!(options.selected_labels, ["bug", "retired"]);
assert_eq!(options.selected_milestone, "v3");
assert_eq!(options.search_text, "login");
let pull_options = pull_filter_options(
&["v2".into(), "v1".into()],
&PullFilter {
milestone: "v3".into(),
search_text: "review".into(),
},
);
assert_eq!(pull_options.milestones, ["v1", "v2", "v3"]);
assert_eq!(pull_options.selected_milestone, "v3");
assert_eq!(pull_options.search_text, "review");
}
#[test]
fn milestone_page_includes_linked_pull_requests() {
let page = milestone_page(MilestoneDetails {
milestone: models::Milestone::default(),
issues: Vec::new(),
pulls: vec![models::Issue {
number: Some(7),
state: Some("closed".into()),
title: Some("Linked pull".into()),
repository: Some(Box::new(models::RepositoryMeta {
name: Some("demo".into()),
owner: Some("octo".into()),
..Default::default()
})),
..Default::default()
}],
has_more: false,
});
assert!(page.issues.is_empty());
assert_eq!(page.pulls.len(), 1);
assert_eq!(page.pulls[0].number, 7);
assert_eq!(page.pulls[0].state, WorkItemState::Closed);
}
#[test]
fn pull_page_exposes_open_closed_state() {
let page = pull_page(PullDetails {
pull: models::PullRequest {
state: Some("closed".into()),
..Default::default()
},
comments: Vec::new(),
files: Vec::new(),
has_more: false,
});
assert_eq!(page.state, WorkItemState::Closed);
}
#[test]
fn milestone_editor_preserves_description_and_optional_due_date() {
let page = milestone_editor_page(Some(models::Milestone {
title: Some("Version 1".into()),
description: Some("Ship it".into()),
due_on: Some("2024-02-29T12:34:56Z".into()),
state: Some("closed".into()),
..Default::default()
}));
assert_eq!(page.title, "Version 1");
assert_eq!(page.description, "Ship it");
assert_eq!(page.due_date, Some(1_709_164_800));
assert!(page.closed);
let new = milestone_editor_page(None);
assert_eq!(new.due_date, None);
assert!(!new.closed);
}
#[test]
fn classifies_repository_files_in_rust() {
let markdown = repository_file_page("docs/README.md", b"# Hello".to_vec());
assert_eq!(
(
markdown.name.as_str(),
markdown.kind,
markdown.language.as_str()
),
("README.md", RepositoryFileKind::Markdown, "markdown")
);
assert_eq!(markdown.text, "# Hello");
let source = repository_file_page("Sources/App.swift", b"import UIKit".to_vec());
assert_eq!(
(source.kind, source.language.as_str()),
(RepositoryFileKind::Source, "swift")
);
let preview = repository_file_page("image.png", vec![0, 159]);
assert_eq!(preview.kind, RepositoryFileKind::Preview);
assert_eq!(preview.data, [0, 159]);
for path in ["manual.pdf", "sound.mp3", "movie.mp4"] {
assert_eq!(
repository_file_page(path, Vec::new()).kind,
RepositoryFileKind::Preview
);
}
}

View File

@@ -1,7 +1,7 @@
[package]
name = "gotcha-cli"
version = "0.1.0"
description = "CLI test bed for the Gotcha Gitea client"
version = "1.0.0"
description = "Command-line Gitea client"
edition.workspace = true
license.workspace = true
rust-version.workspace = true

View File

@@ -463,50 +463,4 @@ impl Args {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_server_selection_and_repository_scope() {
let args = Args::parse(
["--server", "work", "repo", "show"].map(str::to_owned),
None,
)
.unwrap();
let repository = RepositoryScope::parse("alice/project").unwrap();
assert_eq!(args.server.as_deref(), Some("work"));
assert_eq!(args.command, ["repo", "show"]);
assert_eq!(
(repository.owner.as_str(), repository.repository.as_str()),
("alice", "project")
);
assert_eq!(requested_help(&[]).unwrap(), Some(ROOT_HELP));
assert_eq!(requested_help(&["repo".into()]).unwrap(), Some(REPO_HELP));
assert!(
requested_help(&["repo".into(), "show".into(), "--help".into()])
.unwrap()
.unwrap()
.starts_with("Usage: gotcha repo show")
);
}
#[test]
fn formats_bounded_aligned_tables_without_tabs() {
let table = format_table(
&[("INDEX", 8), ("STATE", 10), ("TITLE", 60)],
&[vec![
"22".into(),
"open".into(),
"A deliberately long issue title that must be shortened".into(),
]],
40,
);
assert!(!table.contains('\t'));
assert!(table.contains('…'));
assert_eq!(table.lines().count(), 3);
assert!(table.lines().all(|line| line.chars().count() <= 40));
assert!(table.lines().next().unwrap().starts_with("INDEX STATE"));
}
}
mod tests;

45
crates/cli/src/tests.rs Normal file
View File

@@ -0,0 +1,45 @@
use super::*;
#[test]
fn parses_server_selection_and_repository_scope() {
let args = Args::parse(
["--server", "work", "repo", "show"].map(str::to_owned),
None,
)
.unwrap();
let repository = RepositoryScope::parse("alice/project").unwrap();
assert_eq!(args.server.as_deref(), Some("work"));
assert_eq!(args.command, ["repo", "show"]);
assert_eq!(
(repository.owner.as_str(), repository.repository.as_str()),
("alice", "project")
);
assert_eq!(requested_help(&[]).unwrap(), Some(ROOT_HELP));
assert_eq!(requested_help(&["repo".into()]).unwrap(), Some(REPO_HELP));
assert!(
requested_help(&["repo".into(), "show".into(), "--help".into()])
.unwrap()
.unwrap()
.starts_with("Usage: gotcha repo show")
);
}
#[test]
fn formats_bounded_aligned_tables_without_tabs() {
let table = format_table(
&[("INDEX", 8), ("STATE", 10), ("TITLE", 60)],
&[vec![
"22".into(),
"open".into(),
"A deliberately long issue title that must be shortened".into(),
]],
40,
);
assert!(!table.contains('\t'));
assert!(table.contains('…'));
assert_eq!(table.lines().count(), 3);
assert!(table.lines().all(|line| line.chars().count() <= 40));
assert!(table.lines().next().unwrap().starts_with("INDEX STATE"));
}

View File

@@ -3,246 +3,13 @@ use std::{error::Error, io::Read};
use gotcha_gitea::{
Client, CreateIssue, EditIssue, IssueQuery,
models::{
ChangedFile, Comment, Commit, CreateIssueOption, CreateMilestoneOption,
CreatePullRequestOption, EditIssueOption, EditMilestoneOption, EditPullRequestOption,
Issue, MergePullRequestOption, Milestone, PullRequest, PullReview,
CreateMilestoneOption, CreatePullRequestOption, EditMilestoneOption, EditPullRequestOption,
MergePullRequestOption,
},
pull_state,
};
use serde::{Deserialize, de::DeserializeOwned};
use serde::de::DeserializeOwned;
use crate::{
config::{RepositoryScope, Selection},
print_table,
};
const ISSUE_HELP: &str = "\
Usage: gotcha issue SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY] [OPTIONS]
List and filter issues
show INDEX [OWNER/REPOSITORY] Show an issue
create [OWNER/REPOSITORY] Create from CreateIssueOption YAML on stdin
edit INDEX... [OWNER/REPOSITORY] Edit issues from EditIssueOption YAML on stdin
close INDEX... [OWNER/REPOSITORY]
Close one or more issues
reopen INDEX... [OWNER/REPOSITORY]
Reopen one or more issues
delete INDEX [OWNER/REPOSITORY] Delete an issue
comments INDEX [OWNER/REPOSITORY]
List comments
comment INDEX [OWNER/REPOSITORY] Add a comment read as text from stdin";
const MILESTONE_HELP: &str = "\
Usage: gotcha milestone SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY] List milestones
show ID [OWNER/REPOSITORY] Show a milestone
create [OWNER/REPOSITORY] Create from CreateMilestoneOption YAML on stdin
edit ID [OWNER/REPOSITORY] Edit from EditMilestoneOption YAML on stdin
delete ID [OWNER/REPOSITORY] Delete a milestone";
const PULL_HELP: &str = "\
Usage: gotcha pull SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY] List pull requests
show INDEX [OWNER/REPOSITORY] Show a pull request
create [OWNER/REPOSITORY] Create from CreatePullRequestOption YAML on stdin
edit INDEX [OWNER/REPOSITORY] Edit from EditPullRequestOption YAML on stdin
merge INDEX [OWNER/REPOSITORY] Merge from MergePullRequestOption YAML on stdin
commits INDEX [OWNER/REPOSITORY] List commits
files INDEX [OWNER/REPOSITORY] List changed files
reviews INDEX [OWNER/REPOSITORY] List reviews";
pub fn domain_help(domain: &str) -> Option<&'static str> {
match domain {
"issue" => Some(ISSUE_HELP),
"milestone" => Some(MILESTONE_HELP),
"pull" => Some(PULL_HELP),
_ => None,
}
}
pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> {
match (domain, command) {
("issue", "list") => Some(
"Usage: gotcha issue list [OWNER/REPOSITORY] [OPTIONS]\n\nOptions:\n --state open|closed|all\n -K, --kind issues|pulls|all\n -k, --keyword TEXT\n -L, --labels NAMES\n -m, --milestones NAMES\n -A, --author USER\n -a, --assignee USER\n -M, --mentions USER\n -F, --from TIMESTAMP\n -u, --until TIMESTAMP\n -p, --page NUMBER\n --limit NUMBER",
),
("issue", "show") => Some("Usage: gotcha issue show INDEX [OWNER/REPOSITORY]"),
("issue", "create") => Some(
"Usage: gotcha issue create [OWNER/REPOSITORY] < issue.yaml\n\nReads CreateIssueOption YAML from stdin. label_names and milestone_name accept display names, for example:\n title: Fix the bug\n body: Reproduction steps\n label_names: [bug, critical]\n milestone_name: Version 1.0",
),
("issue", "edit") => Some(
"Usage: gotcha issue edit INDEX... [OWNER/REPOSITORY] < issue.yaml\n\nReads EditIssueOption YAML from stdin and applies it to every index. milestone_name resolves a display name; add_labels and remove_labels accept label names; add_assignees adds users without replacing existing assignees.",
),
("issue", "close") => Some("Usage: gotcha issue close INDEX... [OWNER/REPOSITORY]"),
("issue", "reopen") => Some("Usage: gotcha issue reopen INDEX... [OWNER/REPOSITORY]"),
("issue", "delete") => Some("Usage: gotcha issue delete INDEX [OWNER/REPOSITORY]"),
("issue", "comments") => Some("Usage: gotcha issue comments INDEX [OWNER/REPOSITORY]"),
("issue", "comment") => Some(
"Usage: gotcha issue comment INDEX [OWNER/REPOSITORY] < comment.txt\n\nReads the comment body as text from stdin.",
),
("milestone", "list") => Some("Usage: gotcha milestone list [OWNER/REPOSITORY]"),
("milestone", "show") => Some("Usage: gotcha milestone show ID [OWNER/REPOSITORY]"),
("milestone", "create") => Some(
"Usage: gotcha milestone create [OWNER/REPOSITORY] < milestone.yaml\n\nReads CreateMilestoneOption YAML from stdin, for example:\n title: Version 1.0\n due_on: 2026-09-01T00:00:00Z",
),
("milestone", "edit") => Some(
"Usage: gotcha milestone edit ID [OWNER/REPOSITORY] < milestone.yaml\n\nReads EditMilestoneOption YAML from stdin.",
),
("milestone", "delete") => Some("Usage: gotcha milestone delete ID [OWNER/REPOSITORY]"),
("pull", "list") => Some("Usage: gotcha pull list [OWNER/REPOSITORY]"),
("pull", "show") => Some("Usage: gotcha pull show INDEX [OWNER/REPOSITORY]"),
("pull", "create") => Some(
"Usage: gotcha pull create [OWNER/REPOSITORY] < pull.yaml\n\nReads CreatePullRequestOption YAML from stdin, for example:\n title: Add feature\n head: feature\n base: main",
),
("pull", "edit") => Some(
"Usage: gotcha pull edit INDEX [OWNER/REPOSITORY] < pull.yaml\n\nReads EditPullRequestOption YAML from stdin.",
),
("pull", "merge") => Some(
"Usage: gotcha pull merge INDEX [OWNER/REPOSITORY] < merge.yaml\n\nReads MergePullRequestOption YAML from stdin, for example:\n Do: squash\n delete_branch_after_merge: true",
),
("pull", "commits") => Some("Usage: gotcha pull commits INDEX [OWNER/REPOSITORY]"),
("pull", "files") => Some("Usage: gotcha pull files INDEX [OWNER/REPOSITORY]"),
("pull", "reviews") => Some("Usage: gotcha pull reviews INDEX [OWNER/REPOSITORY]"),
_ => None,
}
}
#[derive(Debug, PartialEq)]
struct IssueListOptions {
repository: Option<String>,
state: String,
kind: String,
keyword: Option<String>,
labels: Option<String>,
milestones: Option<String>,
author: Option<String>,
assignee: Option<String>,
mentions: Option<String>,
from: Option<String>,
until: Option<String>,
page: i32,
limit: i32,
}
#[derive(Deserialize)]
struct CreateIssueInput {
#[serde(flatten)]
issue: CreateIssueOption,
#[serde(default)]
label_names: Vec<String>,
milestone_name: Option<String>,
}
#[derive(Clone, Deserialize)]
struct EditIssueInput {
#[serde(flatten)]
issue: EditIssueOption,
#[serde(default)]
add_labels: Vec<String>,
#[serde(default)]
remove_labels: Vec<String>,
#[serde(default)]
add_assignees: Vec<String>,
milestone_name: Option<String>,
}
impl Default for IssueListOptions {
fn default() -> Self {
Self {
repository: None,
state: "open".into(),
kind: "issues".into(),
keyword: None,
labels: None,
milestones: None,
author: None,
assignee: None,
mentions: None,
from: None,
until: None,
page: 1,
limit: 30,
}
}
}
fn parse_issue_list(arguments: &[String]) -> Result<IssueListOptions, Box<dyn Error>> {
let mut options = IssueListOptions::default();
let mut index = 0;
while index < arguments.len() {
let argument = arguments[index].as_str();
let value = |index: &mut usize| -> Result<String, Box<dyn Error>> {
*index += 1;
arguments
.get(*index)
.cloned()
.ok_or_else(|| format!("{argument} requires a value").into())
};
match argument {
"--state" => options.state = value(&mut index)?,
"-K" | "--kind" => options.kind = value(&mut index)?,
"-k" | "--keyword" => options.keyword = Some(value(&mut index)?),
"-L" | "--labels" => options.labels = Some(value(&mut index)?),
"-m" | "--milestones" => options.milestones = Some(value(&mut index)?),
"-A" | "--author" => options.author = Some(value(&mut index)?),
"-a" | "--assignee" => options.assignee = Some(value(&mut index)?),
"-M" | "--mentions" => options.mentions = Some(value(&mut index)?),
"-F" | "--from" => options.from = Some(value(&mut index)?),
"-u" | "--until" => options.until = Some(value(&mut index)?),
"-p" | "--page" => options.page = positive_i32(&value(&mut index)?, "page")?,
"--limit" | "--lm" => options.limit = positive_i32(&value(&mut index)?, "limit")?,
unknown if unknown.starts_with('-') => {
return Err(format!("unknown issue list option {unknown:?}").into());
}
repository if options.repository.is_none() => {
RepositoryScope::parse(repository)?;
options.repository = Some(repository.into());
}
value => return Err(format!("unexpected issue list argument {value:?}").into()),
}
index += 1;
}
if !matches!(options.state.as_str(), "open" | "closed" | "all") {
return Err("--state must be open, closed, or all".into());
}
if !matches!(options.kind.as_str(), "issues" | "pulls" | "all") {
return Err("--kind must be issues, pulls, or all".into());
}
Ok(options)
}
fn positive_i32(value: &str, name: &str) -> Result<i32, Box<dyn Error>> {
let value: i32 = value.parse()?;
if value < 1 {
return Err(format!("{name} must be a positive integer").into());
}
Ok(value)
}
fn issue_targets(arguments: &[String]) -> Result<(Vec<i64>, Option<String>), Box<dyn Error>> {
let mut indexes = Vec::new();
let mut repository = None;
for argument in arguments {
if argument.contains('/') {
if repository.is_some() {
return Err("only one OWNER/REPOSITORY may be provided".into());
}
RepositoryScope::parse(argument)?;
repository = Some(argument.clone());
} else {
indexes.push(number(argument)?);
}
}
if indexes.is_empty() {
return Err("at least one issue index is required".into());
}
Ok((indexes, repository))
}
use crate::config::{RepositoryScope, Selection};
pub async fn run(
command: &[String],
@@ -633,318 +400,13 @@ async fn pull_details(
Ok(())
}
fn print_issues(issues: &[Issue]) {
print_table(
&[
("INDEX", 8),
("STATE", 10),
("TITLE", 60),
("MILESTONE", 30),
("LABELS", 30),
],
issues
.iter()
.map(|issue| {
vec![
issue.number.unwrap_or_default().to_string(),
issue.state.as_deref().unwrap_or("unknown").into(),
one_line(issue.title.as_deref().unwrap_or("")).into(),
issue
.milestone
.as_deref()
.and_then(|milestone| milestone.title.as_deref())
.unwrap_or("-")
.into(),
issue_labels(issue),
]
})
.collect(),
);
}
mod arguments;
mod help;
mod output;
fn print_issue(issue: &Issue) {
println!(
"#{} [{}] {}",
issue.number.unwrap_or_default(),
issue.state.as_deref().unwrap_or("unknown"),
issue.title.as_deref().unwrap_or("")
);
field("url", issue.html_url.as_deref());
field(
"author",
issue.user.as_deref().and_then(|user| user.login.as_deref()),
);
let milestone = issue
.milestone
.as_deref()
.and_then(|milestone| milestone.title.as_deref());
field("milestone", milestone);
let labels = issue_labels(issue);
field("labels", (!labels.is_empty()).then_some(labels.as_str()));
let assignees = issue
.assignees
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|user| user.login.as_deref())
.collect::<Vec<_>>()
.join(",");
field(
"assignees",
(!assignees.is_empty()).then_some(assignees.as_str()),
);
field("due", issue.due_date.as_deref());
field("created", issue.created_at.as_deref());
field("updated", issue.updated_at.as_deref());
println!("comments: {}", issue.comments.unwrap_or_default());
body(issue.body.as_deref());
}
fn issue_labels(issue: &Issue) -> String {
issue
.labels
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|label| label.name.as_deref())
.collect::<Vec<_>>()
.join(",")
}
fn print_comments(comments: &[Comment]) {
for comment in comments {
println!(
"{} · {}",
comment
.user
.as_deref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown"),
comment.created_at.as_deref().unwrap_or("-")
);
body(comment.body.as_deref());
}
}
fn print_milestones(milestones: &[Milestone]) {
print_table(
&[
("ID", 8),
("STATE", 10),
("TITLE", 60),
("DUE", 25),
("OPEN/CLOSED", 12),
],
milestones
.iter()
.map(|milestone| {
vec![
milestone.id.unwrap_or_default().to_string(),
milestone.state.as_deref().unwrap_or("unknown").into(),
one_line(milestone.title.as_deref().unwrap_or("")).into(),
milestone.due_on.as_deref().unwrap_or("-").into(),
format!(
"{}/{}",
milestone.open_issues.unwrap_or_default(),
milestone.closed_issues.unwrap_or_default()
),
]
})
.collect(),
);
}
fn print_milestone(milestone: &Milestone) {
println!(
"{} [{}] {}",
milestone.id.unwrap_or_default(),
milestone.state.as_deref().unwrap_or("unknown"),
milestone.title.as_deref().unwrap_or("")
);
field("due", milestone.due_on.as_deref());
println!(
"issues: {} open, {} closed",
milestone.open_issues.unwrap_or_default(),
milestone.closed_issues.unwrap_or_default()
);
body(milestone.description.as_deref());
}
fn print_pulls(pulls: &[PullRequest]) {
print_table(
&[("INDEX", 8), ("STATE", 10), ("TITLE", 70), ("UPDATED", 25)],
pulls
.iter()
.map(|pull| {
vec![
pull.number.unwrap_or_default().to_string(),
pull.state.as_deref().unwrap_or("unknown").into(),
one_line(pull.title.as_deref().unwrap_or("")).into(),
pull.updated_at.as_deref().unwrap_or("-").into(),
]
})
.collect(),
);
}
fn print_pull(pull: &PullRequest) {
println!(
"#{} [{}] {}",
pull.number.unwrap_or_default(),
pull_state(pull),
pull.title.as_deref().unwrap_or("")
);
field("url", pull.html_url.as_deref());
field(
"author",
pull.user.as_deref().and_then(|user| user.login.as_deref()),
);
field("updated", pull.updated_at.as_deref());
println!("mergeable: {}", pull.mergeable.unwrap_or(false));
body(pull.body.as_deref());
}
fn print_commits(commits: &[Commit]) {
print_table(
&[("SHA", 40), ("MESSAGE", 80)],
commits
.iter()
.map(|commit| {
vec![
commit.sha.as_deref().unwrap_or("unknown").into(),
one_line(
commit
.commit
.as_deref()
.and_then(|commit| commit.message.as_deref())
.unwrap_or(""),
)
.into(),
]
})
.collect(),
);
}
fn print_files(files: &[ChangedFile]) {
print_table(
&[("STATUS", 12), ("CHANGES", 10), ("FILE", 90)],
files
.iter()
.map(|file| {
vec![
file.status.as_deref().unwrap_or("unknown").into(),
file.changes.unwrap_or_default().to_string(),
file.filename.as_deref().unwrap_or("").into(),
]
})
.collect(),
);
}
fn print_reviews(reviews: &[PullReview]) {
print_table(
&[
("ID", 12),
("STATE", 20),
("REVIEWER", 25),
("SUBMITTED", 25),
],
reviews
.iter()
.map(|review| {
vec![
review.id.unwrap_or_default().to_string(),
review.state.as_deref().unwrap_or("unknown").into(),
review
.user
.as_deref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown")
.into(),
review.submitted_at.as_deref().unwrap_or("-").into(),
]
})
.collect(),
);
}
fn field(name: &str, value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
println!("{name}: {value}");
}
}
fn body(value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
println!("\n{value}");
}
}
fn one_line(value: &str) -> &str {
value.lines().next().unwrap_or_default()
}
use arguments::*;
pub(crate) use help::{domain_help, subcommand_help};
use output::*;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_typed_yaml_and_rejects_bad_indexes() {
let issue: CreateIssueInput = parse_yaml(
"title: Fix it\nbody: Details\nlabel_names: [bug]\nmilestone_name: Version 1\n",
)
.unwrap();
assert_eq!(issue.issue.title, "Fix it");
assert_eq!(issue.issue.body.as_deref(), Some("Details"));
assert_eq!(issue.label_names, ["bug"]);
assert_eq!(issue.milestone_name.as_deref(), Some("Version 1"));
let edit: EditIssueInput = parse_yaml(
"title: Updated\nadd_labels: [critical]\nremove_labels: [bug]\nadd_assignees: [alice]\n",
)
.unwrap();
assert_eq!(edit.issue.title.as_deref(), Some("Updated"));
assert_eq!(edit.add_labels, ["critical"]);
assert_eq!(edit.remove_labels, ["bug"]);
assert_eq!(edit.add_assignees, ["alice"]);
assert!(number("0").is_err());
assert!(number("7").is_ok());
}
#[test]
fn parses_issue_workflow_filters_and_bulk_state_targets() {
let options = parse_issue_list(
&[
"hugo/Gotcha",
"--state",
"all",
"--milestones",
"first feature complete release",
"-L",
"bug,critical",
"--page",
"2",
"--limit",
"50",
]
.map(str::to_owned),
)
.unwrap();
assert_eq!(options.repository.as_deref(), Some("hugo/Gotcha"));
assert_eq!(options.state, "all");
assert_eq!(
options.milestones.as_deref(),
Some("first feature complete release")
);
assert_eq!(options.labels.as_deref(), Some("bug,critical"));
assert_eq!((options.page, options.limit), (2, 50));
let (indexes, repository) =
issue_targets(&["4", "16", "hugo/Gotcha"].map(str::to_owned)).unwrap();
assert_eq!(indexes, [4, 16]);
assert_eq!(repository.as_deref(), Some("hugo/Gotcha"));
assert!(parse_issue_list(&["--state".into(), "invalid".into()]).is_err());
assert!(issue_targets(&[]).is_err());
}
}
mod tests;

View File

@@ -0,0 +1,142 @@
use std::error::Error;
use gotcha_gitea::models::{CreateIssueOption, EditIssueOption};
use serde::Deserialize;
use crate::config::RepositoryScope;
use super::number;
#[derive(Debug, PartialEq)]
pub(super) struct IssueListOptions {
pub(super) repository: Option<String>,
pub(super) state: String,
pub(super) kind: String,
pub(super) keyword: Option<String>,
pub(super) labels: Option<String>,
pub(super) milestones: Option<String>,
pub(super) author: Option<String>,
pub(super) assignee: Option<String>,
pub(super) mentions: Option<String>,
pub(super) from: Option<String>,
pub(super) until: Option<String>,
pub(super) page: i32,
pub(super) limit: i32,
}
#[derive(Deserialize)]
pub(super) struct CreateIssueInput {
#[serde(flatten)]
pub(super) issue: CreateIssueOption,
#[serde(default)]
pub(super) label_names: Vec<String>,
pub(super) milestone_name: Option<String>,
}
#[derive(Clone, Deserialize)]
pub(super) struct EditIssueInput {
#[serde(flatten)]
pub(super) issue: EditIssueOption,
#[serde(default)]
pub(super) add_labels: Vec<String>,
#[serde(default)]
pub(super) remove_labels: Vec<String>,
#[serde(default)]
pub(super) add_assignees: Vec<String>,
pub(super) milestone_name: Option<String>,
}
impl Default for IssueListOptions {
fn default() -> Self {
Self {
repository: None,
state: "open".into(),
kind: "issues".into(),
keyword: None,
labels: None,
milestones: None,
author: None,
assignee: None,
mentions: None,
from: None,
until: None,
page: 1,
limit: 30,
}
}
}
pub(super) fn parse_issue_list(arguments: &[String]) -> Result<IssueListOptions, Box<dyn Error>> {
let mut options = IssueListOptions::default();
let mut index = 0;
while index < arguments.len() {
let argument = arguments[index].as_str();
let value = |index: &mut usize| -> Result<String, Box<dyn Error>> {
*index += 1;
arguments
.get(*index)
.cloned()
.ok_or_else(|| format!("{argument} requires a value").into())
};
match argument {
"--state" => options.state = value(&mut index)?,
"-K" | "--kind" => options.kind = value(&mut index)?,
"-k" | "--keyword" => options.keyword = Some(value(&mut index)?),
"-L" | "--labels" => options.labels = Some(value(&mut index)?),
"-m" | "--milestones" => options.milestones = Some(value(&mut index)?),
"-A" | "--author" => options.author = Some(value(&mut index)?),
"-a" | "--assignee" => options.assignee = Some(value(&mut index)?),
"-M" | "--mentions" => options.mentions = Some(value(&mut index)?),
"-F" | "--from" => options.from = Some(value(&mut index)?),
"-u" | "--until" => options.until = Some(value(&mut index)?),
"-p" | "--page" => options.page = positive_i32(&value(&mut index)?, "page")?,
"--limit" | "--lm" => options.limit = positive_i32(&value(&mut index)?, "limit")?,
unknown if unknown.starts_with('-') => {
return Err(format!("unknown issue list option {unknown:?}").into());
}
repository if options.repository.is_none() => {
RepositoryScope::parse(repository)?;
options.repository = Some(repository.into());
}
value => return Err(format!("unexpected issue list argument {value:?}").into()),
}
index += 1;
}
if !matches!(options.state.as_str(), "open" | "closed" | "all") {
return Err("--state must be open, closed, or all".into());
}
if !matches!(options.kind.as_str(), "issues" | "pulls" | "all") {
return Err("--kind must be issues, pulls, or all".into());
}
Ok(options)
}
fn positive_i32(value: &str, name: &str) -> Result<i32, Box<dyn Error>> {
let value: i32 = value.parse()?;
if value < 1 {
return Err(format!("{name} must be a positive integer").into());
}
Ok(value)
}
pub(super) fn issue_targets(
arguments: &[String],
) -> Result<(Vec<i64>, Option<String>), Box<dyn Error>> {
let mut indexes = Vec::new();
let mut repository = None;
for argument in arguments {
if argument.contains('/') {
if repository.is_some() {
return Err("only one OWNER/REPOSITORY may be provided".into());
}
RepositoryScope::parse(argument)?;
repository = Some(argument.clone());
} else {
indexes.push(number(argument)?);
}
}
if indexes.is_empty() {
return Err("at least one issue index is required".into());
}
Ok((indexes, repository))
}

View File

@@ -0,0 +1,95 @@
const ISSUE_HELP: &str = "\
Usage: gotcha issue SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY] [OPTIONS]
List and filter issues
show INDEX [OWNER/REPOSITORY] Show an issue
create [OWNER/REPOSITORY] Create from CreateIssueOption YAML on stdin
edit INDEX... [OWNER/REPOSITORY] Edit issues from EditIssueOption YAML on stdin
close INDEX... [OWNER/REPOSITORY]
Close one or more issues
reopen INDEX... [OWNER/REPOSITORY]
Reopen one or more issues
delete INDEX [OWNER/REPOSITORY] Delete an issue
comments INDEX [OWNER/REPOSITORY]
List comments
comment INDEX [OWNER/REPOSITORY] Add a comment read as text from stdin";
const MILESTONE_HELP: &str = "\
Usage: gotcha milestone SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY] List milestones
show ID [OWNER/REPOSITORY] Show a milestone
create [OWNER/REPOSITORY] Create from CreateMilestoneOption YAML on stdin
edit ID [OWNER/REPOSITORY] Edit from EditMilestoneOption YAML on stdin
delete ID [OWNER/REPOSITORY] Delete a milestone";
const PULL_HELP: &str = "\
Usage: gotcha pull SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY] List pull requests
show INDEX [OWNER/REPOSITORY] Show a pull request
create [OWNER/REPOSITORY] Create from CreatePullRequestOption YAML on stdin
edit INDEX [OWNER/REPOSITORY] Edit from EditPullRequestOption YAML on stdin
merge INDEX [OWNER/REPOSITORY] Merge from MergePullRequestOption YAML on stdin
commits INDEX [OWNER/REPOSITORY] List commits
files INDEX [OWNER/REPOSITORY] List changed files
reviews INDEX [OWNER/REPOSITORY] List reviews";
pub fn domain_help(domain: &str) -> Option<&'static str> {
match domain {
"issue" => Some(ISSUE_HELP),
"milestone" => Some(MILESTONE_HELP),
"pull" => Some(PULL_HELP),
_ => None,
}
}
pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> {
match (domain, command) {
("issue", "list") => Some(
"Usage: gotcha issue list [OWNER/REPOSITORY] [OPTIONS]\n\nOptions:\n --state open|closed|all\n -K, --kind issues|pulls|all\n -k, --keyword TEXT\n -L, --labels NAMES\n -m, --milestones NAMES\n -A, --author USER\n -a, --assignee USER\n -M, --mentions USER\n -F, --from TIMESTAMP\n -u, --until TIMESTAMP\n -p, --page NUMBER\n --limit NUMBER",
),
("issue", "show") => Some("Usage: gotcha issue show INDEX [OWNER/REPOSITORY]"),
("issue", "create") => Some(
"Usage: gotcha issue create [OWNER/REPOSITORY] < issue.yaml\n\nReads CreateIssueOption YAML from stdin. label_names and milestone_name accept display names, for example:\n title: Fix the bug\n body: Reproduction steps\n label_names: [bug, critical]\n milestone_name: Version 1.0",
),
("issue", "edit") => Some(
"Usage: gotcha issue edit INDEX... [OWNER/REPOSITORY] < issue.yaml\n\nReads EditIssueOption YAML from stdin and applies it to every index. milestone_name resolves a display name; add_labels and remove_labels accept label names; add_assignees adds users without replacing existing assignees.",
),
("issue", "close") => Some("Usage: gotcha issue close INDEX... [OWNER/REPOSITORY]"),
("issue", "reopen") => Some("Usage: gotcha issue reopen INDEX... [OWNER/REPOSITORY]"),
("issue", "delete") => Some("Usage: gotcha issue delete INDEX [OWNER/REPOSITORY]"),
("issue", "comments") => Some("Usage: gotcha issue comments INDEX [OWNER/REPOSITORY]"),
("issue", "comment") => Some(
"Usage: gotcha issue comment INDEX [OWNER/REPOSITORY] < comment.txt\n\nReads the comment body as text from stdin.",
),
("milestone", "list") => Some("Usage: gotcha milestone list [OWNER/REPOSITORY]"),
("milestone", "show") => Some("Usage: gotcha milestone show ID [OWNER/REPOSITORY]"),
("milestone", "create") => Some(
"Usage: gotcha milestone create [OWNER/REPOSITORY] < milestone.yaml\n\nReads CreateMilestoneOption YAML from stdin, for example:\n title: Version 1.0\n due_on: 2026-09-01T00:00:00Z",
),
("milestone", "edit") => Some(
"Usage: gotcha milestone edit ID [OWNER/REPOSITORY] < milestone.yaml\n\nReads EditMilestoneOption YAML from stdin.",
),
("milestone", "delete") => Some("Usage: gotcha milestone delete ID [OWNER/REPOSITORY]"),
("pull", "list") => Some("Usage: gotcha pull list [OWNER/REPOSITORY]"),
("pull", "show") => Some("Usage: gotcha pull show INDEX [OWNER/REPOSITORY]"),
("pull", "create") => Some(
"Usage: gotcha pull create [OWNER/REPOSITORY] < pull.yaml\n\nReads CreatePullRequestOption YAML from stdin, for example:\n title: Add feature\n head: feature\n base: main",
),
("pull", "edit") => Some(
"Usage: gotcha pull edit INDEX [OWNER/REPOSITORY] < pull.yaml\n\nReads EditPullRequestOption YAML from stdin.",
),
("pull", "merge") => Some(
"Usage: gotcha pull merge INDEX [OWNER/REPOSITORY] < merge.yaml\n\nReads MergePullRequestOption YAML from stdin, for example:\n Do: squash\n delete_branch_after_merge: true",
),
("pull", "commits") => Some("Usage: gotcha pull commits INDEX [OWNER/REPOSITORY]"),
("pull", "files") => Some("Usage: gotcha pull files INDEX [OWNER/REPOSITORY]"),
("pull", "reviews") => Some("Usage: gotcha pull reviews INDEX [OWNER/REPOSITORY]"),
_ => None,
}
}

View File

@@ -0,0 +1,258 @@
use gotcha_gitea::models::{
ChangedFile, Comment, Commit, Issue, Milestone, PullRequest, PullReview,
};
use gotcha_gitea::pull_state;
use crate::print_table;
pub(super) fn print_issues(issues: &[Issue]) {
print_table(
&[
("INDEX", 8),
("STATE", 10),
("TITLE", 60),
("MILESTONE", 30),
("LABELS", 30),
],
issues
.iter()
.map(|issue| {
vec![
issue.number.unwrap_or_default().to_string(),
issue.state.as_deref().unwrap_or("unknown").into(),
one_line(issue.title.as_deref().unwrap_or("")).into(),
issue
.milestone
.as_deref()
.and_then(|milestone| milestone.title.as_deref())
.unwrap_or("-")
.into(),
issue_labels(issue),
]
})
.collect(),
);
}
pub(super) fn print_issue(issue: &Issue) {
println!(
"#{} [{}] {}",
issue.number.unwrap_or_default(),
issue.state.as_deref().unwrap_or("unknown"),
issue.title.as_deref().unwrap_or("")
);
field("url", issue.html_url.as_deref());
field(
"author",
issue.user.as_deref().and_then(|user| user.login.as_deref()),
);
let milestone = issue
.milestone
.as_deref()
.and_then(|milestone| milestone.title.as_deref());
field("milestone", milestone);
let labels = issue_labels(issue);
field("labels", (!labels.is_empty()).then_some(labels.as_str()));
let assignees = issue
.assignees
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|user| user.login.as_deref())
.collect::<Vec<_>>()
.join(",");
field(
"assignees",
(!assignees.is_empty()).then_some(assignees.as_str()),
);
field("due", issue.due_date.as_deref());
field("created", issue.created_at.as_deref());
field("updated", issue.updated_at.as_deref());
println!("comments: {}", issue.comments.unwrap_or_default());
body(issue.body.as_deref());
}
fn issue_labels(issue: &Issue) -> String {
issue
.labels
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|label| label.name.as_deref())
.collect::<Vec<_>>()
.join(",")
}
pub(super) fn print_comments(comments: &[Comment]) {
for comment in comments {
println!(
"{} · {}",
comment
.user
.as_deref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown"),
comment.created_at.as_deref().unwrap_or("-")
);
body(comment.body.as_deref());
}
}
pub(super) fn print_milestones(milestones: &[Milestone]) {
print_table(
&[
("ID", 8),
("STATE", 10),
("TITLE", 60),
("DUE", 25),
("OPEN/CLOSED", 12),
],
milestones
.iter()
.map(|milestone| {
vec![
milestone.id.unwrap_or_default().to_string(),
milestone.state.as_deref().unwrap_or("unknown").into(),
one_line(milestone.title.as_deref().unwrap_or("")).into(),
milestone.due_on.as_deref().unwrap_or("-").into(),
format!(
"{}/{}",
milestone.open_issues.unwrap_or_default(),
milestone.closed_issues.unwrap_or_default()
),
]
})
.collect(),
);
}
pub(super) fn print_milestone(milestone: &Milestone) {
println!(
"{} [{}] {}",
milestone.id.unwrap_or_default(),
milestone.state.as_deref().unwrap_or("unknown"),
milestone.title.as_deref().unwrap_or("")
);
field("due", milestone.due_on.as_deref());
println!(
"issues: {} open, {} closed",
milestone.open_issues.unwrap_or_default(),
milestone.closed_issues.unwrap_or_default()
);
body(milestone.description.as_deref());
}
pub(super) fn print_pulls(pulls: &[PullRequest]) {
print_table(
&[("INDEX", 8), ("STATE", 10), ("TITLE", 70), ("UPDATED", 25)],
pulls
.iter()
.map(|pull| {
vec![
pull.number.unwrap_or_default().to_string(),
pull.state.as_deref().unwrap_or("unknown").into(),
one_line(pull.title.as_deref().unwrap_or("")).into(),
pull.updated_at.as_deref().unwrap_or("-").into(),
]
})
.collect(),
);
}
pub(super) fn print_pull(pull: &PullRequest) {
println!(
"#{} [{}] {}",
pull.number.unwrap_or_default(),
pull_state(pull),
pull.title.as_deref().unwrap_or("")
);
field("url", pull.html_url.as_deref());
field(
"author",
pull.user.as_deref().and_then(|user| user.login.as_deref()),
);
field("updated", pull.updated_at.as_deref());
println!("mergeable: {}", pull.mergeable.unwrap_or(false));
body(pull.body.as_deref());
}
pub(super) fn print_commits(commits: &[Commit]) {
print_table(
&[("SHA", 40), ("MESSAGE", 80)],
commits
.iter()
.map(|commit| {
vec![
commit.sha.as_deref().unwrap_or("unknown").into(),
one_line(
commit
.commit
.as_deref()
.and_then(|commit| commit.message.as_deref())
.unwrap_or(""),
)
.into(),
]
})
.collect(),
);
}
pub(super) fn print_files(files: &[ChangedFile]) {
print_table(
&[("STATUS", 12), ("CHANGES", 10), ("FILE", 90)],
files
.iter()
.map(|file| {
vec![
file.status.as_deref().unwrap_or("unknown").into(),
file.changes.unwrap_or_default().to_string(),
file.filename.as_deref().unwrap_or("").into(),
]
})
.collect(),
);
}
pub(super) fn print_reviews(reviews: &[PullReview]) {
print_table(
&[
("ID", 12),
("STATE", 20),
("REVIEWER", 25),
("SUBMITTED", 25),
],
reviews
.iter()
.map(|review| {
vec![
review.id.unwrap_or_default().to_string(),
review.state.as_deref().unwrap_or("unknown").into(),
review
.user
.as_deref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown")
.into(),
review.submitted_at.as_deref().unwrap_or("-").into(),
]
})
.collect(),
);
}
fn field(name: &str, value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
println!("{name}: {value}");
}
}
fn body(value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
println!("\n{value}");
}
}
fn one_line(value: &str) -> &str {
value.lines().next().unwrap_or_default()
}

View File

@@ -0,0 +1,59 @@
use super::*;
#[test]
fn parses_typed_yaml_and_rejects_bad_indexes() {
let issue: CreateIssueInput =
parse_yaml("title: Fix it\nbody: Details\nlabel_names: [bug]\nmilestone_name: Version 1\n")
.unwrap();
assert_eq!(issue.issue.title, "Fix it");
assert_eq!(issue.issue.body.as_deref(), Some("Details"));
assert_eq!(issue.label_names, ["bug"]);
assert_eq!(issue.milestone_name.as_deref(), Some("Version 1"));
let edit: EditIssueInput = parse_yaml(
"title: Updated\nadd_labels: [critical]\nremove_labels: [bug]\nadd_assignees: [alice]\n",
)
.unwrap();
assert_eq!(edit.issue.title.as_deref(), Some("Updated"));
assert_eq!(edit.add_labels, ["critical"]);
assert_eq!(edit.remove_labels, ["bug"]);
assert_eq!(edit.add_assignees, ["alice"]);
assert!(number("0").is_err());
assert!(number("7").is_ok());
}
#[test]
fn parses_issue_workflow_filters_and_bulk_state_targets() {
let options = parse_issue_list(
&[
"hugo/Gotcha",
"--state",
"all",
"--milestones",
"first feature complete release",
"-L",
"bug,critical",
"--page",
"2",
"--limit",
"50",
]
.map(str::to_owned),
)
.unwrap();
assert_eq!(options.repository.as_deref(), Some("hugo/Gotcha"));
assert_eq!(options.state, "all");
assert_eq!(
options.milestones.as_deref(),
Some("first feature complete release")
);
assert_eq!(options.labels.as_deref(), Some("bug,critical"));
assert_eq!((options.page, options.limit), (2, 50));
let (indexes, repository) =
issue_targets(&["4", "16", "hugo/Gotcha"].map(str::to_owned)).unwrap();
assert_eq!(indexes, [4, 16]);
assert_eq!(repository.as_deref(), Some("hugo/Gotcha"));
assert!(parse_issue_list(&["--state".into(), "invalid".into()]).is_err());
assert!(issue_targets(&[]).is_err());
}

View File

@@ -1,6 +1,6 @@
[package]
name = "gotcha_gitea"
version = "0.1.0"
version = "1.0.0"
description = "A small, reusable Rust client for the Gitea API"
edition.workspace = true
license.workspace = true

View File

@@ -187,7 +187,7 @@ pub fn parse_api_date(value: &str) -> Option<i64> {
(civil_from_days(days) == (year, month, day)).then_some(days * 86_400)
}
fn civil_from_days(days: i64) -> (i64, i64, i64) {
pub fn civil_from_days(days: i64) -> (i64, i64, i64) {
let days = days + 719_468;
let era = days.div_euclid(146_097);
let day_of_era = days - era * 146_097;
@@ -202,7 +202,7 @@ fn civil_from_days(days: i64) -> (i64, i64, i64) {
(year, month, day)
}
fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 {
pub fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 {
year -= i64::from(month <= 2);
let era = year.div_euclid(400);
let year_of_era = year - era * 400;

View File

@@ -4,7 +4,7 @@ use crate::{
CreateIssue, EditIssue, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, Page,
RepositoryId,
},
models,
models, positive,
};
use gitea_openapi::apis;
@@ -460,111 +460,8 @@ pub fn comment_can_edit(comment: &models::Comment, viewer_id: Option<i64>) -> bo
)
}
fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption {
models::CreateIssueOption {
body: Some(draft.body),
closed: Some(draft.closed),
due_date: draft.due_date,
labels: Some(draft.label_ids),
milestone: draft.milestone_id,
..models::CreateIssueOption::new(draft.title)
}
}
fn edit_issue_draft(draft: IssueDraft) -> EditIssue {
EditIssue {
option: models::EditIssueOption {
body: Some(draft.body),
due_date: draft.due_date.clone(),
milestone: Some(draft.milestone_id.unwrap_or_default()),
state: Some(if draft.closed { "closed" } else { "open" }.into()),
title: Some(draft.title),
unset_due_date: draft.due_date.is_none().then_some(true),
..models::EditIssueOption::new()
},
replace_labels: Some(draft.label_ids),
add_labels: Vec::new(),
remove_labels: Vec::new(),
add_assignees: Vec::new(),
milestone_name: None,
}
}
fn label_option(labels: &[i64]) -> models::IssueLabelsOption {
models::IssueLabelsOption {
labels: Some(
labels
.iter()
.copied()
.map(serde_json::Value::from)
.collect(),
),
}
}
fn comment_belongs_to_issue(comment: &models::Comment, number: i64) -> bool {
comment
.issue_url
.as_deref()
.map(|url| url.trim_end_matches('/'))
.and_then(|url| url.rsplit('/').next())
.and_then(|index| index.parse().ok())
== Some(number)
}
fn positive(value: i64, name: &str) -> Result<()> {
if value < 1 {
return Err(Error::InvalidInput(format!(
"{name} must be a positive integer"
)));
}
Ok(())
}
mod helpers;
use helpers::*;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scopes_comments_and_builds_label_payloads() {
let comment = models::Comment {
issue_url: Some("https://example.test/api/v1/repos/a/b/issues/7".into()),
..Default::default()
};
assert!(comment_belongs_to_issue(&comment, 7));
assert!(!comment_belongs_to_issue(&comment, 8));
assert!(!comment_can_edit(&comment, Some(1)));
let owned = models::Comment {
id: Some(3),
user: Some(Box::new(models::User {
id: Some(1),
..Default::default()
})),
..Default::default()
};
assert!(comment_can_edit(&owned, Some(1)));
assert_eq!(label_option(&[2, 4]).labels.unwrap().len(), 2);
}
#[test]
fn maps_issue_drafts_without_losing_clear_operations() {
let draft = IssueDraft {
title: "Title".into(),
body: "Body".into(),
label_ids: vec![2, 4],
milestone_id: None,
due_date: None,
closed: true,
};
let create = create_issue_option(draft.clone());
assert_eq!(create.title, "Title");
assert_eq!(create.body.as_deref(), Some("Body"));
assert_eq!(create.closed, Some(true));
assert_eq!(create.labels, Some(vec![2, 4]));
let edit = edit_issue_draft(draft);
assert_eq!(edit.option.milestone, Some(0));
assert_eq!(edit.option.state.as_deref(), Some("closed"));
assert_eq!(edit.option.unset_due_date, Some(true));
assert_eq!(edit.replace_labels, Some(vec![2, 4]));
}
}
mod tests;

View File

@@ -0,0 +1,56 @@
use crate::{
domain::{EditIssue, IssueDraft},
models,
};
pub(super) fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption {
models::CreateIssueOption {
body: Some(draft.body),
closed: Some(draft.closed),
due_date: draft.due_date,
labels: Some(draft.label_ids),
milestone: draft.milestone_id,
..models::CreateIssueOption::new(draft.title)
}
}
pub(super) fn edit_issue_draft(draft: IssueDraft) -> EditIssue {
EditIssue {
option: models::EditIssueOption {
body: Some(draft.body),
due_date: draft.due_date.clone(),
milestone: Some(draft.milestone_id.unwrap_or_default()),
state: Some(if draft.closed { "closed" } else { "open" }.into()),
title: Some(draft.title),
unset_due_date: draft.due_date.is_none().then_some(true),
..models::EditIssueOption::new()
},
replace_labels: Some(draft.label_ids),
add_labels: Vec::new(),
remove_labels: Vec::new(),
add_assignees: Vec::new(),
milestone_name: None,
}
}
pub(super) fn label_option(labels: &[i64]) -> models::IssueLabelsOption {
models::IssueLabelsOption {
labels: Some(
labels
.iter()
.copied()
.map(serde_json::Value::from)
.collect(),
),
}
}
pub(super) fn comment_belongs_to_issue(comment: &models::Comment, number: i64) -> bool {
comment
.issue_url
.as_deref()
.map(|url| url.trim_end_matches('/'))
.and_then(|url| url.rsplit('/').next())
.and_then(|index| index.parse().ok())
== Some(number)
}

View File

@@ -0,0 +1,44 @@
use super::*;
#[test]
fn scopes_comments_and_builds_label_payloads() {
let comment = models::Comment {
issue_url: Some("https://example.test/api/v1/repos/a/b/issues/7".into()),
..Default::default()
};
assert!(comment_belongs_to_issue(&comment, 7));
assert!(!comment_belongs_to_issue(&comment, 8));
assert!(!comment_can_edit(&comment, Some(1)));
let owned = models::Comment {
id: Some(3),
user: Some(Box::new(models::User {
id: Some(1),
..Default::default()
})),
..Default::default()
};
assert!(comment_can_edit(&owned, Some(1)));
assert_eq!(label_option(&[2, 4]).labels.unwrap().len(), 2);
}
#[test]
fn maps_issue_drafts_without_losing_clear_operations() {
let draft = IssueDraft {
title: "Title".into(),
body: "Body".into(),
label_ids: vec![2, 4],
milestone_id: None,
due_date: None,
closed: true,
};
let create = create_issue_option(draft.clone());
assert_eq!(create.title, "Title");
assert_eq!(create.body.as_deref(), Some("Body"));
assert_eq!(create.closed, Some(true));
assert_eq!(create.labels, Some(vec![2, 4]));
let edit = edit_issue_draft(draft);
assert_eq!(edit.option.milestone, Some(0));
assert_eq!(edit.option.state.as_deref(), Some("closed"));
assert_eq!(edit.option.unset_due_date, Some(true));
assert_eq!(edit.replace_labels, Some(vec![2, 4]));
}

View File

@@ -23,13 +23,31 @@ pub use activity::ActivityFilter;
pub use domain::{
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,
api_date, parse_api_date,
api_date, civil_from_days, days_from_civil, parse_api_date,
};
pub use issues::comment_can_edit;
pub use pulls::{PullFileSource, pull_file_source, pull_state};
pub type Result<T> = std::result::Result<T, Error>;
pub(crate) fn validate_page(page: i32, limit: i32) -> Result<()> {
if page < 1 || limit < 1 {
return Err(Error::InvalidInput(
"page and limit must be positive".into(),
));
}
Ok(())
}
pub(crate) fn positive(value: i64, name: &str) -> Result<()> {
if value < 1 {
return Err(Error::InvalidInput(format!(
"{name} must be a positive integer"
)));
}
Ok(())
}
#[derive(Debug)]
pub enum Error {
Configuration(String),

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeSet;
use crate::{
Client, Error, Result,
domain::{DEFAULT_PAGE_SIZE, Page, PullDetails, RepositoryId},
models,
models, positive, validate_page,
};
use gitea_openapi::apis;
@@ -323,21 +323,3 @@ impl Client {
.map_err(Error::generated)
}
}
fn validate_page(page: i32, limit: i32) -> Result<()> {
if page < 1 || limit < 1 {
return Err(Error::InvalidInput(
"page and limit must be positive".into(),
));
}
Ok(())
}
fn positive(value: i64, name: &str) -> Result<()> {
if value < 1 {
return Err(Error::InvalidInput(format!(
"{name} must be a positive integer"
)));
}
Ok(())
}

View File

@@ -8,7 +8,7 @@ use tokio::task::JoinSet;
use crate::{
Client, Error, Result,
domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, RepositoryId},
models,
models, validate_page,
};
use gitea_openapi::apis;
@@ -481,112 +481,5 @@ fn commit_parents(commit: &models::Commit) -> impl Iterator<Item = &str> {
.filter_map(|parent| parent.sha.as_deref())
}
fn validate_page(page: i32, limit: i32) -> Result<()> {
if page < 1 || limit < 1 {
return Err(Error::InvalidInput(
"page and limit must be positive".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn commit(sha: &str, parents: &[&str]) -> models::Commit {
models::Commit {
sha: Some(sha.into()),
parents: Some(
parents
.iter()
.map(|sha| models::CommitMeta {
sha: Some((*sha).into()),
..Default::default()
})
.collect(),
),
..Default::default()
}
}
#[test]
fn lays_out_shared_commit_graph() {
let rows = build_graph(
vec![
(
0,
"main".into(),
vec![
commit("merge", &["left", "right"]),
commit("left", &["base"]),
commit("base", &[]),
],
),
(
1,
"feature".into(),
vec![commit("right", &["base"]), commit("base", &[])],
),
],
PullMetadata::default(),
);
assert_eq!(rows.len(), 4);
assert!(rows.iter().any(|row| row.refs == ["main"]));
assert!(rows.iter().any(|row| row.refs == ["feature"]));
let merge = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("merge"))
.unwrap();
assert_eq!(merge.node_lane, Some(0));
assert_eq!(merge.bottom_connections, [1]);
let right = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("right"))
.unwrap();
assert_eq!(right.node_lane, Some(1));
let base = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("base"))
.unwrap();
assert_eq!(base.node_lane, Some(0));
assert_eq!(base.top_connections, [1]);
}
#[test]
fn labels_first_commit_of_pull_branch() {
let rows = build_graph(
vec![(
0,
"main".into(),
vec![
commit("merge", &["main", "head"]),
commit("head", &["first"]),
commit("first", &["base"]),
commit("main", &["base"]),
commit("base", &[]),
],
)],
PullMetadata {
tips: HashMap::from([("head".into(), vec!["feature".into()])]),
starts: vec![PullBranch {
head: "head".into(),
base: "base".into(),
label: "feature".into(),
}],
},
);
let first = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("first"))
.unwrap();
assert_eq!(first.branch_starts, ["feature"]);
assert!(
rows.iter()
.find(|row| row.commit.sha.as_deref() == Some("head"))
.unwrap()
.refs
.contains(&"feature".into())
);
}
}
mod tests;

View File

@@ -0,0 +1,97 @@
use super::*;
fn commit(sha: &str, parents: &[&str]) -> models::Commit {
models::Commit {
sha: Some(sha.into()),
parents: Some(
parents
.iter()
.map(|sha| models::CommitMeta {
sha: Some((*sha).into()),
..Default::default()
})
.collect(),
),
..Default::default()
}
}
#[test]
fn lays_out_shared_commit_graph() {
let rows = build_graph(
vec![
(
0,
"main".into(),
vec![
commit("merge", &["left", "right"]),
commit("left", &["base"]),
commit("base", &[]),
],
),
(
1,
"feature".into(),
vec![commit("right", &["base"]), commit("base", &[])],
),
],
PullMetadata::default(),
);
assert_eq!(rows.len(), 4);
assert!(rows.iter().any(|row| row.refs == ["main"]));
assert!(rows.iter().any(|row| row.refs == ["feature"]));
let merge = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("merge"))
.unwrap();
assert_eq!(merge.node_lane, Some(0));
assert_eq!(merge.bottom_connections, [1]);
let right = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("right"))
.unwrap();
assert_eq!(right.node_lane, Some(1));
let base = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("base"))
.unwrap();
assert_eq!(base.node_lane, Some(0));
assert_eq!(base.top_connections, [1]);
}
#[test]
fn labels_first_commit_of_pull_branch() {
let rows = build_graph(
vec![(
0,
"main".into(),
vec![
commit("merge", &["main", "head"]),
commit("head", &["first"]),
commit("first", &["base"]),
commit("main", &["base"]),
commit("base", &[]),
],
)],
PullMetadata {
tips: HashMap::from([("head".into(), vec!["feature".into()])]),
starts: vec![PullBranch {
head: "head".into(),
base: "base".into(),
label: "feature".into(),
}],
},
);
let first = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("first"))
.unwrap();
assert_eq!(first.branch_starts, ["feature"]);
assert!(
rows.iter()
.find(|row| row.commit.sha.as_deref() == Some("head"))
.unwrap()
.refs
.contains(&"feature".into())
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -257,31 +257,6 @@ void uniffi_gotcha_core_fn_free_gotchacore(uint64_t handle, RustCallStatus *_Non
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_CONSTRUCTOR_GOTCHACORE_NEW
uint64_t uniffi_gotcha_core_fn_constructor_gotchacore_new(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_index(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_name(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#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
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS
void uniffi_gotcha_core_fn_method_gotchacore_clear_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
void uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS
@@ -299,21 +274,31 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_diff(uint64_t ptr, RustB
uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer branch, RustBuffer path, uint32_t pages
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF
uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull_diff(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer path
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_contents(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS
void uniffi_gotcha_core_fn_method_gotchacore_clear_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_ISSUE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_ISSUE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_MILESTONE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_MILESTONE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page, RustBuffer filter
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, uint32_t page
@@ -339,6 +324,31 @@ int8_t uniffi_gotcha_core_fn_method_gotchacore_issue_filters_active(uint64_t ptr
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issues(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number, RustBuffer title, RustBuffer body, RustBuffer label_ids, RustBuffer milestone_id, RustBuffer due_date, int8_t closed
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer comment_id, RustBuffer body
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_CLOSED
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_CLOSED
uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_issue_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, int8_t closed
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
void uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer milestone, RustBuffer labels, RustBuffer search_text, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_MILESTONE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_MILESTONE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, uint32_t page
@@ -354,16 +364,26 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestone_editor(uint64_t ptr,
uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestones(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id, RustBuffer draft
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_milestone_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, int8_t closed
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
void uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL
uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, uint32_t page
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF
uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull_diff(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer path
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_FILTERS
uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull_filters(uint64_t ptr
@@ -379,34 +399,39 @@ int8_t uniffi_gotcha_core_fn_method_gotchacore_pull_filters_active(uint64_t ptr,
uint64_t uniffi_gotcha_core_fn_method_gotchacore_pulls(uint64_t ptr, uint32_t page
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_FILTERS
void uniffi_gotcha_core_fn_method_gotchacore_set_pull_filters(uint64_t ptr, RustBuffer milestone, RustBuffer search_text, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORIES
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORIES
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repositories(uint64_t ptr, uint32_t page, RustBuffer pane
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_contents(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer pane, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_index(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number, RustBuffer title, RustBuffer body, RustBuffer label_ids, RustBuffer milestone_id, RustBuffer due_date, int8_t closed
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_name(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer comment_id, RustBuffer body
#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
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id, RustBuffer draft
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page, RustBuffer filter
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
@@ -424,31 +449,11 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_servers(uint64_t ptr, RustCal
void uniffi_gotcha_core_fn_method_gotchacore_set_appearance(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_CLOSED
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_CLOSED
uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_issue_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, int8_t closed
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
void uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer milestone, RustBuffer labels, RustBuffer search_text, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_STATUS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_STATUS
void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_milestone_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, int8_t closed
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_FILTERS
void uniffi_gotcha_core_fn_method_gotchacore_set_pull_filters(uint64_t ptr, RustBuffer milestone, RustBuffer search_text, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
void uniffi_gotcha_core_fn_method_gotchacore_set_pull_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
@@ -464,11 +469,6 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_settings(uint64_t ptr, RustCa
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_startup_error(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer pane, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC
RustBuffer ffi_gotcha_core_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status
@@ -727,36 +727,6 @@ void ffi_gotcha_core_rust_future_free_void(uint64_t handle
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID
void ffi_gotcha_core_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_index(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_name(void
);
#endif
#ifndef 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
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_clear_issue_filters(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DETAILS
@@ -775,24 +745,36 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commit_diff(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMITS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commits(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull_diff(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_contents(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_clear_issue_filters(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_ISSUE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_ISSUE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_issue(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_MILESTONE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_MILESTONE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_milestone(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE
@@ -823,6 +805,36 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue_filters_active(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUES
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issues(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_CLOSED
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_CLOSED
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_closed(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_MILESTONE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_MILESTONE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_milestone(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONE
@@ -843,15 +855,27 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_milestones(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull(void
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_milestone(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull_diff(void
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull(void
);
#endif
@@ -871,6 +895,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull_filters_active(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULLS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pulls(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORIES
@@ -879,33 +909,33 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repositories(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_contents(void
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_toggle_favorite(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_index(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_name(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment(void
#ifndef 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
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_milestone(void
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void
);
#endif
@@ -925,36 +955,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_servers(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_APPEARANCE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_appearance(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_CLOSED
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_CLOSED
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_closed(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS
@@ -973,12 +979,6 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_settings(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_STARTUP_ERROR
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_startup_error(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_toggle_favorite(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_CONSTRUCTOR_GOTCHACORE_NEW

View File

@@ -7,35 +7,57 @@
objects = {
/* Begin PBXBuildFile section */
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E10787062D1175125BB4C /* ContentScreens.swift */; };
0020E57CC7B0C5CBBF04291C /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */; };
130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */; };
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */; };
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; };
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.swift */; };
25B30DCE869158C89021EFC6 /* DiffScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E0F726C9668B50418D9ADF /* DiffScreen.swift */; };
267E72DD7E12E3082974337E /* IssueActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17264C5C909501D8ADEC00B7 /* IssueActions.swift */; };
33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B8279189276A084B69D7D0 /* PullScreens.swift */; };
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; };
4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */; };
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */; };
7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; };
8A42B4319AF343566D77D12A /* WorkItemDetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */; };
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; };
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */; };
9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13A39F3C39C1F353D58C307 /* ServerScreens.swift */; };
A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */; };
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */; };
CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */; };
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722405F999984C7CBE838711 /* IssueEditorViewController.swift */; };
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
D78A121E9ADA0B72E2CF94DC /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8AD463F3AC817A44306C2297 /* MarkdownUI */; };
DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */; };
DFB34862C386662D6EE7E6BE /* RepositoryListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */; };
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; };
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61515849F6AACD721FE915C /* AppContext.swift */; };
F55A89489B2758D694F3B27D /* Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75B3E4FFB9C9992517C4D69 /* Support.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryListScreen.swift; sourceTree = "<group>"; };
0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkItemDetailScreens.swift; sourceTree = "<group>"; };
17264C5C909501D8ADEC00B7 /* IssueActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueActions.swift; sourceTree = "<group>"; };
181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryFileScreens.swift; sourceTree = "<group>"; };
41E0F726C9668B50418D9ADF /* DiffScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffScreen.swift; sourceTree = "<group>"; };
5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; };
64B529D84926EEEDC163A929 /* MilestoneScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneScreens.swift; sourceTree = "<group>"; };
722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = "<group>"; };
7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitScreens.swift; sourceTree = "<group>"; };
81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailScreen.swift; sourceTree = "<group>"; };
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = "<group>"; };
99B8279189276A084B69D7D0 /* PullScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullScreens.swift; sourceTree = "<group>"; };
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = "<group>"; };
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = "<group>"; };
C13A39F3C39C1F353D58C307 /* ServerScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerScreens.swift; sourceTree = "<group>"; };
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = "<group>"; };
CE5E10787062D1175125BB4C /* ContentScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScreens.swift; sourceTree = "<group>"; };
D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
F61515849F6AACD721FE915C /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = "<group>"; };
F75B3E4FFB9C9992517C4D69 /* Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Support.swift; sourceTree = "<group>"; };
FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDirectoryScreen.swift; sourceTree = "<group>"; };
FE12480725C13293FAFDDA52 /* gotcha_core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = gotcha_core.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -58,13 +80,23 @@
F61515849F6AACD721FE915C /* AppContext.swift */,
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */,
CE5E10787062D1175125BB4C /* ContentScreens.swift */,
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */,
81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */,
7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */,
41E0F726C9668B50418D9ADF /* DiffScreen.swift */,
D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */,
17264C5C909501D8ADEC00B7 /* IssueActions.swift */,
722405F999984C7CBE838711 /* IssueEditorViewController.swift */,
AC828923A1E11A58AE348A74 /* ListScreens.swift */,
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */,
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */,
64B529D84926EEEDC163A929 /* MilestoneScreens.swift */,
99B8279189276A084B69D7D0 /* PullScreens.swift */,
FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */,
181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */,
000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */,
C13A39F3C39C1F353D58C307 /* ServerScreens.swift */,
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */,
F75B3E4FFB9C9992517C4D69 /* Support.swift */,
0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */,
);
path = Sources;
sourceTree = "<group>";
@@ -81,6 +113,7 @@
isa = PBXGroup;
children = (
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */,
F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */,
94721140EFE7F8E7CD0F5C0B /* Generated */,
710A50F51478401FC642E6E3 /* Sources */,
F059299C038F3CAFCE470831 /* Products */,
@@ -164,6 +197,7 @@
buildActionMask = 2147483647;
files = (
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */,
A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -200,13 +234,23 @@
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */,
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */,
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */,
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */,
CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */,
4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */,
25B30DCE869158C89021EFC6 /* DiffScreen.swift in Sources */,
0020E57CC7B0C5CBBF04291C /* HomeScreen.swift in Sources */,
267E72DD7E12E3082974337E /* IssueActions.swift in Sources */,
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */,
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */,
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */,
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */,
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */,
33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */,
130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */,
DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */,
DFB34862C386662D6EE7E6BE /* RepositoryListScreen.swift in Sources */,
9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */,
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */,
F55A89489B2758D694F3B27D /* Support.swift in Sources */,
8A42B4319AF343566D77D12A /* WorkItemDetailScreens.swift in Sources */,
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -221,6 +265,7 @@
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = MU22FMRGK8;
INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
@@ -229,6 +274,7 @@
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
SDKROOT = iphoneos;
@@ -245,6 +291,7 @@
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = MU22FMRGK8;
INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
@@ -253,6 +300,7 @@
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
SDKROOT = iphoneos;

View File

@@ -17,9 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>

14
ios/PrivacyInfo.xcprivacy Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict>
</plist>

View File

@@ -73,7 +73,7 @@ final class AppContext {
func route(_ activity: ActivityRow) {
switch activity.target {
case "repository":
case .repository:
tabs.selectedIndex = 1
navigationControllers[1].pushViewController(
IssuesViewController(
@@ -83,7 +83,7 @@ final class AppContext {
),
animated: true
)
case "issue":
case .issue:
tabs.selectedIndex = 1
navigationControllers[1].pushViewController(
IssueViewController(
@@ -94,7 +94,7 @@ final class AppContext {
),
animated: true
)
case "pull":
case .pullRequest:
tabs.selectedIndex = 3
navigationControllers[3].pushViewController(
PullViewController(
@@ -105,7 +105,7 @@ final class AppContext {
),
animated: true
)
case "commit":
case .commit:
tabs.selectedIndex = 2
navigationControllers[2].pushViewController(
FilesViewController(
@@ -116,7 +116,7 @@ final class AppContext {
),
animated: true
)
default:
case .none:
break
}
}

View File

@@ -0,0 +1,178 @@
import UIKit
private final class CommitHeaderView: UIView {
private let stack = UIStackView()
private let titleLabel = UILabel()
private var descriptionView: UIView?
private let metadataStack = UIStackView()
private let bottomSeparator = separator()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .systemBackground
directionalLayoutMargins = .init(top: 20, leading: 20, bottom: 20, trailing: 20)
titleLabel.font = .preferredFont(forTextStyle: .title2)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.numberOfLines = 0
titleLabel.accessibilityTraits = .header
metadataStack.axis = .vertical
metadataStack.spacing = 10
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(metadataStack)
addSubview(stack)
bottomSeparator.translatesAutoresizingMaskIntoConstraints = false
addSubview(bottomSeparator)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
stack.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
stack.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
bottomSeparator.leadingAnchor.constraint(equalTo: leadingAnchor),
bottomSeparator.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomSeparator.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func configure(_ page: CommitDetailsPage) {
titleLabel.text = page.title
descriptionView?.removeFromSuperview()
if !page.description.isEmpty {
let view = markdownView(page.description)
stack.insertArrangedSubview(view, at: 1)
descriptionView = view
}
metadataStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for metadata in page.metadata {
let label = UILabel()
label.text = metadata.label.uppercased()
label.font = .preferredFont(forTextStyle: .caption2)
label.adjustsFontForContentSizeCategory = true
label.textColor = .secondaryLabel
let value = UILabel()
value.text = metadata.value
value.font = metadata.monospaced
? UIFontMetrics(forTextStyle: .subheadline).scaledFont(
for: .monospacedSystemFont(ofSize: 15, weight: .regular)
)
: .preferredFont(forTextStyle: .subheadline)
value.adjustsFontForContentSizeCategory = true
value.numberOfLines = 0
value.lineBreakMode = metadata.monospaced ? .byCharWrapping : .byWordWrapping
let row = UIStackView(arrangedSubviews: [label, value])
row.axis = .vertical
row.spacing = 2
row.isAccessibilityElement = true
row.accessibilityLabel = "\(metadata.label): \(metadata.value)"
metadataStack.addArrangedSubview(row)
}
}
}
@MainActor
final class FilesViewController: RefreshingTableViewController {
private let context: AppContext
private let owner: String
private let repository: String
private let sha: String
private let branch: String?
private let commitHeader = CommitHeaderView()
private var page: CommitDetailsPage?
init(context: AppContext, owner: String, repository: String, sha: String, branch: String? = nil) {
self.context = context
self.owner = owner
self.repository = repository
self.sha = sha
self.branch = branch
super.init()
title = "Changed Files"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
loadContent(refreshing: false)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard let header = tableView.tableHeaderView else { return }
let size = header.systemLayoutSizeFitting(
CGSize(width: tableView.bounds.width, height: 0),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
guard header.frame.height != size.height else { return }
header.frame.size.height = size.height
tableView.tableHeaderView = header
}
override func loadContent(refreshing: Bool) {
beginLoading(refreshing: refreshing)
loadingTask?.cancel()
loadingTask = Task {
do {
let page = try await context.core.commitDetails(
owner: owner,
repository: repository,
sha: sha,
branch: branch
)
self.page = page
commitHeader.configure(page)
tableView.tableHeaderView = commitHeader
tableView.reloadData()
tableView.backgroundView = page.files.isEmpty
? EmptyBackgroundView(title: "No changed files", detail: "This commit does not contain file changes.")
: nil
} catch {
if !Task.isCancelled { show(error: error) }
}
endLoading()
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
page?.files.count ?? 0
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "file")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "file")
let row = page!.files[indexPath.row]
configureTextCell(cell, title: row.path, detail: row.status)
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let row = page?.files[indexPath.row] else { return }
navigationController?.pushViewController(
DiffViewController(
context: context,
source: .commit(
owner: owner,
repository: repository,
sha: sha,
path: row.path
)
),
animated: true
)
}
}

View File

@@ -0,0 +1,312 @@
import UIKit
@MainActor
final class CommitsViewController: RefreshingTableViewController {
private enum Mode: Int { case history, files }
private let context: AppContext
private let owner: String
private let repository: String
private var page: CommitPage?
private var contents: [RepositoryContentRow] = []
private var branch: String?
private var mode = Mode.history
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String) {
self.context = context
self.owner = owner
self.repository = repository
super.init()
title = repository
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
let modeControl = UISegmentedControl(items: ["History", "Files"])
modeControl.selectedSegmentIndex = mode.rawValue
modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
modeControl.accessibilityLabel = "Repository view"
navigationItem.titleView = modeControl
updateBranchMenu()
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
guard mode == .history else { return }
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
switch mode {
case .history:
page = try await context.core.commits(
owner: owner,
repository: repository,
branch: branch,
path: "",
pages: requestedPage
)
currentPage = requestedPage
finishPagination(hasMore: page?.hasMore ?? false)
updateBranchMenu()
case .files:
contents = try await context.core.repositoryContents(
owner: owner,
repository: repository,
path: ""
)
}
tableView.reloadData()
updateEmptyView()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
mode == .history ? page?.commits.count ?? 0 : contents.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
switch mode {
case .history:
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) }
return cell
case .files:
let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
configureRepositoryContentCell(cell, row: contents[indexPath.row])
return cell
}
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
mode == .history ? 86 : UITableView.automaticDimension
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
switch mode {
case .history:
guard let row = page?.commits[indexPath.row] else { return }
navigationController?.pushViewController(
FilesViewController(
context: context,
owner: owner,
repository: repository,
sha: row.sha,
branch: row.branchLabel ?? branch
),
animated: true
)
case .files:
showRepositoryContent(
contents[indexPath.row],
context: context,
owner: owner,
repository: repository,
navigationController: navigationController
)
}
}
@objc private func modeChanged(_ sender: UISegmentedControl) {
guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return }
self.mode = mode
navigationItem.rightBarButtonItem = nil
if mode == .history { updateBranchMenu() }
tableView.reloadData()
loadContent(refreshing: false)
}
private func updateEmptyView() {
switch mode {
case .history:
tableView.backgroundView = page?.commits.isEmpty == true
? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.")
: nil
case .files:
tableView.backgroundView = contents.isEmpty
? EmptyBackgroundView(title: "No files", detail: "This repository is empty.")
: nil
}
}
private func updateBranchMenu() {
let branches = page?.branches ?? []
let choices: [String?] = [nil] + branches.map(Optional.some)
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: branch ?? "All",
menu: UIMenu(children: choices.map { choice in
UIAction(
title: choice ?? "All",
state: choice == branch ? .on : .off
) { [weak self] _ in
self?.branch = choice
self?.updateBranchMenu()
self?.loadContent(refreshing: false)
}
})
)
}
}
final class CommitCell: UITableViewCell {
private static let laneOrigin: CGFloat = 12
private static let laneSpacing: CGFloat = 12
private var row: CommitRow?
private var laneCount: UInt32 = 0
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
accessoryType = .disclosureIndicator
backgroundColor = .systemBackground
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func configure(_ row: CommitRow, laneCount: UInt32) {
self.row = row
self.laneCount = laneCount
var content = defaultContentConfiguration()
content.directionalLayoutMargins.leading = laneCount == 0
? 0
: CGFloat(laneCount) * Self.laneSpacing + 10
content.text = row.title
if let branch = row.branchLabel {
let text = NSMutableAttributedString(string: "\(branch)\n\(row.detail)")
text.addAttribute(
.foregroundColor,
value: UIColor.tintColor,
range: NSRange(location: 0, length: (branch as NSString).length)
)
content.secondaryAttributedText = text
} else {
content.secondaryText = row.detail
}
content.secondaryTextProperties.numberOfLines = 2
contentConfiguration = content
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let row, laneCount > 0, let context = UIGraphicsGetCurrentContext() else { return }
let centerY = bounds.midY
let nodeX = row.nodeLane.map(laneX)
context.setLineWidth(3)
context.setLineCap(.round)
context.setLineJoin(.round)
for lane in 0..<laneCount {
if row.topLanes.contains(lane) {
let start = CGPoint(x: laneX(lane), y: 0)
if row.topConnections.contains(lane), let nodeX {
strokeCurve(
context,
from: start,
to: CGPoint(x: nodeX, y: centerY),
color: laneColor(lane)
)
} else {
strokeLine(
context,
from: start,
to: CGPoint(x: start.x, y: centerY),
color: laneColor(lane)
)
}
}
if row.bottomLanes.contains(lane) {
let end = CGPoint(x: laneX(lane), y: bounds.height)
if !row.bottomConnections.contains(lane) || row.topLanes.contains(lane) {
strokeLine(
context,
from: CGPoint(x: end.x, y: centerY),
to: end,
color: laneColor(lane)
)
}
if row.bottomConnections.contains(lane), let nodeX {
strokeCurve(
context,
from: CGPoint(x: nodeX, y: centerY),
to: end,
color: laneColor(lane)
)
}
}
}
if let lane = row.nodeLane, let nodeX {
context.setFillColor(laneColor(lane).cgColor)
context.fillEllipse(
in: CGRect(x: nodeX - 5, y: centerY - 5, width: 10, height: 10)
)
}
}
private func laneX(_ lane: UInt32) -> CGFloat {
Self.laneOrigin + CGFloat(lane) * Self.laneSpacing
}
private func strokeLine(
_ context: CGContext,
from start: CGPoint,
to end: CGPoint,
color: UIColor
) {
context.setStrokeColor(color.cgColor)
context.move(to: start)
context.addLine(to: end)
context.strokePath()
}
private func strokeCurve(
_ context: CGContext,
from start: CGPoint,
to end: CGPoint,
color: UIColor
) {
let middleY = (start.y + end.y) / 2
context.setStrokeColor(color.cgColor)
context.move(to: start)
context.addCurve(
to: end,
control1: CGPoint(x: start.x, y: middleY),
control2: CGPoint(x: end.x, y: middleY)
)
context.strokePath()
}
private func laneColor(_ lane: UInt32) -> UIColor {
UIColor(hue: CGFloat((Double(lane) * 137.508).truncatingRemainder(dividingBy: 360)) / 360,
saturation: 0.78, brightness: 0.78, alpha: 1)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
import UIKit
enum DiffSource {
case commit(owner: String, repository: String, sha: String, path: String)
case pull(owner: String, repository: String, number: Int64, path: String)
}
@MainActor
final class DiffViewController: UIViewController {
private let context: AppContext
private let source: DiffSource
private let codeView = CodeScrollView()
private let spinner = UIActivityIndicatorView(style: .medium)
private var loadingTask: Task<Void, Never>?
init(context: AppContext, source: DiffSource) {
self.context = context
self.source = source
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
codeView.refreshControl = UIRefreshControl()
codeView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged)
view.addSubview(codeView)
NSLayoutConstraint.activate([
codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
beginNavigationLoading(spinner)
reload()
}
deinit { loadingTask?.cancel() }
@objc private func reload() {
loadingTask?.cancel()
loadingTask = Task {
do {
let page: DiffPage
switch source {
case let .commit(owner, repository, sha, path):
page = try await context.core.commitDiff(
owner: owner,
repository: repository,
sha: sha,
path: path
)
case let .pull(owner, repository, number, path):
page = try await context.core.pullDiff(
owner: owner,
repository: repository,
number: number,
path: path
)
}
title = page.title
codeView.display(diffText(page))
} catch {
if !Task.isCancelled { show(error: error) }
}
endNavigationLoading(spinner)
codeView.refreshControl?.endRefreshing()
}
}
private func diffText(_ page: DiffPage) -> NSAttributedString {
let output = NSMutableAttributedString()
let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
for line in page.lines {
let text = String(format: "%4@ %4@ %@\n", line.oldNumber, line.newNumber, line.text)
let color: UIColor
switch line.kind {
case .addition: color = UIColor.systemGreen.withAlphaComponent(0.16)
case .removal: color = UIColor.systemRed.withAlphaComponent(0.16)
case .hunk: color = UIColor.systemBlue.withAlphaComponent(0.14)
case .header: color = UIColor.systemGray.withAlphaComponent(0.14)
case .context: color = .clear
}
output.append(NSAttributedString(string: text, attributes: [
.font: font,
.foregroundColor: UIColor.label,
.backgroundColor: color,
]))
}
return output
}
}

View File

@@ -0,0 +1,295 @@
import UIKit
@MainActor
final class HomeViewController: RefreshingTableViewController {
private enum ActivityFilter: Int {
case all
case issues
case pulls
var coreValue: HomeActivityFilter {
switch self {
case .all: return .all
case .issues: return .issues
case .pulls: return .pullRequests
}
}
}
private let context: AppContext
private var page: HomePage?
private var filter = ActivityFilter.all
private var nextPage: UInt32?
private var activities: [ActivityRow] { page?.activities ?? [] }
init(context: AppContext) {
self.context = context
super.init()
title = "Home"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
let settings = UIBarButtonItem(
image: context.symbol("gearshape"),
primaryAction: UIAction { [weak self] _ in
guard let self else { return }
self.navigationController?.pushViewController(
SettingsViewController(context: self.context),
animated: true
)
}
)
settings.accessibilityLabel = "Settings"
navigationItem.rightBarButtonItem = settings
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard page == nil else { return }
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
guard context.core.activeServerIndex() != nil else {
tableView.backgroundView = EmptyBackgroundView(
title: "No server selected",
detail: "Open Issues or Repos to select a server or add your first one."
)
refreshControl?.endRefreshing()
return
}
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
guard let nextPage else { return }
loadPage(nextPage, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.home(
page: requestedPage,
filter: filter.coreValue
)
if requestedPage == 1 {
page = result
} else {
page?.activities.append(contentsOf: result.activities)
page?.nextPage = result.nextPage
}
nextPage = result.nextPage
finishPagination(hasMore: result.nextPage != nil)
title = page?.serverName
if requestedPage == 1 { tableView.tableHeaderView = page.map { page in
HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in
guard let self, let filter = ActivityFilter(rawValue: index) else { return }
guard filter != self.filter else { return }
self.filter = filter
self.loadPage(1, refreshing: false)
}
} }
updateActivities()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
activities.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
let row = activities[indexPath.row]
configureTextCell(
cell,
title: row.title,
detail: "\(row.detail)\n\(row.meta)",
image: context.symbol(symbolName(for: row.icon))
)
cell.accessoryType = row.target == .none ? .none : .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
92
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
context.route(activities[indexPath.row])
}
private func updateActivities() {
tableView.reloadData()
tableView.backgroundView = activities.isEmpty
? EmptyBackgroundView(
title: "No matching activity",
detail: "This server has no recent activity of the selected type."
)
: nil
}
private func symbolName(for icon: ActivityIcon) -> String {
switch icon {
case .pullRequest: return "arrow.triangle.pull"
case .issue: return "exclamationmark.circle"
case .branch: return "arrow.triangle.branch"
case .tag: return "tag"
case .push: return "arrow.up.circle"
case .release: return "shippingbox"
case .repository: return "books.vertical"
}
}
}
final class HeatmapView: UIView {
private let cells: [HeatCell]
private let calendar = Calendar(identifier: .gregorian)
init(page: HomePage, selectedFilter: Int, onFilter: @escaping (Int) -> Void) {
cells = page.heatCells
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
backgroundColor = .systemBackground
let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22))
title.text = "Activity · last 9 months"
title.font = .preferredFont(forTextStyle: .subheadline)
title.textColor = .secondaryLabel
addSubview(title)
let total = UILabel(frame: CGRect(x: 16, y: 108, width: 300, height: 18))
total.text = "\(page.contributionCount) contributions"
total.font = .preferredFont(forTextStyle: .caption1)
total.textColor = .tertiaryLabel
addSubview(total)
let filters = UIStackView()
filters.axis = .horizontal
filters.spacing = 4
filters.translatesAutoresizingMaskIntoConstraints = false
let filterItems = [
("clock", "clock.fill", "All activity"),
("exclamationmark.circle", "exclamationmark.circle.fill", "Issues"),
("arrow.triangle.pull", "arrow.triangle.pull", "Pull requests"),
]
for (index, item) in filterItems.enumerated() {
let button = UIButton(type: .custom, primaryAction: UIAction { action in
guard
let button = action.sender as? UIButton,
let stack = button.superview as? UIStackView
else { return }
for case let item as UIButton in stack.arrangedSubviews {
item.isSelected = item === button
item.tintColor = item.isSelected ? .tintColor : .secondaryLabel
item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button
}
onFilter(button.tag)
})
button.tag = index
button.setImage(UIImage(systemName: item.0), for: .normal)
button.setImage(UIImage(systemName: item.1), for: .selected)
button.isSelected = index == selectedFilter
button.tintColor = button.isSelected ? .tintColor : .secondaryLabel
button.accessibilityLabel = item.2
button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
filters.addArrangedSubview(button)
}
addSubview(filters)
NSLayoutConstraint.activate([
filters.centerXAnchor.constraint(equalTo: centerXAnchor),
filters.topAnchor.constraint(equalTo: topAnchor, constant: 132),
filters.heightAnchor.constraint(equalToConstant: 44),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func draw(_ rect: CGRect) {
guard
let first = cells.first,
let last = cells.last
else { return }
let firstDate = Date(timeIntervalSince1970: TimeInterval(first.timestamp))
let lastDate = Date(timeIntervalSince1970: TimeInterval(last.timestamp))
guard let firstWeek = calendar.dateInterval(of: .weekOfYear, for: firstDate)?.start else {
return
}
let monthFormatter = DateFormatter()
monthFormatter.dateFormat = "MMM"
let months = cells.reduce(into: [Date]()) { result, cell in
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
let month = calendar.date(
from: calendar.dateComponents([.year, .month], from: date)
) ?? date
if result.last != month { result.append(month) }
}
let weekCount = CGFloat(
(calendar.dateComponents([.day], from: firstWeek, to: lastDate).day ?? 0) / 7 + 1
)
let monthGap: CGFloat = 4
let monthGaps = CGFloat(max(months.count - 1, 0)) * monthGap
let width = max(4, min(6, (bounds.width - 32 - monthGaps) / weekCount - 1))
let gap = width + 1
let graphWidth = (weekCount - 1) * gap + width + monthGaps
let graphOrigin = max(0, (bounds.width - graphWidth) / 2)
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.preferredFont(forTextStyle: .caption2),
.foregroundColor: UIColor.secondaryLabel,
]
for (index, month) in months.enumerated() {
let days = calendar.dateComponents([.day], from: firstWeek, to: month).day ?? 0
monthFormatter.string(from: month).draw(
at: CGPoint(
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(index) * monthGap,
y: 34
),
withAttributes: labelAttributes
)
}
for cell in cells {
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
let days = calendar.dateComponents([.day], from: firstWeek, to: date).day ?? 0
let month = calendar.date(
from: calendar.dateComponents([.year, .month], from: date)
) ?? date
let monthIndex = months.firstIndex(of: month) ?? 0
let colors: [UIColor] = [
.systemGray5,
UIColor(red: 0.72, green: 0.85, blue: 0.96, alpha: 1),
UIColor(red: 0.45, green: 0.71, blue: 0.91, alpha: 1),
UIColor(red: 0.15, green: 0.55, blue: 0.83, alpha: 1),
UIColor(red: 0.04, green: 0.41, blue: 0.72, alpha: 1),
]
colors[Int(min(cell.level, 4))].setFill()
UIBezierPath(
roundedRect: CGRect(
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(monthIndex) * monthGap,
y: 48 + CGFloat(calendar.component(.weekday, from: date) - 1) * gap,
width: width,
height: width
),
cornerRadius: 1
).fill()
}
}
}

View File

@@ -0,0 +1,113 @@
import UIKit
extension UIViewController {
func promptForSearchText(title: String, current: String, apply: @escaping (String) -> Void) {
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
alert.addTextField { field in
field.text = current
field.placeholder = "Search text"
field.accessibilityLabel = "Search text"
field.clearButtonMode = .whileEditing
field.returnKeyType = .search
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Apply", style: .default) { [weak alert] _ in
apply(alert?.textFields?.first?.text ?? "")
})
present(alert, animated: true)
}
}
@MainActor
protocol IssueSwipeActionHost: AnyObject {
var context: AppContext { get }
var owner: String { get }
var repository: String { get }
var issueMutationTask: Task<Void, Never>? { get set }
func reloadIssuesAfterMutation()
}
extension IssueSwipeActionHost where Self: UIViewController {
func issueSwipeActions(for row: IssueRow) -> UISwipeActionsConfiguration {
let close = row.state != .closed
let stateAction = UIContextualAction(
style: .normal,
title: close ? "Close" : "Open"
) { [weak self] _, _, completion in
self?.setIssue(row, closed: close, completion: completion) ?? completion(false)
}
stateAction.image = context.symbol(close ? "checkmark.circle" : "arrow.uturn.left.circle")
stateAction.backgroundColor = close ? .systemPurple : .systemGreen
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
[weak self] _, _, completion in
self?.confirmDeleteIssue(row, completion: completion) ?? completion(false)
}
deleteAction.image = context.symbol("trash")
let configuration = UISwipeActionsConfiguration(actions: [stateAction, deleteAction])
configuration.performsFirstActionWithFullSwipe = true
return configuration
}
private func setIssue(
_ row: IssueRow,
closed: Bool,
completion: @escaping (Bool) -> Void
) {
issueMutationTask?.cancel()
issueMutationTask = Task {
do {
try await context.core.setIssueClosed(
owner: owner,
repository: repository,
number: row.number,
closed: closed
)
guard !Task.isCancelled else {
completion(false)
return
}
completion(true)
reloadIssuesAfterMutation()
} catch {
completion(false)
if !Task.isCancelled { show(error: error) }
}
}
}
private func confirmDeleteIssue(_ row: IssueRow, completion: @escaping (Bool) -> Void) {
let alert = UIAlertController(
title: "Delete Issue #\(row.number)?",
message: "\(row.title)” will be permanently deleted. This cant be undone.",
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.issueMutationTask?.cancel()
self.issueMutationTask = Task {
do {
try await self.context.core.deleteIssue(
owner: self.owner,
repository: self.repository,
number: row.number
)
guard !Task.isCancelled else {
completion(false)
return
}
completion(true)
self.reloadIssuesAfterMutation()
} catch {
completion(false)
if !Task.isCancelled { self.show(error: error) }
}
}
})
present(alert, animated: true)
}
}

View File

@@ -0,0 +1,439 @@
import UIKit
@MainActor
final class IssuesViewController: RefreshingTableViewController, IssueSwipeActionHost {
let context: AppContext
let owner: String
let repository: String
private var rows: [IssueRow] = []
private var filterOptions: IssueFilterOptions?
private var filterTask: Task<Void, Never>?
var issueMutationTask: Task<Void, Never>?
private var currentPage: UInt32 = 0
private lazy var filterButton = UIBarButtonItem(
image: context.symbol("line.3.horizontal.decrease.circle")
)
init(context: AppContext, owner: String, repository: String) {
self.context = context
self.owner = owner
self.repository = repository
super.init()
title = repository
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
deinit {
filterTask?.cancel()
issueMutationTask?.cancel()
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
let addButton = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(createIssue)
)
addButton.accessibilityLabel = "New issue"
navigationItem.rightBarButtonItems = [addButton, filterButton]
updateFilterMenu()
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
loadFilterOptions()
loadIssues(refreshing: refreshing)
}
private func loadIssues(refreshing: Bool) {
loadIssues(page: 1, refreshing: refreshing)
}
override func loadMoreContent() {
loadIssues(page: currentPage + 1, refreshing: false)
}
private func loadIssues(page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.issues(
owner: owner,
repository: repository,
page: page
)
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
updateEmptyState()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if page == 1 { endLoading() }
}
}
private func loadFilterOptions() {
filterTask?.cancel()
filterTask = Task {
do {
let options = try await context.core.issueFilters(
owner: owner,
repository: repository
)
guard !Task.isCancelled else { return }
filterOptions = options
updateFilterMenu()
updateEmptyState()
} catch {
if !Task.isCancelled { show(error: error) }
}
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell
cell.configure(rows[indexPath.row])
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
navigationController?.pushViewController(
IssueViewController(
context: context,
owner: owner,
repository: repository,
number: rows[indexPath.row].number
),
animated: true
)
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
issueSwipeActions(for: rows[indexPath.row])
}
func reloadIssuesAfterMutation() {
loadIssues(refreshing: false)
}
private func updateFilterMenu() {
let current = context.core.settings().issueStatus
let status = UIMenu(
title: "Status",
image: filterMenuImage("circle.lefthalf.filled", active: current != "open"),
options: .singleSelection,
children: ["open", "closed"].map { status in
UIAction(
title: status.capitalized,
state: current == status ? .on : .off
) { [weak self] _ in
guard let self else { return }
do {
try self.context.core.setIssueStatus(status: status)
self.updateFilterMenu()
self.loadIssues(refreshing: false)
} catch {
self.show(error: error)
}
}
}
)
var children: [UIMenuElement] = [status]
if let filterOptions {
children.insert(searchAction(filterOptions), at: 0)
children.append(milestoneMenu(filterOptions))
children.append(labelMenu(filterOptions))
} else {
children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in })
}
children.append(clearFiltersMenu())
filterButton.menu = UIMenu(children: children)
filterButton.accessibilityLabel = "Filter issues"
updateFilterTint()
}
private func searchAction(_ options: IssueFilterOptions) -> UIAction {
UIAction(
title: "Search Text",
subtitle: options.searchText.isEmpty ? "Any text" : options.searchText,
image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty)
) { [weak self] _ in
self?.promptForSearchText(
title: "Search Issues",
current: options.searchText
) { [weak self] searchText in
self?.setSearchText(searchText)
}
}
}
private func milestoneMenu(_ options: IssueFilterOptions) -> UIMenu {
let selected = options.selectedMilestone
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
[weak self] _ in self?.selectMilestone("")
}
let actions = options.milestones.map { milestone in
UIAction(
title: milestone,
state: selected == milestone ? .on : .off
) { [weak self] _ in self?.selectMilestone(milestone) }
}
return UIMenu(
title: "Milestone",
image: filterMenuImage("flag", active: !selected.isEmpty),
options: .singleSelection,
children: [all] + actions
)
}
private func labelMenu(_ options: IssueFilterOptions) -> UIMenu {
let selected = Set(options.selectedLabels)
let actions = options.labels.map { label in
UIAction(
title: options.unavailableLabels.contains(label) ? "\(label) (Unavailable)" : label,
attributes: .keepsMenuPresented,
state: selected.contains(label) ? .on : .off
) { [weak self] action in
guard let self, let options = self.filterOptions else { return }
self.filterTask?.cancel()
var labels = Set(options.selectedLabels)
if labels.remove(label) == nil { labels.insert(label) }
do {
try self.saveFilters(
milestone: options.selectedMilestone,
labels: labels,
searchText: options.searchText
)
self.filterOptions?.selectedLabels = Array(labels)
action.state = labels.contains(label) ? .on : .off
self.updateFilterMenu()
self.loadIssues(refreshing: false)
} catch {
self.show(error: error)
}
}
}
return UIMenu(
title: "Labels",
image: filterMenuImage("tag", active: !selected.isEmpty),
children: actions.isEmpty
? [UIAction(title: "No labels", attributes: .disabled) { _ in }]
: actions
)
}
private func selectMilestone(_ milestone: String) {
guard let options = filterOptions else { return }
filterTask?.cancel()
do {
try saveFilters(
milestone: milestone,
labels: Set(options.selectedLabels),
searchText: options.searchText
)
filterOptions?.selectedMilestone = milestone
updateFilterMenu()
loadIssues(refreshing: false)
} catch {
show(error: error)
}
}
private func setSearchText(_ searchText: String) {
guard let options = filterOptions else { return }
filterTask?.cancel()
do {
try saveFilters(
milestone: options.selectedMilestone,
labels: Set(options.selectedLabels),
searchText: searchText
)
filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
updateFilterMenu()
loadIssues(refreshing: false)
} catch {
show(error: error)
}
}
private func saveFilters(
milestone: String,
labels: Set<String>,
searchText: String
) throws {
try context.core.setIssueFilters(
owner: owner,
repository: repository,
milestone: milestone,
labels: Array(labels),
searchText: searchText
)
}
private func clearFiltersMenu() -> UIMenu {
UIMenu(
options: .displayInline,
children: [
UIAction(
title: "Clear Filters",
image: context.symbol("xmark.circle"),
attributes: filtersActive ? [] : .disabled
) { [weak self] _ in self?.clearFilters() },
]
)
}
private func clearFilters() {
filterTask?.cancel()
do {
try context.core.clearIssueFilters(owner: owner, repository: repository)
filterOptions?.selectedMilestone = ""
filterOptions?.selectedLabels = []
filterOptions?.searchText = ""
updateFilterMenu()
loadIssues(refreshing: false)
} catch {
show(error: error)
}
}
private var filtersActive: Bool {
(try? context.core.issueFiltersActive(owner: owner, repository: repository)) ?? false
}
private func updateFilterTint() {
filterButton.tintColor = filtersActive ? .tintColor : .secondaryLabel
filterButton.accessibilityValue = filtersActive
? "Filters active"
: "Default filters"
}
@objc private func createIssue() {
let editor = IssueEditorViewController(
context: context,
owner: owner,
repository: repository
) { [weak self] number in
guard let self else { return }
self.dismiss(animated: true) {
self.loadContent(refreshing: false)
self.navigationController?.pushViewController(
IssueViewController(
context: self.context,
owner: self.owner,
repository: self.repository,
number: number
),
animated: true
)
}
}
present(UINavigationController(rootViewController: editor), animated: true)
}
private func updateEmptyState() {
guard rows.isEmpty else {
tableView.backgroundView = nil
return
}
let status = context.core.settings().issueStatus
tableView.backgroundView = EmptyBackgroundView(
title: "No \(status) issues",
detail: filtersActive
? "No issues match the selected filters."
: "This repository has no \(status) issues."
)
}
}
final class IssueCell: UITableViewCell {
private let stateIcon = UIImageView()
private let titleLabel = UILabel()
private let summaryLabel = UILabel()
private let labels = UIStackView()
private let metaLabel = UILabel()
private let milestoneLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
accessoryType = .disclosureIndicator
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.numberOfLines = 2
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
titleStack.alignment = .firstBaseline
titleStack.spacing = 8
summaryLabel.font = .preferredFont(forTextStyle: .subheadline)
summaryLabel.textColor = .secondaryLabel
summaryLabel.numberOfLines = 2
labels.axis = .horizontal
labels.spacing = 5
metaLabel.font = .preferredFont(forTextStyle: .caption1)
metaLabel.textColor = .tertiaryLabel
metaLabel.numberOfLines = 2
milestoneLabel.font = .preferredFont(forTextStyle: .caption1)
milestoneLabel.adjustsFontForContentSizeCategory = true
let stack = UIStackView(
arrangedSubviews: [titleStack, summaryLabel, labels, metaLabel, milestoneLabel]
)
stack.axis = .vertical
stack.spacing = 6
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func configure(_ row: IssueRow) {
configureIssueStateIcon(stateIcon, state: row.state, textStyle: .headline)
titleLabel.text = row.title
summaryLabel.text = row.summary
metaLabel.text = row.meta
milestoneLabel.isHidden = row.milestone.isEmpty
milestoneLabel.attributedText = symbolText(
"flag.fill",
text: row.milestone,
font: milestoneLabel.font,
color: .tertiaryLabel
)
milestoneLabel.accessibilityLabel = row.milestone.isEmpty
? nil
: "Milestone \(row.milestone)"
labels.arrangedSubviews.forEach { $0.removeFromSuperview() }
labels.isHidden = row.labels.isEmpty
for label in row.labels.prefix(3) {
labels.addArrangedSubview(issueLabelView(label))
}
labels.addArrangedSubview(UIView())
}
}

View File

@@ -1,608 +0,0 @@
import UIKit
@MainActor
final class ServersViewController: UITableViewController {
private let context: AppContext
private var servers: [ServerRow] = []
init(context: AppContext) {
self.context = context
super.init(style: .plain)
title = "Servers"
tableView.backgroundColor = .systemGroupedBackground
tableView.separatorInset = .zero
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
servers = context.core.servers()
tableView.reloadData()
tableView.backgroundView = servers.isEmpty
? EmptyBackgroundView(title: "No servers", detail: "Add a Gitea server to get started.")
: nil
navigationItem.rightBarButtonItem = UIBarButtonItem(
systemItem: .add,
primaryAction: UIAction { [weak self] _ in self?.showAddServer() }
)
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
servers.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "server")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "server")
let server = servers[indexPath.row]
configureTextCell(cell, title: server.name, detail: server.url)
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
do {
try context.selectServer(index: UInt32(indexPath.row))
} catch {
show(error: error)
}
}
private func showAddServer() {
let controller = AddServerViewController(context: context) { [weak self] in
guard let self else { return }
self.servers = self.context.core.servers()
self.tableView.reloadData()
}
present(UINavigationController(rootViewController: controller), animated: true)
}
}
@MainActor
final class AddServerViewController: UITableViewController, UITextFieldDelegate {
private let context: AppContext
private let completion: () -> Void
private let nameField = UITextField()
private let urlField = UITextField()
private let tokenField = UITextField()
private var saveButton: UIBarButtonItem!
init(context: AppContext, completion: @escaping () -> Void) {
self.context = context
self.completion = completion
super.init(style: .insetGrouped)
title = "Add Server"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .cancel,
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
)
saveButton = UIBarButtonItem(
title: "Add",
style: .done,
target: self,
action: #selector(save)
)
navigationItem.rightBarButtonItem = saveButton
configure(nameField, placeholder: "Work", contentType: .name)
configure(urlField, placeholder: "https://gitea.example.com", contentType: .URL)
urlField.keyboardType = .URL
urlField.autocapitalizationType = .none
configure(tokenField, placeholder: "Access token", contentType: nil)
tokenField.isSecureTextEntry = true
tokenField.autocapitalizationType = .none
tokenField.returnKeyType = .done
}
override func numberOfSections(in tableView: UITableView) -> Int { 3 }
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]
}
override func tableView(
_ tableView: UITableView,
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)
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),
cell.contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48),
])
return cell
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if textField === nameField { urlField.becomeFirstResponder() }
else if textField === urlField { tokenField.becomeFirstResponder() }
else { save() }
return true
}
@objc private func save() {
view.endEditing(true)
saveButton.isEnabled = false
let spinner = UIActivityIndicatorView(style: .medium)
spinner.startAnimating()
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
Task {
do {
let index = try await context.core.addServer(
name: nameField.text ?? "",
url: urlField.text ?? "",
token: tokenField.text ?? ""
)
try context.didAddServer(index: index)
completion()
dismiss(animated: true)
} catch {
navigationItem.rightBarButtonItem = saveButton
saveButton.isEnabled = true
show(error: error)
}
}
}
private func configure(
_ field: UITextField,
placeholder: String,
contentType: UITextContentType?
) {
field.placeholder = placeholder
field.textContentType = contentType
field.clearButtonMode = .whileEditing
field.delegate = self
field.returnKeyType = .next
field.adjustsFontForContentSizeCategory = true
field.font = .preferredFont(forTextStyle: .body)
}
}
@MainActor
final class RepositoriesViewController: RefreshingTableViewController {
private let context: AppContext
private let mode: RepositoryPane
private var rows: [RepositoryRow] = []
private var currentPage: UInt32 = 0
init(context: AppContext, mode: RepositoryPane) {
self.context = context
self.mode = mode
super.init()
title = context.core.activeServerName() ?? "Repositories"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.leftBarButtonItem = UIBarButtonItem(
image: context.symbol("server.rack"),
primaryAction: UIAction { [weak self] _ in
guard let self else { return }
self.navigationController?.pushViewController(
ServersViewController(context: self.context),
animated: true
)
}
)
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.repositories(page: page, pane: mode)
rows = result.rows
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
tableView.backgroundView = rows.isEmpty
? EmptyBackgroundView(
title: "No repositories",
detail: "This account does not own any repositories on this server."
)
: nil
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if page == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "repository")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository")
let row = rows[indexPath.row]
configureTextCell(cell, title: row.name, detail: "\(row.description)\n\(row.meta)")
let button = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in
self?.toggleFavorite(row)
})
button.setImage(context.symbol(row.favorite ? "star.fill" : "star"), for: .normal)
button.tintColor = row.favorite ? .systemYellow : .tertiaryLabel
button.frame.size = CGSize(width: 44, height: 44)
cell.accessoryView = button
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
96
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let row = rows[indexPath.row]
let destination: UIViewController
switch mode {
case .issues:
destination = IssuesViewController(
context: context,
owner: row.owner,
repository: row.name
)
case .commits:
destination = CommitsViewController(
context: context,
owner: row.owner,
repository: row.name
)
case .milestones:
destination = MilestonesViewController(
context: context,
owner: row.owner,
repository: row.name
)
}
navigationController?.pushViewController(destination, animated: true)
}
private func toggleFavorite(_ row: RepositoryRow) {
do {
rows = try context.core.toggleFavorite(
owner: row.owner,
repository: row.name,
pane: mode
)
tableView.reloadData()
} catch {
show(error: error)
}
}
}
@MainActor
final class HomeViewController: RefreshingTableViewController {
private enum ActivityFilter: Int {
case all
case issues
case pulls
var coreValue: HomeActivityFilter {
switch self {
case .all: return .all
case .issues: return .issues
case .pulls: return .pullRequests
}
}
}
private let context: AppContext
private var page: HomePage?
private var filter = ActivityFilter.all
private var nextPage: UInt32?
private var activities: [ActivityRow] { page?.activities ?? [] }
init(context: AppContext) {
self.context = context
super.init()
title = "Home"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
let settings = UIBarButtonItem(
image: context.symbol("gearshape"),
primaryAction: UIAction { [weak self] _ in
guard let self else { return }
self.navigationController?.pushViewController(
SettingsViewController(context: self.context),
animated: true
)
}
)
settings.accessibilityLabel = "Settings"
navigationItem.rightBarButtonItem = settings
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard page == nil else { return }
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
guard context.core.activeServerIndex() != nil else {
tableView.backgroundView = EmptyBackgroundView(
title: "No server selected",
detail: "Open Issues or Repos to select a server or add your first one."
)
refreshControl?.endRefreshing()
return
}
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
guard let nextPage else { return }
loadPage(nextPage, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.home(
page: requestedPage,
filter: filter.coreValue
)
if requestedPage == 1 {
page = result
} else {
page?.activities.append(contentsOf: result.activities)
page?.nextPage = result.nextPage
}
nextPage = result.nextPage
finishPagination(hasMore: result.nextPage != nil)
title = page?.serverName
if requestedPage == 1 { tableView.tableHeaderView = page.map { page in
HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in
guard let self, let filter = ActivityFilter(rawValue: index) else { return }
guard filter != self.filter else { return }
self.filter = filter
self.loadPage(1, refreshing: false)
}
} }
updateActivities()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
activities.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
let row = activities[indexPath.row]
configureTextCell(
cell,
title: row.title,
detail: "\(row.detail)\n\(row.meta)",
image: context.symbol(symbolName(for: row.icon))
)
cell.accessoryType = row.target.isEmpty ? .none : .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
92
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
context.route(activities[indexPath.row])
}
private func updateActivities() {
tableView.reloadData()
tableView.backgroundView = activities.isEmpty
? EmptyBackgroundView(
title: "No matching activity",
detail: "This server has no recent activity of the selected type."
)
: nil
}
private func symbolName(for icon: String) -> String {
switch icon {
case let value where value.contains("pull"): return "arrow.triangle.pull"
case let value where value.contains("issue"): return "exclamationmark.circle"
case let value where value.contains("branch"): return "arrow.triangle.branch"
case let value where value.contains("tag"): return "tag"
case "push": return "arrow.up.circle"
case "release": return "shippingbox"
default: return "books.vertical"
}
}
}
final class HeatmapView: UIView {
private let cells: [HeatCell]
private let calendar = Calendar(identifier: .gregorian)
init(page: HomePage, selectedFilter: Int, onFilter: @escaping (Int) -> Void) {
cells = page.heatCells
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
backgroundColor = .systemBackground
let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22))
title.text = "Activity · last 9 months"
title.font = .preferredFont(forTextStyle: .subheadline)
title.textColor = .secondaryLabel
addSubview(title)
let total = UILabel(frame: CGRect(x: 16, y: 108, width: 300, height: 18))
total.text = "\(page.contributionCount) contributions"
total.font = .preferredFont(forTextStyle: .caption1)
total.textColor = .tertiaryLabel
addSubview(total)
let filters = UIStackView()
filters.axis = .horizontal
filters.spacing = 4
filters.translatesAutoresizingMaskIntoConstraints = false
let filterItems = [
("clock", "clock.fill", "All activity"),
("exclamationmark.circle", "exclamationmark.circle.fill", "Issues"),
("arrow.triangle.pull", "arrow.triangle.pull", "Pull requests"),
]
for (index, item) in filterItems.enumerated() {
let button = UIButton(type: .custom, primaryAction: UIAction { action in
guard
let button = action.sender as? UIButton,
let stack = button.superview as? UIStackView
else { return }
for case let item as UIButton in stack.arrangedSubviews {
item.isSelected = item === button
item.tintColor = item.isSelected ? .tintColor : .secondaryLabel
item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button
}
onFilter(button.tag)
})
button.tag = index
button.setImage(UIImage(systemName: item.0), for: .normal)
button.setImage(UIImage(systemName: item.1), for: .selected)
button.isSelected = index == selectedFilter
button.tintColor = button.isSelected ? .tintColor : .secondaryLabel
button.accessibilityLabel = item.2
button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
filters.addArrangedSubview(button)
}
addSubview(filters)
NSLayoutConstraint.activate([
filters.centerXAnchor.constraint(equalTo: centerXAnchor),
filters.topAnchor.constraint(equalTo: topAnchor, constant: 132),
filters.heightAnchor.constraint(equalToConstant: 44),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func draw(_ rect: CGRect) {
guard
let first = cells.first,
let last = cells.last
else { return }
let firstDate = Date(timeIntervalSince1970: TimeInterval(first.timestamp))
let lastDate = Date(timeIntervalSince1970: TimeInterval(last.timestamp))
guard let firstWeek = calendar.dateInterval(of: .weekOfYear, for: firstDate)?.start else {
return
}
let monthFormatter = DateFormatter()
monthFormatter.dateFormat = "MMM"
let months = cells.reduce(into: [Date]()) { result, cell in
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
let month = calendar.date(
from: calendar.dateComponents([.year, .month], from: date)
) ?? date
if result.last != month { result.append(month) }
}
let weekCount = CGFloat(
(calendar.dateComponents([.day], from: firstWeek, to: lastDate).day ?? 0) / 7 + 1
)
let monthGap: CGFloat = 4
let monthGaps = CGFloat(max(months.count - 1, 0)) * monthGap
let width = max(4, min(6, (bounds.width - 32 - monthGaps) / weekCount - 1))
let gap = width + 1
let graphWidth = (weekCount - 1) * gap + width + monthGaps
let graphOrigin = max(0, (bounds.width - graphWidth) / 2)
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.preferredFont(forTextStyle: .caption2),
.foregroundColor: UIColor.secondaryLabel,
]
for (index, month) in months.enumerated() {
let days = calendar.dateComponents([.day], from: firstWeek, to: month).day ?? 0
monthFormatter.string(from: month).draw(
at: CGPoint(
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(index) * monthGap,
y: 34
),
withAttributes: labelAttributes
)
}
for cell in cells {
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
let days = calendar.dateComponents([.day], from: firstWeek, to: date).day ?? 0
let month = calendar.date(
from: calendar.dateComponents([.year, .month], from: date)
) ?? date
let monthIndex = months.firstIndex(of: month) ?? 0
let colors: [UIColor] = [
.systemGray5,
UIColor(red: 0.72, green: 0.85, blue: 0.96, alpha: 1),
UIColor(red: 0.45, green: 0.71, blue: 0.91, alpha: 1),
UIColor(red: 0.15, green: 0.55, blue: 0.83, alpha: 1),
UIColor(red: 0.04, green: 0.41, blue: 0.72, alpha: 1),
]
colors[Int(min(cell.level, 4))].setFill()
UIBezierPath(
roundedRect: CGRect(
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(monthIndex) * monthGap,
y: 48 + CGFloat(calendar.component(.weekday, from: date) - 1) * gap,
width: width,
height: width
),
cornerRadius: 1
).fill()
}
}
}

View File

@@ -0,0 +1,466 @@
import UIKit
@MainActor
final class MilestonesViewController: RefreshingTableViewController {
private let context: AppContext
private let owner: String
private let repository: String
private var rows: [MilestoneRow] = []
private var mutationTask: Task<Void, Never>?
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String) {
self.context = context
self.owner = owner
self.repository = repository
super.init()
title = repository
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
deinit { mutationTask?.cancel() }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 118
let addButton = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(createMilestone)
)
addButton.accessibilityLabel = "New milestone"
navigationItem.rightBarButtonItem = addButton
loadContent(refreshing: false)
}
@objc private func createMilestone() {
let editor = MilestoneEditorViewController(
context: context,
owner: owner,
repository: repository
) { [weak self] _ in
guard let self else { return }
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
}
present(UINavigationController(rootViewController: editor), animated: true)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.milestones(
owner: owner,
repository: repository,
page: page
)
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
tableView.backgroundView = rows.isEmpty
? EmptyBackgroundView(
title: "No milestones",
detail: "This repository does not have any milestones."
)
: nil
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if page == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: "milestone",
for: indexPath
) as! MilestoneCell
cell.configure(rows[indexPath.row], disclosure: true)
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
navigationController?.pushViewController(
MilestoneViewController(
context: context,
owner: owner,
repository: repository,
id: rows[indexPath.row].id
),
animated: true
)
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
let row = rows[indexPath.row]
let close = row.state != .closed
let stateAction = UIContextualAction(
style: .normal,
title: close ? "Close" : "Open"
) { [weak self] _, _, completion in
self?.setMilestone(row, closed: close, completion: completion) ?? completion(false)
}
stateAction.image = context.symbol(close ? "checkmark.circle" : "arrow.uturn.left.circle")
stateAction.backgroundColor = close ? .systemPurple : .systemGreen
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
[weak self] _, _, completion in
self?.confirmDelete(row, completion: completion) ?? completion(false)
}
deleteAction.image = context.symbol("trash")
let configuration = UISwipeActionsConfiguration(actions: [stateAction, deleteAction])
configuration.performsFirstActionWithFullSwipe = true
return configuration
}
private func setMilestone(
_ row: MilestoneRow,
closed: Bool,
completion: @escaping (Bool) -> Void
) {
mutationTask?.cancel()
mutationTask = Task {
do {
try await context.core.setMilestoneClosed(
owner: owner,
repository: repository,
id: row.id,
closed: closed
)
guard !Task.isCancelled else {
completion(false)
return
}
completion(true)
loadContent(refreshing: false)
} catch {
completion(false)
if !Task.isCancelled { show(error: error) }
}
}
}
private func confirmDelete(_ row: MilestoneRow, completion: @escaping (Bool) -> Void) {
guard !row.hasIssues else {
let alert = UIAlertController(
title: "Milestone Cant Be Deleted",
message: "Remove all assigned issues and pull requests first.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completion(false) })
present(alert, animated: true)
return
}
let alert = UIAlertController(
title: "Delete \(row.title)?",
message: "This milestone will be permanently deleted. This cant be undone.",
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.deleteMilestone(
owner: self.owner,
repository: self.repository,
id: row.id
)
guard !Task.isCancelled else {
completion(false)
return
}
completion(true)
self.loadContent(refreshing: false)
} catch {
completion(false)
if !Task.isCancelled { self.show(error: error) }
}
}
})
present(alert, animated: true)
}
}
@MainActor
final class MilestoneViewController: RefreshingTableViewController, IssueSwipeActionHost {
let context: AppContext
let owner: String
let repository: String
private let id: Int64
private var page: MilestonePage?
var issueMutationTask: Task<Void, Never>?
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String, id: Int64) {
self.context = context
self.owner = owner
self.repository = repository
self.id = id
super.init()
title = "Milestone"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
deinit { issueMutationTask?.cancel() }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 118
let editButton = UIBarButtonItem(
image: context.symbol("pencil"),
style: .plain,
target: self,
action: #selector(editMilestone)
)
editButton.accessibilityLabel = "Edit milestone"
navigationItem.rightBarButtonItem = editButton
loadContent(refreshing: false)
}
@objc private func editMilestone() {
let editor = MilestoneEditorViewController(
context: context,
owner: owner,
repository: repository,
id: id
) { [weak self] _ in
guard let self else { return }
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
}
present(UINavigationController(rootViewController: editor), animated: true)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.milestone(
owner: owner,
repository: repository,
id: id,
page: requestedPage
)
if requestedPage == 1 {
page = result
} else {
page?.issues.append(contentsOf: result.issues)
page?.pulls.append(contentsOf: result.pulls)
page?.hasMore = result.hasMore
}
currentPage = requestedPage
finishPagination(hasMore: result.hasMore)
title = page?.milestone.title
tableView.reloadData()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 3 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0: 1
case 1: page?.issues.count ?? 0
default: page?.pulls.count ?? 0
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch section {
case 1: "Issues"
case 2: "Pull Requests"
default: nil
}
}
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
if section == 1, page?.issues.isEmpty == true {
return "No issues are assigned to this milestone."
}
if section == 2, page?.pulls.isEmpty == true {
return "No pull requests are assigned to this milestone."
}
return nil
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
guard let page else { return UITableViewCell() }
if indexPath.section == 0 {
let cell = tableView.dequeueReusableCell(
withIdentifier: "milestone",
for: indexPath
) as! MilestoneCell
cell.configure(page.milestone, disclosure: false)
return cell
}
if indexPath.section == 1 {
let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell
cell.configure(page.issues[indexPath.row])
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: "pull", for: indexPath) as! PullCell
cell.configure(page.pulls[indexPath.row])
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 1, let issue = page?.issues[indexPath.row] {
navigationController?.pushViewController(
IssueViewController(
context: context,
owner: owner,
repository: repository,
number: issue.number
),
animated: true
)
} else if indexPath.section == 2, let pull = page?.pulls[indexPath.row] {
navigationController?.pushViewController(
PullViewController(
context: context,
owner: pull.owner,
repository: pull.repository,
number: pull.number
),
animated: true
)
}
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
guard indexPath.section == 1, let issue = page?.issues[indexPath.row] else { return nil }
return issueSwipeActions(for: issue)
}
func reloadIssuesAfterMutation() {
loadContent(refreshing: false)
}
}
final class MilestoneCell: UITableViewCell {
private let stateIcon = UIImageView()
private let titleLabel = UILabel()
private let stack = UIStackView()
private var descriptionView: UIView?
private let metaLabel = UILabel()
private let progress = UIProgressView(progressViewStyle: .bar)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.numberOfLines = 2
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
titleStack.alignment = .firstBaseline
titleStack.spacing = 8
metaLabel.font = .preferredFont(forTextStyle: .caption1)
metaLabel.textColor = .tertiaryLabel
metaLabel.numberOfLines = 2
progress.progressTintColor = .systemGreen
stack.addArrangedSubview(titleStack)
stack.addArrangedSubview(progress)
stack.addArrangedSubview(metaLabel)
stack.axis = .vertical
stack.spacing = 7
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func configure(_ row: MilestoneRow, disclosure: Bool) {
accessoryType = disclosure ? .disclosureIndicator : .none
configureOpenClosedStateIcon(
stateIcon,
state: row.state,
subject: "milestone",
textStyle: .headline
)
titleLabel.text = row.title
descriptionView?.removeFromSuperview()
if !row.description.isEmpty {
let view = markdownView(row.description)
stack.insertArrangedSubview(view, at: 1)
descriptionView = view
}
metaLabel.text = row.meta
progress.progress = Float(row.progress)
progress.trackTintColor = row.hasIssues ? .systemOrange : .systemGray5
progress.accessibilityLabel = "Milestone progress"
progress.accessibilityValue = row.progressAccessibility
}
}

View File

@@ -0,0 +1,323 @@
import UIKit
final class PullCell: UITableViewCell {
private let stateIcon = UIImageView()
private let titleLabel = UILabel()
private let summaryLabel = UILabel()
private let metaLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
accessoryType = .disclosureIndicator
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.numberOfLines = 2
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
titleStack.alignment = .firstBaseline
titleStack.spacing = 8
summaryLabel.font = .preferredFont(forTextStyle: .subheadline)
summaryLabel.textColor = .secondaryLabel
summaryLabel.numberOfLines = 2
metaLabel.font = .preferredFont(forTextStyle: .caption1)
metaLabel.textColor = .tertiaryLabel
metaLabel.numberOfLines = 2
[titleLabel, summaryLabel, metaLabel].forEach {
$0.adjustsFontForContentSizeCategory = true
}
let stack = UIStackView(arrangedSubviews: [titleStack, summaryLabel, metaLabel])
stack.axis = .vertical
stack.spacing = 6
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func configure(_ row: PullRow) {
configureOpenClosedStateIcon(
stateIcon,
state: row.state,
subject: "pull request",
textStyle: .headline
)
titleLabel.text = row.title
summaryLabel.text = row.summary
metaLabel.text = row.meta
}
}
@MainActor
final class PullsViewController: RefreshingTableViewController {
private let context: AppContext
private var rows: [PullRow] = []
private var filterOptions: PullFilterOptions?
private var filterTask: Task<Void, Never>?
private var currentPage: UInt32 = 0
init(context: AppContext) {
self.context = context
super.init()
title = "Pull Requests"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
deinit { filterTask?.cancel() }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 116
navigationItem.leftBarButtonItem = UIBarButtonItem(
image: context.symbol("server.rack"),
primaryAction: UIAction { [weak self] _ in
guard let self else { return }
self.navigationController?.pushViewController(
ServersViewController(context: self.context),
animated: true
)
}
)
updateFilterMenu()
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
guard context.core.activeServerIndex() != nil else {
tableView.backgroundView = EmptyBackgroundView(
title: "No server selected",
detail: "Open Issues or Repos to select a server or add your first one."
)
refreshControl?.endRefreshing()
return
}
loadFilterOptions()
loadPulls(refreshing: refreshing)
}
private func loadPulls(refreshing: Bool) {
loadPulls(page: 1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPulls(page: currentPage + 1, refreshing: false)
}
private func loadPulls(page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.pulls(page: page)
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
let status = context.core.settings().pullStatus
tableView.backgroundView = rows.isEmpty
? EmptyBackgroundView(
title: "No \(status) pull requests",
detail: filtersActive
? "No pull requests match the selected filters."
: "No pull requests match the selected status."
)
: nil
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if page == 1 { endLoading() }
}
}
private func loadFilterOptions() {
filterTask?.cancel()
filterTask = Task {
do {
filterOptions = try await context.core.pullFilters()
guard !Task.isCancelled else { return }
updateFilterMenu()
} catch {
if !Task.isCancelled { show(error: error) }
}
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "pull", for: indexPath) as! PullCell
cell.configure(rows[indexPath.row])
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let row = rows[indexPath.row]
navigationController?.pushViewController(
PullViewController(
context: context,
owner: row.owner,
repository: row.repository,
number: row.number
),
animated: true
)
}
private func updateFilterMenu() {
let current = context.core.settings().pullStatus
let status = UIMenu(
title: "Status",
image: filterMenuImage("circle.lefthalf.filled", active: current != "open"),
options: .singleSelection,
children: ["open", "closed"].map { status in
UIAction(title: status.capitalized, state: current == status ? .on : .off) {
[weak self] _ in
guard let self else { return }
do {
try self.context.core.setPullStatus(status: status)
self.updateFilterMenu()
self.loadPulls(refreshing: false)
} catch {
self.show(error: error)
}
}
}
)
let milestone: UIMenuElement = filterOptions.map(milestoneMenu)
?? UIAction(title: "Loading milestones…", attributes: .disabled) { _ in }
let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem(
image: context.symbol("line.3.horizontal.decrease.circle")
)
var children: [UIMenuElement] = [status]
if let filterOptions {
children.insert(searchAction(filterOptions), at: 0)
}
children.append(milestone)
children.append(clearFiltersMenu())
item.menu = UIMenu(children: children)
item.accessibilityLabel = "Filter pull requests"
navigationItem.rightBarButtonItem = item
updateFilterTint()
}
private func searchAction(_ options: PullFilterOptions) -> UIAction {
UIAction(
title: "Search Text",
subtitle: options.searchText.isEmpty ? "Any text" : options.searchText,
image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty)
) { [weak self] _ in
self?.promptForSearchText(
title: "Search Pull Requests",
current: options.searchText
) { [weak self] searchText in
self?.setSearchText(searchText)
}
}
}
private func milestoneMenu(_ options: PullFilterOptions) -> UIMenu {
let selected = options.selectedMilestone
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
[weak self] _ in self?.selectMilestone("")
}
let milestones = options.milestones.map { milestone in
UIAction(title: milestone, state: selected == milestone ? .on : .off) {
[weak self] _ in self?.selectMilestone(milestone)
}
}
return UIMenu(
title: "Milestone",
image: filterMenuImage("flag", active: !selected.isEmpty),
options: .singleSelection,
children: [all] + milestones
)
}
private func selectMilestone(_ milestone: String) {
guard let options = filterOptions else { return }
filterTask?.cancel()
do {
try context.core.setPullFilters(
milestone: milestone,
searchText: options.searchText
)
filterOptions?.selectedMilestone = milestone
updateFilterMenu()
loadPulls(refreshing: false)
} catch {
show(error: error)
}
}
private func setSearchText(_ searchText: String) {
guard let options = filterOptions else { return }
filterTask?.cancel()
do {
try context.core.setPullFilters(
milestone: options.selectedMilestone,
searchText: searchText
)
filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
updateFilterMenu()
loadPulls(refreshing: false)
} catch {
show(error: error)
}
}
private func clearFiltersMenu() -> UIMenu {
UIMenu(
options: .displayInline,
children: [
UIAction(
title: "Clear Filters",
image: context.symbol("xmark.circle"),
attributes: filtersActive ? [] : .disabled
) { [weak self] _ in self?.clearFilters() },
]
)
}
private func clearFilters() {
filterTask?.cancel()
do {
try context.core.clearPullFilters()
filterOptions?.selectedMilestone = ""
filterOptions?.searchText = ""
updateFilterMenu()
loadPulls(refreshing: false)
} catch {
show(error: error)
}
}
private var filtersActive: Bool {
(try? context.core.pullFiltersActive()) ?? false
}
private func updateFilterTint() {
navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel
navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive
? "Filters active"
: "Default filters"
}
}

View File

@@ -0,0 +1,199 @@
import UIKit
@MainActor
final class RepositoryDirectoryViewController: RefreshingTableViewController {
private enum Mode: Int { case files, history }
private let context: AppContext
private let owner: String
private let repository: String
private let path: String
private var mode = Mode.files
private var rows: [RepositoryContentRow] = []
private var history: CommitPage?
private var currentPage: UInt32 = 0
private var loadedFiles = false
init(context: AppContext, owner: String, repository: String, path: String, name: String) {
self.context = context
self.owner = owner
self.repository = repository
self.path = path
super.init()
title = name
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
navigationItem.prompt = title
let modeControl = UISegmentedControl(items: ["Files", "History"])
modeControl.selectedSegmentIndex = mode.rawValue
modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
modeControl.accessibilityLabel = "Directory view"
navigationItem.titleView = modeControl
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
switch mode {
case .files: loadFiles(refreshing: refreshing)
case .history: loadHistory(page: 1, refreshing: refreshing)
}
}
override func loadMoreContent() {
guard mode == .history else { return }
loadHistory(page: currentPage + 1, refreshing: false)
}
private func loadFiles(refreshing: Bool) {
resetPagination()
beginLoading(refreshing: refreshing)
loadingTask?.cancel()
loadingTask = Task {
do {
let rows = try await context.core.repositoryContents(
owner: owner,
repository: repository,
path: path
)
guard !Task.isCancelled, mode == .files else {
endLoading()
return
}
self.rows = rows
loadedFiles = true
tableView.reloadData()
updateEmptyView()
} catch {
if !Task.isCancelled { show(error: error) }
}
endLoading()
}
}
private func loadHistory(page requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let history = try await context.core.commits(
owner: owner,
repository: repository,
branch: nil,
path: path,
pages: requestedPage
)
guard !Task.isCancelled, mode == .history else {
if requestedPage == 1 { endLoading() }
return
}
self.history = history
currentPage = requestedPage
finishPagination(hasMore: history.hasMore)
tableView.reloadData()
updateEmptyView()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
mode == .files ? rows.count : history?.commits.count ?? 0
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
switch mode {
case .files:
let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
configureRepositoryContentCell(cell, row: rows[indexPath.row])
return cell
case .history:
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
if let history {
cell.configure(history.commits[indexPath.row], laneCount: history.laneCount)
}
return cell
}
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
mode == .history ? 86 : UITableView.automaticDimension
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
switch mode {
case .files:
showRepositoryContent(
rows[indexPath.row],
context: context,
owner: owner,
repository: repository,
navigationController: navigationController
)
case .history:
guard let commit = history?.commits[indexPath.row] else { return }
navigationController?.pushViewController(
FilesViewController(
context: context,
owner: owner,
repository: repository,
sha: commit.sha,
branch: commit.branchLabel
),
animated: true
)
}
}
@objc private func modeChanged(_ sender: UISegmentedControl) {
guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return }
loadingTask?.cancel()
endLoading()
self.mode = mode
resetPagination()
tableView.backgroundView = nil
tableView.reloadData()
switch mode {
case .files:
if loadedFiles { updateEmptyView() } else { loadFiles(refreshing: false) }
case .history:
if let history {
finishPagination(hasMore: history.hasMore)
updateEmptyView()
} else {
loadHistory(page: 1, refreshing: false)
}
}
}
private func updateEmptyView() {
switch mode {
case .files:
tableView.backgroundView = loadedFiles && rows.isEmpty
? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.")
: nil
case .history:
tableView.backgroundView = history?.commits.isEmpty == true
? EmptyBackgroundView(title: "No commits", detail: "No commits affect this folder.")
: nil
}
}
}

View File

@@ -0,0 +1,383 @@
import Highlighter
import QuickLook
import SwiftUI
import UIKit
@MainActor
final class RepositoryHistoryViewController: RefreshingTableViewController {
private let context: AppContext
private let owner: String
private let repository: String
private let path: String
private let emptyDetail: String
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private var page: CommitPage?
private var currentPage: UInt32 = 0
init(
context: AppContext,
owner: String,
repository: String,
path: String,
emptyDetail: String
) {
self.context = context
self.owner = owner
self.repository = repository
self.path = path
self.emptyDetail = emptyDetail
super.init()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
if !refreshing {
loadingIndicator.startAnimating()
tableView.backgroundView = loadingIndicator
}
}
loadingTask?.cancel()
loadingTask = Task {
do {
let page = try await context.core.commits(
owner: owner,
repository: repository,
branch: nil,
path: path,
pages: requestedPage
)
guard !Task.isCancelled else { return }
self.page = page
currentPage = requestedPage
finishPagination(hasMore: page.hasMore)
tableView.reloadData()
updateEmptyView()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 {
loadingIndicator.stopAnimating()
refreshControl?.endRefreshing()
}
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
page?.commits.count ?? 0
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) }
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
86
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let commit = page?.commits[indexPath.row] else { return }
navigationController?.pushViewController(
FilesViewController(
context: context,
owner: owner,
repository: repository,
sha: commit.sha,
branch: commit.branchLabel
),
animated: true
)
}
private func updateEmptyView() {
tableView.backgroundView = page?.commits.isEmpty == true
? EmptyBackgroundView(title: "No commits", detail: emptyDetail)
: nil
}
}
final class CodeScrollView: UIScrollView {
private let textView = UITextView(frame: .zero)
override init(frame: CGRect) {
super.init(frame: frame)
translatesAutoresizingMaskIntoConstraints = false
alwaysBounceVertical = true
alwaysBounceHorizontal = true
isDirectionalLockEnabled = true
showsHorizontalScrollIndicator = true
contentInsetAdjustmentBehavior = .never
textView.textContainer.lineBreakMode = .byClipping
textView.textContainer.widthTracksTextView = false
textView.isEditable = false
textView.isScrollEnabled = false
textView.isSelectable = true
addSubview(textView)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func display(_ text: NSAttributedString) {
let text = NSMutableAttributedString(attributedString: text)
let paragraph = NSMutableParagraphStyle()
paragraph.lineBreakMode = .byClipping
text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length))
textView.textStorage.setAttributedString(text)
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
textView.textContainer.size = CGSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.layoutManager.ensureLayout(for: textView.textContainer)
let textSize = textView.layoutManager.usedRect(for: textView.textContainer).size
let size = CGSize(
width: max(
bounds.width,
ceil(textSize.width + textView.textContainerInset.left + textView.textContainerInset.right)
),
height: max(
bounds.height,
ceil(textSize.height + textView.textContainerInset.top + textView.textContainerInset.bottom)
)
)
textView.frame = CGRect(origin: .zero, size: size)
contentSize = size
}
}
@MainActor
final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource {
private enum Mode: String {
case preview = "Preview"
case source = "Source"
case content = "Content"
case history = "History"
}
private let context: AppContext
private let owner: String
private let repository: String
private let path: String
private let spinner = UIActivityIndicatorView(style: .medium)
private let modeControl = UISegmentedControl()
private var loadingTask: Task<Void, Never>?
private var page: RepositoryFilePage?
private var previewURL: URL?
private var fileLanguage: String?
private var historyController: RepositoryHistoryViewController?
private var displayedController: UIViewController?
private var displayedView: UIView?
private var modes: [Mode] = []
init(context: AppContext, owner: String, repository: String, path: String, name: String) {
self.context = context
self.owner = owner
self.repository = repository
self.path = path
super.init(nibName: nil, bundle: nil)
title = name
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
beginNavigationLoading(spinner)
loadingTask = Task {
do {
let page = try await context.core.repositoryFile(
owner: owner,
repository: repository,
path: path
)
guard !Task.isCancelled else { return }
self.page = page
title = page.name
fileLanguage = page.language.isEmpty ? nil : page.language
configureModes(for: page)
} catch {
if !Task.isCancelled { show(error: error) }
}
endNavigationLoading(spinner)
}
}
deinit {
loadingTask?.cancel()
if let previewURL { try? FileManager.default.removeItem(at: previewURL) }
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
previewURL == nil ? 0 : 1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
previewURL! as NSURL
}
private func configureModes(for page: RepositoryFilePage) {
navigationItem.prompt = title
modeControl.removeAllSegments()
modes = page.kind == .markdown
? [.preview, .source, .history]
: [.content, .history]
for (index, mode) in modes.enumerated() {
modeControl.insertSegment(withTitle: mode.rawValue, at: index, animated: false)
}
modeControl.selectedSegmentIndex = 0
modeControl.addTarget(self, action: #selector(fileModeChanged(_:)), for: .valueChanged)
modeControl.accessibilityLabel = "File view"
navigationItem.titleView = modeControl
showContent(page, mode: modes[0])
}
@objc private func fileModeChanged(_ sender: UISegmentedControl) {
guard let page, modes.indices.contains(sender.selectedSegmentIndex) else { return }
showContent(page, mode: modes[sender.selectedSegmentIndex])
}
private func showContent(_ page: RepositoryFilePage, mode: Mode) {
switch mode {
case .preview: showMarkdownPreview(page.text)
case .source: showSource(page.text)
case .content:
if page.kind == .source {
showSource(page.text)
} else {
do {
try showQuickLook(page.data, name: page.name)
} catch {
show(error: error)
}
}
case .history: showHistory()
}
}
private func showHistory() {
removeDisplayedView()
let history = historyController ?? RepositoryHistoryViewController(
context: context,
owner: owner,
repository: repository,
path: path,
emptyDetail: "No commits affect this file."
)
historyController = history
addChild(history)
history.view.frame = view.bounds
history.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
displayedController = history
displayedView = history.view
view.addSubview(history.view)
history.didMove(toParent: self)
}
private func showMarkdownPreview(_ source: String) {
removeDisplayedView()
let preview = UIHostingController(rootView: RepositoryMarkdownPreview(source: source))
addChild(preview)
preview.view.frame = view.bounds
preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
displayedController = preview
displayedView = preview.view
view.addSubview(preview.view)
preview.didMove(toParent: self)
}
private func showSource(_ source: String) {
removeDisplayedView()
let highlighter = Highlighter()
highlighter?.setTheme(
traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light"
)
highlighter?.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular))
let highlighted = highlighter?.highlight(source, as: fileLanguage)
?? NSAttributedString(string: source, attributes: [
.font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular),
.foregroundColor: UIColor.label,
])
let codeView = CodeScrollView()
codeView.display(highlighted)
displayedView = codeView
view.addSubview(codeView)
NSLayoutConstraint.activate([
codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
}
private func removeDisplayedView() {
displayedController?.willMove(toParent: nil)
displayedView?.removeFromSuperview()
displayedController?.removeFromParent()
displayedController = nil
displayedView = nil
}
private func showQuickLook(_ data: Data, name: String) throws {
removeDisplayedView()
if previewURL == nil {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("\(UUID().uuidString)-\(name)")
try data.write(to: url, options: .atomic)
previewURL = url
}
let preview = QLPreviewController()
preview.dataSource = self
addChild(preview)
preview.view.frame = view.bounds
preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
displayedController = preview
displayedView = preview.view
view.addSubview(preview.view)
preview.didMove(toParent: self)
}
}
struct RepositoryMarkdownPreview: View {
let source: String
var body: some View {
ScrollView {
MarkdownContent(source: source)
.padding()
}
.background(Color(uiColor: .systemBackground))
}
}

View File

@@ -0,0 +1,137 @@
import UIKit
@MainActor
final class RepositoriesViewController: RefreshingTableViewController {
private let context: AppContext
private let mode: RepositoryPane
private var rows: [RepositoryRow] = []
private var currentPage: UInt32 = 0
init(context: AppContext, mode: RepositoryPane) {
self.context = context
self.mode = mode
super.init()
title = context.core.activeServerName() ?? "Repositories"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.leftBarButtonItem = UIBarButtonItem(
image: context.symbol("server.rack"),
primaryAction: UIAction { [weak self] _ in
guard let self else { return }
self.navigationController?.pushViewController(
ServersViewController(context: self.context),
animated: true
)
}
)
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.repositories(page: page, pane: mode)
rows = result.rows
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
tableView.backgroundView = rows.isEmpty
? EmptyBackgroundView(
title: "No repositories",
detail: "This account does not own any repositories on this server."
)
: nil
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if page == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "repository")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository")
let row = rows[indexPath.row]
configureTextCell(cell, title: row.name, detail: "\(row.description)\n\(row.meta)")
let button = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in
self?.toggleFavorite(row)
})
button.setImage(context.symbol(row.favorite ? "star.fill" : "star"), for: .normal)
button.tintColor = row.favorite ? .systemYellow : .tertiaryLabel
button.frame.size = CGSize(width: 44, height: 44)
cell.accessoryView = button
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
96
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let row = rows[indexPath.row]
let destination: UIViewController
switch mode {
case .issues:
destination = IssuesViewController(
context: context,
owner: row.owner,
repository: row.name
)
case .commits:
destination = CommitsViewController(
context: context,
owner: row.owner,
repository: row.name
)
case .milestones:
destination = MilestonesViewController(
context: context,
owner: row.owner,
repository: row.name
)
}
navigationController?.pushViewController(destination, animated: true)
}
private func toggleFavorite(_ row: RepositoryRow) {
do {
rows = try context.core.toggleFavorite(
owner: row.owner,
repository: row.name,
pane: mode
)
tableView.reloadData()
} catch {
show(error: error)
}
}
}

View File

@@ -0,0 +1,177 @@
import UIKit
@MainActor
final class ServersViewController: UITableViewController {
private let context: AppContext
private var servers: [ServerRow] = []
init(context: AppContext) {
self.context = context
super.init(style: .plain)
title = "Servers"
tableView.backgroundColor = .systemGroupedBackground
tableView.separatorInset = .zero
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
servers = context.core.servers()
tableView.reloadData()
tableView.backgroundView = servers.isEmpty
? EmptyBackgroundView(title: "No servers", detail: "Add a Gitea server to get started.")
: nil
navigationItem.rightBarButtonItem = UIBarButtonItem(
systemItem: .add,
primaryAction: UIAction { [weak self] _ in self?.showAddServer() }
)
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
servers.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "server")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "server")
let server = servers[indexPath.row]
configureTextCell(cell, title: server.name, detail: server.url)
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
do {
try context.selectServer(index: UInt32(indexPath.row))
} catch {
show(error: error)
}
}
private func showAddServer() {
let controller = AddServerViewController(context: context) { [weak self] in
guard let self else { return }
self.servers = self.context.core.servers()
self.tableView.reloadData()
}
present(UINavigationController(rootViewController: controller), animated: true)
}
}
@MainActor
final class AddServerViewController: UITableViewController, UITextFieldDelegate {
private let context: AppContext
private let completion: () -> Void
private let nameField = UITextField()
private let urlField = UITextField()
private let tokenField = UITextField()
private var saveButton: UIBarButtonItem!
init(context: AppContext, completion: @escaping () -> Void) {
self.context = context
self.completion = completion
super.init(style: .insetGrouped)
title = "Add Server"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .cancel,
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
)
saveButton = UIBarButtonItem(
title: "Add",
style: .done,
target: self,
action: #selector(save)
)
navigationItem.rightBarButtonItem = saveButton
configure(nameField, placeholder: "Work", contentType: .name)
configure(urlField, placeholder: "https://gitea.example.com", contentType: .URL)
urlField.keyboardType = .URL
urlField.autocapitalizationType = .none
configure(tokenField, placeholder: "Access token", contentType: nil)
tokenField.isSecureTextEntry = true
tokenField.autocapitalizationType = .none
tokenField.returnKeyType = .done
}
override func numberOfSections(in tableView: UITableView) -> Int { 3 }
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]
}
override func tableView(
_ tableView: UITableView,
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)
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),
cell.contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48),
])
return cell
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if textField === nameField { urlField.becomeFirstResponder() }
else if textField === urlField { tokenField.becomeFirstResponder() }
else { save() }
return true
}
@objc private func save() {
view.endEditing(true)
saveButton.isEnabled = false
let spinner = UIActivityIndicatorView(style: .medium)
spinner.startAnimating()
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
Task {
do {
let index = try await context.core.addServer(
name: nameField.text ?? "",
url: urlField.text ?? "",
token: tokenField.text ?? ""
)
try context.didAddServer(index: index)
completion()
dismiss(animated: true)
} catch {
navigationItem.rightBarButtonItem = saveButton
saveButton.isEnabled = true
show(error: error)
}
}
}
private func configure(
_ field: UITextField,
placeholder: String,
contentType: UITextContentType?
) {
field.placeholder = placeholder
field.textContentType = contentType
field.clearButtonMode = .whileEditing
field.delegate = self
field.returnKeyType = .next
field.adjustsFontForContentSizeCategory = true
field.font = .preferredFont(forTextStyle: .body)
}
}

View File

@@ -221,7 +221,7 @@ func filterMenuImage(_ symbol: String, active: Bool) -> UIImage? {
}
func configureRepositoryContentCell(_ cell: UITableViewCell, row: RepositoryContentRow) {
let directory = row.kind == "dir"
let directory = row.kind == .directory
let detail = directory
? "Folder"
: ByteCountFormatter.string(fromByteCount: row.size, countStyle: .file)
@@ -242,7 +242,7 @@ func showRepositoryContent(
repository: String,
navigationController: UINavigationController?
) {
let destination: UIViewController = row.kind == "dir"
let destination: UIViewController = row.kind == .directory
? RepositoryDirectoryViewController(
context: context,
owner: owner,
@@ -277,7 +277,7 @@ func symbolText(_ symbol: String, text: String, font: UIFont, color: UIColor) ->
func configureIssueStateIcon(
_ icon: UIImageView,
state: String,
state: WorkItemState,
textStyle: UIFont.TextStyle
) {
configureOpenClosedStateIcon(icon, state: state, subject: "issue", textStyle: textStyle)
@@ -285,7 +285,7 @@ func configureIssueStateIcon(
func configureOpenClosedStateIcon(
_ icon: UIImageView,
state: String,
state: WorkItemState,
subject: String,
textStyle: UIFont.TextStyle
) {
@@ -293,15 +293,15 @@ func configureOpenClosedStateIcon(
let color: UIColor
let accessibilityLabel: String
switch state {
case "open":
case .open:
(symbol, color, accessibilityLabel) = (
"exclamationmark.circle.fill", .systemGreen, "Open \(subject)"
)
case "closed":
case .closed:
(symbol, color, accessibilityLabel) = (
"checkmark.circle.fill", .systemPurple, "Closed \(subject)"
)
default:
case .unknown:
(symbol, color, accessibilityLabel) = (
"questionmark.circle.fill", .systemGray, "Unknown \(subject) state"
)
@@ -385,3 +385,10 @@ final class IssueLabelsView: UIView {
setNeedsLayout()
}
}
func separator() -> UIView {
let line = UIView()
line.backgroundColor = .separator
line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
return line
}

View File

@@ -0,0 +1,495 @@
import Highlighter
import QuickLook
import SwiftUI
import UIKit
@MainActor
class MarkdownPageViewController: UIViewController, UIScrollViewDelegate {
let context: AppContext
let scrollView = UIScrollView()
let stack = UIStackView()
private let spinner = UIActivityIndicatorView(style: .medium)
var loadingTask: Task<Void, Never>?
private lazy var moreButton = UIButton(
configuration: .plain(),
primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() }
)
private var hasMoreContent = false
private var loadingMore = false
init(context: AppContext) {
self.context = context
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGroupedBackground
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceVertical = true
scrollView.delegate = self
scrollView.refreshControl = UIRefreshControl()
scrollView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
view.addSubview(scrollView)
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stack)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 18),
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -18),
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 18),
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -18),
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -36),
])
}
deinit { loadingTask?.cancel() }
func loadContent(refreshing: Bool) {}
func loadMoreContent() {}
func beginLoading(refreshing: Bool) {
if !refreshing { beginNavigationLoading(spinner) }
}
func endLoading() {
endNavigationLoading(spinner)
scrollView.refreshControl?.endRefreshing()
}
func replaceContent(_ views: [UIView]) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
views.forEach(stack.addArrangedSubview)
}
func resetPagination() {
hasMoreContent = false
loadingMore = false
stack.removeArrangedSubview(moreButton)
moreButton.removeFromSuperview()
}
func finishPagination(hasMore: Bool) {
hasMoreContent = hasMore
loadingMore = false
guard hasMore else { return }
var configuration = moreButton.configuration
configuration?.title = "Pull up or tap to load more"
configuration?.showsActivityIndicator = false
moreButton.configuration = configuration
moreButton.accessibilityLabel = "Load more results"
if moreButton.superview !== stack { stack.addArrangedSubview(moreButton) }
if moreButton.constraints.isEmpty {
moreButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
}
}
func failPagination() {
loadingMore = false
finishPagination(hasMore: hasMoreContent)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isDragging, hasMoreContent, !loadingMore else { return }
let bottom = max(
-scrollView.adjustedContentInset.top,
scrollView.contentSize.height
+ scrollView.adjustedContentInset.bottom
- scrollView.bounds.height
)
if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() }
}
private func requestMoreContent() {
guard hasMoreContent, !loadingMore else { return }
loadingMore = true
var configuration = moreButton.configuration
configuration?.title = "Loading more…"
configuration?.showsActivityIndicator = true
moreButton.configuration = configuration
loadMoreContent()
}
@objc private func refreshRequested() {
loadContent(refreshing: true)
}
}
@MainActor
final class IssueViewController: MarkdownPageViewController {
private let owner: String
private let repository: String
private let number: Int64
private var page: IssuePage?
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String, number: Int64) {
self.owner = owner
self.repository = repository
self.number = number
super.init(context: context)
title = "Issue"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
let addComment = UIBarButtonItem(
image: context.symbol("plus.bubble"),
style: .plain,
target: self,
action: #selector(addComment)
)
addComment.accessibilityLabel = "Add comment"
let editIssue = UIBarButtonItem(
image: context.symbol("pencil"),
style: .plain,
target: self,
action: #selector(editIssue)
)
editIssue.accessibilityLabel = "Edit issue"
navigationItem.rightBarButtonItems = [addComment, editIssue]
loadContent(refreshing: false)
}
@objc private func addComment() { presentCommentEditor() }
private func editComment(_ comment: CommentRow) { presentCommentEditor(comment) }
private func presentCommentEditor(_ comment: CommentRow? = nil) {
let editor = CommentEditorViewController(
context: context,
owner: owner,
repository: repository,
number: number,
comment: comment
) { [weak self] in
guard let self else { return }
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
}
present(UINavigationController(rootViewController: editor), animated: true)
}
@objc private func editIssue() {
let editor = IssueEditorViewController(
context: context,
owner: owner,
repository: repository,
number: number
) { [weak self] _ in
guard let self else { return }
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
}
present(UINavigationController(rootViewController: editor), animated: true)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.issue(
owner: owner,
repository: repository,
number: number,
page: requestedPage
)
if requestedPage == 1 {
page = result
} else {
page?.comments.append(contentsOf: result.comments)
page?.hasMore = result.hasMore
}
currentPage = requestedPage
if let page {
replaceContent(detailViews(
title: page.title,
state: page.state,
stateSubject: "issue",
meta: page.meta,
body: page.body,
comments: page.comments,
editComment: { [weak self] comment in self?.editComment(comment) },
milestone: page.milestone,
labels: page.labels
))
finishPagination(hasMore: page.hasMore)
}
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
}
@MainActor
final class PullViewController: MarkdownPageViewController {
private let owner: String
private let repository: String
private let number: Int64
private var page: PullPage?
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String, number: Int64) {
self.owner = owner
self.repository = repository
self.number = number
super.init(context: context)
title = "Pull Request"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.pull(
owner: owner,
repository: repository,
number: number,
page: requestedPage
)
if requestedPage == 1 {
page = result
} else {
page?.files.append(contentsOf: result.files)
page?.comments.append(contentsOf: result.comments)
page?.hasMore = result.hasMore
}
currentPage = requestedPage
if let page {
var views = detailViews(
title: page.title,
state: page.state,
stateSubject: "pull request",
meta: page.meta,
body: page.body,
comments: []
)
if !page.files.isEmpty {
views.append(sectionLabel(page.filesRef))
views.append(FileListView(rows: page.files) { [weak self] file in
guard let self else { return }
self.navigationController?.pushViewController(
DiffViewController(
context: self.context,
source: .pull(
owner: self.owner,
repository: self.repository,
number: self.number,
path: file.path
)
),
animated: true
)
})
}
views.append(contentsOf: commentViews(page.comments))
replaceContent(views)
finishPagination(hasMore: page.hasMore)
}
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
}
private final class FileListView: UITableView, UITableViewDataSource, UITableViewDelegate {
private let rows: [FileRow]
private let select: (FileRow) -> Void
private var contentHeight: NSLayoutConstraint!
init(rows: [FileRow], select: @escaping (FileRow) -> Void) {
self.rows = rows
self.select = select
super.init(frame: .zero, style: .plain)
translatesAutoresizingMaskIntoConstraints = false
dataSource = self
delegate = self
isScrollEnabled = false
rowHeight = UITableView.automaticDimension
estimatedRowHeight = 55
separatorInset = .zero
backgroundColor = .systemGroupedBackground
contentHeight = heightAnchor.constraint(equalToConstant: CGFloat(rows.count) * estimatedRowHeight)
contentHeight.isActive = true
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func layoutSubviews() {
super.layoutSubviews()
if abs(contentHeight.constant - contentSize.height) > 0.5 {
contentHeight.constant = contentSize.height
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "file")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "file")
let row = rows[indexPath.row]
configureTextCell(cell, title: row.path, detail: row.status)
cell.accessoryType = .disclosureIndicator
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
select(rows[indexPath.row])
}
}
@MainActor
private func detailViews(
title: String,
state: WorkItemState? = nil,
stateSubject: String = "",
meta: String,
body: String,
comments: [CommentRow],
editComment: ((CommentRow) -> Void)? = nil,
milestone: String = "",
labels: [LabelRow] = []
) -> [UIView] {
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .preferredFont(forTextStyle: .title1)
titleLabel.numberOfLines = 0
let metaLabel = UILabel()
metaLabel.text = meta
metaLabel.font = .preferredFont(forTextStyle: .subheadline)
metaLabel.textColor = .secondaryLabel
metaLabel.numberOfLines = 0
let bodyView = markdownView(body)
var views: [UIView] = [
state.map { stateTitle(titleLabel, state: $0, subject: stateSubject) } ?? titleLabel,
metaLabel,
]
if !milestone.isEmpty {
let milestoneLabel = UILabel()
milestoneLabel.attributedText = symbolText(
"flag.fill",
text: milestone,
font: .preferredFont(forTextStyle: .subheadline),
color: .secondaryLabel
)
milestoneLabel.accessibilityLabel = "Milestone \(milestone)"
views.append(milestoneLabel)
}
if !labels.isEmpty { views.append(IssueLabelsView(labels)) }
return views + [separator(), bodyView] + commentViews(comments, editComment: editComment)
}
private func stateTitle(_ titleLabel: UILabel, state: WorkItemState, subject: String) -> UIView {
let icon = UIImageView()
configureOpenClosedStateIcon(icon, state: state, subject: subject, textStyle: .title1)
let stack = UIStackView(arrangedSubviews: [icon, titleLabel])
stack.axis = .horizontal
stack.alignment = .firstBaseline
stack.spacing = 8
return stack
}
@MainActor
private func commentViews(
_ comments: [CommentRow],
editComment: ((CommentRow) -> Void)? = nil
) -> [UIView] {
guard !comments.isEmpty else { return [] }
var views: [UIView] = [sectionLabel("Comments")]
for comment in comments {
let author = UILabel()
author.text = comment.author
author.font = .preferredFont(forTextStyle: .headline)
var headerViews: [UIView] = [author, UIView()]
if comment.canEdit, let editComment {
var configuration = UIButton.Configuration.plain()
configuration.image = UIImage(systemName: "pencil")
configuration.contentInsets = .zero
let button = UIButton(
configuration: configuration,
primaryAction: UIAction { _ in editComment(comment) }
)
button.accessibilityLabel = "Edit comment by \(comment.author)"
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
button.heightAnchor.constraint(equalToConstant: 44).isActive = true
headerViews.append(button)
}
let header = UIStackView(arrangedSubviews: headerViews)
header.alignment = .center
let date = UILabel()
date.text = comment.meta
date.font = .preferredFont(forTextStyle: .caption1)
date.textColor = .tertiaryLabel
let stack = UIStackView(arrangedSubviews: [header, markdownView(comment.body), date, separator()])
stack.axis = .vertical
stack.spacing = 6
views.append(stack)
}
return views
}
private func sectionLabel(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .preferredFont(forTextStyle: .headline)
return label
}

View File

@@ -17,6 +17,8 @@ targets:
deploymentTarget: "17.0"
settings:
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.gotcha
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "1"
DEVELOPMENT_TEAM: MU22FMRGK8
CODE_SIGN_STYLE: Automatic
TARGETED_DEVICE_FAMILY: "1"
@@ -29,11 +31,15 @@ targets:
path: Info.plist
properties:
CFBundleDisplayName: Gotcha
CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
ITSAppUsesNonExemptEncryption: false
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
sources:
- Assets.xcassets
- PrivacyInfo.xcprivacy
- Sources
- Generated/gotcha_core.swift
dependencies: