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]]
|
[[package]]
|
||||||
name = "gotcha-app"
|
name = "gotcha-app"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gotcha_gitea",
|
"gotcha_gitea",
|
||||||
"security-framework",
|
"security-framework",
|
||||||
@@ -457,7 +457,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gotcha-cli"
|
name = "gotcha-cli"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gotcha_gitea",
|
"gotcha_gitea",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
@@ -469,7 +469,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gotcha_gitea"
|
name = "gotcha_gitea"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gitea-client",
|
"gitea-client",
|
||||||
"reqwest",
|
"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
|
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
|
## Add Server and native text editing
|
||||||
|
|
||||||
- [ ] With no configured server, Issues and Repos show the Servers screen and
|
- [ ] 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
|
## Release sign-off
|
||||||
|
|
||||||
- [ ] All build gates passed.
|
- [ ] 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.
|
- [ ] Fresh-install and upgrade passes completed.
|
||||||
- [ ] Every supported iOS version and required device class completed.
|
- [ ] Every supported iOS version and required device class completed.
|
||||||
- [ ] Every scenario passed; any discovered failures were resolved and retested.
|
- [ ] Every scenario passed; any discovered failures were resolved and retested.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gotcha-app"
|
name = "gotcha-app"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "Lightweight Slint client for Gitea on iOS"
|
description = "Rust application core for the Gotcha iOS client"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.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::{
|
pub use gotcha_gitea::{
|
||||||
HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails,
|
HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails,
|
||||||
parse_api_date,
|
civil_from_days, days_from_civil, parse_api_date,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -146,31 +146,6 @@ pub fn open_status() -> String {
|
|||||||
"open".into()
|
"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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use gotcha_gitea::Client;
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
|
mod core;
|
||||||
mod domain;
|
mod domain;
|
||||||
mod presentation;
|
mod presentation;
|
||||||
mod storage;
|
mod storage;
|
||||||
@@ -65,766 +64,6 @@ pub struct GotchaCore {
|
|||||||
startup_error: Option<String>,
|
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>(
|
fn validate_repository<'a>(
|
||||||
owner: &'a str,
|
owner: &'a str,
|
||||||
repository: &'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]
|
[package]
|
||||||
name = "gotcha-cli"
|
name = "gotcha-cli"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "CLI test bed for the Gotcha Gitea client"
|
description = "Command-line Gitea client"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|||||||
@@ -463,50 +463,4 @@ impl Args {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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::{
|
use gotcha_gitea::{
|
||||||
Client, CreateIssue, EditIssue, IssueQuery,
|
Client, CreateIssue, EditIssue, IssueQuery,
|
||||||
models::{
|
models::{
|
||||||
ChangedFile, Comment, Commit, CreateIssueOption, CreateMilestoneOption,
|
CreateMilestoneOption, CreatePullRequestOption, EditMilestoneOption, EditPullRequestOption,
|
||||||
CreatePullRequestOption, EditIssueOption, EditMilestoneOption, EditPullRequestOption,
|
MergePullRequestOption,
|
||||||
Issue, MergePullRequestOption, Milestone, PullRequest, PullReview,
|
|
||||||
},
|
},
|
||||||
pull_state,
|
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, de::DeserializeOwned};
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
use crate::{
|
use crate::config::{RepositoryScope, Selection};
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
command: &[String],
|
command: &[String],
|
||||||
@@ -633,318 +400,13 @@ async fn pull_details(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_issues(issues: &[Issue]) {
|
mod arguments;
|
||||||
print_table(
|
mod help;
|
||||||
&[
|
mod output;
|
||||||
("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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_issue(issue: &Issue) {
|
use arguments::*;
|
||||||
println!(
|
pub(crate) use help::{domain_help, subcommand_help};
|
||||||
"#{} [{}] {}",
|
use output::*;
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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]
|
[package]
|
||||||
name = "gotcha_gitea"
|
name = "gotcha_gitea"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "A small, reusable Rust client for the Gitea API"
|
description = "A small, reusable Rust client for the Gitea API"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.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)
|
(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 days = days + 719_468;
|
||||||
let era = days.div_euclid(146_097);
|
let era = days.div_euclid(146_097);
|
||||||
let day_of_era = days - era * 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)
|
(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);
|
year -= i64::from(month <= 2);
|
||||||
let era = year.div_euclid(400);
|
let era = year.div_euclid(400);
|
||||||
let year_of_era = year - era * 400;
|
let year_of_era = year - era * 400;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::{
|
|||||||
CreateIssue, EditIssue, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, Page,
|
CreateIssue, EditIssue, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, Page,
|
||||||
RepositoryId,
|
RepositoryId,
|
||||||
},
|
},
|
||||||
models,
|
models, positive,
|
||||||
};
|
};
|
||||||
use gitea_openapi::apis;
|
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 {
|
mod helpers;
|
||||||
models::CreateIssueOption {
|
use helpers::*;
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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::{
|
pub use domain::{
|
||||||
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
|
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
|
||||||
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,
|
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 issues::comment_can_edit;
|
||||||
pub use pulls::{PullFileSource, pull_file_source, pull_state};
|
pub use pulls::{PullFileSource, pull_file_source, pull_state};
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
Configuration(String),
|
Configuration(String),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::collections::BTreeSet;
|
|||||||
use crate::{
|
use crate::{
|
||||||
Client, Error, Result,
|
Client, Error, Result,
|
||||||
domain::{DEFAULT_PAGE_SIZE, Page, PullDetails, RepositoryId},
|
domain::{DEFAULT_PAGE_SIZE, Page, PullDetails, RepositoryId},
|
||||||
models,
|
models, positive, validate_page,
|
||||||
};
|
};
|
||||||
use gitea_openapi::apis;
|
use gitea_openapi::apis;
|
||||||
|
|
||||||
@@ -323,21 +323,3 @@ impl Client {
|
|||||||
.map_err(Error::generated)
|
.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::{
|
use crate::{
|
||||||
Client, Error, Result,
|
Client, Error, Result,
|
||||||
domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, RepositoryId},
|
domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, RepositoryId},
|
||||||
models,
|
models, validate_page,
|
||||||
};
|
};
|
||||||
use gitea_openapi::apis;
|
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())
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_CONSTRUCTOR_GOTCHACORE_NEW
|
||||||
uint64_t uniffi_gotcha_core_fn_constructor_gotchacore_new(RustCallStatus *_Nonnull out_status
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS
|
#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
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer branch, RustBuffer path, uint32_t pages
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_ISSUE
|
||||||
#define 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
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE
|
||||||
#define 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
|
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
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issues(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE
|
||||||
#define 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
|
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
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestones(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL
|
||||||
#define 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
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, uint32_t page
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_FILTERS
|
||||||
#define 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
|
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
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_pulls(uint64_t ptr, uint32_t page
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORIES
|
||||||
#define 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
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repositories(uint64_t ptr, uint32_t page, RustBuffer pane
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE
|
||||||
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_contents(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer pane, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
|
||||||
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_index(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
|
||||||
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
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_name(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER
|
||||||
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
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_add_server(uint64_t ptr, RustBuffer name, RustBuffer url, RustBuffer token
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
||||||
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id, RustBuffer draft
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page, RustBuffer filter
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
|
#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
|
void uniffi_gotcha_core_fn_method_gotchacore_set_appearance(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
||||||
#define 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
|
void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
|
||||||
#define 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
|
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
|
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_startup_error(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC
|
||||||
#define 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
|
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
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID
|
||||||
#define 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
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DETAILS
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMITS
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commits(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_ISSUE
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_ISSUE
|
||||||
#define 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
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUES
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issues(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONE
|
#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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_milestone(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull_diff(void
|
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
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULLS
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pulls(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORIES
|
#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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_contents(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_toggle_favorite(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_index(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_name(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ADD_SERVER
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ADD_SERVER
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_add_server(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_milestone(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_APPEARANCE
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_appearance(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
||||||
#define 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
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_STARTUP_ERROR
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_startup_error(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_CONSTRUCTOR_GOTCHACORE_NEW
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_CONSTRUCTOR_GOTCHACORE_NEW
|
||||||
|
|||||||
@@ -7,35 +7,57 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */; };
|
||||||
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; };
|
25B30DCE869158C89021EFC6 /* DiffScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E0F726C9668B50418D9ADF /* DiffScreen.swift */; };
|
||||||
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.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 */; };
|
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 */; };
|
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 */; };
|
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; };
|
||||||
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.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 */; };
|
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722405F999984C7CBE838711 /* IssueEditorViewController.swift */; };
|
||||||
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
||||||
D78A121E9ADA0B72E2CF94DC /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8AD463F3AC817A44306C2297 /* MarkdownUI */; };
|
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 */; };
|
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; };
|
||||||
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61515849F6AACD721FE915C /* AppContext.swift */; };
|
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61515849F6AACD721FE915C /* AppContext.swift */; };
|
||||||
F55A89489B2758D694F3B27D /* Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75B3E4FFB9C9992517C4D69 /* Support.swift */; };
|
F55A89489B2758D694F3B27D /* Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75B3E4FFB9C9992517C4D69 /* Support.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
FE12480725C13293FAFDDA52 /* gotcha_core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = gotcha_core.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -58,13 +80,23 @@
|
|||||||
F61515849F6AACD721FE915C /* AppContext.swift */,
|
F61515849F6AACD721FE915C /* AppContext.swift */,
|
||||||
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
|
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
|
||||||
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */,
|
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */,
|
||||||
CE5E10787062D1175125BB4C /* ContentScreens.swift */,
|
81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */,
|
||||||
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */,
|
7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */,
|
||||||
|
41E0F726C9668B50418D9ADF /* DiffScreen.swift */,
|
||||||
|
D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */,
|
||||||
|
17264C5C909501D8ADEC00B7 /* IssueActions.swift */,
|
||||||
722405F999984C7CBE838711 /* IssueEditorViewController.swift */,
|
722405F999984C7CBE838711 /* IssueEditorViewController.swift */,
|
||||||
AC828923A1E11A58AE348A74 /* ListScreens.swift */,
|
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */,
|
||||||
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.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 */,
|
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */,
|
||||||
F75B3E4FFB9C9992517C4D69 /* Support.swift */,
|
F75B3E4FFB9C9992517C4D69 /* Support.swift */,
|
||||||
|
0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */,
|
||||||
);
|
);
|
||||||
path = Sources;
|
path = Sources;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -81,6 +113,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */,
|
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */,
|
||||||
|
F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */,
|
||||||
94721140EFE7F8E7CD0F5C0B /* Generated */,
|
94721140EFE7F8E7CD0F5C0B /* Generated */,
|
||||||
710A50F51478401FC642E6E3 /* Sources */,
|
710A50F51478401FC642E6E3 /* Sources */,
|
||||||
F059299C038F3CAFCE470831 /* Products */,
|
F059299C038F3CAFCE470831 /* Products */,
|
||||||
@@ -164,6 +197,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */,
|
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */,
|
||||||
|
A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -200,13 +234,23 @@
|
|||||||
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */,
|
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */,
|
||||||
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
|
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
|
||||||
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */,
|
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */,
|
||||||
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */,
|
CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */,
|
||||||
25BDBDED14B3B886E19F511C /* DetailScreens.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 */,
|
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */,
|
||||||
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */,
|
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */,
|
||||||
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.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 */,
|
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */,
|
||||||
F55A89489B2758D694F3B27D /* Support.swift in Sources */,
|
F55A89489B2758D694F3B27D /* Support.swift in Sources */,
|
||||||
|
8A42B4319AF343566D77D12A /* WorkItemDetailScreens.swift in Sources */,
|
||||||
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */,
|
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -221,6 +265,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = MU22FMRGK8;
|
DEVELOPMENT_TEAM = MU22FMRGK8;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
@@ -229,6 +274,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -245,6 +291,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = MU22FMRGK8;
|
DEVELOPMENT_TEAM = MU22FMRGK8;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
@@ -253,6 +300,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust";
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
OTHER_LDFLAGS = "$(inherited) -lgotcha_core";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|||||||
@@ -17,9 +17,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<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) {
|
func route(_ activity: ActivityRow) {
|
||||||
switch activity.target {
|
switch activity.target {
|
||||||
case "repository":
|
case .repository:
|
||||||
tabs.selectedIndex = 1
|
tabs.selectedIndex = 1
|
||||||
navigationControllers[1].pushViewController(
|
navigationControllers[1].pushViewController(
|
||||||
IssuesViewController(
|
IssuesViewController(
|
||||||
@@ -83,7 +83,7 @@ final class AppContext {
|
|||||||
),
|
),
|
||||||
animated: true
|
animated: true
|
||||||
)
|
)
|
||||||
case "issue":
|
case .issue:
|
||||||
tabs.selectedIndex = 1
|
tabs.selectedIndex = 1
|
||||||
navigationControllers[1].pushViewController(
|
navigationControllers[1].pushViewController(
|
||||||
IssueViewController(
|
IssueViewController(
|
||||||
@@ -94,7 +94,7 @@ final class AppContext {
|
|||||||
),
|
),
|
||||||
animated: true
|
animated: true
|
||||||
)
|
)
|
||||||
case "pull":
|
case .pullRequest:
|
||||||
tabs.selectedIndex = 3
|
tabs.selectedIndex = 3
|
||||||
navigationControllers[3].pushViewController(
|
navigationControllers[3].pushViewController(
|
||||||
PullViewController(
|
PullViewController(
|
||||||
@@ -105,7 +105,7 @@ final class AppContext {
|
|||||||
),
|
),
|
||||||
animated: true
|
animated: true
|
||||||
)
|
)
|
||||||
case "commit":
|
case .commit:
|
||||||
tabs.selectedIndex = 2
|
tabs.selectedIndex = 2
|
||||||
navigationControllers[2].pushViewController(
|
navigationControllers[2].pushViewController(
|
||||||
FilesViewController(
|
FilesViewController(
|
||||||
@@ -116,7 +116,7 @@ final class AppContext {
|
|||||||
),
|
),
|
||||||
animated: true
|
animated: true
|
||||||
)
|
)
|
||||||
default:
|
case .none:
|
||||||
break
|
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) {
|
func configureRepositoryContentCell(_ cell: UITableViewCell, row: RepositoryContentRow) {
|
||||||
let directory = row.kind == "dir"
|
let directory = row.kind == .directory
|
||||||
let detail = directory
|
let detail = directory
|
||||||
? "Folder"
|
? "Folder"
|
||||||
: ByteCountFormatter.string(fromByteCount: row.size, countStyle: .file)
|
: ByteCountFormatter.string(fromByteCount: row.size, countStyle: .file)
|
||||||
@@ -242,7 +242,7 @@ func showRepositoryContent(
|
|||||||
repository: String,
|
repository: String,
|
||||||
navigationController: UINavigationController?
|
navigationController: UINavigationController?
|
||||||
) {
|
) {
|
||||||
let destination: UIViewController = row.kind == "dir"
|
let destination: UIViewController = row.kind == .directory
|
||||||
? RepositoryDirectoryViewController(
|
? RepositoryDirectoryViewController(
|
||||||
context: context,
|
context: context,
|
||||||
owner: owner,
|
owner: owner,
|
||||||
@@ -277,7 +277,7 @@ func symbolText(_ symbol: String, text: String, font: UIFont, color: UIColor) ->
|
|||||||
|
|
||||||
func configureIssueStateIcon(
|
func configureIssueStateIcon(
|
||||||
_ icon: UIImageView,
|
_ icon: UIImageView,
|
||||||
state: String,
|
state: WorkItemState,
|
||||||
textStyle: UIFont.TextStyle
|
textStyle: UIFont.TextStyle
|
||||||
) {
|
) {
|
||||||
configureOpenClosedStateIcon(icon, state: state, subject: "issue", textStyle: textStyle)
|
configureOpenClosedStateIcon(icon, state: state, subject: "issue", textStyle: textStyle)
|
||||||
@@ -285,7 +285,7 @@ func configureIssueStateIcon(
|
|||||||
|
|
||||||
func configureOpenClosedStateIcon(
|
func configureOpenClosedStateIcon(
|
||||||
_ icon: UIImageView,
|
_ icon: UIImageView,
|
||||||
state: String,
|
state: WorkItemState,
|
||||||
subject: String,
|
subject: String,
|
||||||
textStyle: UIFont.TextStyle
|
textStyle: UIFont.TextStyle
|
||||||
) {
|
) {
|
||||||
@@ -293,15 +293,15 @@ func configureOpenClosedStateIcon(
|
|||||||
let color: UIColor
|
let color: UIColor
|
||||||
let accessibilityLabel: String
|
let accessibilityLabel: String
|
||||||
switch state {
|
switch state {
|
||||||
case "open":
|
case .open:
|
||||||
(symbol, color, accessibilityLabel) = (
|
(symbol, color, accessibilityLabel) = (
|
||||||
"exclamationmark.circle.fill", .systemGreen, "Open \(subject)"
|
"exclamationmark.circle.fill", .systemGreen, "Open \(subject)"
|
||||||
)
|
)
|
||||||
case "closed":
|
case .closed:
|
||||||
(symbol, color, accessibilityLabel) = (
|
(symbol, color, accessibilityLabel) = (
|
||||||
"checkmark.circle.fill", .systemPurple, "Closed \(subject)"
|
"checkmark.circle.fill", .systemPurple, "Closed \(subject)"
|
||||||
)
|
)
|
||||||
default:
|
case .unknown:
|
||||||
(symbol, color, accessibilityLabel) = (
|
(symbol, color, accessibilityLabel) = (
|
||||||
"questionmark.circle.fill", .systemGray, "Unknown \(subject) state"
|
"questionmark.circle.fill", .systemGray, "Unknown \(subject) state"
|
||||||
)
|
)
|
||||||
@@ -385,3 +385,10 @@ final class IssueLabelsView: UIView {
|
|||||||
setNeedsLayout()
|
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"
|
deploymentTarget: "17.0"
|
||||||
settings:
|
settings:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.gotcha
|
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.gotcha
|
||||||
|
MARKETING_VERSION: "1.0"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
DEVELOPMENT_TEAM: MU22FMRGK8
|
DEVELOPMENT_TEAM: MU22FMRGK8
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
TARGETED_DEVICE_FAMILY: "1"
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
@@ -29,11 +31,15 @@ targets:
|
|||||||
path: Info.plist
|
path: Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Gotcha
|
CFBundleDisplayName: Gotcha
|
||||||
|
CFBundleShortVersionString: "$(MARKETING_VERSION)"
|
||||||
|
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
|
||||||
|
ITSAppUsesNonExemptEncryption: false
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UISupportedInterfaceOrientations:
|
UISupportedInterfaceOrientations:
|
||||||
- UIInterfaceOrientationPortrait
|
- UIInterfaceOrientationPortrait
|
||||||
sources:
|
sources:
|
||||||
- Assets.xcassets
|
- Assets.xcassets
|
||||||
|
- PrivacyInfo.xcprivacy
|
||||||
- Sources
|
- Sources
|
||||||
- Generated/gotcha_core.swift
|
- Generated/gotcha_core.swift
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user