From 6eb27537e866cf78cc475269ae4ec4d9d57025ee Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 13:42:06 +0200 Subject: [PATCH] Add issue label and milestone filters --- TESTING.md | 8 ++ crates/app/src/api.rs | 35 ++++++- crates/app/src/domain.rs | 13 ++- crates/app/src/lib.rs | 79 ++++++++++++++- crates/app/src/presentation.rs | 72 ++++++++++++- crates/app/src/storage.rs | 17 ++++ ios/Generated/gotcha_core.swift | 95 +++++++++++++++++ ios/Generated/gotcha_coreFFI.h | 22 ++++ ios/Sources/ContentScreens.swift | 169 ++++++++++++++++++++++++++++--- 9 files changed, 492 insertions(+), 18 deletions(-) diff --git a/TESTING.md b/TESTING.md index 0473e64..6fc6100 100644 --- a/TESTING.md +++ b/TESTING.md @@ -133,6 +133,14 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] Open a repository; the issue 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 one and then multiple labels in the issue filter. Checkmarks and + rows update without dismissing the menu; clearing every label restores + the unfiltered label result. +- [ ] Select a milestone and then All Milestones; the checkmark and issue rows + update. Relaunch and revisit repositories to verify label and milestone + selections persist independently per repository and status persists. +- [ ] The issue-filter icon uses a neutral color for Open + All Milestones + no + labels, and the app accent color whenever any non-default filter is set. - [ ] Open both an open and a closed issue. A green open or purple closed icon appears beside the title and VoiceOver announces the state; author metadata, Markdown body, and comments remain correct. Links and diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index c194aa8..3a2c368 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -61,18 +61,21 @@ pub async fn load_issues( owner: &str, repository: &str, status: &str, + labels: &[String], + milestone: &str, ) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let labels = (!labels.is_empty()).then(|| labels.join(",")); apis::issue_api::issue_list_issues( &client.configuration(), owner, repository, Some(status), - None, + labels.as_deref(), None, Some("issues"), - None, + (!milestone.is_empty()).then_some(milestone), None, None, None, @@ -85,6 +88,34 @@ pub async fn load_issues( .map_err(|error| error.to_string()) } +pub async fn load_labels( + server: &Server, + owner: &str, + repository: &str, +) -> Result, String> { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let configuration = client.configuration(); + let mut labels = Vec::new(); + for page in 1.. { + let batch = apis::issue_api::issue_list_labels( + &configuration, + owner, + repository, + Some(page), + Some(100), + ) + .await + .map_err(|error| error.to_string())?; + let done = batch.len() < 100; + labels.extend(batch); + if done { + break; + } + } + Ok(labels) +} + pub async fn load_milestones( server: &Server, owner: &str, diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index 3021db2..65b26d0 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use gotcha_gitea::models; use serde::{Deserialize, Serialize}; @@ -50,6 +50,8 @@ pub struct Preferences { pub last_server: Option, #[serde(default = "open_status")] pub issue_status: String, + #[serde(default)] + pub issue_filters: BTreeMap, #[serde(default = "open_status")] pub pull_status: String, #[serde(default)] @@ -63,12 +65,21 @@ impl Default for Preferences { favorites: BTreeSet::new(), last_server: None, issue_status: open_status(), + issue_filters: BTreeMap::new(), pull_status: open_status(), appearance: AppearanceMode::default(), } } } +#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct IssueFilter { + #[serde(default)] + pub milestone: String, + #[serde(default)] + pub labels: BTreeSet, +} + #[derive(Clone)] pub struct RepositoryData { pub owner: String, diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 0c064e7..46b4def 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -211,12 +211,87 @@ impl GotchaCore { repository: String, ) -> Result, GotchaError> { let server = self.server()?; - let status = self.state.lock().unwrap().preferences.issue_status.clone(); + 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 labels: Vec<_> = filter.labels.into_iter().collect(); Ok(issue_rows( - &load_issues(&server, &owner, &repository, &status).await?, + &load_issues( + &server, + &owner, + &repository, + &status, + &labels, + &filter.milestone, + ) + .await?, )) } + 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 set_issue_filters( + &self, + owner: String, + repository: String, + milestone: String, + labels: Vec, + ) -> 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(), + }; + 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 async fn issue( &self, owner: String, diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 506364e..205a1c8 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -3,7 +3,8 @@ use gotcha_gitea::models; use crate::{ activity, domain::{ - HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData, + HistoryCommit, HomeData, IssueDetails, IssueFilter, MilestoneDetails, PullDetails, + RepositoryData, }, }; @@ -66,6 +67,14 @@ pub struct IssuePage { pub comments: Vec, } +#[derive(Clone, uniffi::Record)] +pub struct IssueFilterOptions { + pub milestones: Vec, + pub labels: Vec, + pub selected_milestone: String, + pub selected_labels: Vec, +} + #[derive(Clone, uniffi::Record)] pub struct MilestoneRow { pub id: i64, @@ -219,6 +228,31 @@ pub fn issue_rows(issues: &[models::Issue]) -> Vec { .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(); + labels.sort_by_key(|label| label.to_lowercase()); + labels.dedup(); + let mut milestones: Vec<_> = milestones + .iter() + .filter_map(|milestone| milestone.title.clone()) + .collect(); + milestones.sort_by_key(|milestone| milestone.to_lowercase()); + milestones.dedup(); + IssueFilterOptions { + milestones, + labels, + selected_milestone: filter.milestone.clone(), + selected_labels: filter.labels.iter().cloned().collect(), + } +} + pub fn milestone_rows(milestones: &[models::Milestone]) -> Vec { let mut milestones = milestones.to_vec(); milestones.sort_by_key(|milestone| { @@ -879,4 +913,40 @@ mod tests { assert_eq!(page.state, "closed"); } + + #[test] + fn builds_sorted_issue_filter_options() { + let filter = IssueFilter { + milestone: "v2".into(), + labels: ["bug".into(), "critical".into()].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"]); + assert_eq!(options.milestones, ["v1", "v2"]); + assert_eq!(options.selected_labels, ["bug", "critical"]); + assert_eq!(options.selected_milestone, "v2"); + } } diff --git a/crates/app/src/storage.rs b/crates/app/src/storage.rs index e4525c7..4fdb567 100644 --- a/crates/app/src/storage.rs +++ b/crates/app/src/storage.rs @@ -6,6 +6,10 @@ use security_framework::passwords::{get_generic_password, set_generic_password}; use crate::domain::{Preferences, Server, open_status}; pub fn favorite_key(server: &str, owner: &str, repository: &str) -> String { + repository_key(server, owner, repository) +} + +pub fn repository_key(server: &str, owner: &str, repository: &str) -> String { format!("{server}|{owner}/{repository}") } @@ -95,6 +99,19 @@ mod tests { favorite_key("https://gitea.example.com", "octo", "demo"), "https://gitea.example.com|octo/demo" ); + let preferences: Preferences = serde_json::from_str( + r#"{"issue_filters":{"server|octo/demo":{"milestone":"v1","labels":["bug"]}}}"#, + ) + .unwrap(); + assert_eq!( + preferences.issue_filters["server|octo/demo"].milestone, + "v1" + ); + assert!( + preferences.issue_filters["server|octo/demo"] + .labels + .contains("bug") + ); 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 28146f0..8837860 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -603,6 +603,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func issue(owner: String, repository: String, number: Int64) async throws -> IssuePage + func issueFilters(owner: String, repository: String) async throws -> IssueFilterOptions + func issues(owner: String, repository: String) async throws -> [IssueRow] func milestone(owner: String, repository: String, id: Int64) async throws -> MilestonePage @@ -627,6 +629,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func setAppearance(index: UInt32) throws + func setIssueFilters(owner: String, repository: String, milestone: String, labels: [String]) throws + func setIssueStatus(status: String) throws func setPullStatus(status: String) throws @@ -813,6 +817,22 @@ open func issue(owner: String, repository: String, number: Int64)async throws - ) } +open func issueFilters(owner: String, repository: String)async throws -> IssueFilterOptions { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_issue_filters( + self.uniffiCloneHandle(), FfiConverterString.lower(owner), FfiConverterString.lower(repository) + ) + }, + 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: FfiConverterTypeIssueFilterOptions_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func issues(owner: String, repository: String)async throws -> [IssueRow] { return try await uniffiRustCallAsync( @@ -984,6 +1004,18 @@ open func setAppearance(index: UInt32)throws {try rustCallWithError(FfiConvert } } +open func setIssueFilters(owner: String, repository: String, milestone: String, labels: [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), uniffiCallStatus + ) +} +} + open func setIssueStatus(status: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_set_issue_status( @@ -1657,6 +1689,63 @@ public func FfiConverterTypeHomePage_lower(_ value: HomePage) -> RustBuffer { } +public struct IssueFilterOptions: Equatable, Hashable { + public var milestones: [String] + public var labels: [String] + public var selectedMilestone: String + public var selectedLabels: [String] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(milestones: [String], labels: [String], selectedMilestone: String, selectedLabels: [String]) { + self.milestones = milestones + self.labels = labels + self.selectedMilestone = selectedMilestone + self.selectedLabels = selectedLabels + } +} + +#if compiler(>=6) +extension IssueFilterOptions: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeIssueFilterOptions: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueFilterOptions { + return + try IssueFilterOptions( + milestones: FfiConverterSequenceString.read(from: &buf), + labels: FfiConverterSequenceString.read(from: &buf), + selectedMilestone: FfiConverterString.read(from: &buf), + selectedLabels: FfiConverterSequenceString.read(from: &buf) + ) + } + + public static func write(_ value: IssueFilterOptions, into buf: inout [UInt8]) { + FfiConverterSequenceString.write(value.milestones, into: &buf) + FfiConverterSequenceString.write(value.labels, into: &buf) + FfiConverterString.write(value.selectedMilestone, into: &buf) + FfiConverterSequenceString.write(value.selectedLabels, into: &buf) + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIssueFilterOptions_lift(_ buf: RustBuffer) throws -> IssueFilterOptions { + return try FfiConverterTypeIssueFilterOptions.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIssueFilterOptions_lower(_ value: IssueFilterOptions) -> RustBuffer { + return FfiConverterTypeIssueFilterOptions.lower(value) +} + + public struct IssuePage: Equatable, Hashable { public var title: String public var state: String @@ -2943,6 +3032,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 56735) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters() != 24054) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_issues() != 1316) { return InitializationResult.apiChecksumMismatch } @@ -2979,6 +3071,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_set_appearance() != 61293) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters() != 45204) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status() != 44445) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 2ab2e86..2ae88b8 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -299,6 +299,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS +uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUES #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUES uint64_t uniffi_gotcha_core_fn_method_gotchacore_issues(uint64_t ptr, RustBuffer owner, RustBuffer repository @@ -359,6 +364,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_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, 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 @@ -690,6 +700,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_FILTERS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_FILTERS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue_filters(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUES @@ -762,6 +778,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_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 diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index 1208b2b..1d604ce 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -6,6 +6,8 @@ final class IssuesViewController: RefreshingTableViewController { private let owner: String private let repository: String private var rows: [IssueRow] = [] + private var filterOptions: IssueFilterOptions? + private var filterTask: Task? init(context: AppContext, owner: String, repository: String) { self.context = context @@ -18,6 +20,8 @@ final class IssuesViewController: RefreshingTableViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + deinit { filterTask?.cancel() } + override func viewDidLoad() { super.viewDidLoad() tableView.register(IssueCell.self, forCellReuseIdentifier: "issue") @@ -26,19 +30,18 @@ final class IssuesViewController: RefreshingTableViewController { } override func loadContent(refreshing: Bool) { + loadFilterOptions() + loadIssues(refreshing: refreshing) + } + + private func loadIssues(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { rows = try await context.core.issues(owner: owner, repository: repository) tableView.reloadData() - let status = context.core.settings().issueStatus - tableView.backgroundView = rows.isEmpty - ? EmptyBackgroundView( - title: "No \(status) issues", - detail: "No issues match the selected status." - ) - : nil + updateEmptyState() } catch { if !Task.isCancelled { show(error: error) } } @@ -46,6 +49,24 @@ final class IssuesViewController: RefreshingTableViewController { } } + 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 } @@ -74,9 +95,11 @@ final class IssuesViewController: RefreshingTableViewController { private func updateFilterMenu() { let current = context.core.settings().issueStatus - 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 @@ -85,12 +108,134 @@ final class IssuesViewController: RefreshingTableViewController { do { try self.context.core.setIssueStatus(status: status) self.updateFilterMenu() - self.loadContent(refreshing: false) + self.loadIssues(refreshing: false) } catch { self.show(error: error) } } - }) + } + ) + var children: [UIMenuElement] = [status] + if let filterOptions { + children.append(milestoneMenu(filterOptions)) + children.append(labelMenu(filterOptions)) + } else { + children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in }) + } + let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem( + image: context.symbol("line.3.horizontal.decrease.circle") + ) + item.menu = UIMenu(children: children) + item.accessibilityLabel = "Filter issues" + if navigationItem.rightBarButtonItem == nil { + navigationItem.rightBarButtonItem = item + } + updateFilterTint() + } + + private func milestoneMenu(_ options: IssueFilterOptions) -> UIMenu { + let selected = options.selectedMilestone + var milestones = options.milestones + if !selected.isEmpty, !milestones.contains(selected) { milestones.append(selected) } + let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) { + [weak self] _ in self?.selectMilestone("") + } + let actions = milestones.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + .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] + actions + ) + } + + private func labelMenu(_ options: IssueFilterOptions) -> UIMenu { + let available = Set(options.labels) + let selected = Set(options.selectedLabels) + let labels = available.union(selected) + .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + let actions = labels.map { label in + UIAction( + title: available.contains(label) ? label : "\(label) (Unavailable)", + 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) + self.filterOptions?.selectedLabels = labels.sorted() + action.state = labels.contains(label) ? .on : .off + self.updateFilterMenu() + self.loadIssues(refreshing: false) + } catch { + self.show(error: error) + } + } + } + return UIMenu( + title: "Labels", + image: context.symbol("tag"), + 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)) + filterOptions?.selectedMilestone = milestone + updateFilterMenu() + loadIssues(refreshing: false) + } catch { + show(error: error) + } + } + + private func saveFilters(milestone: String, labels: Set) throws { + try context.core.setIssueFilters( + owner: owner, + repository: repository, + milestone: milestone, + labels: labels.sorted() + ) + } + + private var filtersActive: Bool { + context.core.settings().issueStatus != "open" + || filterOptions?.selectedMilestone.isEmpty == false + || filterOptions?.selectedLabels.isEmpty == false + } + + private func updateFilterTint() { + navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel + navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive + ? "Filters active" + : "Default filters" + } + + 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." ) } }