Prepare Gotcha 1.0 for release
This commit is contained in:
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -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",
|
||||
|
||||
21
TESTING.md
21
TESTING.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
102
crates/app/src/core/content.rs
Normal file
102
crates/app/src/core/content.rs
Normal 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?,
|
||||
))
|
||||
}
|
||||
}
|
||||
265
crates/app/src/core/issues.rs
Normal file
265
crates/app/src/core/issues.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
109
crates/app/src/core/milestones.rs
Normal file
109
crates/app/src/core/milestones.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
6
crates/app/src/core/mod.rs
Normal file
6
crates/app/src/core/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod content;
|
||||
mod issues;
|
||||
mod milestones;
|
||||
mod pulls;
|
||||
mod repositories;
|
||||
mod servers;
|
||||
115
crates/app/src/core/pulls.rs
Normal file
115
crates/app/src/core/pulls.rs
Normal 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?,
|
||||
))
|
||||
}
|
||||
}
|
||||
50
crates/app/src/core/repositories.rs
Normal file
50
crates/app/src/core/repositories.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
144
crates/app/src/core/servers.rs
Normal file
144
crates/app/src/core/servers.rs
Normal 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?,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
256
crates/app/src/presentation/details.rs
Normal file
256
crates/app/src/presentation/details.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
115
crates/app/src/presentation/files.rs
Normal file
115
crates/app/src/presentation/files.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
198
crates/app/src/presentation/helpers.rs
Normal file
198
crates/app/src/presentation/helpers.rs
Normal 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()
|
||||
}
|
||||
229
crates/app/src/presentation/home.rs
Normal file
229
crates/app/src/presentation/home.rs
Normal 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)
|
||||
}
|
||||
272
crates/app/src/presentation/lists.rs
Normal file
272
crates/app/src/presentation/lists.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
387
crates/app/src/presentation/tests.rs
Normal file
387
crates/app/src/presentation/tests.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
45
crates/cli/src/tests.rs
Normal 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"));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
142
crates/cli/src/work_items/arguments.rs
Normal file
142
crates/cli/src/work_items/arguments.rs
Normal 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))
|
||||
}
|
||||
95
crates/cli/src/work_items/help.rs
Normal file
95
crates/cli/src/work_items/help.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
258
crates/cli/src/work_items/output.rs
Normal file
258
crates/cli/src/work_items/output.rs
Normal 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()
|
||||
}
|
||||
59
crates/cli/src/work_items/tests.rs
Normal file
59
crates/cli/src/work_items/tests.rs
Normal 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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
56
crates/gitea/src/issues/helpers.rs
Normal file
56
crates/gitea/src/issues/helpers.rs
Normal 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)
|
||||
}
|
||||
44
crates/gitea/src/issues/tests.rs
Normal file
44
crates/gitea/src/issues/tests.rs
Normal 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]));
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
97
crates/gitea/src/repositories/tests.rs
Normal file
97
crates/gitea/src/repositories/tests.rs
Normal 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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
14
ios/PrivacyInfo.xcprivacy
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
178
ios/Sources/CommitDetailScreen.swift
Normal file
178
ios/Sources/CommitDetailScreen.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
312
ios/Sources/CommitScreens.swift
Normal file
312
ios/Sources/CommitScreens.swift
Normal 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
95
ios/Sources/DiffScreen.swift
Normal file
95
ios/Sources/DiffScreen.swift
Normal 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
|
||||
}
|
||||
}
|
||||
295
ios/Sources/HomeScreen.swift
Normal file
295
ios/Sources/HomeScreen.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
113
ios/Sources/IssueActions.swift
Normal file
113
ios/Sources/IssueActions.swift
Normal 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 can’t 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)
|
||||
}
|
||||
}
|
||||
439
ios/Sources/IssueScreens.swift
Normal file
439
ios/Sources/IssueScreens.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
466
ios/Sources/MilestoneScreens.swift
Normal file
466
ios/Sources/MilestoneScreens.swift
Normal 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 Can’t 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 can’t 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
|
||||
}
|
||||
}
|
||||
323
ios/Sources/PullScreens.swift
Normal file
323
ios/Sources/PullScreens.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
199
ios/Sources/RepositoryDirectoryScreen.swift
Normal file
199
ios/Sources/RepositoryDirectoryScreen.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
383
ios/Sources/RepositoryFileScreens.swift
Normal file
383
ios/Sources/RepositoryFileScreens.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
137
ios/Sources/RepositoryListScreen.swift
Normal file
137
ios/Sources/RepositoryListScreen.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
177
ios/Sources/ServerScreens.swift
Normal file
177
ios/Sources/ServerScreens.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
495
ios/Sources/WorkItemDetailScreens.swift
Normal file
495
ios/Sources/WorkItemDetailScreens.swift
Normal 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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user