Add server-side work item search

This commit is contained in:
Georg Bauer
2026-07-31 21:03:01 +02:00
parent af392209e8
commit a3962449a2
9 changed files with 234 additions and 42 deletions

View File

@@ -157,15 +157,20 @@ xcrun simctl launch booted de.rfc1437.gotcha
- [ ] Select one and then multiple labels in the issue filter. Checkmarks and - [ ] Select one and then multiple labels in the issue filter. Checkmarks and
rows update without dismissing the menu; clearing every label restores rows update without dismissing the menu; clearing every label restores
the unfiltered label result. the unfiltered label result.
- [ ] Open **Search Text** in the issue filter, enter a term, and verify the
server-filtered rows and menu subtitle update. Combine it with Closed and
one or more labels and verify every filter applies together.
- [ ] Select a milestone and then All Milestones; the checkmark and issue rows - [ ] Select a milestone and then All Milestones; the checkmark and issue rows
update. Relaunch and revisit repositories to verify label and milestone update. Relaunch and revisit repositories to verify label and milestone
selections persist independently per repository and status persists. selections persist independently per repository and status persists.
- [ ] The issue-filter icon uses a neutral color for Open + All Milestones + no - [ ] 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. labels, and the app accent color whenever any non-default filter is set.
- [ ] In the issue filter panel, the Status, Milestone, and Labels icons use the - [ ] In the issue filter panel, the Search Text, Status, Milestone, and Labels
app accent color independently when their filter is non-default. **Clear icons use the app accent color independently when their filter is
Filters** restores Open + All Milestones + no labels, refreshes the rows, non-default. Search text remains while navigating during the current app
returns every filter icon to neutral, and remains cleared after relaunch. session but is empty after relaunch. **Clear Filters** restores empty
search + Open + All Milestones + no labels, refreshes the rows, and
returns every filter icon to neutral.
- [ ] Tap the add button. **New Issue** appears as a native modal with Cancel - [ ] Tap the add button. **New Issue** appears as a native modal with Cancel
and Save, title and Markdown body fields, multi-select labels, a and Save, title and Markdown body fields, multi-select labels, a
single-select optional milestone, and an optional inline due-date picker. single-select optional milestone, and an optional inline due-date picker.
@@ -257,15 +262,19 @@ xcrun simctl launch booted de.rfc1437.gotcha
- [ ] The list defaults to the saved Open/Closed filter. - [ ] The 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.
- [ ] Open **Search Text** in the pull-request filter, enter a term, and verify
the server-filtered rows and menu subtitle update. Combine it with Closed
and a milestone and verify every filter applies together.
- [ ] Select a milestone and then All Milestones; the checkmark and pull-request - [ ] Select a milestone and then All Milestones; the checkmark and pull-request
rows update, and the milestone selection persists independently per rows update, and the milestone selection persists independently per
server after relaunch. server after relaunch.
- [ ] The pull-request filter icon uses a neutral color for Open + All - [ ] The pull-request filter icon uses a neutral color for Open + All
Milestones and the app accent color whenever either filter is non-default. Milestones and the app accent color whenever either filter is non-default.
- [ ] In the pull-request filter panel, Status and Milestone icons use the app - [ ] In the pull-request filter panel, Search Text, Status, and Milestone icons
accent color independently when non-default. **Clear Filters** restores use the app accent color independently when non-default. Search text
Open + All Milestones, refreshes the rows, returns both icons to neutral, remains while navigating during the current app session but is empty
and remains cleared after relaunch. after relaunch. **Clear Filters** restores empty search + Open + All
Milestones, refreshes the rows, and returns every icon to neutral.
- [ ] Each row shows repository/number, title, author/update metadata, comment - [ ] Each row shows repository/number, title, author/update metadata, comment
count, and draft/merged state where applicable. Open rows show a green count, and draft/merged state where applicable. Open rows show a green
icon, closed rows show a purple icon, and VoiceOver announces the state. icon, closed rows show a purple icon, and VoiceOver announces the state.

View File

@@ -4,8 +4,8 @@ use gotcha_gitea::{
use crate::{ use crate::{
domain::{ domain::{
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails, HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueFilter,
MilestoneDraft, Page, PullDetails, RepositoryData, Server, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryData, Server,
}, },
presentation::compact_date, presentation::compact_date,
}; };
@@ -46,8 +46,7 @@ pub async fn load_issues(
owner: &str, owner: &str,
repository: &str, repository: &str,
status: &str, status: &str,
labels: &[String], filter: &IssueFilter,
milestone: &str,
page: i32, page: i32,
) -> Result<Page<models::Issue>, String> { ) -> Result<Page<models::Issue>, String> {
client(server)? client(server)?
@@ -55,8 +54,10 @@ pub async fn load_issues(
&scope(owner, repository)?, &scope(owner, repository)?,
&IssueQuery { &IssueQuery {
state: status.into(), state: status.into(),
labels: (!labels.is_empty()).then(|| labels.join(",")), labels: (!filter.labels.is_empty())
milestones: (!milestone.is_empty()).then(|| milestone.into()), .then(|| filter.labels.iter().cloned().collect::<Vec<_>>().join(",")),
milestones: (!filter.milestone.is_empty()).then(|| filter.milestone.clone()),
keyword: (!filter.search_text.is_empty()).then(|| filter.search_text.clone()),
page, page,
limit: PAGE_SIZE, limit: PAGE_SIZE,
..Default::default() ..Default::default()
@@ -150,12 +151,14 @@ pub async fn load_pulls(
server: &Server, server: &Server,
status: &str, status: &str,
milestone: &str, milestone: &str,
search_text: &str,
page: i32, page: i32,
) -> Result<Page<models::Issue>, String> { ) -> Result<Page<models::Issue>, String> {
client(server)? client(server)?
.search_pulls( .search_pulls(
status, status,
(!milestone.is_empty()).then_some(milestone), (!milestone.is_empty()).then_some(milestone),
(!search_text.is_empty()).then_some(search_text),
page, page,
PAGE_SIZE, PAGE_SIZE,
) )

View File

@@ -84,6 +84,8 @@ pub struct IssueFilter {
pub milestone: String, pub milestone: String,
#[serde(default)] #[serde(default)]
pub labels: BTreeSet<String>, pub labels: BTreeSet<String>,
#[serde(skip)]
pub search_text: String,
} }
impl IssueFilter { impl IssueFilter {
@@ -96,6 +98,8 @@ impl IssueFilter {
pub struct PullFilter { pub struct PullFilter {
#[serde(default)] #[serde(default)]
pub milestone: String, pub milestone: String,
#[serde(skip)]
pub search_text: String,
} }
impl PullFilter { impl PullFilter {
@@ -185,8 +189,44 @@ mod tests {
assert!( assert!(
PullFilter { PullFilter {
milestone: "v1".into(), milestone: "v1".into(),
..Default::default()
}
.is_active("open")
);
assert!(
IssueFilter {
search_text: "needle".into(),
..Default::default()
}
.is_active("open")
);
assert!(
PullFilter {
search_text: "needle".into(),
..Default::default()
} }
.is_active("open") .is_active("open")
); );
} }
#[test]
fn search_text_is_session_only() {
let issue = IssueFilter {
search_text: "needle".into(),
..Default::default()
};
let pull = PullFilter {
search_text: "needle".into(),
..Default::default()
};
assert!(!serde_json::to_string(&issue).unwrap().contains("needle"));
assert!(!serde_json::to_string(&pull).unwrap().contains("needle"));
assert_eq!(
serde_json::from_str::<IssueFilter>(r#"{"milestone":"v1"}"#)
.unwrap()
.search_text,
""
);
}
} }

View File

@@ -268,14 +268,12 @@ impl GotchaCore {
.unwrap_or_default(), .unwrap_or_default(),
) )
}; };
let labels: Vec<_> = filter.labels.into_iter().collect();
let page = load_issues( let page = load_issues(
&server, &server,
&owner, &owner,
&repository, &repository,
&status, &status,
&labels, &filter,
&filter.milestone,
valid_page(page)?, valid_page(page)?,
) )
.await?; .await?;
@@ -333,6 +331,7 @@ impl GotchaCore {
repository: String, repository: String,
milestone: String, milestone: String,
labels: Vec<String>, labels: Vec<String>,
search_text: String,
) -> Result<(), GotchaError> { ) -> Result<(), GotchaError> {
let owner = owner.trim(); let owner = owner.trim();
let repository = repository.trim(); let repository = repository.trim();
@@ -345,6 +344,7 @@ impl GotchaCore {
.into_iter() .into_iter()
.filter(|label| !label.is_empty()) .filter(|label| !label.is_empty())
.collect(), .collect(),
search_text: search_text.trim().into(),
}; };
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
let server = state let server = state
@@ -585,8 +585,15 @@ impl GotchaCore {
Ok(filter.is_active(&state.preferences.pull_status)) Ok(filter.is_active(&state.preferences.pull_status))
} }
pub fn set_pull_filters(&self, milestone: String) -> Result<(), GotchaError> { pub fn set_pull_filters(
let filter = PullFilter { milestone }; &self,
milestone: String,
search_text: String,
) -> Result<(), GotchaError> {
let filter = PullFilter {
milestone,
search_text: search_text.trim().into(),
};
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
let server = state let server = state
.active_server .active_server
@@ -627,7 +634,14 @@ impl GotchaCore {
.unwrap_or_default(), .unwrap_or_default(),
) )
}; };
let page = load_pulls(&server, &status, &filter.milestone, valid_page(page)?).await?; let page = load_pulls(
&server,
&status,
&filter.milestone,
&filter.search_text,
valid_page(page)?,
)
.await?;
Ok(PullListPage { Ok(PullListPage {
rows: pull_rows(&page.items), rows: pull_rows(&page.items),
has_more: page.has_more, has_more: page.has_more,

View File

@@ -114,6 +114,7 @@ pub struct IssueFilterOptions {
pub unavailable_labels: Vec<String>, pub unavailable_labels: Vec<String>,
pub selected_milestone: String, pub selected_milestone: String,
pub selected_labels: Vec<String>, pub selected_labels: Vec<String>,
pub search_text: String,
} }
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
@@ -153,6 +154,7 @@ pub struct MilestoneEditorPage {
pub struct PullFilterOptions { pub struct PullFilterOptions {
pub milestones: Vec<String>, pub milestones: Vec<String>,
pub selected_milestone: String, pub selected_milestone: String,
pub search_text: String,
} }
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
@@ -365,6 +367,7 @@ pub fn issue_filter_options(
unavailable_labels, unavailable_labels,
selected_milestone: filter.milestone.clone(), selected_milestone: filter.milestone.clone(),
selected_labels: filter.labels.iter().cloned().collect(), selected_labels: filter.labels.iter().cloned().collect(),
search_text: filter.search_text.clone(),
} }
} }
@@ -419,6 +422,7 @@ pub fn pull_filter_options(milestones: &[String], filter: &PullFilter) -> PullFi
PullFilterOptions { PullFilterOptions {
milestones, milestones,
selected_milestone: filter.milestone.clone(), selected_milestone: filter.milestone.clone(),
search_text: filter.search_text.clone(),
} }
} }
@@ -1365,6 +1369,7 @@ mod tests {
let filter = IssueFilter { let filter = IssueFilter {
milestone: "v3".into(), milestone: "v3".into(),
labels: ["bug".into(), "retired".into()].into(), labels: ["bug".into(), "retired".into()].into(),
search_text: "login".into(),
}; };
let options = issue_filter_options( let options = issue_filter_options(
&[ &[
@@ -1395,15 +1400,18 @@ mod tests {
assert_eq!(options.milestones, ["v1", "v2", "v3"]); assert_eq!(options.milestones, ["v1", "v2", "v3"]);
assert_eq!(options.selected_labels, ["bug", "retired"]); assert_eq!(options.selected_labels, ["bug", "retired"]);
assert_eq!(options.selected_milestone, "v3"); assert_eq!(options.selected_milestone, "v3");
assert_eq!(options.search_text, "login");
let pull_options = pull_filter_options( let pull_options = pull_filter_options(
&["v2".into(), "v1".into()], &["v2".into(), "v1".into()],
&PullFilter { &PullFilter {
milestone: "v3".into(), milestone: "v3".into(),
search_text: "review".into(),
}, },
); );
assert_eq!(pull_options.milestones, ["v1", "v2", "v3"]); assert_eq!(pull_options.milestones, ["v1", "v2", "v3"]);
assert_eq!(pull_options.selected_milestone, "v3"); assert_eq!(pull_options.selected_milestone, "v3");
assert_eq!(pull_options.search_text, "review");
} }
#[test] #[test]

View File

@@ -70,6 +70,7 @@ impl Client {
&self, &self,
state: &str, state: &str,
milestone: Option<&str>, milestone: Option<&str>,
keyword: Option<&str>,
page: i32, page: i32,
limit: i32, limit: i32,
) -> Result<Page<models::Issue>> { ) -> Result<Page<models::Issue>> {
@@ -84,7 +85,7 @@ impl Client {
Some(state), Some(state),
None, None,
milestone, milestone,
None, keyword,
None, None,
Some("pulls"), Some("pulls"),
None, None,
@@ -122,7 +123,7 @@ impl Client {
let mut pulls = Vec::new(); let mut pulls = Vec::new();
for page in 1.. { for page in 1.. {
let batch = self let batch = self
.search_pulls(state, None, page, DEFAULT_PAGE_SIZE) .search_pulls(state, None, None, page, DEFAULT_PAGE_SIZE)
.await?; .await?;
pulls.extend(batch.items); pulls.extend(batch.items);
if !batch.has_more { if !batch.has_more {

View File

@@ -665,11 +665,11 @@ 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 setIssueFilters(owner: String, repository: String, milestone: String, labels: [String], searchText: String) throws
func setIssueStatus(status: String) throws func setIssueStatus(status: String) throws
func setPullFilters(milestone: String) throws func setPullFilters(milestone: String, searchText: String) throws
func setPullStatus(status: String) throws func setPullStatus(status: String) throws
@@ -1176,14 +1176,15 @@ 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) { open func setIssueFilters(owner: String, repository: String, milestone: String, labels: [String], searchText: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters( uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters(
self.uniffiCloneHandle(), self.uniffiCloneHandle(),
FfiConverterString.lower(owner), FfiConverterString.lower(owner),
FfiConverterString.lower(repository), FfiConverterString.lower(repository),
FfiConverterString.lower(milestone), FfiConverterString.lower(milestone),
FfiConverterSequenceString.lower(labels),uniffiCallStatus FfiConverterSequenceString.lower(labels),
FfiConverterString.lower(searchText),uniffiCallStatus
) )
} }
} }
@@ -1197,11 +1198,12 @@ open func setIssueStatus(status: String)throws {try rustCallWithError(FfiConve
} }
} }
open func setPullFilters(milestone: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { open func setPullFilters(milestone: String, searchText: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_set_pull_filters( uniffi_gotcha_core_fn_method_gotchacore_set_pull_filters(
self.uniffiCloneHandle(), self.uniffiCloneHandle(),
FfiConverterString.lower(milestone),uniffiCallStatus FfiConverterString.lower(milestone),
FfiConverterString.lower(searchText),uniffiCallStatus
) )
} }
} }
@@ -2117,15 +2119,17 @@ public struct IssueFilterOptions: Equatable, Hashable {
public var unavailableLabels: [String] public var unavailableLabels: [String]
public var selectedMilestone: String public var selectedMilestone: String
public var selectedLabels: [String] public var selectedLabels: [String]
public var searchText: String
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // declare one manually.
public init(milestones: [String], labels: [String], unavailableLabels: [String], selectedMilestone: String, selectedLabels: [String]) { public init(milestones: [String], labels: [String], unavailableLabels: [String], selectedMilestone: String, selectedLabels: [String], searchText: String) {
self.milestones = milestones self.milestones = milestones
self.labels = labels self.labels = labels
self.unavailableLabels = unavailableLabels self.unavailableLabels = unavailableLabels
self.selectedMilestone = selectedMilestone self.selectedMilestone = selectedMilestone
self.selectedLabels = selectedLabels self.selectedLabels = selectedLabels
self.searchText = searchText
} }
@@ -2148,7 +2152,8 @@ public struct FfiConverterTypeIssueFilterOptions: FfiConverterRustBuffer {
labels: FfiConverterSequenceString.read(from: &buf), labels: FfiConverterSequenceString.read(from: &buf),
unavailableLabels: FfiConverterSequenceString.read(from: &buf), unavailableLabels: FfiConverterSequenceString.read(from: &buf),
selectedMilestone: FfiConverterString.read(from: &buf), selectedMilestone: FfiConverterString.read(from: &buf),
selectedLabels: FfiConverterSequenceString.read(from: &buf) selectedLabels: FfiConverterSequenceString.read(from: &buf),
searchText: FfiConverterString.read(from: &buf)
) )
} }
@@ -2158,6 +2163,7 @@ public struct FfiConverterTypeIssueFilterOptions: FfiConverterRustBuffer {
FfiConverterSequenceString.write(value.unavailableLabels, into: &buf) FfiConverterSequenceString.write(value.unavailableLabels, into: &buf)
FfiConverterString.write(value.selectedMilestone, into: &buf) FfiConverterString.write(value.selectedMilestone, into: &buf)
FfiConverterSequenceString.write(value.selectedLabels, into: &buf) FfiConverterSequenceString.write(value.selectedLabels, into: &buf)
FfiConverterString.write(value.searchText, into: &buf)
} }
} }
@@ -2696,12 +2702,14 @@ public func FfiConverterTypeMilestoneRow_lower(_ value: MilestoneRow) -> RustBuf
public struct PullFilterOptions: Equatable, Hashable { public struct PullFilterOptions: Equatable, Hashable {
public var milestones: [String] public var milestones: [String]
public var selectedMilestone: String public var selectedMilestone: String
public var searchText: String
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // declare one manually.
public init(milestones: [String], selectedMilestone: String) { public init(milestones: [String], selectedMilestone: String, searchText: String) {
self.milestones = milestones self.milestones = milestones
self.selectedMilestone = selectedMilestone self.selectedMilestone = selectedMilestone
self.searchText = searchText
} }
@@ -2721,13 +2729,15 @@ public struct FfiConverterTypePullFilterOptions: FfiConverterRustBuffer {
return return
try PullFilterOptions( try PullFilterOptions(
milestones: FfiConverterSequenceString.read(from: &buf), milestones: FfiConverterSequenceString.read(from: &buf),
selectedMilestone: FfiConverterString.read(from: &buf) selectedMilestone: FfiConverterString.read(from: &buf),
searchText: FfiConverterString.read(from: &buf)
) )
} }
public static func write(_ value: PullFilterOptions, into buf: inout [UInt8]) { public static func write(_ value: PullFilterOptions, into buf: inout [UInt8]) {
FfiConverterSequenceString.write(value.milestones, into: &buf) FfiConverterSequenceString.write(value.milestones, into: &buf)
FfiConverterString.write(value.selectedMilestone, into: &buf) FfiConverterString.write(value.selectedMilestone, into: &buf)
FfiConverterString.write(value.searchText, into: &buf)
} }
} }
@@ -4211,13 +4221,13 @@ 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) { if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters() != 24891) {
return InitializationResult.apiChecksumMismatch 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
} }
if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters() != 39023) { if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters() != 25211) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_status() != 1356) { if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_status() != 1356) {

View File

@@ -416,7 +416,7 @@ void uniffi_gotcha_core_fn_method_gotchacore_set_appearance(uint64_t ptr, uint32
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS
#define 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 void uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer milestone, RustBuffer labels, RustBuffer search_text, RustCallStatus *_Nonnull out_status
); );
#endif #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
@@ -426,7 +426,7 @@ void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, Rust
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_FILTERS #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_FILTERS
#define 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 void uniffi_gotcha_core_fn_method_gotchacore_set_pull_filters(uint64_t ptr, RustBuffer milestone, RustBuffer search_text, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS

View File

@@ -1,5 +1,23 @@
import UIKit import UIKit
private extension UIViewController {
func promptForSearchText(title: String, current: String, apply: @escaping (String) -> Void) {
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
alert.addTextField { field in
field.text = current
field.placeholder = "Search text"
field.accessibilityLabel = "Search text"
field.clearButtonMode = .whileEditing
field.returnKeyType = .search
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Apply", style: .default) { [weak alert] _ in
apply(alert?.textFields?.first?.text ?? "")
})
present(alert, animated: true)
}
}
@MainActor @MainActor
final class IssuesViewController: RefreshingTableViewController { final class IssuesViewController: RefreshingTableViewController {
private let context: AppContext private let context: AppContext
@@ -149,6 +167,7 @@ final class IssuesViewController: RefreshingTableViewController {
) )
var children: [UIMenuElement] = [status] var children: [UIMenuElement] = [status]
if let filterOptions { if let filterOptions {
children.insert(searchAction(filterOptions), at: 0)
children.append(milestoneMenu(filterOptions)) children.append(milestoneMenu(filterOptions))
children.append(labelMenu(filterOptions)) children.append(labelMenu(filterOptions))
} else { } else {
@@ -160,6 +179,21 @@ final class IssuesViewController: RefreshingTableViewController {
updateFilterTint() updateFilterTint()
} }
private func searchAction(_ options: IssueFilterOptions) -> UIAction {
UIAction(
title: "Search Text",
subtitle: options.searchText.isEmpty ? "Any text" : options.searchText,
image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty)
) { [weak self] _ in
self?.promptForSearchText(
title: "Search Issues",
current: options.searchText
) { [weak self] searchText in
self?.setSearchText(searchText)
}
}
}
private func milestoneMenu(_ options: IssueFilterOptions) -> UIMenu { private func milestoneMenu(_ options: IssueFilterOptions) -> UIMenu {
let selected = options.selectedMilestone let selected = options.selectedMilestone
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) { let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
@@ -192,7 +226,11 @@ final class IssuesViewController: RefreshingTableViewController {
var labels = Set(options.selectedLabels) var labels = Set(options.selectedLabels)
if labels.remove(label) == nil { labels.insert(label) } if labels.remove(label) == nil { labels.insert(label) }
do { do {
try self.saveFilters(milestone: options.selectedMilestone, labels: labels) try self.saveFilters(
milestone: options.selectedMilestone,
labels: labels,
searchText: options.searchText
)
self.filterOptions?.selectedLabels = Array(labels) self.filterOptions?.selectedLabels = Array(labels)
action.state = labels.contains(label) ? .on : .off action.state = labels.contains(label) ? .on : .off
self.updateFilterMenu() self.updateFilterMenu()
@@ -215,7 +253,11 @@ final class IssuesViewController: RefreshingTableViewController {
guard let options = filterOptions else { return } guard let options = filterOptions else { return }
filterTask?.cancel() filterTask?.cancel()
do { do {
try saveFilters(milestone: milestone, labels: Set(options.selectedLabels)) try saveFilters(
milestone: milestone,
labels: Set(options.selectedLabels),
searchText: options.searchText
)
filterOptions?.selectedMilestone = milestone filterOptions?.selectedMilestone = milestone
updateFilterMenu() updateFilterMenu()
loadIssues(refreshing: false) loadIssues(refreshing: false)
@@ -224,12 +266,34 @@ final class IssuesViewController: RefreshingTableViewController {
} }
} }
private func saveFilters(milestone: String, labels: Set<String>) throws { private func setSearchText(_ searchText: String) {
guard let options = filterOptions else { return }
filterTask?.cancel()
do {
try saveFilters(
milestone: options.selectedMilestone,
labels: Set(options.selectedLabels),
searchText: searchText
)
filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
updateFilterMenu()
loadIssues(refreshing: false)
} catch {
show(error: error)
}
}
private func saveFilters(
milestone: String,
labels: Set<String>,
searchText: String
) throws {
try context.core.setIssueFilters( try context.core.setIssueFilters(
owner: owner, owner: owner,
repository: repository, repository: repository,
milestone: milestone, milestone: milestone,
labels: Array(labels) labels: Array(labels),
searchText: searchText
) )
} }
@@ -252,6 +316,7 @@ final class IssuesViewController: RefreshingTableViewController {
try context.core.clearIssueFilters(owner: owner, repository: repository) try context.core.clearIssueFilters(owner: owner, repository: repository)
filterOptions?.selectedMilestone = "" filterOptions?.selectedMilestone = ""
filterOptions?.selectedLabels = [] filterOptions?.selectedLabels = []
filterOptions?.searchText = ""
updateFilterMenu() updateFilterMenu()
loadIssues(refreshing: false) loadIssues(refreshing: false)
} catch { } catch {
@@ -930,12 +995,33 @@ final class PullsViewController: RefreshingTableViewController {
let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem( let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem(
image: context.symbol("line.3.horizontal.decrease.circle") image: context.symbol("line.3.horizontal.decrease.circle")
) )
item.menu = UIMenu(children: [status, milestone, clearFiltersMenu()]) var children: [UIMenuElement] = [status]
if let filterOptions {
children.insert(searchAction(filterOptions), at: 0)
}
children.append(milestone)
children.append(clearFiltersMenu())
item.menu = UIMenu(children: children)
item.accessibilityLabel = "Filter pull requests" item.accessibilityLabel = "Filter pull requests"
navigationItem.rightBarButtonItem = item navigationItem.rightBarButtonItem = item
updateFilterTint() updateFilterTint()
} }
private func searchAction(_ options: PullFilterOptions) -> UIAction {
UIAction(
title: "Search Text",
subtitle: options.searchText.isEmpty ? "Any text" : options.searchText,
image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty)
) { [weak self] _ in
self?.promptForSearchText(
title: "Search Pull Requests",
current: options.searchText
) { [weak self] searchText in
self?.setSearchText(searchText)
}
}
}
private func milestoneMenu(_ options: PullFilterOptions) -> UIMenu { private func milestoneMenu(_ options: PullFilterOptions) -> UIMenu {
let selected = options.selectedMilestone let selected = options.selectedMilestone
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) { let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
@@ -955,9 +1041,13 @@ final class PullsViewController: RefreshingTableViewController {
} }
private func selectMilestone(_ milestone: String) { private func selectMilestone(_ milestone: String) {
guard let options = filterOptions else { return }
filterTask?.cancel() filterTask?.cancel()
do { do {
try context.core.setPullFilters(milestone: milestone) try context.core.setPullFilters(
milestone: milestone,
searchText: options.searchText
)
filterOptions?.selectedMilestone = milestone filterOptions?.selectedMilestone = milestone
updateFilterMenu() updateFilterMenu()
loadPulls(refreshing: false) loadPulls(refreshing: false)
@@ -966,6 +1056,22 @@ final class PullsViewController: RefreshingTableViewController {
} }
} }
private func setSearchText(_ searchText: String) {
guard let options = filterOptions else { return }
filterTask?.cancel()
do {
try context.core.setPullFilters(
milestone: options.selectedMilestone,
searchText: searchText
)
filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
updateFilterMenu()
loadPulls(refreshing: false)
} catch {
show(error: error)
}
}
private func clearFiltersMenu() -> UIMenu { private func clearFiltersMenu() -> UIMenu {
UIMenu( UIMenu(
options: .displayInline, options: .displayInline,
@@ -984,6 +1090,7 @@ final class PullsViewController: RefreshingTableViewController {
do { do {
try context.core.clearPullFilters() try context.core.clearPullFilters()
filterOptions?.selectedMilestone = "" filterOptions?.selectedMilestone = ""
filterOptions?.searchText = ""
updateFilterMenu() updateFilterMenu()
loadPulls(refreshing: false) loadPulls(refreshing: false)
} catch { } catch {