Add issue label and milestone filters
This commit is contained in:
@@ -133,6 +133,14 @@ xcrun simctl launch booted de.rfc1437.gotcha
|
|||||||
- [ ] Open a repository; the issue list defaults to the saved Open/Closed filter.
|
- [ ] Open a repository; the issue list defaults to the saved Open/Closed filter.
|
||||||
- [ ] Change the native filter menu between Open and Closed; the checkmark,
|
- [ ] Change the native filter menu between Open and Closed; the checkmark,
|
||||||
rows, and persisted selection update.
|
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
|
- [ ] 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
|
appears beside the title and VoiceOver announces the state; author
|
||||||
metadata, Markdown body, and comments remain correct. Links and
|
metadata, Markdown body, and comments remain correct. Links and
|
||||||
|
|||||||
@@ -61,18 +61,21 @@ pub async fn load_issues(
|
|||||||
owner: &str,
|
owner: &str,
|
||||||
repository: &str,
|
repository: &str,
|
||||||
status: &str,
|
status: &str,
|
||||||
|
labels: &[String],
|
||||||
|
milestone: &str,
|
||||||
) -> Result<Vec<models::Issue>, String> {
|
) -> Result<Vec<models::Issue>, String> {
|
||||||
let client =
|
let client =
|
||||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
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(
|
apis::issue_api::issue_list_issues(
|
||||||
&client.configuration(),
|
&client.configuration(),
|
||||||
owner,
|
owner,
|
||||||
repository,
|
repository,
|
||||||
Some(status),
|
Some(status),
|
||||||
None,
|
labels.as_deref(),
|
||||||
None,
|
None,
|
||||||
Some("issues"),
|
Some("issues"),
|
||||||
None,
|
(!milestone.is_empty()).then_some(milestone),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -85,6 +88,34 @@ pub async fn load_issues(
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn load_labels(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
) -> Result<Vec<models::Label>, 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(
|
pub async fn load_milestones(
|
||||||
server: &Server,
|
server: &Server,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use gotcha_gitea::models;
|
use gotcha_gitea::models;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -50,6 +50,8 @@ pub struct Preferences {
|
|||||||
pub last_server: Option<usize>,
|
pub last_server: Option<usize>,
|
||||||
#[serde(default = "open_status")]
|
#[serde(default = "open_status")]
|
||||||
pub issue_status: String,
|
pub issue_status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub issue_filters: BTreeMap<String, IssueFilter>,
|
||||||
#[serde(default = "open_status")]
|
#[serde(default = "open_status")]
|
||||||
pub pull_status: String,
|
pub pull_status: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -63,12 +65,21 @@ impl Default for Preferences {
|
|||||||
favorites: BTreeSet::new(),
|
favorites: BTreeSet::new(),
|
||||||
last_server: None,
|
last_server: None,
|
||||||
issue_status: open_status(),
|
issue_status: open_status(),
|
||||||
|
issue_filters: BTreeMap::new(),
|
||||||
pull_status: open_status(),
|
pull_status: open_status(),
|
||||||
appearance: AppearanceMode::default(),
|
appearance: AppearanceMode::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct IssueFilter {
|
||||||
|
#[serde(default)]
|
||||||
|
pub milestone: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub labels: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RepositoryData {
|
pub struct RepositoryData {
|
||||||
pub owner: String,
|
pub owner: String,
|
||||||
|
|||||||
@@ -211,12 +211,87 @@ impl GotchaCore {
|
|||||||
repository: String,
|
repository: String,
|
||||||
) -> Result<Vec<IssueRow>, GotchaError> {
|
) -> Result<Vec<IssueRow>, GotchaError> {
|
||||||
let server = self.server()?;
|
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(
|
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<IssueFilterOptions, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let filter = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.preferences
|
||||||
|
.issue_filters
|
||||||
|
.get(&repository_key(&server.url, &owner, &repository))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (labels, milestones) = tokio::try_join!(
|
||||||
|
load_labels(&server, &owner, &repository),
|
||||||
|
load_milestones(&server, &owner, &repository)
|
||||||
|
)?;
|
||||||
|
Ok(issue_filter_options(&labels, &milestones, &filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_issue_filters(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
milestone: String,
|
||||||
|
labels: Vec<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(),
|
||||||
|
};
|
||||||
|
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(
|
pub async fn issue(
|
||||||
&self,
|
&self,
|
||||||
owner: String,
|
owner: String,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use gotcha_gitea::models;
|
|||||||
use crate::{
|
use crate::{
|
||||||
activity,
|
activity,
|
||||||
domain::{
|
domain::{
|
||||||
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
|
HistoryCommit, HomeData, IssueDetails, IssueFilter, MilestoneDetails, PullDetails,
|
||||||
|
RepositoryData,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,6 +67,14 @@ pub struct IssuePage {
|
|||||||
pub comments: Vec<CommentRow>,
|
pub comments: Vec<CommentRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssueFilterOptions {
|
||||||
|
pub milestones: Vec<String>,
|
||||||
|
pub labels: Vec<String>,
|
||||||
|
pub selected_milestone: String,
|
||||||
|
pub selected_labels: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, uniffi::Record)]
|
#[derive(Clone, uniffi::Record)]
|
||||||
pub struct MilestoneRow {
|
pub struct MilestoneRow {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -219,6 +228,31 @@ pub fn issue_rows(issues: &[models::Issue]) -> Vec<IssueRow> {
|
|||||||
.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();
|
||||||
|
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<MilestoneRow> {
|
pub fn milestone_rows(milestones: &[models::Milestone]) -> Vec<MilestoneRow> {
|
||||||
let mut milestones = milestones.to_vec();
|
let mut milestones = milestones.to_vec();
|
||||||
milestones.sort_by_key(|milestone| {
|
milestones.sort_by_key(|milestone| {
|
||||||
@@ -879,4 +913,40 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(page.state, "closed");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ use security_framework::passwords::{get_generic_password, set_generic_password};
|
|||||||
use crate::domain::{Preferences, Server, open_status};
|
use crate::domain::{Preferences, Server, open_status};
|
||||||
|
|
||||||
pub fn favorite_key(server: &str, owner: &str, repository: &str) -> String {
|
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}")
|
format!("{server}|{owner}/{repository}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +99,19 @@ mod tests {
|
|||||||
favorite_key("https://gitea.example.com", "octo", "demo"),
|
favorite_key("https://gitea.example.com", "octo", "demo"),
|
||||||
"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().pull_status, "open");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Preferences::default().appearance,
|
Preferences::default().appearance,
|
||||||
|
|||||||
@@ -603,6 +603,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func issue(owner: String, repository: String, number: Int64) async throws -> IssuePage
|
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 issues(owner: String, repository: String) async throws -> [IssueRow]
|
||||||
|
|
||||||
func milestone(owner: String, repository: String, id: Int64) async throws -> MilestonePage
|
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 setAppearance(index: UInt32) throws
|
||||||
|
|
||||||
|
func setIssueFilters(owner: String, repository: String, milestone: String, labels: [String]) throws
|
||||||
|
|
||||||
func setIssueStatus(status: String) throws
|
func setIssueStatus(status: String) throws
|
||||||
|
|
||||||
func setPullStatus(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] {
|
open func issues(owner: String, repository: String)async throws -> [IssueRow] {
|
||||||
return
|
return
|
||||||
try await uniffiRustCallAsync(
|
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) {
|
open func setIssueStatus(status: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(
|
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 struct IssuePage: Equatable, Hashable {
|
||||||
public var title: String
|
public var title: String
|
||||||
public var state: String
|
public var state: String
|
||||||
@@ -2943,6 +3032,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 56735) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 56735) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_issues() != 1316) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -2979,6 +3071,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_set_appearance() != 61293) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_set_appearance() != 61293) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status() != 44445) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUES
|
||||||
#define 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
|
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
|
void uniffi_gotcha_core_fn_method_gotchacore_set_appearance(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
||||||
void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
|
void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
|
||||||
@@ -690,6 +700,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUES
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_APPEARANCE
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_appearance(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_appearance(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
private let owner: String
|
private let owner: String
|
||||||
private let repository: String
|
private let repository: String
|
||||||
private var rows: [IssueRow] = []
|
private var rows: [IssueRow] = []
|
||||||
|
private var filterOptions: IssueFilterOptions?
|
||||||
|
private var filterTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(context: AppContext, owner: String, repository: String) {
|
init(context: AppContext, owner: String, repository: String) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -18,6 +20,8 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { filterTask?.cancel() }
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
||||||
@@ -26,19 +30,18 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func loadContent(refreshing: Bool) {
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadFilterOptions()
|
||||||
|
loadIssues(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadIssues(refreshing: Bool) {
|
||||||
beginLoading(refreshing: refreshing)
|
beginLoading(refreshing: refreshing)
|
||||||
loadingTask?.cancel()
|
loadingTask?.cancel()
|
||||||
loadingTask = Task {
|
loadingTask = Task {
|
||||||
do {
|
do {
|
||||||
rows = try await context.core.issues(owner: owner, repository: repository)
|
rows = try await context.core.issues(owner: owner, repository: repository)
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
let status = context.core.settings().issueStatus
|
updateEmptyState()
|
||||||
tableView.backgroundView = rows.isEmpty
|
|
||||||
? EmptyBackgroundView(
|
|
||||||
title: "No \(status) issues",
|
|
||||||
detail: "No issues match the selected status."
|
|
||||||
)
|
|
||||||
: nil
|
|
||||||
} catch {
|
} catch {
|
||||||
if !Task.isCancelled { show(error: error) }
|
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 {
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
rows.count
|
rows.count
|
||||||
}
|
}
|
||||||
@@ -74,9 +95,11 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
|
|
||||||
private func updateFilterMenu() {
|
private func updateFilterMenu() {
|
||||||
let current = context.core.settings().issueStatus
|
let current = context.core.settings().issueStatus
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
let status = UIMenu(
|
||||||
image: context.symbol("line.3.horizontal.decrease.circle"),
|
title: "Status",
|
||||||
menu: UIMenu(children: ["open", "closed"].map { status in
|
image: context.symbol("circle.lefthalf.filled"),
|
||||||
|
options: .singleSelection,
|
||||||
|
children: ["open", "closed"].map { status in
|
||||||
UIAction(
|
UIAction(
|
||||||
title: status.capitalized,
|
title: status.capitalized,
|
||||||
state: current == status ? .on : .off
|
state: current == status ? .on : .off
|
||||||
@@ -85,12 +108,134 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
do {
|
do {
|
||||||
try self.context.core.setIssueStatus(status: status)
|
try self.context.core.setIssueStatus(status: status)
|
||||||
self.updateFilterMenu()
|
self.updateFilterMenu()
|
||||||
self.loadContent(refreshing: false)
|
self.loadIssues(refreshing: false)
|
||||||
} catch {
|
} catch {
|
||||||
self.show(error: error)
|
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."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user