From 86200b46685dda1567132211ad7fd13c8d04cca8 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 14:22:21 +0200 Subject: [PATCH] Add pull request milestone features --- TESTING.md | 9 +- crates/app/src/api.rs | 77 +++++++++++----- crates/app/src/domain.rs | 24 +++++ crates/app/src/lib.rs | 62 ++++++++++++- crates/app/src/presentation.rs | 54 ++++++++++- crates/app/src/storage.rs | 3 +- ios/Generated/gotcha_core.swift | 111 +++++++++++++++++++++- ios/Generated/gotcha_coreFFI.h | 33 +++++++ ios/Sources/ContentScreens.swift | 153 ++++++++++++++++++++++++++----- 9 files changed, 474 insertions(+), 52 deletions(-) diff --git a/TESTING.md b/TESTING.md index bafdc2a..6b0fdc2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -192,6 +192,11 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] The list defaults to the saved Open/Closed filter. - [ ] Change the native filter menu between Open and Closed; the checkmark, rows, and persisted selection update. +- [ ] Select a milestone and then All Milestones; the checkmark and pull-request + rows update, and the milestone selection persists independently per + server after relaunch. +- [ ] The pull-request filter icon uses a neutral color for Open + All + Milestones and the app accent color whenever either filter is non-default. - [ ] Each row shows repository/number, title, author/update metadata, comment count, and draft/merged state where applicable. - [ ] Open a pull request and verify title, metadata, Markdown body, comments, @@ -204,8 +209,8 @@ xcrun simctl launch booted de.rfc1437.gotcha both open and closed milestones. - [ ] Each milestone shows its title, description, state, due date, issue counts, and a green/amber closed/open progress bar. -- [ ] Open a milestone and verify every assigned issue is listed; tapping an - issue opens its normal issue detail. +- [ ] Open a milestone and verify every assigned issue and pull request is + listed; tapping either opens its normal detail. - [ ] Repository issue lists and issue details show the assigned milestone with a flag icon instead of a text label. - [ ] Repositories without milestones show the native empty state. diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 3a2c368..022d754 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -159,29 +159,48 @@ pub async fn load_milestone( apis::issue_api::issue_get_milestone(&configuration, owner, repository, &id.to_string()) .await .map_err(|error| error.to_string())?; - let issues = apis::issue_api::issue_list_issues( - &configuration, - owner, - repository, - Some("all"), - None, - None, - Some("issues"), - milestone.title.as_deref(), - None, - None, - None, - None, - None, - Some(1), - Some(100), - ) - .await - .map_err(|error| error.to_string())?; - Ok(MilestoneDetails { milestone, issues }) + let load = |kind| { + apis::issue_api::issue_list_issues( + &configuration, + owner, + repository, + Some("all"), + None, + None, + Some(kind), + milestone.title.as_deref(), + None, + None, + None, + None, + None, + Some(1), + Some(100), + ) + }; + let (issues, pulls) = tokio::join!(load("issues"), load("pulls")); + let mut pulls = pulls.map_err(|error| error.to_string())?; + for pull in &mut pulls { + pull.repository.get_or_insert_with(|| { + Box::new(models::RepositoryMeta { + name: Some(repository.into()), + owner: Some(owner.into()), + ..Default::default() + }) + }); + } + Ok(MilestoneDetails { + milestone, + issues: issues.map_err(|error| error.to_string())?, + pulls, + }) } -pub async fn load_pulls(server: &Server, status: &str) -> Result, String> { +pub async fn load_pulls( + server: &Server, + status: &str, + milestone: &str, +) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); @@ -197,7 +216,7 @@ pub async fn load_pulls(server: &Server, status: &str) -> Result Result Result, String> { + let (open, closed) = tokio::try_join!( + load_pulls(server, "open", ""), + load_pulls(server, "closed", "") + )?; + Ok(open + .into_iter() + .chain(closed) + .filter_map(|pull| pull.milestone?.title) + .collect::>() + .into_iter() + .collect()) +} + pub async fn load_branches( server: &Server, owner: &str, diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index d568faa..d5f4e48 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -55,6 +55,8 @@ pub struct Preferences { #[serde(default = "open_status")] pub pull_status: String, #[serde(default)] + pub pull_filters: BTreeMap, + #[serde(default)] pub appearance: AppearanceMode, } @@ -67,6 +69,7 @@ impl Default for Preferences { issue_status: open_status(), issue_filters: BTreeMap::new(), pull_status: open_status(), + pull_filters: BTreeMap::new(), appearance: AppearanceMode::default(), } } @@ -86,6 +89,18 @@ impl IssueFilter { } } +#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct PullFilter { + #[serde(default)] + pub milestone: String, +} + +impl PullFilter { + pub fn is_active(&self, status: &str) -> bool { + status != "open" || self != &Self::default() + } +} + #[derive(Clone)] pub struct RepositoryData { pub owner: String, @@ -127,6 +142,7 @@ pub struct IssueDetails { pub struct MilestoneDetails { pub milestone: models::Milestone, pub issues: Vec, + pub pulls: Vec, } pub struct PullDetails { @@ -154,5 +170,13 @@ mod tests { } .is_active("open") ); + assert!(!PullFilter::default().is_active("open")); + assert!(PullFilter::default().is_active("closed")); + assert!( + PullFilter { + milestone: "v1".into(), + } + .is_active("open") + ); } } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 8041fc4..ba3e2aa 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -344,10 +344,68 @@ 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) -> Result<(), GotchaError> { + let filter = PullFilter { milestone }; + 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 async fn pulls(&self) -> Result, GotchaError> { let server = self.server()?; - let status = self.state.lock().unwrap().preferences.pull_status.clone(); - Ok(pull_rows(&load_pulls(&server, &status).await?)) + 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(), + ) + }; + Ok(pull_rows( + &load_pulls(&server, &status, &filter.milestone).await?, + )) } pub async fn pull( diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 3f6b788..ca560e1 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -4,7 +4,7 @@ use crate::{ activity, domain::{ HistoryCommit, HomeData, IssueDetails, IssueFilter, MilestoneDetails, PullDetails, - RepositoryData, + PullFilter, RepositoryData, }, }; @@ -91,6 +91,13 @@ pub struct MilestoneRow { pub struct MilestonePage { pub milestone: MilestoneRow, pub issues: Vec, + pub pulls: Vec, +} + +#[derive(Clone, uniffi::Record)] +pub struct PullFilterOptions { + pub milestones: Vec, + pub selected_milestone: String, } #[derive(Clone, uniffi::Record)] @@ -292,6 +299,20 @@ pub fn milestone_page(details: MilestoneDetails) -> MilestonePage { MilestonePage { milestone: milestone_row(&details.milestone), issues: issue_rows(&details.issues), + pulls: pull_rows(&details.pulls), + } +} + +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(), } } @@ -1186,6 +1207,37 @@ mod tests { assert_eq!(options.milestones, ["v1", "v2", "v3"]); assert_eq!(options.selected_labels, ["bug", "retired"]); assert_eq!(options.selected_milestone, "v3"); + + let pull_options = pull_filter_options( + &["v2".into(), "v1".into()], + &PullFilter { + milestone: "v3".into(), + }, + ); + assert_eq!(pull_options.milestones, ["v1", "v2", "v3"]); + assert_eq!(pull_options.selected_milestone, "v3"); + } + + #[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), + title: Some("Linked pull".into()), + repository: Some(Box::new(models::RepositoryMeta { + name: Some("demo".into()), + owner: Some("octo".into()), + ..Default::default() + })), + ..Default::default() + }], + }); + + assert!(page.issues.is_empty()); + assert_eq!(page.pulls.len(), 1); + assert_eq!(page.pulls[0].number, 7); } #[test] diff --git a/crates/app/src/storage.rs b/crates/app/src/storage.rs index 4fdb567..4ea9483 100644 --- a/crates/app/src/storage.rs +++ b/crates/app/src/storage.rs @@ -100,7 +100,7 @@ mod tests { "https://gitea.example.com|octo/demo" ); let preferences: Preferences = serde_json::from_str( - r#"{"issue_filters":{"server|octo/demo":{"milestone":"v1","labels":["bug"]}}}"#, + r#"{"issue_filters":{"server|octo/demo":{"milestone":"v1","labels":["bug"]}},"pull_filters":{"server":{"milestone":"v2"}}}"#, ) .unwrap(); assert_eq!( @@ -112,6 +112,7 @@ mod tests { .labels .contains("bug") ); + assert_eq!(preferences.pull_filters["server"].milestone, "v2"); assert_eq!(Preferences::default().pull_status, "open"); assert_eq!( Preferences::default().appearance, diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index b1d9c7e..fecc56d 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -633,6 +633,10 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func pullDiff(owner: String, repository: String, number: Int64, path: String) async throws -> DiffPage + func pullFilters() async throws -> PullFilterOptions + + func pullFiltersActive() throws -> Bool + func pulls() async throws -> [PullRow] func repositories() async throws -> [RepositoryRow] @@ -651,6 +655,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func setIssueStatus(status: String) throws + func setPullFilters(milestone: String) throws + func setPullStatus(status: String) throws func settings() -> Settings @@ -942,6 +948,31 @@ open func pullDiff(owner: String, repository: String, number: Int64, path: Strin ) } +open func pullFilters()async throws -> PullFilterOptions { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_pull_filters( + self.uniffiCloneHandle() + ) + }, + 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: FfiConverterTypePullFilterOptions_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func pullFiltersActive()throws -> Bool { + return try FfiConverterBool.lift(try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_pull_filters_active( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + open func pulls()async throws -> [PullRow] { return try await uniffiRustCallAsync( @@ -1054,6 +1085,15 @@ open func setIssueStatus(status: String)throws {try rustCallWithError(FfiConve } } +open func setPullFilters(milestone: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_set_pull_filters( + self.uniffiCloneHandle(), + FfiConverterString.lower(milestone),uniffiCallStatus + ) +} +} + open func setPullStatus(status: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_set_pull_status( @@ -1977,12 +2017,14 @@ public func FfiConverterTypeLabelRow_lower(_ value: LabelRow) -> RustBuffer { public struct MilestonePage: Equatable, Hashable { public var milestone: MilestoneRow public var issues: [IssueRow] + public var pulls: [PullRow] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(milestone: MilestoneRow, issues: [IssueRow]) { + public init(milestone: MilestoneRow, issues: [IssueRow], pulls: [PullRow]) { self.milestone = milestone self.issues = issues + self.pulls = pulls } @@ -2002,13 +2044,15 @@ public struct FfiConverterTypeMilestonePage: FfiConverterRustBuffer { return try MilestonePage( milestone: FfiConverterTypeMilestoneRow.read(from: &buf), - issues: FfiConverterSequenceTypeIssueRow.read(from: &buf) + issues: FfiConverterSequenceTypeIssueRow.read(from: &buf), + pulls: FfiConverterSequenceTypePullRow.read(from: &buf) ) } public static func write(_ value: MilestonePage, into buf: inout [UInt8]) { FfiConverterTypeMilestoneRow.write(value.milestone, into: &buf) FfiConverterSequenceTypeIssueRow.write(value.issues, into: &buf) + FfiConverterSequenceTypePullRow.write(value.pulls, into: &buf) } } @@ -2102,6 +2146,60 @@ public func FfiConverterTypeMilestoneRow_lower(_ value: MilestoneRow) -> RustBuf } +public struct PullFilterOptions: Equatable, Hashable { + public var milestones: [String] + public var selectedMilestone: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(milestones: [String], selectedMilestone: String) { + self.milestones = milestones + self.selectedMilestone = selectedMilestone + } + + + + +} + +#if compiler(>=6) +extension PullFilterOptions: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePullFilterOptions: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PullFilterOptions { + return + try PullFilterOptions( + milestones: FfiConverterSequenceString.read(from: &buf), + selectedMilestone: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: PullFilterOptions, into buf: inout [UInt8]) { + FfiConverterSequenceString.write(value.milestones, into: &buf) + FfiConverterString.write(value.selectedMilestone, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePullFilterOptions_lift(_ buf: RustBuffer) throws -> PullFilterOptions { + return try FfiConverterTypePullFilterOptions.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePullFilterOptions_lower(_ value: PullFilterOptions) -> RustBuffer { + return FfiConverterTypePullFilterOptions.lower(value) +} + + public struct PullPage: Equatable, Hashable { public var title: String public var meta: String @@ -3153,6 +3251,12 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_pull_diff() != 61384) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_pull_filters() != 10064) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_gotcha_core_checksum_method_gotchacore_pull_filters_active() != 20260) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_pulls() != 13092) { return InitializationResult.apiChecksumMismatch } @@ -3180,6 +3284,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status() != 44445) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters() != 39023) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_status() != 1356) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 6228515..474b545 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -334,6 +334,16 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull(uint64_t ptr, RustBuffer o 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 +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_FILTERS_ACTIVE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_FILTERS_ACTIVE +int8_t uniffi_gotcha_core_fn_method_gotchacore_pull_filters_active(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULLS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULLS uint64_t uniffi_gotcha_core_fn_method_gotchacore_pulls(uint64_t ptr @@ -379,6 +389,11 @@ void uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters(uint64_t ptr, Rus 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_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, 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 @@ -747,6 +762,18 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull(void #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_PULL_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_FILTERS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull_filters(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_FILTERS_ACTIVE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULL_FILTERS_ACTIVE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pull_filters_active(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_PULLS @@ -801,6 +828,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters(void #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_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 diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index bac4cc6..fab6984 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -426,18 +426,32 @@ final class MilestoneViewController: RefreshingTableViewController { } } - override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 } + override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 3 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? 1 : page?.issues.count ?? 0 + 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? { - section == 1 ? "Issues" : nil + switch section { + case 1: "Issues" + case 2: "Pull Requests" + default: nil + } } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - section == 1 && page?.issues.isEmpty == true ? "No issues are assigned to this milestone." : nil + 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( @@ -453,23 +467,42 @@ final class MilestoneViewController: RefreshingTableViewController { cell.configure(page.milestone, disclosure: false) return cell } - let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell - cell.configure(page.issues[indexPath.row]) + 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") + ?? UITableViewCell(style: .subtitle, reuseIdentifier: "pull") + let pull = page.pulls[indexPath.row] + configureTextCell(cell, title: pull.title, detail: "\(pull.summary)\n\(pull.meta)") + cell.accessoryType = .disclosureIndicator return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard indexPath.section == 1, let issue = page?.issues[indexPath.row] else { return } tableView.deselectRow(at: indexPath, animated: true) - navigationController?.pushViewController( - IssueViewController( - context: context, - owner: owner, - repository: repository, - number: issue.number - ), - 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 + ) + } } } @@ -522,6 +555,8 @@ final class MilestoneCell: UITableViewCell { final class PullsViewController: RefreshingTableViewController { private let context: AppContext private var rows: [PullRow] = [] + private var filterOptions: PullFilterOptions? + private var filterTask: Task? init(context: AppContext) { self.context = context @@ -532,6 +567,8 @@ final class PullsViewController: RefreshingTableViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + deinit { filterTask?.cancel() } + override func viewDidLoad() { super.viewDidLoad() navigationItem.leftBarButtonItem = UIBarButtonItem( @@ -557,6 +594,11 @@ final class PullsViewController: RefreshingTableViewController { refreshControl?.endRefreshing() return } + loadFilterOptions() + loadPulls(refreshing: refreshing) + } + + private func loadPulls(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { @@ -567,7 +609,9 @@ final class PullsViewController: RefreshingTableViewController { tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView( title: "No \(status) pull requests", - detail: "No pull requests match the selected status." + detail: filtersActive + ? "No pull requests match the selected filters." + : "No pull requests match the selected status." ) : nil } catch { @@ -577,6 +621,19 @@ final class PullsViewController: RefreshingTableViewController { } } + 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 } @@ -617,22 +674,74 @@ final class PullsViewController: RefreshingTableViewController { private func updateFilterMenu() { let current = context.core.settings().pullStatus - navigationItem.rightBarButtonItem = UIBarButtonItem( - image: context.symbol("line.3.horizontal.decrease.circle"), - menu: UIMenu(children: ["open", "closed"].map { status in + let status = UIMenu( + title: "Status", + image: context.symbol("circle.lefthalf.filled"), + 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.loadContent(refreshing: false) + 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") + ) + item.menu = UIMenu(children: [status, milestone]) + item.accessibilityLabel = "Filter pull requests" + navigationItem.rightBarButtonItem = item + updateFilterTint() + } + + 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: context.symbol("flag"), + options: .singleSelection, + children: [all] + milestones + ) + } + + private func selectMilestone(_ milestone: String) { + filterTask?.cancel() + do { + try context.core.setPullFilters(milestone: milestone) + filterOptions?.selectedMilestone = milestone + 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" } }