Add issue label and milestone filters

This commit is contained in:
Georg Bauer
2026-07-31 13:42:06 +02:00
parent 6374636b04
commit 6eb27537e8
9 changed files with 492 additions and 18 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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<Void, Never>?
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<String>) 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."
)
}
}