From 46cca8fdd2f582ae1a08b9d6d7b8e3c1788fa126 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Mon, 3 Aug 2026 17:54:07 +0200 Subject: [PATCH] Prepare Gotcha 1.0 for release --- Cargo.lock | 6 +- TESTING.md | 21 + crates/app/Cargo.toml | 4 +- crates/app/src/core/content.rs | 102 ++ crates/app/src/core/issues.rs | 265 +++ crates/app/src/core/milestones.rs | 109 ++ crates/app/src/core/mod.rs | 6 + crates/app/src/core/pulls.rs | 115 ++ crates/app/src/core/repositories.rs | 50 + crates/app/src/core/servers.rs | 144 ++ crates/app/src/domain.rs | 27 +- crates/app/src/lib.rs | 765 +-------- crates/app/src/presentation.rs | 1462 +--------------- crates/app/src/presentation/details.rs | 256 +++ crates/app/src/presentation/files.rs | 115 ++ crates/app/src/presentation/helpers.rs | 198 +++ crates/app/src/presentation/home.rs | 229 +++ crates/app/src/presentation/lists.rs | 272 +++ crates/app/src/presentation/tests.rs | 387 +++++ crates/cli/Cargo.toml | 4 +- crates/cli/src/main.rs | 48 +- crates/cli/src/tests.rs | 45 + crates/cli/src/work_items.rs | 560 +------ crates/cli/src/work_items/arguments.rs | 142 ++ crates/cli/src/work_items/help.rs | 95 ++ crates/cli/src/work_items/output.rs | 258 +++ crates/cli/src/work_items/tests.rs | 59 + crates/gitea/Cargo.toml | 2 +- crates/gitea/src/domain.rs | 4 +- crates/gitea/src/issues.rs | 111 +- crates/gitea/src/issues/helpers.rs | 56 + crates/gitea/src/issues/tests.rs | 44 + crates/gitea/src/lib.rs | 20 +- crates/gitea/src/pulls.rs | 20 +- crates/gitea/src/repositories.rs | 111 +- crates/gitea/src/repositories/tests.rs | 97 ++ ios/Generated/gotcha_core.swift | 1191 +++++++++---- ios/Generated/gotcha_coreFFI.h | 346 ++-- ios/Gotcha.xcodeproj/project.pbxproj | 72 +- ios/Info.plist | 6 +- ios/PrivacyInfo.xcprivacy | 14 + ios/Sources/AppContext.swift | 10 +- ios/Sources/CommitDetailScreen.swift | 178 ++ ios/Sources/CommitScreens.swift | 312 ++++ ios/Sources/ContentScreens.swift | 1651 ------------------- ios/Sources/DetailScreens.swift | 1343 --------------- ios/Sources/DiffScreen.swift | 95 ++ ios/Sources/HomeScreen.swift | 295 ++++ ios/Sources/IssueActions.swift | 113 ++ ios/Sources/IssueScreens.swift | 439 +++++ ios/Sources/ListScreens.swift | 608 ------- ios/Sources/MilestoneScreens.swift | 466 ++++++ ios/Sources/PullScreens.swift | 323 ++++ ios/Sources/RepositoryDirectoryScreen.swift | 199 +++ ios/Sources/RepositoryFileScreens.swift | 383 +++++ ios/Sources/RepositoryListScreen.swift | 137 ++ ios/Sources/ServerScreens.swift | 177 ++ ios/Sources/Support.swift | 21 +- ios/Sources/WorkItemDetailScreens.swift | 495 ++++++ ios/project.yml | 6 + 60 files changed, 7916 insertions(+), 7173 deletions(-) create mode 100644 crates/app/src/core/content.rs create mode 100644 crates/app/src/core/issues.rs create mode 100644 crates/app/src/core/milestones.rs create mode 100644 crates/app/src/core/mod.rs create mode 100644 crates/app/src/core/pulls.rs create mode 100644 crates/app/src/core/repositories.rs create mode 100644 crates/app/src/core/servers.rs create mode 100644 crates/app/src/presentation/details.rs create mode 100644 crates/app/src/presentation/files.rs create mode 100644 crates/app/src/presentation/helpers.rs create mode 100644 crates/app/src/presentation/home.rs create mode 100644 crates/app/src/presentation/lists.rs create mode 100644 crates/app/src/presentation/tests.rs create mode 100644 crates/cli/src/tests.rs create mode 100644 crates/cli/src/work_items/arguments.rs create mode 100644 crates/cli/src/work_items/help.rs create mode 100644 crates/cli/src/work_items/output.rs create mode 100644 crates/cli/src/work_items/tests.rs create mode 100644 crates/gitea/src/issues/helpers.rs create mode 100644 crates/gitea/src/issues/tests.rs create mode 100644 crates/gitea/src/repositories/tests.rs create mode 100644 ios/PrivacyInfo.xcprivacy create mode 100644 ios/Sources/CommitDetailScreen.swift create mode 100644 ios/Sources/CommitScreens.swift delete mode 100644 ios/Sources/ContentScreens.swift delete mode 100644 ios/Sources/DetailScreens.swift create mode 100644 ios/Sources/DiffScreen.swift create mode 100644 ios/Sources/HomeScreen.swift create mode 100644 ios/Sources/IssueActions.swift create mode 100644 ios/Sources/IssueScreens.swift delete mode 100644 ios/Sources/ListScreens.swift create mode 100644 ios/Sources/MilestoneScreens.swift create mode 100644 ios/Sources/PullScreens.swift create mode 100644 ios/Sources/RepositoryDirectoryScreen.swift create mode 100644 ios/Sources/RepositoryFileScreens.swift create mode 100644 ios/Sources/RepositoryListScreen.swift create mode 100644 ios/Sources/ServerScreens.swift create mode 100644 ios/Sources/WorkItemDetailScreens.swift diff --git a/Cargo.lock b/Cargo.lock index b408e4e..3959882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -444,7 +444,7 @@ dependencies = [ [[package]] name = "gotcha-app" -version = "0.1.0" +version = "1.0.0" dependencies = [ "gotcha_gitea", "security-framework", @@ -457,7 +457,7 @@ dependencies = [ [[package]] name = "gotcha-cli" -version = "0.1.0" +version = "1.0.0" dependencies = [ "gotcha_gitea", "rpassword", @@ -469,7 +469,7 @@ dependencies = [ [[package]] name = "gotcha_gitea" -version = "0.1.0" +version = "1.0.0" dependencies = [ "gitea-client", "reqwest", diff --git a/TESTING.md b/TESTING.md index 3e8499a..12f4fe7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,6 +70,25 @@ xcrun simctl install booted "$gotcha_build_dir/Gotcha.app" xcrun simctl launch booted de.rfc1437.gotcha ``` +Before App Store submission, create a signed Release archive and inspect the +packaged application rather than relying on Debug build settings: + +```sh +cd ios +xcodebuild \ + -project Gotcha.xcodeproj \ + -scheme Gotcha \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath /private/tmp/Gotcha.xcarchive \ + archive +``` + +Verify the archived app reports version `1.0` and a positive build number, is +signed for distribution with the expected bundle identifier and entitlements, +contains `PrivacyInfo.xcprivacy`, and declares the correct export-compliance +answer before uploading it to App Store Connect. + ## Add Server and native text editing - [ ] With no configured server, Issues and Repos show the Servers screen and @@ -358,6 +377,8 @@ xcrun simctl launch booted de.rfc1437.gotcha ## Release sign-off - [ ] All build gates passed. +- [ ] A signed Release archive passed the version, identity, entitlement, + privacy-manifest, and export-compliance checks above. - [ ] Fresh-install and upgrade passes completed. - [ ] Every supported iOS version and required device class completed. - [ ] Every scenario passed; any discovered failures were resolved and retested. diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index c5727b7..8c4ab67 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "gotcha-app" -version = "0.1.0" -description = "Lightweight Slint client for Gitea on iOS" +version = "1.0.0" +description = "Rust application core for the Gotcha iOS client" edition.workspace = true license.workspace = true rust-version.workspace = true diff --git a/crates/app/src/core/content.rs b/crates/app/src/core/content.rs new file mode 100644 index 0000000..85a35e3 --- /dev/null +++ b/crates/app/src/core/content.rs @@ -0,0 +1,102 @@ +use crate::*; + +#[uniffi::export(async_runtime = "tokio")] +impl GotchaCore { + pub async fn commits( + &self, + owner: String, + repository: String, + branch: Option, + path: String, + pages: u32, + ) -> Result { + 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, 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 { + 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, + ) -> Result { + 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 { + 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 { + Ok(diff_page( + &path, + load_pull_diff(&self.server()?, &owner, &repository, number, &path).await?, + )) + } +} diff --git a/crates/app/src/core/issues.rs b/crates/app/src/core/issues.rs new file mode 100644 index 0000000..e959c25 --- /dev/null +++ b/crates/app/src/core/issues.rs @@ -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 { + 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 { + 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 { + 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, + 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 { + 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, + 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, + ) -> Result { + 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, + title: String, + body: String, + label_ids: Vec, + milestone_id: Option, + due_date: Option, + closed: bool, + ) -> Result { + 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::>() + .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(()) + } +} diff --git a/crates/app/src/core/milestones.rs b/crates/app/src/core/milestones.rs new file mode 100644 index 0000000..7686f96 --- /dev/null +++ b/crates/app/src/core/milestones.rs @@ -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 { + 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 { + 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, + ) -> Result { + 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, + draft: MilestoneEditorPage, + ) -> Result { + 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(()) + } +} diff --git a/crates/app/src/core/mod.rs b/crates/app/src/core/mod.rs new file mode 100644 index 0000000..7691915 --- /dev/null +++ b/crates/app/src/core/mod.rs @@ -0,0 +1,6 @@ +mod content; +mod issues; +mod milestones; +mod pulls; +mod repositories; +mod servers; diff --git a/crates/app/src/core/pulls.rs b/crates/app/src/core/pulls.rs new file mode 100644 index 0000000..66343c2 --- /dev/null +++ b/crates/app/src/core/pulls.rs @@ -0,0 +1,115 @@ +use crate::*; + +#[uniffi::export(async_runtime = "tokio")] +impl GotchaCore { + pub async fn pull_filters(&self) -> Result { + 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 { + 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 { + 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 { + Ok(pull_page( + load_pull( + &self.server()?, + &owner, + &repository, + number, + valid_page(page)?, + ) + .await?, + )) + } +} diff --git a/crates/app/src/core/repositories.rs b/crates/app/src/core/repositories.rs new file mode 100644 index 0000000..1e51833 --- /dev/null +++ b/crates/app/src/core/repositories.rs @@ -0,0 +1,50 @@ +use crate::*; + +#[uniffi::export(async_runtime = "tokio")] +impl GotchaCore { + pub async fn repositories( + &self, + page: u32, + pane: RepositoryPane, + ) -> Result { + 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, 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)) + } +} diff --git a/crates/app/src/core/servers.rs b/crates/app/src/core/servers.rs new file mode 100644 index 0000000..7a08ebd --- /dev/null +++ b/crates/app/src/core/servers.rs @@ -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 { + 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 { + self.startup_error.clone() + } + + pub fn servers(&self) -> Vec { + 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 { + self.state + .lock() + .unwrap() + .active_server + .map(|index| index as u32) + } + + pub fn active_server_name(&self) -> Option { + 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 { + 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 { + let server = self.server()?; + let name = server.name.clone(); + Ok(home_page( + name, + load_home(&server, valid_page(page)?, filter.into()).await?, + )) + } +} diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index 3c2513c..679175c 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; pub use gotcha_gitea::{ HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails, - parse_api_date, + civil_from_days, days_from_civil, parse_api_date, }; use serde::{Deserialize, Serialize}; @@ -146,31 +146,6 @@ pub fn open_status() -> String { "open".into() } -pub fn civil_from_days(days: i64) -> (i64, i64, i64) { - let days = days + 719_468; - let era = days.div_euclid(146_097); - let day_of_era = days - era * 146_097; - let year_of_era = - (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; - let mut year = year_of_era + era * 400; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let month_prime = (5 * day_of_year + 2) / 153; - let day = day_of_year - (153 * month_prime + 2) / 5 + 1; - let month = month_prime + if month_prime < 10 { 3 } else { -9 }; - year += i64::from(month <= 2); - (year, month, day) -} - -pub fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 { - year -= i64::from(month <= 2); - let era = year.div_euclid(400); - let year_of_era = year - era * 400; - let month_prime = month + if month > 2 { -3 } else { 9 }; - let day_of_year = (153 * month_prime + 2) / 5 + day - 1; - let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; - era * 146_097 + day_of_era - 719_468 -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 1582589..13a6d94 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -1,9 +1,8 @@ -use std::sync::{Arc, Mutex}; - -use gotcha_gitea::Client; +use std::sync::Mutex; use thiserror::Error; mod api; +mod core; mod domain; mod presentation; mod storage; @@ -65,766 +64,6 @@ pub struct GotchaCore { startup_error: Option, } -#[uniffi::export(async_runtime = "tokio")] -impl GotchaCore { - #[uniffi::constructor] - pub fn new() -> Arc { - 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 { - self.startup_error.clone() - } - - pub fn servers(&self) -> Vec { - 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 { - self.state - .lock() - .unwrap() - .active_server - .map(|index| index as u32) - } - - pub fn active_server_name(&self) -> Option { - 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 { - 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 { - 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 { - 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, 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 { - 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 { - 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 { - 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, - 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 { - 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, - 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, - ) -> Result { - 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, - title: String, - body: String, - label_ids: Vec, - milestone_id: Option, - due_date: Option, - closed: bool, - ) -> Result { - 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::>() - .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 { - 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 { - 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, - ) -> Result { - 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, - draft: MilestoneEditorPage, - ) -> Result { - 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 { - 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 { - 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 { - 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 { - 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, - path: String, - pages: u32, - ) -> Result { - 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, 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 { - 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, - ) -> Result { - 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 { - 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 { - Ok(diff_page( - &path, - load_pull_diff(&self.server()?, &owner, &repository, number, &path).await?, - )) - } -} - fn validate_repository<'a>( owner: &'a str, repository: &'a str, diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index a42afc7..fbd4d72 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -7,6 +7,13 @@ use crate::domain::{ PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date, }; +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum WorkItemState { + Open, + Closed, + Unknown, +} + #[derive(Clone, uniffi::Record)] pub struct ServerRow { pub name: String, @@ -38,7 +45,7 @@ pub struct LabelRow { #[derive(Clone, uniffi::Record)] pub struct IssueRow { pub number: i64, - pub state: String, + pub state: WorkItemState, pub title: String, pub summary: String, pub meta: String, @@ -57,7 +64,7 @@ pub struct PullRow { pub number: i64, pub owner: String, pub repository: String, - pub state: String, + pub state: WorkItemState, pub title: String, pub summary: String, pub meta: String, @@ -75,7 +82,7 @@ pub struct CommentRow { #[derive(Clone, uniffi::Record)] pub struct IssuePage { pub title: String, - pub state: String, + pub state: WorkItemState, pub meta: String, pub milestone: String, pub labels: Vec, @@ -121,7 +128,7 @@ pub struct IssueFilterOptions { #[derive(Clone, uniffi::Record)] pub struct MilestoneRow { pub id: i64, - pub state: String, + pub state: WorkItemState, pub title: String, pub description: String, pub meta: String, @@ -162,7 +169,7 @@ pub struct PullFilterOptions { #[derive(Clone, uniffi::Record)] pub struct PullPage { pub title: String, - pub state: String, + pub state: WorkItemState, pub meta: String, pub body: String, pub files_ref: String, @@ -202,6 +209,7 @@ pub struct CommitPage { pub struct CommitMetadataRow { pub label: String, pub value: String, + pub monospaced: bool, } #[derive(Clone, uniffi::Record)] @@ -228,25 +236,47 @@ pub struct FileListPage { pub struct RepositoryContentRow { pub name: String, pub path: String, - pub kind: String, + pub kind: RepositoryContentKind, pub size: i64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum RepositoryContentKind { + Directory, + File, +} + #[derive(Clone, uniffi::Record)] pub struct RepositoryFilePage { pub name: String, - pub kind: String, + pub kind: RepositoryFileKind, pub language: String, pub text: String, pub data: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum RepositoryFileKind { + Markdown, + Source, + Preview, +} + #[derive(Clone, uniffi::Record)] pub struct DiffLine { pub old_number: String, pub new_number: String, pub text: String, - pub kind: String, + pub kind: DiffLineKind, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum DiffLineKind { + Addition, + Removal, + Hunk, + Header, + Context, } #[derive(Clone, uniffi::Record)] @@ -258,17 +288,37 @@ pub struct DiffPage { #[derive(Clone, uniffi::Record)] pub struct ActivityRow { - pub icon: String, + pub icon: ActivityIcon, pub title: String, pub detail: String, pub meta: String, - pub target: String, + pub target: ActivityTargetKind, pub owner: String, pub repository: String, pub number: i64, pub sha: String, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum ActivityIcon { + Repository, + Issue, + PullRequest, + Branch, + Tag, + Push, + Release, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum ActivityTargetKind { + None, + Repository, + Issue, + PullRequest, + Commit, +} + #[derive(Clone, Copy, Debug, uniffi::Enum)] pub enum HomeActivityFilter { All, @@ -301,1387 +351,17 @@ pub struct HomePage { pub next_page: Option, } -pub fn repository_rows( - repositories: &[RepositoryData], - is_favorite: impl Fn(&RepositoryData) -> bool, -) -> Vec { - 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() -} +mod details; +mod files; +mod helpers; +mod home; +mod lists; -pub fn issue_rows(issues: &[models::Issue]) -> Vec { - issues - .iter() - .map(|issue| IssueRow { - number: issue.number.unwrap_or_default(), - state: issue.state.clone().unwrap_or_else(|| "unknown".into()), - 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 { - 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) -> 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 { - 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: pull.state.clone().unwrap_or_else(|| "unknown".into()), - 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, - 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, - } -} - -pub fn issue_page(details: IssueDetails) -> IssuePage { - IssuePage { - title: details - .issue - .title - .clone() - .unwrap_or_else(|| "Untitled issue".into()), - state: details - .issue - .state - .clone() - .unwrap_or_else(|| "unknown".into()), - 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: pull.state.clone().unwrap_or_else(|| "unknown".into()), - 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) -> Vec { - files.iter().filter_map(file_row).collect() -} - -pub fn commit_file_rows(files: Vec) -> Vec { - 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) -> 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::>().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)); - } - if let Some(committer) = committer { - metadata.push(metadata_row("Committer", committer)); - } - if date.is_some() { - metadata.push(metadata_row("Committed", compact_date(date))); - } - if let Some(branch) = branch.filter(|branch| !branch.is_empty()) { - metadata.push(metadata_row("Branch", branch)); - } - metadata.push(metadata_row( - "Commit", - commit.sha.clone().unwrap_or_else(|| "unknown".into()), - )); - 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(), - }, - )); - CommitDetailsPage { - title, - description, - metadata, - files: commit_file_rows(files), - } -} - -fn commit_user(user: &models::CommitUser) -> Option { - 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) -> CommitMetadataRow { - CommitMetadataRow { - label: label.into(), - value, - } -} - -pub fn repository_content_rows( - contents: Vec, -) -> Vec { - let mut rows: Vec<_> = contents - .into_iter() - .filter_map(|content| { - Some(RepositoryContentRow { - name: content.name?, - path: content.path?, - kind: content.r#type.unwrap_or_else(|| "file".into()), - size: content.size.unwrap_or_default(), - }) - }) - .collect(); - rows.sort_by_key(|row| (row.kind != "dir", row.name.to_lowercase())); - rows -} - -pub fn repository_file_page(path: &str, data: Vec) -> 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: "preview".into(), - language, - text: String::new(), - data, - }; - } - match String::from_utf8(data) { - Ok(text) => RepositoryFilePage { - name, - kind: if markdown { "markdown" } else { "source" }.into(), - language, - text, - data: Vec::new(), - }, - Err(error) => RepositoryFilePage { - name, - kind: "preview".into(), - 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: line.kind.into(), - }) - .collect(), - } -} - -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() => ( - "branch-create", - format!("Created branch {branch} in {repository}"), - ), - Some(OpType::CommitRepo | OpType::MirrorSyncPush) => { - ("push", format!("Pushed to {branch} in {repository}")) - } - Some(OpType::CreateRepo) => ("repo-create", format!("Created {repository}")), - Some(OpType::RenameRepo) => ("repo-rename", format!("Renamed {repository}")), - Some(OpType::StarRepo) => ("repo-star", format!("Starred {repository}")), - Some(OpType::WatchRepo) => ("repo-watch", format!("Started watching {repository}")), - Some(OpType::CreateIssue) => ("issue-open", format!("Opened an issue in {repository}")), - Some(OpType::CloseIssue) => ("issue-close", format!("Closed an issue in {repository}")), - Some(OpType::ReopenIssue) => ("issue-reopen", format!("Reopened an issue in {repository}")), - Some(OpType::CommentIssue) => ( - "issue-comment", - format!("Commented on an issue in {repository}"), - ), - Some(OpType::CreatePullRequest) => ( - "pull-open", - format!("Opened a pull request in {repository}"), - ), - Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => ( - "pull-merge", - format!("Merged a pull request in {repository}"), - ), - Some(OpType::ClosePullRequest) => ( - "pull-close", - format!("Closed a pull request in {repository}"), - ), - Some(OpType::ReopenPullRequest) => ( - "pull-reopen", - format!("Reopened a pull request in {repository}"), - ), - Some(OpType::CommentPull) => ( - "pull-comment", - format!("Commented on a pull request in {repository}"), - ), - Some(OpType::ApprovePullRequest) => ( - "pull-approve", - format!("Approved a pull request in {repository}"), - ), - Some(OpType::RejectPullRequest) => { - ("pull-reject", format!("Requested changes in {repository}")) - } - Some(OpType::PushTag) => ("tag-push", format!("Pushed tag {branch} in {repository}")), - Some(OpType::DeleteTag) => ( - "tag-delete", - format!("Deleted tag {branch} in {repository}"), - ), - Some(OpType::DeleteBranch) => ( - "branch-delete", - format!("Deleted branch {branch} in {repository}"), - ), - Some(OpType::PublishRelease) => ("release", format!("Published a release in {repository}")), - Some(_) | None => ("update", format!("Updated {repository}")), - }; - let target = activity::target(activity); - let (target, owner, repository, number, sha) = match target { - Some(activity::Target::Repository { owner, repository }) => { - ("repository", owner, repository, 0, String::new()) - } - Some(activity::Target::Issue { - owner, - repository, - number, - }) => ("issue", owner, repository, number, String::new()), - Some(activity::Target::Pull { - owner, - repository, - number, - }) => ("pull", owner, repository, number, String::new()), - Some(activity::Target::Commit { - owner, - repository, - sha, - }) => ("commit", owner, repository, 0, sha), - None => ("", String::new(), String::new(), 0, String::new()), - }; - ActivityRow { - icon: icon.into(), - title, - detail: activity_detail(activity), - meta: compact_date(activity.created.as_deref()), - target: target.into(), - 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::(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 - } -} - -fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec, 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) -} - -fn label_row(label: &models::Label) -> Option { - 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, - }) -} - -fn comment_row(comment: &models::Comment, viewer_id: Option) -> 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), - } -} - -fn file_row(file: &models::ChangedFile) -> Option { - Some(FileRow { - path: file.filename.clone()?, - status: file.status.clone().unwrap_or_else(|| "modified".into()), - }) -} - -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(), - } -} - -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() - ) -} - -fn issue_milestone(issue: &models::Issue) -> String { - issue - .milestone - .as_ref() - .and_then(|milestone| milestone.title.as_deref()) - .unwrap_or_default() - .into() -} - -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: milestone.state.clone().unwrap_or_else(|| "unknown".into()), - 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"), - } -} - -fn short_sha(sha: &str) -> &str { - &sha[..sha.len().min(8)] -} - -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, - } -} - -fn summary(body: &str) -> String { - body.split_whitespace() - .take(24) - .collect::>() - .join(" ") -} - -fn indices(values: &[usize]) -> Vec { - 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() -} +pub use details::*; +pub use files::*; +pub use helpers::compact_date; +pub use home::*; +pub use lists::*; #[cfg(test)] -mod tests { - use super::*; - - #[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::>(), - ["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::>(), - [ - ("Author", "Ada "), - ("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, "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, "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, "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, "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, "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.as_str(), - markdown.language.as_str() - ), - ("README.md", "markdown", "markdown") - ); - assert_eq!(markdown.text, "# Hello"); - - let source = repository_file_page("Sources/App.swift", b"import UIKit".to_vec()); - assert_eq!( - (source.kind.as_str(), source.language.as_str()), - ("source", "swift") - ); - - let preview = repository_file_page("image.png", vec![0, 159]); - assert_eq!(preview.kind, "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, "preview"); - } - } -} +mod tests; diff --git a/crates/app/src/presentation/details.rs b/crates/app/src/presentation/details.rs new file mode 100644 index 0000000..e1e62a0 --- /dev/null +++ b/crates/app/src/presentation/details.rs @@ -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) -> Vec { + files.iter().filter_map(file_row).collect() +} + +pub fn commit_file_rows(files: Vec) -> Vec { + 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) -> 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::>().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 { + 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, + } +} diff --git a/crates/app/src/presentation/files.rs b/crates/app/src/presentation/files.rs new file mode 100644 index 0000000..e098d82 --- /dev/null +++ b/crates/app/src/presentation/files.rs @@ -0,0 +1,115 @@ +use super::{helpers::*, *}; + +pub fn repository_content_rows( + contents: Vec, +) -> Vec { + 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) -> 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(), + } +} diff --git a/crates/app/src/presentation/helpers.rs b/crates/app/src/presentation/helpers.rs new file mode 100644 index 0000000..80c7fb0 --- /dev/null +++ b/crates/app/src/presentation/helpers.rs @@ -0,0 +1,198 @@ +use super::*; + +pub(super) fn label_row(label: &models::Label) -> Option { + 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) -> 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 { + 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::>() + .join(" ") +} + +pub(super) fn indices(values: &[usize]) -> Vec { + 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() +} diff --git a/crates/app/src/presentation/home.rs b/crates/app/src/presentation/home.rs new file mode 100644 index 0000000..e577ead --- /dev/null +++ b/crates/app/src/presentation/home.rs @@ -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::(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, 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) +} diff --git a/crates/app/src/presentation/lists.rs b/crates/app/src/presentation/lists.rs new file mode 100644 index 0000000..761a442 --- /dev/null +++ b/crates/app/src/presentation/lists.rs @@ -0,0 +1,272 @@ +use super::{helpers::*, *}; + +pub fn repository_rows( + repositories: &[RepositoryData], + is_favorite: impl Fn(&RepositoryData) -> bool, +) -> Vec { + 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 { + 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 { + 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) -> 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 { + 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, + 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, + } +} diff --git a/crates/app/src/presentation/tests.rs b/crates/app/src/presentation/tests.rs new file mode 100644 index 0000000..b5fd2c7 --- /dev/null +++ b/crates/app/src/presentation/tests.rs @@ -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::>(), + ["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::>(), + [ + ("Author", "Ada "), + ("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 + ); + } +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 7bd43dd..03a031d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "gotcha-cli" -version = "0.1.0" -description = "CLI test bed for the Gotcha Gitea client" +version = "1.0.0" +description = "Command-line Gitea client" edition.workspace = true license.workspace = true rust-version.workspace = true diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 1090dc5..732904c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -463,50 +463,4 @@ impl Args { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_server_selection_and_repository_scope() { - let args = Args::parse( - ["--server", "work", "repo", "show"].map(str::to_owned), - None, - ) - .unwrap(); - let repository = RepositoryScope::parse("alice/project").unwrap(); - - assert_eq!(args.server.as_deref(), Some("work")); - assert_eq!(args.command, ["repo", "show"]); - assert_eq!( - (repository.owner.as_str(), repository.repository.as_str()), - ("alice", "project") - ); - assert_eq!(requested_help(&[]).unwrap(), Some(ROOT_HELP)); - assert_eq!(requested_help(&["repo".into()]).unwrap(), Some(REPO_HELP)); - assert!( - requested_help(&["repo".into(), "show".into(), "--help".into()]) - .unwrap() - .unwrap() - .starts_with("Usage: gotcha repo show") - ); - } - - #[test] - fn formats_bounded_aligned_tables_without_tabs() { - let table = format_table( - &[("INDEX", 8), ("STATE", 10), ("TITLE", 60)], - &[vec![ - "22".into(), - "open".into(), - "A deliberately long issue title that must be shortened".into(), - ]], - 40, - ); - - assert!(!table.contains('\t')); - assert!(table.contains('…')); - assert_eq!(table.lines().count(), 3); - assert!(table.lines().all(|line| line.chars().count() <= 40)); - assert!(table.lines().next().unwrap().starts_with("INDEX STATE")); - } -} +mod tests; diff --git a/crates/cli/src/tests.rs b/crates/cli/src/tests.rs new file mode 100644 index 0000000..2bd8843 --- /dev/null +++ b/crates/cli/src/tests.rs @@ -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")); +} diff --git a/crates/cli/src/work_items.rs b/crates/cli/src/work_items.rs index 930dc06..dfc663b 100644 --- a/crates/cli/src/work_items.rs +++ b/crates/cli/src/work_items.rs @@ -3,246 +3,13 @@ use std::{error::Error, io::Read}; use gotcha_gitea::{ Client, CreateIssue, EditIssue, IssueQuery, models::{ - ChangedFile, Comment, Commit, CreateIssueOption, CreateMilestoneOption, - CreatePullRequestOption, EditIssueOption, EditMilestoneOption, EditPullRequestOption, - Issue, MergePullRequestOption, Milestone, PullRequest, PullReview, + CreateMilestoneOption, CreatePullRequestOption, EditMilestoneOption, EditPullRequestOption, + MergePullRequestOption, }, - pull_state, }; -use serde::{Deserialize, de::DeserializeOwned}; +use serde::de::DeserializeOwned; -use crate::{ - config::{RepositoryScope, Selection}, - print_table, -}; - -const ISSUE_HELP: &str = "\ -Usage: gotcha issue SUBCOMMAND - -Subcommands: - list [OWNER/REPOSITORY] [OPTIONS] - List and filter issues - show INDEX [OWNER/REPOSITORY] Show an issue - create [OWNER/REPOSITORY] Create from CreateIssueOption YAML on stdin - edit INDEX... [OWNER/REPOSITORY] Edit issues from EditIssueOption YAML on stdin - close INDEX... [OWNER/REPOSITORY] - Close one or more issues - reopen INDEX... [OWNER/REPOSITORY] - Reopen one or more issues - delete INDEX [OWNER/REPOSITORY] Delete an issue - comments INDEX [OWNER/REPOSITORY] - List comments - comment INDEX [OWNER/REPOSITORY] Add a comment read as text from stdin"; - -const MILESTONE_HELP: &str = "\ -Usage: gotcha milestone SUBCOMMAND - -Subcommands: - list [OWNER/REPOSITORY] List milestones - show ID [OWNER/REPOSITORY] Show a milestone - create [OWNER/REPOSITORY] Create from CreateMilestoneOption YAML on stdin - edit ID [OWNER/REPOSITORY] Edit from EditMilestoneOption YAML on stdin - delete ID [OWNER/REPOSITORY] Delete a milestone"; - -const PULL_HELP: &str = "\ -Usage: gotcha pull SUBCOMMAND - -Subcommands: - list [OWNER/REPOSITORY] List pull requests - show INDEX [OWNER/REPOSITORY] Show a pull request - create [OWNER/REPOSITORY] Create from CreatePullRequestOption YAML on stdin - edit INDEX [OWNER/REPOSITORY] Edit from EditPullRequestOption YAML on stdin - merge INDEX [OWNER/REPOSITORY] Merge from MergePullRequestOption YAML on stdin - commits INDEX [OWNER/REPOSITORY] List commits - files INDEX [OWNER/REPOSITORY] List changed files - reviews INDEX [OWNER/REPOSITORY] List reviews"; - -pub fn domain_help(domain: &str) -> Option<&'static str> { - match domain { - "issue" => Some(ISSUE_HELP), - "milestone" => Some(MILESTONE_HELP), - "pull" => Some(PULL_HELP), - _ => None, - } -} - -pub fn subcommand_help(domain: &str, command: &str) -> Option<&'static str> { - match (domain, command) { - ("issue", "list") => Some( - "Usage: gotcha issue list [OWNER/REPOSITORY] [OPTIONS]\n\nOptions:\n --state open|closed|all\n -K, --kind issues|pulls|all\n -k, --keyword TEXT\n -L, --labels NAMES\n -m, --milestones NAMES\n -A, --author USER\n -a, --assignee USER\n -M, --mentions USER\n -F, --from TIMESTAMP\n -u, --until TIMESTAMP\n -p, --page NUMBER\n --limit NUMBER", - ), - ("issue", "show") => Some("Usage: gotcha issue show INDEX [OWNER/REPOSITORY]"), - ("issue", "create") => Some( - "Usage: gotcha issue create [OWNER/REPOSITORY] < issue.yaml\n\nReads CreateIssueOption YAML from stdin. label_names and milestone_name accept display names, for example:\n title: Fix the bug\n body: Reproduction steps\n label_names: [bug, critical]\n milestone_name: Version 1.0", - ), - ("issue", "edit") => Some( - "Usage: gotcha issue edit INDEX... [OWNER/REPOSITORY] < issue.yaml\n\nReads EditIssueOption YAML from stdin and applies it to every index. milestone_name resolves a display name; add_labels and remove_labels accept label names; add_assignees adds users without replacing existing assignees.", - ), - ("issue", "close") => Some("Usage: gotcha issue close INDEX... [OWNER/REPOSITORY]"), - ("issue", "reopen") => Some("Usage: gotcha issue reopen INDEX... [OWNER/REPOSITORY]"), - ("issue", "delete") => Some("Usage: gotcha issue delete INDEX [OWNER/REPOSITORY]"), - ("issue", "comments") => Some("Usage: gotcha issue comments INDEX [OWNER/REPOSITORY]"), - ("issue", "comment") => Some( - "Usage: gotcha issue comment INDEX [OWNER/REPOSITORY] < comment.txt\n\nReads the comment body as text from stdin.", - ), - ("milestone", "list") => Some("Usage: gotcha milestone list [OWNER/REPOSITORY]"), - ("milestone", "show") => Some("Usage: gotcha milestone show ID [OWNER/REPOSITORY]"), - ("milestone", "create") => Some( - "Usage: gotcha milestone create [OWNER/REPOSITORY] < milestone.yaml\n\nReads CreateMilestoneOption YAML from stdin, for example:\n title: Version 1.0\n due_on: 2026-09-01T00:00:00Z", - ), - ("milestone", "edit") => Some( - "Usage: gotcha milestone edit ID [OWNER/REPOSITORY] < milestone.yaml\n\nReads EditMilestoneOption YAML from stdin.", - ), - ("milestone", "delete") => Some("Usage: gotcha milestone delete ID [OWNER/REPOSITORY]"), - ("pull", "list") => Some("Usage: gotcha pull list [OWNER/REPOSITORY]"), - ("pull", "show") => Some("Usage: gotcha pull show INDEX [OWNER/REPOSITORY]"), - ("pull", "create") => Some( - "Usage: gotcha pull create [OWNER/REPOSITORY] < pull.yaml\n\nReads CreatePullRequestOption YAML from stdin, for example:\n title: Add feature\n head: feature\n base: main", - ), - ("pull", "edit") => Some( - "Usage: gotcha pull edit INDEX [OWNER/REPOSITORY] < pull.yaml\n\nReads EditPullRequestOption YAML from stdin.", - ), - ("pull", "merge") => Some( - "Usage: gotcha pull merge INDEX [OWNER/REPOSITORY] < merge.yaml\n\nReads MergePullRequestOption YAML from stdin, for example:\n Do: squash\n delete_branch_after_merge: true", - ), - ("pull", "commits") => Some("Usage: gotcha pull commits INDEX [OWNER/REPOSITORY]"), - ("pull", "files") => Some("Usage: gotcha pull files INDEX [OWNER/REPOSITORY]"), - ("pull", "reviews") => Some("Usage: gotcha pull reviews INDEX [OWNER/REPOSITORY]"), - _ => None, - } -} - -#[derive(Debug, PartialEq)] -struct IssueListOptions { - repository: Option, - state: String, - kind: String, - keyword: Option, - labels: Option, - milestones: Option, - author: Option, - assignee: Option, - mentions: Option, - from: Option, - until: Option, - page: i32, - limit: i32, -} - -#[derive(Deserialize)] -struct CreateIssueInput { - #[serde(flatten)] - issue: CreateIssueOption, - #[serde(default)] - label_names: Vec, - milestone_name: Option, -} - -#[derive(Clone, Deserialize)] -struct EditIssueInput { - #[serde(flatten)] - issue: EditIssueOption, - #[serde(default)] - add_labels: Vec, - #[serde(default)] - remove_labels: Vec, - #[serde(default)] - add_assignees: Vec, - milestone_name: Option, -} - -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> { - 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> { - *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> { - 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, Option), Box> { - let mut indexes = Vec::new(); - let mut repository = None; - for argument in arguments { - if argument.contains('/') { - if repository.is_some() { - return Err("only one OWNER/REPOSITORY may be provided".into()); - } - RepositoryScope::parse(argument)?; - repository = Some(argument.clone()); - } else { - indexes.push(number(argument)?); - } - } - if indexes.is_empty() { - return Err("at least one issue index is required".into()); - } - Ok((indexes, repository)) -} +use crate::config::{RepositoryScope, Selection}; pub async fn run( command: &[String], @@ -633,318 +400,13 @@ async fn pull_details( Ok(()) } -fn print_issues(issues: &[Issue]) { - print_table( - &[ - ("INDEX", 8), - ("STATE", 10), - ("TITLE", 60), - ("MILESTONE", 30), - ("LABELS", 30), - ], - issues - .iter() - .map(|issue| { - vec![ - issue.number.unwrap_or_default().to_string(), - issue.state.as_deref().unwrap_or("unknown").into(), - one_line(issue.title.as_deref().unwrap_or("")).into(), - issue - .milestone - .as_deref() - .and_then(|milestone| milestone.title.as_deref()) - .unwrap_or("-") - .into(), - issue_labels(issue), - ] - }) - .collect(), - ); -} +mod arguments; +mod help; +mod output; -fn print_issue(issue: &Issue) { - println!( - "#{} [{}] {}", - issue.number.unwrap_or_default(), - issue.state.as_deref().unwrap_or("unknown"), - issue.title.as_deref().unwrap_or("") - ); - field("url", issue.html_url.as_deref()); - field( - "author", - issue.user.as_deref().and_then(|user| user.login.as_deref()), - ); - let milestone = issue - .milestone - .as_deref() - .and_then(|milestone| milestone.title.as_deref()); - field("milestone", milestone); - let labels = issue_labels(issue); - field("labels", (!labels.is_empty()).then_some(labels.as_str())); - let assignees = issue - .assignees - .as_deref() - .unwrap_or_default() - .iter() - .filter_map(|user| user.login.as_deref()) - .collect::>() - .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::>() - .join(",") -} - -fn print_comments(comments: &[Comment]) { - for comment in comments { - println!( - "{} Ā· {}", - comment - .user - .as_deref() - .and_then(|user| user.login.as_deref()) - .unwrap_or("unknown"), - comment.created_at.as_deref().unwrap_or("-") - ); - body(comment.body.as_deref()); - } -} - -fn print_milestones(milestones: &[Milestone]) { - print_table( - &[ - ("ID", 8), - ("STATE", 10), - ("TITLE", 60), - ("DUE", 25), - ("OPEN/CLOSED", 12), - ], - milestones - .iter() - .map(|milestone| { - vec![ - milestone.id.unwrap_or_default().to_string(), - milestone.state.as_deref().unwrap_or("unknown").into(), - one_line(milestone.title.as_deref().unwrap_or("")).into(), - milestone.due_on.as_deref().unwrap_or("-").into(), - format!( - "{}/{}", - milestone.open_issues.unwrap_or_default(), - milestone.closed_issues.unwrap_or_default() - ), - ] - }) - .collect(), - ); -} - -fn print_milestone(milestone: &Milestone) { - println!( - "{} [{}] {}", - milestone.id.unwrap_or_default(), - milestone.state.as_deref().unwrap_or("unknown"), - milestone.title.as_deref().unwrap_or("") - ); - field("due", milestone.due_on.as_deref()); - println!( - "issues: {} open, {} closed", - milestone.open_issues.unwrap_or_default(), - milestone.closed_issues.unwrap_or_default() - ); - body(milestone.description.as_deref()); -} - -fn print_pulls(pulls: &[PullRequest]) { - print_table( - &[("INDEX", 8), ("STATE", 10), ("TITLE", 70), ("UPDATED", 25)], - pulls - .iter() - .map(|pull| { - vec![ - pull.number.unwrap_or_default().to_string(), - pull.state.as_deref().unwrap_or("unknown").into(), - one_line(pull.title.as_deref().unwrap_or("")).into(), - pull.updated_at.as_deref().unwrap_or("-").into(), - ] - }) - .collect(), - ); -} - -fn print_pull(pull: &PullRequest) { - println!( - "#{} [{}] {}", - pull.number.unwrap_or_default(), - pull_state(pull), - pull.title.as_deref().unwrap_or("") - ); - field("url", pull.html_url.as_deref()); - field( - "author", - pull.user.as_deref().and_then(|user| user.login.as_deref()), - ); - field("updated", pull.updated_at.as_deref()); - println!("mergeable: {}", pull.mergeable.unwrap_or(false)); - body(pull.body.as_deref()); -} - -fn print_commits(commits: &[Commit]) { - print_table( - &[("SHA", 40), ("MESSAGE", 80)], - commits - .iter() - .map(|commit| { - vec![ - commit.sha.as_deref().unwrap_or("unknown").into(), - one_line( - commit - .commit - .as_deref() - .and_then(|commit| commit.message.as_deref()) - .unwrap_or(""), - ) - .into(), - ] - }) - .collect(), - ); -} - -fn print_files(files: &[ChangedFile]) { - print_table( - &[("STATUS", 12), ("CHANGES", 10), ("FILE", 90)], - files - .iter() - .map(|file| { - vec![ - file.status.as_deref().unwrap_or("unknown").into(), - file.changes.unwrap_or_default().to_string(), - file.filename.as_deref().unwrap_or("").into(), - ] - }) - .collect(), - ); -} - -fn print_reviews(reviews: &[PullReview]) { - print_table( - &[ - ("ID", 12), - ("STATE", 20), - ("REVIEWER", 25), - ("SUBMITTED", 25), - ], - reviews - .iter() - .map(|review| { - vec![ - review.id.unwrap_or_default().to_string(), - review.state.as_deref().unwrap_or("unknown").into(), - review - .user - .as_deref() - .and_then(|user| user.login.as_deref()) - .unwrap_or("unknown") - .into(), - review.submitted_at.as_deref().unwrap_or("-").into(), - ] - }) - .collect(), - ); -} - -fn field(name: &str, value: Option<&str>) { - if let Some(value) = value.filter(|value| !value.is_empty()) { - println!("{name}: {value}"); - } -} - -fn body(value: Option<&str>) { - if let Some(value) = value.filter(|value| !value.is_empty()) { - println!("\n{value}"); - } -} - -fn one_line(value: &str) -> &str { - value.lines().next().unwrap_or_default() -} +use arguments::*; +pub(crate) use help::{domain_help, subcommand_help}; +use output::*; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_typed_yaml_and_rejects_bad_indexes() { - let issue: CreateIssueInput = parse_yaml( - "title: Fix it\nbody: Details\nlabel_names: [bug]\nmilestone_name: Version 1\n", - ) - .unwrap(); - - assert_eq!(issue.issue.title, "Fix it"); - assert_eq!(issue.issue.body.as_deref(), Some("Details")); - assert_eq!(issue.label_names, ["bug"]); - assert_eq!(issue.milestone_name.as_deref(), Some("Version 1")); - let edit: EditIssueInput = parse_yaml( - "title: Updated\nadd_labels: [critical]\nremove_labels: [bug]\nadd_assignees: [alice]\n", - ) - .unwrap(); - assert_eq!(edit.issue.title.as_deref(), Some("Updated")); - assert_eq!(edit.add_labels, ["critical"]); - assert_eq!(edit.remove_labels, ["bug"]); - assert_eq!(edit.add_assignees, ["alice"]); - assert!(number("0").is_err()); - assert!(number("7").is_ok()); - } - - #[test] - fn parses_issue_workflow_filters_and_bulk_state_targets() { - let options = parse_issue_list( - &[ - "hugo/Gotcha", - "--state", - "all", - "--milestones", - "first feature complete release", - "-L", - "bug,critical", - "--page", - "2", - "--limit", - "50", - ] - .map(str::to_owned), - ) - .unwrap(); - assert_eq!(options.repository.as_deref(), Some("hugo/Gotcha")); - assert_eq!(options.state, "all"); - assert_eq!( - options.milestones.as_deref(), - Some("first feature complete release") - ); - assert_eq!(options.labels.as_deref(), Some("bug,critical")); - assert_eq!((options.page, options.limit), (2, 50)); - - let (indexes, repository) = - issue_targets(&["4", "16", "hugo/Gotcha"].map(str::to_owned)).unwrap(); - assert_eq!(indexes, [4, 16]); - assert_eq!(repository.as_deref(), Some("hugo/Gotcha")); - assert!(parse_issue_list(&["--state".into(), "invalid".into()]).is_err()); - assert!(issue_targets(&[]).is_err()); - } -} +mod tests; diff --git a/crates/cli/src/work_items/arguments.rs b/crates/cli/src/work_items/arguments.rs new file mode 100644 index 0000000..2b692aa --- /dev/null +++ b/crates/cli/src/work_items/arguments.rs @@ -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, + pub(super) state: String, + pub(super) kind: String, + pub(super) keyword: Option, + pub(super) labels: Option, + pub(super) milestones: Option, + pub(super) author: Option, + pub(super) assignee: Option, + pub(super) mentions: Option, + pub(super) from: Option, + pub(super) until: Option, + 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, + pub(super) milestone_name: Option, +} + +#[derive(Clone, Deserialize)] +pub(super) struct EditIssueInput { + #[serde(flatten)] + pub(super) issue: EditIssueOption, + #[serde(default)] + pub(super) add_labels: Vec, + #[serde(default)] + pub(super) remove_labels: Vec, + #[serde(default)] + pub(super) add_assignees: Vec, + pub(super) milestone_name: Option, +} + +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> { + 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> { + *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> { + 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, Option), Box> { + 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)) +} diff --git a/crates/cli/src/work_items/help.rs b/crates/cli/src/work_items/help.rs new file mode 100644 index 0000000..34086f9 --- /dev/null +++ b/crates/cli/src/work_items/help.rs @@ -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, + } +} diff --git a/crates/cli/src/work_items/output.rs b/crates/cli/src/work_items/output.rs new file mode 100644 index 0000000..2454408 --- /dev/null +++ b/crates/cli/src/work_items/output.rs @@ -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::>() + .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::>() + .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() +} diff --git a/crates/cli/src/work_items/tests.rs b/crates/cli/src/work_items/tests.rs new file mode 100644 index 0000000..90cb3ad --- /dev/null +++ b/crates/cli/src/work_items/tests.rs @@ -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()); +} diff --git a/crates/gitea/Cargo.toml b/crates/gitea/Cargo.toml index 6b9341b..9091f41 100644 --- a/crates/gitea/Cargo.toml +++ b/crates/gitea/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gotcha_gitea" -version = "0.1.0" +version = "1.0.0" description = "A small, reusable Rust client for the Gitea API" edition.workspace = true license.workspace = true diff --git a/crates/gitea/src/domain.rs b/crates/gitea/src/domain.rs index 1d80025..e9415c3 100644 --- a/crates/gitea/src/domain.rs +++ b/crates/gitea/src/domain.rs @@ -187,7 +187,7 @@ pub fn parse_api_date(value: &str) -> Option { (civil_from_days(days) == (year, month, day)).then_some(days * 86_400) } -fn civil_from_days(days: i64) -> (i64, i64, i64) { +pub fn civil_from_days(days: i64) -> (i64, i64, i64) { let days = days + 719_468; let era = days.div_euclid(146_097); let day_of_era = days - era * 146_097; @@ -202,7 +202,7 @@ fn civil_from_days(days: i64) -> (i64, i64, i64) { (year, month, day) } -fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 { +pub fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 { year -= i64::from(month <= 2); let era = year.div_euclid(400); let year_of_era = year - era * 400; diff --git a/crates/gitea/src/issues.rs b/crates/gitea/src/issues.rs index 8063c24..4c22f12 100644 --- a/crates/gitea/src/issues.rs +++ b/crates/gitea/src/issues.rs @@ -4,7 +4,7 @@ use crate::{ CreateIssue, EditIssue, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, Page, RepositoryId, }, - models, + models, positive, }; use gitea_openapi::apis; @@ -460,111 +460,8 @@ pub fn comment_can_edit(comment: &models::Comment, viewer_id: Option) -> bo ) } -fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption { - models::CreateIssueOption { - body: Some(draft.body), - closed: Some(draft.closed), - due_date: draft.due_date, - labels: Some(draft.label_ids), - milestone: draft.milestone_id, - ..models::CreateIssueOption::new(draft.title) - } -} - -fn edit_issue_draft(draft: IssueDraft) -> EditIssue { - EditIssue { - option: models::EditIssueOption { - body: Some(draft.body), - due_date: draft.due_date.clone(), - milestone: Some(draft.milestone_id.unwrap_or_default()), - state: Some(if draft.closed { "closed" } else { "open" }.into()), - title: Some(draft.title), - unset_due_date: draft.due_date.is_none().then_some(true), - ..models::EditIssueOption::new() - }, - replace_labels: Some(draft.label_ids), - add_labels: Vec::new(), - remove_labels: Vec::new(), - add_assignees: Vec::new(), - milestone_name: None, - } -} - -fn label_option(labels: &[i64]) -> models::IssueLabelsOption { - models::IssueLabelsOption { - labels: Some( - labels - .iter() - .copied() - .map(serde_json::Value::from) - .collect(), - ), - } -} - -fn comment_belongs_to_issue(comment: &models::Comment, number: i64) -> bool { - comment - .issue_url - .as_deref() - .map(|url| url.trim_end_matches('/')) - .and_then(|url| url.rsplit('/').next()) - .and_then(|index| index.parse().ok()) - == Some(number) -} - -fn positive(value: i64, name: &str) -> Result<()> { - if value < 1 { - return Err(Error::InvalidInput(format!( - "{name} must be a positive integer" - ))); - } - Ok(()) -} +mod helpers; +use helpers::*; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn scopes_comments_and_builds_label_payloads() { - let comment = models::Comment { - issue_url: Some("https://example.test/api/v1/repos/a/b/issues/7".into()), - ..Default::default() - }; - assert!(comment_belongs_to_issue(&comment, 7)); - assert!(!comment_belongs_to_issue(&comment, 8)); - assert!(!comment_can_edit(&comment, Some(1))); - let owned = models::Comment { - id: Some(3), - user: Some(Box::new(models::User { - id: Some(1), - ..Default::default() - })), - ..Default::default() - }; - assert!(comment_can_edit(&owned, Some(1))); - assert_eq!(label_option(&[2, 4]).labels.unwrap().len(), 2); - } - - #[test] - fn maps_issue_drafts_without_losing_clear_operations() { - let draft = IssueDraft { - title: "Title".into(), - body: "Body".into(), - label_ids: vec![2, 4], - milestone_id: None, - due_date: None, - closed: true, - }; - let create = create_issue_option(draft.clone()); - assert_eq!(create.title, "Title"); - assert_eq!(create.body.as_deref(), Some("Body")); - assert_eq!(create.closed, Some(true)); - assert_eq!(create.labels, Some(vec![2, 4])); - let edit = edit_issue_draft(draft); - assert_eq!(edit.option.milestone, Some(0)); - assert_eq!(edit.option.state.as_deref(), Some("closed")); - assert_eq!(edit.option.unset_due_date, Some(true)); - assert_eq!(edit.replace_labels, Some(vec![2, 4])); - } -} +mod tests; diff --git a/crates/gitea/src/issues/helpers.rs b/crates/gitea/src/issues/helpers.rs new file mode 100644 index 0000000..a254b3a --- /dev/null +++ b/crates/gitea/src/issues/helpers.rs @@ -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) +} diff --git a/crates/gitea/src/issues/tests.rs b/crates/gitea/src/issues/tests.rs new file mode 100644 index 0000000..cbd0b76 --- /dev/null +++ b/crates/gitea/src/issues/tests.rs @@ -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])); +} diff --git a/crates/gitea/src/lib.rs b/crates/gitea/src/lib.rs index 9e6e60f..9a1c09a 100644 --- a/crates/gitea/src/lib.rs +++ b/crates/gitea/src/lib.rs @@ -23,13 +23,31 @@ pub use activity::ActivityFilter; pub use domain::{ CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId, - api_date, parse_api_date, + api_date, civil_from_days, days_from_civil, parse_api_date, }; pub use issues::comment_can_edit; pub use pulls::{PullFileSource, pull_file_source, pull_state}; pub type Result = std::result::Result; +pub(crate) fn validate_page(page: i32, limit: i32) -> Result<()> { + if page < 1 || limit < 1 { + return Err(Error::InvalidInput( + "page and limit must be positive".into(), + )); + } + Ok(()) +} + +pub(crate) fn positive(value: i64, name: &str) -> Result<()> { + if value < 1 { + return Err(Error::InvalidInput(format!( + "{name} must be a positive integer" + ))); + } + Ok(()) +} + #[derive(Debug)] pub enum Error { Configuration(String), diff --git a/crates/gitea/src/pulls.rs b/crates/gitea/src/pulls.rs index fb4e023..ec601be 100644 --- a/crates/gitea/src/pulls.rs +++ b/crates/gitea/src/pulls.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use crate::{ Client, Error, Result, domain::{DEFAULT_PAGE_SIZE, Page, PullDetails, RepositoryId}, - models, + models, positive, validate_page, }; use gitea_openapi::apis; @@ -323,21 +323,3 @@ impl Client { .map_err(Error::generated) } } - -fn validate_page(page: i32, limit: i32) -> Result<()> { - if page < 1 || limit < 1 { - return Err(Error::InvalidInput( - "page and limit must be positive".into(), - )); - } - Ok(()) -} - -fn positive(value: i64, name: &str) -> Result<()> { - if value < 1 { - return Err(Error::InvalidInput(format!( - "{name} must be a positive integer" - ))); - } - Ok(()) -} diff --git a/crates/gitea/src/repositories.rs b/crates/gitea/src/repositories.rs index d61bf24..d9f4c51 100644 --- a/crates/gitea/src/repositories.rs +++ b/crates/gitea/src/repositories.rs @@ -8,7 +8,7 @@ use tokio::task::JoinSet; use crate::{ Client, Error, Result, domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, RepositoryId}, - models, + models, validate_page, }; use gitea_openapi::apis; @@ -481,112 +481,5 @@ fn commit_parents(commit: &models::Commit) -> impl Iterator { .filter_map(|parent| parent.sha.as_deref()) } -fn validate_page(page: i32, limit: i32) -> Result<()> { - if page < 1 || limit < 1 { - return Err(Error::InvalidInput( - "page and limit must be positive".into(), - )); - } - Ok(()) -} - #[cfg(test)] -mod tests { - use super::*; - - fn commit(sha: &str, parents: &[&str]) -> models::Commit { - models::Commit { - sha: Some(sha.into()), - parents: Some( - parents - .iter() - .map(|sha| models::CommitMeta { - sha: Some((*sha).into()), - ..Default::default() - }) - .collect(), - ), - ..Default::default() - } - } - - #[test] - fn lays_out_shared_commit_graph() { - let rows = build_graph( - vec![ - ( - 0, - "main".into(), - vec![ - commit("merge", &["left", "right"]), - commit("left", &["base"]), - commit("base", &[]), - ], - ), - ( - 1, - "feature".into(), - vec![commit("right", &["base"]), commit("base", &[])], - ), - ], - PullMetadata::default(), - ); - assert_eq!(rows.len(), 4); - assert!(rows.iter().any(|row| row.refs == ["main"])); - assert!(rows.iter().any(|row| row.refs == ["feature"])); - let merge = rows - .iter() - .find(|row| row.commit.sha.as_deref() == Some("merge")) - .unwrap(); - assert_eq!(merge.node_lane, Some(0)); - assert_eq!(merge.bottom_connections, [1]); - let right = rows - .iter() - .find(|row| row.commit.sha.as_deref() == Some("right")) - .unwrap(); - assert_eq!(right.node_lane, Some(1)); - let base = rows - .iter() - .find(|row| row.commit.sha.as_deref() == Some("base")) - .unwrap(); - assert_eq!(base.node_lane, Some(0)); - assert_eq!(base.top_connections, [1]); - } - - #[test] - fn labels_first_commit_of_pull_branch() { - let rows = build_graph( - vec![( - 0, - "main".into(), - vec![ - commit("merge", &["main", "head"]), - commit("head", &["first"]), - commit("first", &["base"]), - commit("main", &["base"]), - commit("base", &[]), - ], - )], - PullMetadata { - tips: HashMap::from([("head".into(), vec!["feature".into()])]), - starts: vec![PullBranch { - head: "head".into(), - base: "base".into(), - label: "feature".into(), - }], - }, - ); - let first = rows - .iter() - .find(|row| row.commit.sha.as_deref() == Some("first")) - .unwrap(); - assert_eq!(first.branch_starts, ["feature"]); - assert!( - rows.iter() - .find(|row| row.commit.sha.as_deref() == Some("head")) - .unwrap() - .refs - .contains(&"feature".into()) - ); - } -} +mod tests; diff --git a/crates/gitea/src/repositories/tests.rs b/crates/gitea/src/repositories/tests.rs new file mode 100644 index 0000000..127f426 --- /dev/null +++ b/crates/gitea/src/repositories/tests.rs @@ -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()) + ); +} diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 97d79ad..745c4b8 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -603,28 +603,22 @@ fileprivate struct FfiConverterData: FfiConverterRustBuffer { public protocol GotchaCoreProtocol: AnyObject, Sendable { - func activeServerIndex() -> UInt32? - - func activeServerName() -> String? - - func addServer(name: String, url: String, token: String) async throws -> UInt32 - - func clearIssueFilters(owner: String, repository: String) throws - - func clearPullFilters() throws - func commitDetails(owner: String, repository: String, sha: String, branch: String?) async throws -> CommitDetailsPage func commitDiff(owner: String, repository: String, sha: String, path: String) async throws -> DiffPage func commits(owner: String, repository: String, branch: String?, path: String, pages: UInt32) async throws -> CommitPage + func pullDiff(owner: String, repository: String, number: Int64, path: String) async throws -> DiffPage + + func repositoryContents(owner: String, repository: String, path: String) async throws -> [RepositoryContentRow] + + func repositoryFile(owner: String, repository: String, path: String) async throws -> RepositoryFilePage + + func clearIssueFilters(owner: String, repository: String) throws + func deleteIssue(owner: String, repository: String, number: Int64) async throws - func deleteMilestone(owner: String, repository: String, id: Int64) async throws - - func home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage - func issue(owner: String, repository: String, number: Int64, page: UInt32) async throws -> IssuePage func issueEditor(owner: String, repository: String, number: Int64?) async throws -> IssueEditorPage @@ -635,15 +629,29 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func issues(owner: String, repository: String, page: UInt32) async throws -> IssueListPage + func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?, closed: Bool) async throws -> Int64 + + func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String) async throws + + func setIssueClosed(owner: String, repository: String, number: Int64, closed: Bool) async throws + + func setIssueFilters(owner: String, repository: String, milestone: String, labels: [String], searchText: String) throws + + func deleteMilestone(owner: String, repository: String, id: Int64) async throws + func milestone(owner: String, repository: String, id: Int64, page: UInt32) async throws -> MilestonePage func milestoneEditor(owner: String, repository: String, id: Int64?) async throws -> MilestoneEditorPage func milestones(owner: String, repository: String, page: UInt32) async throws -> MilestoneListPage - func pull(owner: String, repository: String, number: Int64, page: UInt32) async throws -> PullPage + func saveMilestone(owner: String, repository: String, id: Int64?, draft: MilestoneEditorPage) async throws -> Int64 - func pullDiff(owner: String, repository: String, number: Int64, path: String) async throws -> DiffPage + func setMilestoneClosed(owner: String, repository: String, id: Int64, closed: Bool) async throws + + func clearPullFilters() throws + + func pull(owner: String, repository: String, number: Int64, page: UInt32) async throws -> PullPage func pullFilters() async throws -> PullFilterOptions @@ -651,17 +659,19 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func pulls(page: UInt32) async throws -> PullListPage + func setPullFilters(milestone: String, searchText: String) throws + func repositories(page: UInt32, pane: RepositoryPane) async throws -> RepositoryListPage - func repositoryContents(owner: String, repository: String, path: String) async throws -> [RepositoryContentRow] + func toggleFavorite(owner: String, repository: String, pane: RepositoryPane) throws -> [RepositoryRow] - func repositoryFile(owner: String, repository: String, path: String) async throws -> RepositoryFilePage + func activeServerIndex() -> UInt32? - func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?, closed: Bool) async throws -> Int64 + func activeServerName() -> String? - func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String) async throws + func addServer(name: String, url: String, token: String) async throws -> UInt32 - func saveMilestone(owner: String, repository: String, id: Int64?, draft: MilestoneEditorPage) async throws -> Int64 + func home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage func selectServer(index: UInt32) throws @@ -669,24 +679,14 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func setAppearance(index: UInt32) throws - func setIssueClosed(owner: String, repository: String, number: Int64, closed: Bool) async throws - - func setIssueFilters(owner: String, repository: String, milestone: String, labels: [String], searchText: String) throws - func setIssueStatus(status: String) throws - func setMilestoneClosed(owner: String, repository: String, id: Int64, closed: Bool) async throws - - func setPullFilters(milestone: String, searchText: String) throws - func setPullStatus(status: String) throws func settings() -> Settings func startupError() -> String? - func toggleFavorite(owner: String, repository: String, pane: RepositoryPane) throws -> [RepositoryRow] - } open class GotchaCore: GotchaCoreProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -749,58 +749,6 @@ public convenience init() { -open func activeServerIndex() -> UInt32? { - return try! FfiConverterOptionUInt32.lift(try! rustCall() { - uniffiCallStatus in - uniffi_gotcha_core_fn_method_gotchacore_active_server_index( - self.uniffiCloneHandle(),uniffiCallStatus - ) -}) -} - -open func activeServerName() -> String? { - return try! FfiConverterOptionString.lift(try! rustCall() { - uniffiCallStatus in - uniffi_gotcha_core_fn_method_gotchacore_active_server_name( - self.uniffiCloneHandle(),uniffiCallStatus - ) -}) -} - -open func addServer(name: String, url: String, token: String)async throws -> UInt32 { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_add_server( - self.uniffiCloneHandle(),FfiConverterString.lower(name),FfiConverterString.lower(url),FfiConverterString.lower(token) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_u32, - completeFunc: ffi_gotcha_core_rust_future_complete_u32, - freeFunc: ffi_gotcha_core_rust_future_free_u32, - liftFunc: FfiConverterUInt32.lift, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - -open func clearIssueFilters(owner: String, repository: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { - uniffiCallStatus in - uniffi_gotcha_core_fn_method_gotchacore_clear_issue_filters( - self.uniffiCloneHandle(), - FfiConverterString.lower(owner), - FfiConverterString.lower(repository),uniffiCallStatus - ) -} -} - -open func clearPullFilters()throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { - uniffiCallStatus in - uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters( - self.uniffiCloneHandle(),uniffiCallStatus - ) -} -} - open func commitDetails(owner: String, repository: String, sha: String, branch: String?)async throws -> CommitDetailsPage { return try await uniffiRustCallAsync( @@ -849,6 +797,64 @@ open func commits(owner: String, repository: String, branch: String?, path: Stri ) } +open func pullDiff(owner: String, repository: String, number: Int64, path: String)async throws -> DiffPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_pull_diff( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterString.lower(path) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, + completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, + freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeDiffPage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func repositoryContents(owner: String, repository: String, path: String)async throws -> [RepositoryContentRow] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_repository_contents( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterString.lower(path) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, + completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, + freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, + liftFunc: FfiConverterSequenceTypeRepositoryContentRow.lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func repositoryFile(owner: String, repository: String, path: String)async throws -> RepositoryFilePage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_repository_file( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterString.lower(path) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, + completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, + freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeRepositoryFilePage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func clearIssueFilters(owner: String, repository: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_clear_issue_filters( + self.uniffiCloneHandle(), + FfiConverterString.lower(owner), + FfiConverterString.lower(repository),uniffiCallStatus + ) +} +} + open func deleteIssue(owner: String, repository: String, number: Int64)async throws { return try await uniffiRustCallAsync( @@ -865,38 +871,6 @@ open func deleteIssue(owner: String, repository: String, number: Int64)async thr ) } -open func deleteMilestone(owner: String, repository: String, id: Int64)async throws { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_delete_milestone( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(id) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_void, - completeFunc: ffi_gotcha_core_rust_future_complete_void, - freeFunc: ffi_gotcha_core_rust_future_free_void, - liftFunc: { $0 }, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - -open func home(page: UInt32, filter: HomeActivityFilter)async throws -> HomePage { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_home( - self.uniffiCloneHandle(),FfiConverterUInt32.lower(page),FfiConverterTypeHomeActivityFilter_lower(filter) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, - completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, - freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, - liftFunc: FfiConverterTypeHomePage_lift, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - open func issue(owner: String, repository: String, number: Int64, page: UInt32)async throws -> IssuePage { return try await uniffiRustCallAsync( @@ -972,6 +946,83 @@ open func issues(owner: String, repository: String, page: UInt32)async throws - ) } +open func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?, closed: Bool)async throws -> Int64 { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_save_issue( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(number),FfiConverterString.lower(title),FfiConverterString.lower(body),FfiConverterSequenceInt64.lower(labelIds),FfiConverterOptionInt64.lower(milestoneId),FfiConverterOptionInt64.lower(dueDate),FfiConverterBool.lower(closed) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_i64, + completeFunc: ffi_gotcha_core_rust_future_complete_i64, + freeFunc: ffi_gotcha_core_rust_future_free_i64, + liftFunc: FfiConverterInt64.lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterOptionInt64.lower(commentId),FfiConverterString.lower(body) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func setIssueClosed(owner: String, repository: String, number: Int64, closed: Bool)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_set_issue_closed( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterBool.lower(closed) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func setIssueFilters(owner: String, repository: String, milestone: String, labels: [String], searchText: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters( + self.uniffiCloneHandle(), + FfiConverterString.lower(owner), + FfiConverterString.lower(repository), + FfiConverterString.lower(milestone), + FfiConverterSequenceString.lower(labels), + FfiConverterString.lower(searchText),uniffiCallStatus + ) +} +} + +open func deleteMilestone(owner: String, repository: String, id: Int64)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_delete_milestone( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(id) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func milestone(owner: String, repository: String, id: Int64, page: UInt32)async throws -> MilestonePage { return try await uniffiRustCallAsync( @@ -1020,6 +1071,46 @@ open func milestones(owner: String, repository: String, page: UInt32)async throw ) } +open func saveMilestone(owner: String, repository: String, id: Int64?, draft: MilestoneEditorPage)async throws -> Int64 { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_save_milestone( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(id),FfiConverterTypeMilestoneEditorPage_lower(draft) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_i64, + completeFunc: ffi_gotcha_core_rust_future_complete_i64, + freeFunc: ffi_gotcha_core_rust_future_free_i64, + liftFunc: FfiConverterInt64.lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func setMilestoneClosed(owner: String, repository: String, id: Int64, closed: Bool)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_set_milestone_closed( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(id),FfiConverterBool.lower(closed) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func clearPullFilters()throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + open func pull(owner: String, repository: String, number: Int64, page: UInt32)async throws -> PullPage { return try await uniffiRustCallAsync( @@ -1036,22 +1127,6 @@ open func pull(owner: String, repository: String, number: Int64, page: UInt32)as ) } -open func pullDiff(owner: String, repository: String, number: Int64, path: String)async throws -> DiffPage { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_pull_diff( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterString.lower(path) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, - completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, - freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, - liftFunc: FfiConverterTypeDiffPage_lift, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - open func pullFilters()async throws -> PullFilterOptions { return try await uniffiRustCallAsync( @@ -1093,6 +1168,16 @@ open func pulls(page: UInt32)async throws -> PullListPage { ) } +open func setPullFilters(milestone: String, searchText: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_set_pull_filters( + self.uniffiCloneHandle(), + FfiConverterString.lower(milestone), + FfiConverterString.lower(searchText),uniffiCallStatus + ) +} +} + open func repositories(page: UInt32, pane: RepositoryPane)async throws -> RepositoryListPage { return try await uniffiRustCallAsync( @@ -1109,82 +1194,64 @@ open func repositories(page: UInt32, pane: RepositoryPane)async throws -> Repos ) } -open func repositoryContents(owner: String, repository: String, path: String)async throws -> [RepositoryContentRow] { +open func toggleFavorite(owner: String, repository: String, pane: RepositoryPane)throws -> [RepositoryRow] { + return try FfiConverterSequenceTypeRepositoryRow.lift(try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite( + self.uniffiCloneHandle(), + FfiConverterString.lower(owner), + FfiConverterString.lower(repository), + FfiConverterTypeRepositoryPane_lower(pane),uniffiCallStatus + ) +}) +} + +open func activeServerIndex() -> UInt32? { + return try! FfiConverterOptionUInt32.lift(try! rustCall() { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_active_server_index( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func activeServerName() -> String? { + return try! FfiConverterOptionString.lift(try! rustCall() { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_active_server_name( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + +open func addServer(name: String, url: String, token: String)async throws -> UInt32 { return try await uniffiRustCallAsync( rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_repository_contents( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterString.lower(path) + uniffi_gotcha_core_fn_method_gotchacore_add_server( + self.uniffiCloneHandle(),FfiConverterString.lower(name),FfiConverterString.lower(url),FfiConverterString.lower(token) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_u32, + completeFunc: ffi_gotcha_core_rust_future_complete_u32, + freeFunc: ffi_gotcha_core_rust_future_free_u32, + liftFunc: FfiConverterUInt32.lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func home(page: UInt32, filter: HomeActivityFilter)async throws -> HomePage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_home( + self.uniffiCloneHandle(),FfiConverterUInt32.lower(page),FfiConverterTypeHomeActivityFilter_lower(filter) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, - liftFunc: FfiConverterSequenceTypeRepositoryContentRow.lift, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - -open func repositoryFile(owner: String, repository: String, path: String)async throws -> RepositoryFilePage { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_repository_file( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterString.lower(path) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, - completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, - freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, - liftFunc: FfiConverterTypeRepositoryFilePage_lift, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - -open func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?, closed: Bool)async throws -> Int64 { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_save_issue( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(number),FfiConverterString.lower(title),FfiConverterString.lower(body),FfiConverterSequenceInt64.lower(labelIds),FfiConverterOptionInt64.lower(milestoneId),FfiConverterOptionInt64.lower(dueDate),FfiConverterBool.lower(closed) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_i64, - completeFunc: ffi_gotcha_core_rust_future_complete_i64, - freeFunc: ffi_gotcha_core_rust_future_free_i64, - liftFunc: FfiConverterInt64.lift, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - -open func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String)async throws { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterOptionInt64.lower(commentId),FfiConverterString.lower(body) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_void, - completeFunc: ffi_gotcha_core_rust_future_complete_void, - freeFunc: ffi_gotcha_core_rust_future_free_void, - liftFunc: { $0 }, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - -open func saveMilestone(owner: String, repository: String, id: Int64?, draft: MilestoneEditorPage)async throws -> Int64 { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_save_milestone( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(id),FfiConverterTypeMilestoneEditorPage_lower(draft) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_i64, - completeFunc: ffi_gotcha_core_rust_future_complete_i64, - freeFunc: ffi_gotcha_core_rust_future_free_i64, - liftFunc: FfiConverterInt64.lift, + liftFunc: FfiConverterTypeHomePage_lift, errorHandler: FfiConverterTypeGotchaError_lift ) } @@ -1216,35 +1283,6 @@ open func setAppearance(index: UInt32)throws {try rustCallWithError(FfiConvert } } -open func setIssueClosed(owner: String, repository: String, number: Int64, closed: Bool)async throws { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_set_issue_closed( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterBool.lower(closed) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_void, - completeFunc: ffi_gotcha_core_rust_future_complete_void, - freeFunc: ffi_gotcha_core_rust_future_free_void, - liftFunc: { $0 }, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - -open func setIssueFilters(owner: String, repository: String, milestone: String, labels: [String], searchText: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { - uniffiCallStatus in - uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters( - self.uniffiCloneHandle(), - FfiConverterString.lower(owner), - FfiConverterString.lower(repository), - FfiConverterString.lower(milestone), - FfiConverterSequenceString.lower(labels), - FfiConverterString.lower(searchText),uniffiCallStatus - ) -} -} - open func setIssueStatus(status: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_set_issue_status( @@ -1254,32 +1292,6 @@ open func setIssueStatus(status: String)throws {try rustCallWithError(FfiConve } } -open func setMilestoneClosed(owner: String, repository: String, id: Int64, closed: Bool)async throws { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_gotcha_core_fn_method_gotchacore_set_milestone_closed( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(id),FfiConverterBool.lower(closed) - ) - }, - pollFunc: ffi_gotcha_core_rust_future_poll_void, - completeFunc: ffi_gotcha_core_rust_future_complete_void, - freeFunc: ffi_gotcha_core_rust_future_free_void, - liftFunc: { $0 }, - errorHandler: FfiConverterTypeGotchaError_lift - ) -} - -open func setPullFilters(milestone: String, searchText: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { - uniffiCallStatus in - uniffi_gotcha_core_fn_method_gotchacore_set_pull_filters( - self.uniffiCloneHandle(), - FfiConverterString.lower(milestone), - FfiConverterString.lower(searchText),uniffiCallStatus - ) -} -} - open func setPullStatus(status: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_set_pull_status( @@ -1307,18 +1319,6 @@ open func startupError() -> String? { }) } -open func toggleFavorite(owner: String, repository: String, pane: RepositoryPane)throws -> [RepositoryRow] { - return try FfiConverterSequenceTypeRepositoryRow.lift(try rustCallWithError(FfiConverterTypeGotchaError_lift) { - uniffiCallStatus in - uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite( - self.uniffiCloneHandle(), - FfiConverterString.lower(owner), - FfiConverterString.lower(repository), - FfiConverterTypeRepositoryPane_lower(pane),uniffiCallStatus - ) -}) -} - } @@ -1368,11 +1368,11 @@ public func FfiConverterTypeGotchaCore_lower(_ value: GotchaCore) -> UInt64 { public struct ActivityRow: Equatable, Hashable { - public var icon: String + public var icon: ActivityIcon public var title: String public var detail: String public var meta: String - public var target: String + public var target: ActivityTargetKind public var owner: String public var repository: String public var number: Int64 @@ -1380,7 +1380,7 @@ public struct ActivityRow: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(icon: String, title: String, detail: String, meta: String, target: String, owner: String, repository: String, number: Int64, sha: String) { + public init(icon: ActivityIcon, title: String, detail: String, meta: String, target: ActivityTargetKind, owner: String, repository: String, number: Int64, sha: String) { self.icon = icon self.title = title self.detail = detail @@ -1408,11 +1408,11 @@ public struct FfiConverterTypeActivityRow: FfiConverterRustBuffer { public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActivityRow { return try ActivityRow( - icon: FfiConverterString.read(from: &buf), + icon: FfiConverterTypeActivityIcon.read(from: &buf), title: FfiConverterString.read(from: &buf), detail: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf), - target: FfiConverterString.read(from: &buf), + target: FfiConverterTypeActivityTargetKind.read(from: &buf), owner: FfiConverterString.read(from: &buf), repository: FfiConverterString.read(from: &buf), number: FfiConverterInt64.read(from: &buf), @@ -1421,11 +1421,11 @@ public struct FfiConverterTypeActivityRow: FfiConverterRustBuffer { } public static func write(_ value: ActivityRow, into buf: inout [UInt8]) { - FfiConverterString.write(value.icon, into: &buf) + FfiConverterTypeActivityIcon.write(value.icon, into: &buf) FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.detail, into: &buf) FfiConverterString.write(value.meta, into: &buf) - FfiConverterString.write(value.target, into: &buf) + FfiConverterTypeActivityTargetKind.write(value.target, into: &buf) FfiConverterString.write(value.owner, into: &buf) FfiConverterString.write(value.repository, into: &buf) FfiConverterInt64.write(value.number, into: &buf) @@ -1580,12 +1580,14 @@ public func FfiConverterTypeCommitDetailsPage_lower(_ value: CommitDetailsPage) public struct CommitMetadataRow: Equatable, Hashable { public var label: String public var value: String + public var monospaced: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(label: String, value: String) { + public init(label: String, value: String, monospaced: Bool) { self.label = label self.value = value + self.monospaced = monospaced } @@ -1605,13 +1607,15 @@ public struct FfiConverterTypeCommitMetadataRow: FfiConverterRustBuffer { return try CommitMetadataRow( label: FfiConverterString.read(from: &buf), - value: FfiConverterString.read(from: &buf) + value: FfiConverterString.read(from: &buf), + monospaced: FfiConverterBool.read(from: &buf) ) } public static func write(_ value: CommitMetadataRow, into buf: inout [UInt8]) { FfiConverterString.write(value.label, into: &buf) FfiConverterString.write(value.value, into: &buf) + FfiConverterBool.write(value.monospaced, into: &buf) } } @@ -1779,11 +1783,11 @@ public struct DiffLine: Equatable, Hashable { public var oldNumber: String public var newNumber: String public var text: String - public var kind: String + public var kind: DiffLineKind // Default memberwise initializers are never public by default, so we // declare one manually. - public init(oldNumber: String, newNumber: String, text: String, kind: String) { + public init(oldNumber: String, newNumber: String, text: String, kind: DiffLineKind) { self.oldNumber = oldNumber self.newNumber = newNumber self.text = text @@ -1809,7 +1813,7 @@ public struct FfiConverterTypeDiffLine: FfiConverterRustBuffer { oldNumber: FfiConverterString.read(from: &buf), newNumber: FfiConverterString.read(from: &buf), text: FfiConverterString.read(from: &buf), - kind: FfiConverterString.read(from: &buf) + kind: FfiConverterTypeDiffLineKind.read(from: &buf) ) } @@ -1817,7 +1821,7 @@ public struct FfiConverterTypeDiffLine: FfiConverterRustBuffer { FfiConverterString.write(value.oldNumber, into: &buf) FfiConverterString.write(value.newNumber, into: &buf) FfiConverterString.write(value.text, into: &buf) - FfiConverterString.write(value.kind, into: &buf) + FfiConverterTypeDiffLineKind.write(value.kind, into: &buf) } } @@ -2435,7 +2439,7 @@ public func FfiConverterTypeIssueListPage_lower(_ value: IssueListPage) -> RustB public struct IssuePage: Equatable, Hashable { public var title: String - public var state: String + public var state: WorkItemState public var meta: String public var milestone: String public var labels: [LabelRow] @@ -2445,7 +2449,7 @@ public struct IssuePage: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(title: String, state: String, meta: String, milestone: String, labels: [LabelRow], body: String, comments: [CommentRow], hasMore: Bool) { + public init(title: String, state: WorkItemState, meta: String, milestone: String, labels: [LabelRow], body: String, comments: [CommentRow], hasMore: Bool) { self.title = title self.state = state self.meta = meta @@ -2473,7 +2477,7 @@ public struct FfiConverterTypeIssuePage: FfiConverterRustBuffer { return try IssuePage( title: FfiConverterString.read(from: &buf), - state: FfiConverterString.read(from: &buf), + state: FfiConverterTypeWorkItemState.read(from: &buf), meta: FfiConverterString.read(from: &buf), milestone: FfiConverterString.read(from: &buf), labels: FfiConverterSequenceTypeLabelRow.read(from: &buf), @@ -2485,7 +2489,7 @@ public struct FfiConverterTypeIssuePage: FfiConverterRustBuffer { public static func write(_ value: IssuePage, into buf: inout [UInt8]) { FfiConverterString.write(value.title, into: &buf) - FfiConverterString.write(value.state, into: &buf) + FfiConverterTypeWorkItemState.write(value.state, into: &buf) FfiConverterString.write(value.meta, into: &buf) FfiConverterString.write(value.milestone, into: &buf) FfiConverterSequenceTypeLabelRow.write(value.labels, into: &buf) @@ -2513,7 +2517,7 @@ public func FfiConverterTypeIssuePage_lower(_ value: IssuePage) -> RustBuffer { public struct IssueRow: Equatable, Hashable { public var number: Int64 - public var state: String + public var state: WorkItemState public var title: String public var summary: String public var meta: String @@ -2522,7 +2526,7 @@ public struct IssueRow: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(number: Int64, state: String, title: String, summary: String, meta: String, milestone: String, labels: [LabelRow]) { + public init(number: Int64, state: WorkItemState, title: String, summary: String, meta: String, milestone: String, labels: [LabelRow]) { self.number = number self.state = state self.title = title @@ -2549,7 +2553,7 @@ public struct FfiConverterTypeIssueRow: FfiConverterRustBuffer { return try IssueRow( number: FfiConverterInt64.read(from: &buf), - state: FfiConverterString.read(from: &buf), + state: FfiConverterTypeWorkItemState.read(from: &buf), title: FfiConverterString.read(from: &buf), summary: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf), @@ -2560,7 +2564,7 @@ public struct FfiConverterTypeIssueRow: FfiConverterRustBuffer { public static func write(_ value: IssueRow, into buf: inout [UInt8]) { FfiConverterInt64.write(value.number, into: &buf) - FfiConverterString.write(value.state, into: &buf) + FfiConverterTypeWorkItemState.write(value.state, into: &buf) FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.summary, into: &buf) FfiConverterString.write(value.meta, into: &buf) @@ -2823,7 +2827,7 @@ public func FfiConverterTypeMilestonePage_lower(_ value: MilestonePage) -> RustB public struct MilestoneRow: Equatable, Hashable { public var id: Int64 - public var state: String + public var state: WorkItemState public var title: String public var description: String public var meta: String @@ -2833,7 +2837,7 @@ public struct MilestoneRow: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: Int64, state: String, title: String, description: String, meta: String, hasIssues: Bool, progress: Double, progressAccessibility: String) { + public init(id: Int64, state: WorkItemState, title: String, description: String, meta: String, hasIssues: Bool, progress: Double, progressAccessibility: String) { self.id = id self.state = state self.title = title @@ -2861,7 +2865,7 @@ public struct FfiConverterTypeMilestoneRow: FfiConverterRustBuffer { return try MilestoneRow( id: FfiConverterInt64.read(from: &buf), - state: FfiConverterString.read(from: &buf), + state: FfiConverterTypeWorkItemState.read(from: &buf), title: FfiConverterString.read(from: &buf), description: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf), @@ -2873,7 +2877,7 @@ public struct FfiConverterTypeMilestoneRow: FfiConverterRustBuffer { public static func write(_ value: MilestoneRow, into buf: inout [UInt8]) { FfiConverterInt64.write(value.id, into: &buf) - FfiConverterString.write(value.state, into: &buf) + FfiConverterTypeWorkItemState.write(value.state, into: &buf) FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.description, into: &buf) FfiConverterString.write(value.meta, into: &buf) @@ -3013,7 +3017,7 @@ public func FfiConverterTypePullListPage_lower(_ value: PullListPage) -> RustBuf public struct PullPage: Equatable, Hashable { public var title: String - public var state: String + public var state: WorkItemState public var meta: String public var body: String public var filesRef: String @@ -3023,7 +3027,7 @@ public struct PullPage: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(title: String, state: String, meta: String, body: String, filesRef: String, files: [FileRow], comments: [CommentRow], hasMore: Bool) { + public init(title: String, state: WorkItemState, meta: String, body: String, filesRef: String, files: [FileRow], comments: [CommentRow], hasMore: Bool) { self.title = title self.state = state self.meta = meta @@ -3051,7 +3055,7 @@ public struct FfiConverterTypePullPage: FfiConverterRustBuffer { return try PullPage( title: FfiConverterString.read(from: &buf), - state: FfiConverterString.read(from: &buf), + state: FfiConverterTypeWorkItemState.read(from: &buf), meta: FfiConverterString.read(from: &buf), body: FfiConverterString.read(from: &buf), filesRef: FfiConverterString.read(from: &buf), @@ -3063,7 +3067,7 @@ public struct FfiConverterTypePullPage: FfiConverterRustBuffer { public static func write(_ value: PullPage, into buf: inout [UInt8]) { FfiConverterString.write(value.title, into: &buf) - FfiConverterString.write(value.state, into: &buf) + FfiConverterTypeWorkItemState.write(value.state, into: &buf) FfiConverterString.write(value.meta, into: &buf) FfiConverterString.write(value.body, into: &buf) FfiConverterString.write(value.filesRef, into: &buf) @@ -3093,14 +3097,14 @@ public struct PullRow: Equatable, Hashable { public var number: Int64 public var owner: String public var repository: String - public var state: String + public var state: WorkItemState public var title: String public var summary: String public var meta: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(number: Int64, owner: String, repository: String, state: String, title: String, summary: String, meta: String) { + public init(number: Int64, owner: String, repository: String, state: WorkItemState, title: String, summary: String, meta: String) { self.number = number self.owner = owner self.repository = repository @@ -3129,7 +3133,7 @@ public struct FfiConverterTypePullRow: FfiConverterRustBuffer { number: FfiConverterInt64.read(from: &buf), owner: FfiConverterString.read(from: &buf), repository: FfiConverterString.read(from: &buf), - state: FfiConverterString.read(from: &buf), + state: FfiConverterTypeWorkItemState.read(from: &buf), title: FfiConverterString.read(from: &buf), summary: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf) @@ -3140,7 +3144,7 @@ public struct FfiConverterTypePullRow: FfiConverterRustBuffer { FfiConverterInt64.write(value.number, into: &buf) FfiConverterString.write(value.owner, into: &buf) FfiConverterString.write(value.repository, into: &buf) - FfiConverterString.write(value.state, into: &buf) + FfiConverterTypeWorkItemState.write(value.state, into: &buf) FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.summary, into: &buf) FfiConverterString.write(value.meta, into: &buf) @@ -3166,12 +3170,12 @@ public func FfiConverterTypePullRow_lower(_ value: PullRow) -> RustBuffer { public struct RepositoryContentRow: Equatable, Hashable { public var name: String public var path: String - public var kind: String + public var kind: RepositoryContentKind public var size: Int64 // Default memberwise initializers are never public by default, so we // declare one manually. - public init(name: String, path: String, kind: String, size: Int64) { + public init(name: String, path: String, kind: RepositoryContentKind, size: Int64) { self.name = name self.path = path self.kind = kind @@ -3196,7 +3200,7 @@ public struct FfiConverterTypeRepositoryContentRow: FfiConverterRustBuffer { try RepositoryContentRow( name: FfiConverterString.read(from: &buf), path: FfiConverterString.read(from: &buf), - kind: FfiConverterString.read(from: &buf), + kind: FfiConverterTypeRepositoryContentKind.read(from: &buf), size: FfiConverterInt64.read(from: &buf) ) } @@ -3204,7 +3208,7 @@ public struct FfiConverterTypeRepositoryContentRow: FfiConverterRustBuffer { public static func write(_ value: RepositoryContentRow, into buf: inout [UInt8]) { FfiConverterString.write(value.name, into: &buf) FfiConverterString.write(value.path, into: &buf) - FfiConverterString.write(value.kind, into: &buf) + FfiConverterTypeRepositoryContentKind.write(value.kind, into: &buf) FfiConverterInt64.write(value.size, into: &buf) } } @@ -3227,14 +3231,14 @@ public func FfiConverterTypeRepositoryContentRow_lower(_ value: RepositoryConten public struct RepositoryFilePage: Equatable, Hashable { public var name: String - public var kind: String + public var kind: RepositoryFileKind public var language: String public var text: String public var data: Data // Default memberwise initializers are never public by default, so we // declare one manually. - public init(name: String, kind: String, language: String, text: String, data: Data) { + public init(name: String, kind: RepositoryFileKind, language: String, text: String, data: Data) { self.name = name self.kind = kind self.language = language @@ -3259,7 +3263,7 @@ public struct FfiConverterTypeRepositoryFilePage: FfiConverterRustBuffer { return try RepositoryFilePage( name: FfiConverterString.read(from: &buf), - kind: FfiConverterString.read(from: &buf), + kind: FfiConverterTypeRepositoryFileKind.read(from: &buf), language: FfiConverterString.read(from: &buf), text: FfiConverterString.read(from: &buf), data: FfiConverterData.read(from: &buf) @@ -3268,7 +3272,7 @@ public struct FfiConverterTypeRepositoryFilePage: FfiConverterRustBuffer { public static func write(_ value: RepositoryFilePage, into buf: inout [UInt8]) { FfiConverterString.write(value.name, into: &buf) - FfiConverterString.write(value.kind, into: &buf) + FfiConverterTypeRepositoryFileKind.write(value.kind, into: &buf) FfiConverterString.write(value.language, into: &buf) FfiConverterString.write(value.text, into: &buf) FfiConverterData.write(value.data, into: &buf) @@ -3523,6 +3527,281 @@ public func FfiConverterTypeSettings_lower(_ value: Settings) -> RustBuffer { } + +public enum ActivityIcon: Equatable, Hashable { + + case repository + case issue + case pullRequest + case branch + case tag + case push + case release + + + + + +} + +#if compiler(>=6) +extension ActivityIcon: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActivityIcon: FfiConverterRustBuffer { + typealias SwiftType = ActivityIcon + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActivityIcon { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .repository + + case 2: return .issue + + case 3: return .pullRequest + + case 4: return .branch + + case 5: return .tag + + case 6: return .push + + case 7: return .release + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ActivityIcon, into buf: inout [UInt8]) { + switch value { + + + case .repository: + writeInt(&buf, Int32(1)) + + + case .issue: + writeInt(&buf, Int32(2)) + + + case .pullRequest: + writeInt(&buf, Int32(3)) + + + case .branch: + writeInt(&buf, Int32(4)) + + + case .tag: + writeInt(&buf, Int32(5)) + + + case .push: + writeInt(&buf, Int32(6)) + + + case .release: + writeInt(&buf, Int32(7)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActivityIcon_lift(_ buf: RustBuffer) throws -> ActivityIcon { + return try FfiConverterTypeActivityIcon.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActivityIcon_lower(_ value: ActivityIcon) -> RustBuffer { + return FfiConverterTypeActivityIcon.lower(value) +} + + + + +public enum ActivityTargetKind: Equatable, Hashable { + + case none + case repository + case issue + case pullRequest + case commit + + + + + +} + +#if compiler(>=6) +extension ActivityTargetKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActivityTargetKind: FfiConverterRustBuffer { + typealias SwiftType = ActivityTargetKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActivityTargetKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .none + + case 2: return .repository + + case 3: return .issue + + case 4: return .pullRequest + + case 5: return .commit + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ActivityTargetKind, into buf: inout [UInt8]) { + switch value { + + + case .none: + writeInt(&buf, Int32(1)) + + + case .repository: + writeInt(&buf, Int32(2)) + + + case .issue: + writeInt(&buf, Int32(3)) + + + case .pullRequest: + writeInt(&buf, Int32(4)) + + + case .commit: + writeInt(&buf, Int32(5)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActivityTargetKind_lift(_ buf: RustBuffer) throws -> ActivityTargetKind { + return try FfiConverterTypeActivityTargetKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActivityTargetKind_lower(_ value: ActivityTargetKind) -> RustBuffer { + return FfiConverterTypeActivityTargetKind.lower(value) +} + + + + +public enum DiffLineKind: Equatable, Hashable { + + case addition + case removal + case hunk + case header + case context + + + + + +} + +#if compiler(>=6) +extension DiffLineKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeDiffLineKind: FfiConverterRustBuffer { + typealias SwiftType = DiffLineKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> DiffLineKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .addition + + case 2: return .removal + + case 3: return .hunk + + case 4: return .header + + case 5: return .context + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: DiffLineKind, into buf: inout [UInt8]) { + switch value { + + + case .addition: + writeInt(&buf, Int32(1)) + + + case .removal: + writeInt(&buf, Int32(2)) + + + case .hunk: + writeInt(&buf, Int32(3)) + + + case .header: + writeInt(&buf, Int32(4)) + + + case .context: + writeInt(&buf, Int32(5)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiffLineKind_lift(_ buf: RustBuffer) throws -> DiffLineKind { + return try FfiConverterTypeDiffLineKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeDiffLineKind_lower(_ value: DiffLineKind) -> RustBuffer { + return FfiConverterTypeDiffLineKind.lower(value) +} + + + public enum GotchaError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { @@ -3672,6 +3951,145 @@ public func FfiConverterTypeHomeActivityFilter_lower(_ value: HomeActivityFilter +public enum RepositoryContentKind: Equatable, Hashable { + + case directory + case file + + + + + +} + +#if compiler(>=6) +extension RepositoryContentKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeRepositoryContentKind: FfiConverterRustBuffer { + typealias SwiftType = RepositoryContentKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RepositoryContentKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .directory + + case 2: return .file + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: RepositoryContentKind, into buf: inout [UInt8]) { + switch value { + + + case .directory: + writeInt(&buf, Int32(1)) + + + case .file: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryContentKind_lift(_ buf: RustBuffer) throws -> RepositoryContentKind { + return try FfiConverterTypeRepositoryContentKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryContentKind_lower(_ value: RepositoryContentKind) -> RustBuffer { + return FfiConverterTypeRepositoryContentKind.lower(value) +} + + + + +public enum RepositoryFileKind: Equatable, Hashable { + + case markdown + case source + case preview + + + + + +} + +#if compiler(>=6) +extension RepositoryFileKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeRepositoryFileKind: FfiConverterRustBuffer { + typealias SwiftType = RepositoryFileKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RepositoryFileKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .markdown + + case 2: return .source + + case 3: return .preview + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: RepositoryFileKind, into buf: inout [UInt8]) { + switch value { + + + case .markdown: + writeInt(&buf, Int32(1)) + + + case .source: + writeInt(&buf, Int32(2)) + + + case .preview: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryFileKind_lift(_ buf: RustBuffer) throws -> RepositoryFileKind { + return try FfiConverterTypeRepositoryFileKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryFileKind_lower(_ value: RepositoryFileKind) -> RustBuffer { + return FfiConverterTypeRepositoryFileKind.lower(value) +} + + + + public enum RepositoryPane: Equatable, Hashable { case issues @@ -3743,6 +4161,79 @@ public func FfiConverterTypeRepositoryPane_lower(_ value: RepositoryPane) -> Rus } + + +public enum WorkItemState: Equatable, Hashable { + + case `open` + case closed + case unknown + + + + + +} + +#if compiler(>=6) +extension WorkItemState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWorkItemState: FfiConverterRustBuffer { + typealias SwiftType = WorkItemState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WorkItemState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .`open` + + case 2: return .closed + + case 3: return .unknown + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: WorkItemState, into buf: inout [UInt8]) { + switch value { + + + case .`open`: + writeInt(&buf, Int32(1)) + + + case .closed: + writeInt(&buf, Int32(2)) + + + case .unknown: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWorkItemState_lift(_ buf: RustBuffer) throws -> WorkItemState { + return try FfiConverterTypeWorkItemState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWorkItemState_lower(_ value: WorkItemState) -> RustBuffer { + return FfiConverterTypeWorkItemState.lower(value) +} + + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -4353,133 +4844,133 @@ private let initializationResult: InitializationResult = { if bindings_contract_version != scaffolding_contract_version { return InitializationResult.contractVersionMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_active_server_index() != 48940) { + if (uniffi_gotcha_core_checksum_method_gotchacore_commit_details() != 61777) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_active_server_name() != 62299) { + if (uniffi_gotcha_core_checksum_method_gotchacore_commit_diff() != 61482) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_add_server() != 42705) { + if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 32481) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_clear_issue_filters() != 11092) { + if (uniffi_gotcha_core_checksum_method_gotchacore_pull_diff() != 62574) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters() != 36676) { + if (uniffi_gotcha_core_checksum_method_gotchacore_repository_contents() != 53943) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_commit_details() != 19611) { + if (uniffi_gotcha_core_checksum_method_gotchacore_repository_file() != 13870) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_commit_diff() != 56887) { + if (uniffi_gotcha_core_checksum_method_gotchacore_clear_issue_filters() != 21897) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 60062) { + if (uniffi_gotcha_core_checksum_method_gotchacore_delete_issue() != 22660) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_delete_issue() != 9524) { + if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 15614) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_delete_milestone() != 21716) { + if (uniffi_gotcha_core_checksum_method_gotchacore_issue_editor() != 32394) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 2797) { + if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters() != 12134) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 3108) { + if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters_active() != 21302) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_issue_editor() != 10078) { + if (uniffi_gotcha_core_checksum_method_gotchacore_issues() != 57513) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters() != 24054) { + if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue() != 58663) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters_active() != 51470) { + if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 13391) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_issues() != 8566) { + if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_closed() != 30617) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_milestone() != 57186) { + if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters() != 4176) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_milestone_editor() != 52261) { + if (uniffi_gotcha_core_checksum_method_gotchacore_delete_milestone() != 3994) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_milestones() != 50431) { + if (uniffi_gotcha_core_checksum_method_gotchacore_milestone() != 34814) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_pull() != 12919) { + if (uniffi_gotcha_core_checksum_method_gotchacore_milestone_editor() != 38638) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_pull_diff() != 61384) { + if (uniffi_gotcha_core_checksum_method_gotchacore_milestones() != 56160) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_pull_filters() != 10064) { + if (uniffi_gotcha_core_checksum_method_gotchacore_save_milestone() != 63660) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_pull_filters_active() != 20260) { + if (uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed() != 33) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_pulls() != 37027) { + if (uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters() != 61566) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_repositories() != 4035) { + if (uniffi_gotcha_core_checksum_method_gotchacore_pull() != 15127) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_repository_contents() != 8454) { + if (uniffi_gotcha_core_checksum_method_gotchacore_pull_filters() != 53612) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_repository_file() != 44948) { + if (uniffi_gotcha_core_checksum_method_gotchacore_pull_filters_active() != 11719) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue() != 3056) { + if (uniffi_gotcha_core_checksum_method_gotchacore_pulls() != 36665) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) { + if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters() != 42079) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_save_milestone() != 9828) { + if (uniffi_gotcha_core_checksum_method_gotchacore_repositories() != 43682) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) { + if (uniffi_gotcha_core_checksum_method_gotchacore_toggle_favorite() != 61398) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_servers() != 2778) { + if (uniffi_gotcha_core_checksum_method_gotchacore_active_server_index() != 39193) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_set_appearance() != 61293) { + if (uniffi_gotcha_core_checksum_method_gotchacore_active_server_name() != 45045) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_closed() != 38814) { + if (uniffi_gotcha_core_checksum_method_gotchacore_add_server() != 8514) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters() != 24891) { + if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 37602) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status() != 44445) { + if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 10204) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed() != 44255) { + if (uniffi_gotcha_core_checksum_method_gotchacore_servers() != 16732) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters() != 25211) { + if (uniffi_gotcha_core_checksum_method_gotchacore_set_appearance() != 42609) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_status() != 1356) { + if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status() != 5573) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_settings() != 59418) { + if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_status() != 40421) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_startup_error() != 56765) { + if (uniffi_gotcha_core_checksum_method_gotchacore_settings() != 5604) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_toggle_favorite() != 24958) { + if (uniffi_gotcha_core_checksum_method_gotchacore_startup_error() != 58843) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_constructor_gotchacore_new() != 35775) { + if (uniffi_gotcha_core_checksum_constructor_gotchacore_new() != 35044) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 7b5ca2d..7b99368 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -257,31 +257,6 @@ void uniffi_gotcha_core_fn_free_gotchacore(uint64_t handle, RustCallStatus *_Non #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_CONSTRUCTOR_GOTCHACORE_NEW uint64_t uniffi_gotcha_core_fn_constructor_gotchacore_new(RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX -RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_index(uint64_t ptr, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME -RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_name(uint64_t ptr, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER -uint64_t uniffi_gotcha_core_fn_method_gotchacore_add_server(uint64_t ptr, RustBuffer name, RustBuffer url, RustBuffer token -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS -void uniffi_gotcha_core_fn_method_gotchacore_clear_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS -void uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS @@ -299,21 +274,31 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_diff(uint64_t ptr, RustB uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer branch, RustBuffer path, uint32_t pages ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF +uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull_diff(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer path +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS +uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_contents(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE +uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS +void uniffi_gotcha_core_fn_method_gotchacore_clear_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_ISSUE #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_ISSUE uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_MILESTONE -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_MILESTONE -uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME -uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page, RustBuffer filter -); -#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, uint32_t page @@ -339,6 +324,31 @@ int8_t uniffi_gotcha_core_fn_method_gotchacore_issue_filters_active(uint64_t ptr uint64_t uniffi_gotcha_core_fn_method_gotchacore_issues(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE +uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number, RustBuffer title, RustBuffer body, RustBuffer label_ids, RustBuffer milestone_id, RustBuffer due_date, int8_t closed +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT +uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer comment_id, RustBuffer body +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_CLOSED +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_CLOSED +uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_issue_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, int8_t closed +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS +void uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer milestone, RustBuffer labels, RustBuffer search_text, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_MILESTONE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_MILESTONE +uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, uint32_t page @@ -354,16 +364,26 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestone_editor(uint64_t ptr, uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestones(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE +uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id, RustBuffer draft +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED +uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_milestone_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, int8_t closed +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS +void uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, uint32_t page ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF -uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull_diff(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer path -); -#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_FILTERS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_FILTERS uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull_filters(uint64_t ptr @@ -379,34 +399,39 @@ int8_t uniffi_gotcha_core_fn_method_gotchacore_pull_filters_active(uint64_t ptr, uint64_t uniffi_gotcha_core_fn_method_gotchacore_pulls(uint64_t ptr, uint32_t page ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_FILTERS +void uniffi_gotcha_core_fn_method_gotchacore_set_pull_filters(uint64_t ptr, RustBuffer milestone, RustBuffer search_text, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORIES #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORIES uint64_t uniffi_gotcha_core_fn_method_gotchacore_repositories(uint64_t ptr, uint32_t page, RustBuffer pane ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS -uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_contents(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE +RustBuffer uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer pane, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE -uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX +RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_index(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE -uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number, RustBuffer title, RustBuffer body, RustBuffer label_ids, RustBuffer milestone_id, RustBuffer due_date, int8_t closed +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME +RustBuffer uniffi_gotcha_core_fn_method_gotchacore_active_server_name(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT -uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer comment_id, RustBuffer body +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ADD_SERVER +uint64_t uniffi_gotcha_core_fn_method_gotchacore_add_server(uint64_t ptr, RustBuffer name, RustBuffer url, RustBuffer token ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE -uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id, RustBuffer draft +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME +uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page, RustBuffer filter ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER @@ -424,31 +449,11 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_servers(uint64_t ptr, RustCal void uniffi_gotcha_core_fn_method_gotchacore_set_appearance(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_CLOSED -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_CLOSED -uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_issue_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, int8_t closed -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS -void uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer milestone, RustBuffer labels, RustBuffer search_text, RustCallStatus *_Nonnull out_status -); -#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_STATUS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_STATUS void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED -uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_milestone_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, int8_t closed -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_FILTERS -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_FILTERS -void uniffi_gotcha_core_fn_method_gotchacore_set_pull_filters(uint64_t ptr, RustBuffer milestone, RustBuffer search_text, RustCallStatus *_Nonnull out_status -); -#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS void uniffi_gotcha_core_fn_method_gotchacore_set_pull_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status @@ -464,11 +469,6 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_settings(uint64_t ptr, RustCa RustBuffer uniffi_gotcha_core_fn_method_gotchacore_startup_error(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE -RustBuffer uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer pane, RustCallStatus *_Nonnull out_status -); -#endif #ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC #define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC RustBuffer ffi_gotcha_core_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status @@ -727,36 +727,6 @@ void ffi_gotcha_core_rust_future_free_void(uint64_t handle #ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID #define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID void ffi_gotcha_core_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_index(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_name(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ADD_SERVER -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ADD_SERVER -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_add_server(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_clear_issue_filters(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DETAILS @@ -775,24 +745,36 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commit_diff(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMITS uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commits(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull_diff(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_contents(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_ISSUE_FILTERS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_clear_issue_filters(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_ISSUE #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_ISSUE uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_issue(void -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_MILESTONE -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_MILESTONE -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_milestone(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE @@ -823,6 +805,36 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue_filters_active(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUES uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issues(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_CLOSED +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_CLOSED +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_closed(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_MILESTONE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_MILESTONE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_milestone(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONE @@ -843,15 +855,27 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_milestones(void ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull(void +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_milestone(void ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_DIFF -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull_diff(void +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull(void ); #endif @@ -871,6 +895,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull_filters_active(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULLS uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pulls(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORIES @@ -879,33 +909,33 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repositories(void ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_contents(void +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_toggle_favorite(void ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_INDEX +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_index(void ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTIVE_SERVER_NAME +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_active_server_name(void ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment(void +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ADD_SERVER +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ADD_SERVER +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_add_server(void ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_milestone(void +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void ); #endif @@ -925,36 +955,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_servers(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_APPEARANCE uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_appearance(void -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_CLOSED -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_CLOSED -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_closed(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status(void -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS @@ -973,12 +979,6 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_settings(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_STARTUP_ERROR uint16_t uniffi_gotcha_core_checksum_method_gotchacore_startup_error(void -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE -#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_TOGGLE_FAVORITE -uint16_t uniffi_gotcha_core_checksum_method_gotchacore_toggle_favorite(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_CONSTRUCTOR_GOTCHACORE_NEW diff --git a/ios/Gotcha.xcodeproj/project.pbxproj b/ios/Gotcha.xcodeproj/project.pbxproj index 14fa9b9..f3e767f 100644 --- a/ios/Gotcha.xcodeproj/project.pbxproj +++ b/ios/Gotcha.xcodeproj/project.pbxproj @@ -7,35 +7,57 @@ objects = { /* Begin PBXBuildFile section */ - 1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E10787062D1175125BB4C /* ContentScreens.swift */; }; + 0020E57CC7B0C5CBBF04291C /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */; }; + 130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */; }; 1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */; }; - 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; }; - 374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.swift */; }; + 25B30DCE869158C89021EFC6 /* DiffScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E0F726C9668B50418D9ADF /* DiffScreen.swift */; }; + 267E72DD7E12E3082974337E /* IssueActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17264C5C909501D8ADEC00B7 /* IssueActions.swift */; }; + 33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B8279189276A084B69D7D0 /* PullScreens.swift */; }; 381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; }; + 4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */; }; + 7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */; }; 7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; }; + 8A42B4319AF343566D77D12A /* WorkItemDetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */; }; 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; }; 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */; }; + 9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13A39F3C39C1F353D58C307 /* ServerScreens.swift */; }; + A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */; }; + C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */; }; + CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */; }; D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722405F999984C7CBE838711 /* IssueEditorViewController.swift */; }; D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; }; D78A121E9ADA0B72E2CF94DC /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8AD463F3AC817A44306C2297 /* MarkdownUI */; }; + DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */; }; + DFB34862C386662D6EE7E6BE /* RepositoryListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */; }; E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; }; EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61515849F6AACD721FE915C /* AppContext.swift */; }; F55A89489B2758D694F3B27D /* Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75B3E4FFB9C9992517C4D69 /* Support.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = ""; }; + 000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryListScreen.swift; sourceTree = ""; }; + 0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkItemDetailScreens.swift; sourceTree = ""; }; + 17264C5C909501D8ADEC00B7 /* IssueActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueActions.swift; sourceTree = ""; }; + 181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryFileScreens.swift; sourceTree = ""; }; + 41E0F726C9668B50418D9ADF /* DiffScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffScreen.swift; sourceTree = ""; }; 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 = ""; }; 722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = ""; }; + 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitScreens.swift; sourceTree = ""; }; + 81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailScreen.swift; sourceTree = ""; }; 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = ""; }; + 99B8279189276A084B69D7D0 /* PullScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullScreens.swift; sourceTree = ""; }; 9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = ""; }; + ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = ""; }; + C13A39F3C39C1F353D58C307 /* ServerScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerScreens.swift; sourceTree = ""; }; CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = ""; }; - CE5E10787062D1175125BB4C /* ContentScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScreens.swift; sourceTree = ""; }; + D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; F61515849F6AACD721FE915C /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = ""; }; F75B3E4FFB9C9992517C4D69 /* Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Support.swift; sourceTree = ""; }; + FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDirectoryScreen.swift; sourceTree = ""; }; FE12480725C13293FAFDDA52 /* gotcha_core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = gotcha_core.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -58,13 +80,23 @@ F61515849F6AACD721FE915C /* AppContext.swift */, 9C0921C76676A14A024BA417 /* AppDelegate.swift */, 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */, - CE5E10787062D1175125BB4C /* ContentScreens.swift */, - 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */, + 81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */, + 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */, + 41E0F726C9668B50418D9ADF /* DiffScreen.swift */, + D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */, + 17264C5C909501D8ADEC00B7 /* IssueActions.swift */, 722405F999984C7CBE838711 /* IssueEditorViewController.swift */, - AC828923A1E11A58AE348A74 /* ListScreens.swift */, + ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */, CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */, + 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */, + 99B8279189276A084B69D7D0 /* PullScreens.swift */, + FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */, + 181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */, + 000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */, + C13A39F3C39C1F353D58C307 /* ServerScreens.swift */, 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */, F75B3E4FFB9C9992517C4D69 /* Support.swift */, + 0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */, ); path = Sources; sourceTree = ""; @@ -81,6 +113,7 @@ isa = PBXGroup; children = ( DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */, + F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */, 94721140EFE7F8E7CD0F5C0B /* Generated */, 710A50F51478401FC642E6E3 /* Sources */, F059299C038F3CAFCE470831 /* Products */, @@ -164,6 +197,7 @@ buildActionMask = 2147483647; files = ( E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */, + A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -200,13 +234,23 @@ EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */, 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */, 381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */, - 1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */, - 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */, + CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */, + 4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */, + 25B30DCE869158C89021EFC6 /* DiffScreen.swift in Sources */, + 0020E57CC7B0C5CBBF04291C /* HomeScreen.swift in Sources */, + 267E72DD7E12E3082974337E /* IssueActions.swift in Sources */, D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */, - 374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */, + 7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */, 1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */, + C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */, + 33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */, + 130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */, + DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */, + DFB34862C386662D6EE7E6BE /* RepositoryListScreen.swift in Sources */, + 9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */, 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */, F55A89489B2758D694F3B27D /* Support.swift in Sources */, + 8A42B4319AF343566D77D12A /* WorkItemDetailScreens.swift in Sources */, D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -221,6 +265,7 @@ CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = MU22FMRGK8; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -229,6 +274,7 @@ "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust"; + MARKETING_VERSION = 1.0; OTHER_LDFLAGS = "$(inherited) -lgotcha_core"; PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha; SDKROOT = iphoneos; @@ -245,6 +291,7 @@ CODE_SIGN_ENTITLEMENTS = Gotcha.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = MU22FMRGK8; INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -253,6 +300,7 @@ "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust"; + MARKETING_VERSION = 1.0; OTHER_LDFLAGS = "$(inherited) -lgotcha_core"; PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha; SDKROOT = iphoneos; diff --git a/ios/Info.plist b/ios/Info.plist index 93c12b3..0c03d19 100644 --- a/ios/Info.plist +++ b/ios/Info.plist @@ -17,9 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + UILaunchScreen UISupportedInterfaceOrientations diff --git a/ios/PrivacyInfo.xcprivacy b/ios/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5397adc --- /dev/null +++ b/ios/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/ios/Sources/AppContext.swift b/ios/Sources/AppContext.swift index a086cb4..ad33b20 100644 --- a/ios/Sources/AppContext.swift +++ b/ios/Sources/AppContext.swift @@ -73,7 +73,7 @@ final class AppContext { func route(_ activity: ActivityRow) { switch activity.target { - case "repository": + case .repository: tabs.selectedIndex = 1 navigationControllers[1].pushViewController( IssuesViewController( @@ -83,7 +83,7 @@ final class AppContext { ), animated: true ) - case "issue": + case .issue: tabs.selectedIndex = 1 navigationControllers[1].pushViewController( IssueViewController( @@ -94,7 +94,7 @@ final class AppContext { ), animated: true ) - case "pull": + case .pullRequest: tabs.selectedIndex = 3 navigationControllers[3].pushViewController( PullViewController( @@ -105,7 +105,7 @@ final class AppContext { ), animated: true ) - case "commit": + case .commit: tabs.selectedIndex = 2 navigationControllers[2].pushViewController( FilesViewController( @@ -116,7 +116,7 @@ final class AppContext { ), animated: true ) - default: + case .none: break } } diff --git a/ios/Sources/CommitDetailScreen.swift b/ios/Sources/CommitDetailScreen.swift new file mode 100644 index 0000000..ae0f913 --- /dev/null +++ b/ios/Sources/CommitDetailScreen.swift @@ -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 + ) + } +} diff --git a/ios/Sources/CommitScreens.swift b/ios/Sources/CommitScreens.swift new file mode 100644 index 0000000..b57e351 --- /dev/null +++ b/ios/Sources/CommitScreens.swift @@ -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.. 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) + } +} diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift deleted file mode 100644 index 407d576..0000000 --- a/ios/Sources/ContentScreens.swift +++ /dev/null @@ -1,1651 +0,0 @@ -import UIKit - -private 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 -private protocol IssueSwipeActionHost: AnyObject { - var context: AppContext { get } - var owner: String { get } - var repository: String { get } - var issueMutationTask: Task? { get set } - func reloadIssuesAfterMutation() -} - -private 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) - } -} - -@MainActor -final class IssuesViewController: RefreshingTableViewController, IssueSwipeActionHost { - fileprivate let context: AppContext - fileprivate let owner: String - fileprivate let repository: String - private var rows: [IssueRow] = [] - private var filterOptions: IssueFilterOptions? - private var filterTask: Task? - fileprivate var issueMutationTask: Task? - 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]) - } - - fileprivate 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, - 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()) - } -} - -@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? - 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 { - fileprivate let context: AppContext - fileprivate let owner: String - fileprivate let repository: String - private let id: Int64 - private var page: MilestonePage? - fileprivate var issueMutationTask: Task? - 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) - } - - fileprivate 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 - } -} - -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? - 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" - } -} - -@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.. 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) - } -} diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift deleted file mode 100644 index 3251d92..0000000 --- a/ios/Sources/DetailScreens.swift +++ /dev/null @@ -1,1343 +0,0 @@ -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? - 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 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 isCommit = metadata.label == "Commit" - 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 = isCommit - ? UIFontMetrics(forTextStyle: .subheadline).scaledFont( - for: .monospacedSystemFont(ofSize: 15, weight: .regular) - ) - : .preferredFont(forTextStyle: .subheadline) - value.adjustsFontForContentSizeCategory = true - value.numberOfLines = 0 - value.lineBreakMode = isCommit ? .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 - ) - } -} - -@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 - } - } -} - -@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 - } -} - -private 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 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? - private var page: RepositoryFilePage? - private var previewURL: URL? - private var fileLanguage: String? - private var historyController: RepositoryHistoryViewController? - private var displayedController: UIViewController? - private var displayedView: UIView? - - 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() - let modes = page.kind == "markdown" - ? ["Preview", "Source", "History"] - : ["Content", "History"] - for (index, mode) in modes.enumerated() { - modeControl.insertSegment(withTitle: mode, 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, let mode = sender.titleForSegment(at: sender.selectedSegmentIndex) else { return } - showContent(page, mode: mode) - } - - private func showContent(_ page: RepositoryFilePage, mode: String) { - switch mode { - case "Preview": showMarkdownPreview(page.text) - case "Source": showSource(page.text) - case "Content" where page.kind == "source": showSource(page.text) - case "Content": - do { - try showQuickLook(page.data, name: page.name) - } catch { - show(error: error) - } - case "History": showHistory() - default: break - } - } - - 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)) - } -} - -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? - - 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) - default: color = .clear - } - output.append(NSAttributedString(string: text, attributes: [ - .font: font, - .foregroundColor: UIColor.label, - .backgroundColor: color, - ])) - } - return output - } -} - -@MainActor -private func detailViews( - title: String, - state: String? = 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: String, 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 -} - -private func separator() -> UIView { - let line = UIView() - line.backgroundColor = .separator - line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true - return line -} diff --git a/ios/Sources/DiffScreen.swift b/ios/Sources/DiffScreen.swift new file mode 100644 index 0000000..39ad1df --- /dev/null +++ b/ios/Sources/DiffScreen.swift @@ -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? + + 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 + } +} diff --git a/ios/Sources/HomeScreen.swift b/ios/Sources/HomeScreen.swift new file mode 100644 index 0000000..1185070 --- /dev/null +++ b/ios/Sources/HomeScreen.swift @@ -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() + } + } +} diff --git a/ios/Sources/IssueActions.swift b/ios/Sources/IssueActions.swift new file mode 100644 index 0000000..9642f5c --- /dev/null +++ b/ios/Sources/IssueActions.swift @@ -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? { 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) + } +} diff --git a/ios/Sources/IssueScreens.swift b/ios/Sources/IssueScreens.swift new file mode 100644 index 0000000..2d3b7aa --- /dev/null +++ b/ios/Sources/IssueScreens.swift @@ -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? + var issueMutationTask: Task? + 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, + 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()) + } +} diff --git a/ios/Sources/ListScreens.swift b/ios/Sources/ListScreens.swift deleted file mode 100644 index fdd60a4..0000000 --- a/ios/Sources/ListScreens.swift +++ /dev/null @@ -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() - } - } -} diff --git a/ios/Sources/MilestoneScreens.swift b/ios/Sources/MilestoneScreens.swift new file mode 100644 index 0000000..ad6a14a --- /dev/null +++ b/ios/Sources/MilestoneScreens.swift @@ -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? + 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? + 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 + } +} diff --git a/ios/Sources/PullScreens.swift b/ios/Sources/PullScreens.swift new file mode 100644 index 0000000..6f05562 --- /dev/null +++ b/ios/Sources/PullScreens.swift @@ -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? + 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" + } +} diff --git a/ios/Sources/RepositoryDirectoryScreen.swift b/ios/Sources/RepositoryDirectoryScreen.swift new file mode 100644 index 0000000..cbfa5e5 --- /dev/null +++ b/ios/Sources/RepositoryDirectoryScreen.swift @@ -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 + } + } +} diff --git a/ios/Sources/RepositoryFileScreens.swift b/ios/Sources/RepositoryFileScreens.swift new file mode 100644 index 0000000..74a6a88 --- /dev/null +++ b/ios/Sources/RepositoryFileScreens.swift @@ -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? + 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)) + } +} diff --git a/ios/Sources/RepositoryListScreen.swift b/ios/Sources/RepositoryListScreen.swift new file mode 100644 index 0000000..6e4e8e4 --- /dev/null +++ b/ios/Sources/RepositoryListScreen.swift @@ -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) + } + } +} diff --git a/ios/Sources/ServerScreens.swift b/ios/Sources/ServerScreens.swift new file mode 100644 index 0000000..2b5b339 --- /dev/null +++ b/ios/Sources/ServerScreens.swift @@ -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) + } +} diff --git a/ios/Sources/Support.swift b/ios/Sources/Support.swift index f08d137..e5e87a9 100644 --- a/ios/Sources/Support.swift +++ b/ios/Sources/Support.swift @@ -221,7 +221,7 @@ func filterMenuImage(_ symbol: String, active: Bool) -> UIImage? { } func configureRepositoryContentCell(_ cell: UITableViewCell, row: RepositoryContentRow) { - let directory = row.kind == "dir" + let directory = row.kind == .directory let detail = directory ? "Folder" : ByteCountFormatter.string(fromByteCount: row.size, countStyle: .file) @@ -242,7 +242,7 @@ func showRepositoryContent( repository: String, navigationController: UINavigationController? ) { - let destination: UIViewController = row.kind == "dir" + let destination: UIViewController = row.kind == .directory ? RepositoryDirectoryViewController( context: context, owner: owner, @@ -277,7 +277,7 @@ func symbolText(_ symbol: String, text: String, font: UIFont, color: UIColor) -> func configureIssueStateIcon( _ icon: UIImageView, - state: String, + state: WorkItemState, textStyle: UIFont.TextStyle ) { configureOpenClosedStateIcon(icon, state: state, subject: "issue", textStyle: textStyle) @@ -285,7 +285,7 @@ func configureIssueStateIcon( func configureOpenClosedStateIcon( _ icon: UIImageView, - state: String, + state: WorkItemState, subject: String, textStyle: UIFont.TextStyle ) { @@ -293,15 +293,15 @@ func configureOpenClosedStateIcon( let color: UIColor let accessibilityLabel: String switch state { - case "open": + case .open: (symbol, color, accessibilityLabel) = ( "exclamationmark.circle.fill", .systemGreen, "Open \(subject)" ) - case "closed": + case .closed: (symbol, color, accessibilityLabel) = ( "checkmark.circle.fill", .systemPurple, "Closed \(subject)" ) - default: + case .unknown: (symbol, color, accessibilityLabel) = ( "questionmark.circle.fill", .systemGray, "Unknown \(subject) state" ) @@ -385,3 +385,10 @@ final class IssueLabelsView: UIView { setNeedsLayout() } } + +func separator() -> UIView { + let line = UIView() + line.backgroundColor = .separator + line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true + return line +} diff --git a/ios/Sources/WorkItemDetailScreens.swift b/ios/Sources/WorkItemDetailScreens.swift new file mode 100644 index 0000000..35f9cbd --- /dev/null +++ b/ios/Sources/WorkItemDetailScreens.swift @@ -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? + 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 +} diff --git a/ios/project.yml b/ios/project.yml index 3056f64..f04e53f 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -17,6 +17,8 @@ targets: deploymentTarget: "17.0" settings: PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.gotcha + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" DEVELOPMENT_TEAM: MU22FMRGK8 CODE_SIGN_STYLE: Automatic TARGETED_DEVICE_FAMILY: "1" @@ -29,11 +31,15 @@ targets: path: Info.plist properties: CFBundleDisplayName: Gotcha + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + ITSAppUsesNonExemptEncryption: false UILaunchScreen: {} UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait sources: - Assets.xcassets + - PrivacyInfo.xcprivacy - Sources - Generated/gotcha_core.swift dependencies: