feat(ios): extend swipe actions to milestones
This commit is contained in:
15
TESTING.md
15
TESTING.md
@@ -185,11 +185,11 @@ xcrun simctl launch booted de.rfc1437.gotcha
|
|||||||
clear labels, select and clear its milestone, and add, change, and remove
|
clear labels, select and clear its milestone, and add, change, and remove
|
||||||
its due date. Turn **Closed** on and save, then edit it again and turn
|
its due date. Turn **Closed** on and save, then edit it again and turn
|
||||||
**Closed** off. Save, refresh, and relaunch to verify every change persists.
|
**Closed** off. Save, refresh, and relaunch to verify every change persists.
|
||||||
- [ ] In the issue list, partially swipe an open issue to reveal **Close** and
|
- [ ] In both the repository issue list and the issue section below a milestone,
|
||||||
**Delete**. Cancel the destructive Delete confirmation and verify nothing
|
partially swipe an open issue to reveal **Close** and **Delete**. Cancel
|
||||||
changes. Full-swipe the same row to close it, switch to Closed issues, and
|
the destructive Delete confirmation and verify nothing changes. Full-swipe
|
||||||
full-swipe it again to reopen it; each row leaves the current filtered list
|
the same row to close it, then full-swipe the closed row to reopen it; each
|
||||||
only after the server mutation succeeds.
|
list refreshes only after the server mutation succeeds.
|
||||||
- [ ] Cancel both new and edited issues, including with the keyboard and date
|
- [ ] Cancel both new and edited issues, including with the keyboard and date
|
||||||
picker visible. Nothing is saved, the modal dismisses normally, and the
|
picker visible. Nothing is saved, the modal dismisses normally, and the
|
||||||
issue list/detail remains usable with Dynamic Type and VoiceOver labels.
|
issue list/detail remains usable with Dynamic Type and VoiceOver labels.
|
||||||
@@ -318,6 +318,11 @@ xcrun simctl launch booted de.rfc1437.gotcha
|
|||||||
the milestone list and detail.
|
the milestone list and detail.
|
||||||
- [ ] Open a milestone and verify every assigned issue and pull request is
|
- [ ] Open a milestone and verify every assigned issue and pull request is
|
||||||
listed; tapping either opens its normal detail.
|
listed; tapping either opens its normal detail.
|
||||||
|
- [ ] Partially swipe an open milestone to reveal **Close** and **Delete**;
|
||||||
|
full-swipe closes it as the primary action, and full-swiping a closed
|
||||||
|
milestone reopens it. Delete an empty milestone after confirming. Attempt
|
||||||
|
to delete a milestone with assigned issues or pull requests and verify an
|
||||||
|
informational dialog explains that the assignments must be removed first.
|
||||||
- [ ] Tap the add button. **New Milestone** has native Cancel and Save controls,
|
- [ ] Tap the add button. **New Milestone** has native Cancel and Save controls,
|
||||||
title and multiline description fields, a **Closed** switch that defaults
|
title and multiline description fields, a **Closed** switch that defaults
|
||||||
off, and an optional inline date picker. Save is disabled for an empty or
|
off, and an optional inline date picker. Save is disabled for an empty or
|
||||||
|
|||||||
@@ -148,6 +148,32 @@ pub async fn save_milestone(
|
|||||||
.map_err(message)
|
.map_err(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_milestone_closed(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
id: i64,
|
||||||
|
closed: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
client(server)?
|
||||||
|
.set_milestone_closed(&scope(owner, repository)?, id, closed)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_milestone(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
client(server)?
|
||||||
|
.delete_milestone(&scope(owner, repository)?, &id.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(message)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_pulls(
|
pub async fn load_pulls(
|
||||||
server: &Server,
|
server: &Server,
|
||||||
status: &str,
|
status: &str,
|
||||||
|
|||||||
@@ -586,6 +586,35 @@ impl GotchaCore {
|
|||||||
.ok_or_else(|| "The saved milestone has no ID.".into())
|
.ok_or_else(|| "The saved milestone has no ID.".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_milestone_closed(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
id: i64,
|
||||||
|
closed: bool,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
if id <= 0 {
|
||||||
|
return Err("Invalid milestone selection.".into());
|
||||||
|
}
|
||||||
|
set_milestone_closed(&self.server()?, owner, repository, id, closed).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_milestone(
|
||||||
|
&self,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
|
if id <= 0 {
|
||||||
|
return Err("Invalid milestone selection.".into());
|
||||||
|
}
|
||||||
|
delete_milestone(&self.server()?, owner, repository, id).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn pull_filters(&self) -> Result<PullFilterOptions, GotchaError> {
|
pub async fn pull_filters(&self) -> Result<PullFilterOptions, GotchaError> {
|
||||||
let server = self.server()?;
|
let server = self.server()?;
|
||||||
let filter = self
|
let filter = self
|
||||||
|
|||||||
@@ -208,18 +208,68 @@ impl Client {
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_milestone_closed(
|
||||||
|
&self,
|
||||||
|
repository: &RepositoryId,
|
||||||
|
id: i64,
|
||||||
|
closed: bool,
|
||||||
|
) -> Result<models::Milestone> {
|
||||||
|
if id < 1 {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"milestone id must be a positive integer".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.edit_milestone(
|
||||||
|
repository,
|
||||||
|
&id.to_string(),
|
||||||
|
models::EditMilestoneOption {
|
||||||
|
state: Some(if closed { "closed" } else { "open" }.into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_milestone(&self, repository: &RepositoryId, id: &str) -> Result<()> {
|
pub async fn delete_milestone(&self, repository: &RepositoryId, id: &str) -> Result<()> {
|
||||||
|
let id = id
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| Error::InvalidInput("milestone id must be a positive integer".into()))?;
|
||||||
|
let milestone = self.milestone(repository, id).await?;
|
||||||
|
let title = milestone
|
||||||
|
.title
|
||||||
|
.ok_or_else(|| Error::Generated("The milestone has no title.".into()))?;
|
||||||
|
if !self
|
||||||
|
.issues(repository, &assigned_items_query(title))
|
||||||
|
.await?
|
||||||
|
.items
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Remove all assigned issues and pull requests before deleting this milestone."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
apis::issue_api::issue_delete_milestone(
|
apis::issue_api::issue_delete_milestone(
|
||||||
&self.configuration(),
|
&self.configuration(),
|
||||||
&repository.owner,
|
&repository.owner,
|
||||||
&repository.repository,
|
&repository.repository,
|
||||||
id,
|
&id.to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(Error::generated)
|
.map_err(Error::generated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assigned_items_query(title: String) -> IssueQuery {
|
||||||
|
IssueQuery {
|
||||||
|
state: "all".into(),
|
||||||
|
kind: "all".into(),
|
||||||
|
milestones: Some(title),
|
||||||
|
limit: 1,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct MilestoneRequest {
|
struct MilestoneRequest {
|
||||||
title: String,
|
title: String,
|
||||||
@@ -244,4 +294,13 @@ mod tests {
|
|||||||
assert!(value.get("due_on").unwrap().is_null());
|
assert!(value.get("due_on").unwrap().is_null());
|
||||||
assert_eq!(value["state"], "closed");
|
assert_eq!(value["state"], "closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assigned_item_check_includes_issues_and_pull_requests() {
|
||||||
|
let query = assigned_items_query("Release".into());
|
||||||
|
assert_eq!(query.state, "all");
|
||||||
|
assert_eq!(query.kind, "all");
|
||||||
|
assert_eq!(query.milestones.as_deref(), Some("Release"));
|
||||||
|
assert_eq!(query.limit, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -621,6 +621,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func deleteIssue(owner: String, repository: String, number: Int64) async throws
|
func deleteIssue(owner: String, repository: String, number: Int64) async throws
|
||||||
|
|
||||||
|
func deleteMilestone(owner: String, repository: String, id: Int64) async throws
|
||||||
|
|
||||||
func home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage
|
func home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage
|
||||||
|
|
||||||
func issue(owner: String, repository: String, number: Int64, page: UInt32) async throws -> IssuePage
|
func issue(owner: String, repository: String, number: Int64, page: UInt32) async throws -> IssuePage
|
||||||
@@ -673,6 +675,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func setIssueStatus(status: String) throws
|
func setIssueStatus(status: String) throws
|
||||||
|
|
||||||
|
func setMilestoneClosed(owner: String, repository: String, id: Int64, closed: Bool) async throws
|
||||||
|
|
||||||
func setPullFilters(milestone: String, searchText: String) throws
|
func setPullFilters(milestone: String, searchText: String) throws
|
||||||
|
|
||||||
func setPullStatus(status: String) throws
|
func setPullStatus(status: String) throws
|
||||||
@@ -861,6 +865,22 @@ open func deleteIssue(owner: String, repository: String, number: Int64)async thr
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func deleteMilestone(owner: String, repository: String, id: Int64)async throws {
|
||||||
|
return
|
||||||
|
try await uniffiRustCallAsync(
|
||||||
|
rustFutureFunc: {
|
||||||
|
uniffi_gotcha_core_fn_method_gotchacore_delete_milestone(
|
||||||
|
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(id)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pollFunc: ffi_gotcha_core_rust_future_poll_void,
|
||||||
|
completeFunc: ffi_gotcha_core_rust_future_complete_void,
|
||||||
|
freeFunc: ffi_gotcha_core_rust_future_free_void,
|
||||||
|
liftFunc: { $0 },
|
||||||
|
errorHandler: FfiConverterTypeGotchaError_lift
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
open func home(page: UInt32, filter: HomeActivityFilter)async throws -> HomePage {
|
open func home(page: UInt32, filter: HomeActivityFilter)async throws -> HomePage {
|
||||||
return
|
return
|
||||||
try await uniffiRustCallAsync(
|
try await uniffiRustCallAsync(
|
||||||
@@ -1234,6 +1254,22 @@ open func setIssueStatus(status: String)throws {try rustCallWithError(FfiConve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func setMilestoneClosed(owner: String, repository: String, id: Int64, closed: Bool)async throws {
|
||||||
|
return
|
||||||
|
try await uniffiRustCallAsync(
|
||||||
|
rustFutureFunc: {
|
||||||
|
uniffi_gotcha_core_fn_method_gotchacore_set_milestone_closed(
|
||||||
|
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(id),FfiConverterBool.lower(closed)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pollFunc: ffi_gotcha_core_rust_future_poll_void,
|
||||||
|
completeFunc: ffi_gotcha_core_rust_future_complete_void,
|
||||||
|
freeFunc: ffi_gotcha_core_rust_future_free_void,
|
||||||
|
liftFunc: { $0 },
|
||||||
|
errorHandler: FfiConverterTypeGotchaError_lift
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
open func setPullFilters(milestone: String, searchText: 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(
|
||||||
@@ -4344,6 +4380,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_delete_issue() != 9524) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_delete_issue() != 9524) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_gotcha_core_checksum_method_gotchacore_delete_milestone() != 21716) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 2797) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 2797) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -4422,6 +4461,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
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_milestone_closed() != 44255) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters() != 25211) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_filters() != 25211) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,6 +304,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffe
|
|||||||
uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_MILESTONE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_MILESTONE
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
||||||
uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page, RustBuffer filter
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page, RustBuffer filter
|
||||||
@@ -434,6 +439,11 @@ void uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters(uint64_t ptr, Rus
|
|||||||
void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
|
void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_milestone_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, int8_t closed
|
||||||
|
);
|
||||||
|
#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, RustBuffer search_text, 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
|
||||||
@@ -771,6 +781,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commits(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_ISSUE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_ISSUE
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_issue(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_issue(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_MILESTONE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_MILESTONE
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_milestone(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME
|
||||||
@@ -927,6 +943,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS
|
||||||
|
|||||||
@@ -19,14 +19,109 @@ private extension UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class IssuesViewController: RefreshingTableViewController {
|
private protocol IssueSwipeActionHost: AnyObject {
|
||||||
private let context: AppContext
|
var context: AppContext { get }
|
||||||
private let owner: String
|
var owner: String { get }
|
||||||
private let repository: String
|
var repository: String { get }
|
||||||
|
var issueMutationTask: Task<Void, Never>? { get set }
|
||||||
|
func reloadIssuesAfterMutation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension IssueSwipeActionHost where Self: UIViewController {
|
||||||
|
func issueSwipeActions(for row: IssueRow) -> UISwipeActionsConfiguration {
|
||||||
|
let close = row.state != "closed"
|
||||||
|
let stateAction = UIContextualAction(
|
||||||
|
style: .normal,
|
||||||
|
title: close ? "Close" : "Open"
|
||||||
|
) { [weak self] _, _, completion in
|
||||||
|
self?.setIssue(row, closed: close, completion: completion) ?? completion(false)
|
||||||
|
}
|
||||||
|
stateAction.image = context.symbol(close ? "checkmark.circle" : "arrow.uturn.left.circle")
|
||||||
|
stateAction.backgroundColor = close ? .systemPurple : .systemGreen
|
||||||
|
|
||||||
|
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
|
||||||
|
[weak self] _, _, completion in
|
||||||
|
self?.confirmDeleteIssue(row, completion: completion) ?? completion(false)
|
||||||
|
}
|
||||||
|
deleteAction.image = context.symbol("trash")
|
||||||
|
|
||||||
|
let configuration = UISwipeActionsConfiguration(actions: [stateAction, deleteAction])
|
||||||
|
configuration.performsFirstActionWithFullSwipe = true
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setIssue(
|
||||||
|
_ row: IssueRow,
|
||||||
|
closed: Bool,
|
||||||
|
completion: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
issueMutationTask?.cancel()
|
||||||
|
issueMutationTask = Task {
|
||||||
|
do {
|
||||||
|
try await context.core.setIssueClosed(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: row.number,
|
||||||
|
closed: closed
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(true)
|
||||||
|
reloadIssuesAfterMutation()
|
||||||
|
} catch {
|
||||||
|
completion(false)
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmDeleteIssue(_ row: IssueRow, completion: @escaping (Bool) -> Void) {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Delete Issue #\(row.number)?",
|
||||||
|
message: "“\(row.title)” will be permanently deleted. This can’t be undone.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) })
|
||||||
|
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.issueMutationTask?.cancel()
|
||||||
|
self.issueMutationTask = Task {
|
||||||
|
do {
|
||||||
|
try await self.context.core.deleteIssue(
|
||||||
|
owner: self.owner,
|
||||||
|
repository: self.repository,
|
||||||
|
number: row.number
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(true)
|
||||||
|
self.reloadIssuesAfterMutation()
|
||||||
|
} catch {
|
||||||
|
completion(false)
|
||||||
|
if !Task.isCancelled { self.show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class IssuesViewController: RefreshingTableViewController, IssueSwipeActionHost {
|
||||||
|
fileprivate let context: AppContext
|
||||||
|
fileprivate let owner: String
|
||||||
|
fileprivate let repository: String
|
||||||
private var rows: [IssueRow] = []
|
private var rows: [IssueRow] = []
|
||||||
private var filterOptions: IssueFilterOptions?
|
private var filterOptions: IssueFilterOptions?
|
||||||
private var filterTask: Task<Void, Never>?
|
private var filterTask: Task<Void, Never>?
|
||||||
private var mutationTask: Task<Void, Never>?
|
fileprivate var issueMutationTask: Task<Void, Never>?
|
||||||
private var currentPage: UInt32 = 0
|
private var currentPage: UInt32 = 0
|
||||||
private lazy var filterButton = UIBarButtonItem(
|
private lazy var filterButton = UIBarButtonItem(
|
||||||
image: context.symbol("line.3.horizontal.decrease.circle")
|
image: context.symbol("line.3.horizontal.decrease.circle")
|
||||||
@@ -45,7 +140,7 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
filterTask?.cancel()
|
filterTask?.cancel()
|
||||||
mutationTask?.cancel()
|
issueMutationTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
@@ -151,88 +246,11 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
_ tableView: UITableView,
|
_ tableView: UITableView,
|
||||||
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||||
) -> UISwipeActionsConfiguration? {
|
) -> UISwipeActionsConfiguration? {
|
||||||
let row = rows[indexPath.row]
|
issueSwipeActions(for: rows[indexPath.row])
|
||||||
let close = row.state != "closed"
|
|
||||||
let stateAction = UIContextualAction(
|
|
||||||
style: .normal,
|
|
||||||
title: close ? "Close" : "Open"
|
|
||||||
) { [weak self] _, _, completion in
|
|
||||||
self?.setIssue(row, closed: close, completion: completion) ?? completion(false)
|
|
||||||
}
|
|
||||||
stateAction.image = context.symbol(close ? "checkmark.circle" : "arrow.uturn.left.circle")
|
|
||||||
stateAction.backgroundColor = close ? .systemPurple : .systemGreen
|
|
||||||
|
|
||||||
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
|
|
||||||
[weak self] _, _, completion in
|
|
||||||
self?.confirmDelete(row, completion: completion) ?? completion(false)
|
|
||||||
}
|
|
||||||
deleteAction.image = context.symbol("trash")
|
|
||||||
|
|
||||||
let configuration = UISwipeActionsConfiguration(actions: [stateAction, deleteAction])
|
|
||||||
configuration.performsFirstActionWithFullSwipe = true
|
|
||||||
return configuration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setIssue(
|
fileprivate func reloadIssuesAfterMutation() {
|
||||||
_ row: IssueRow,
|
loadIssues(refreshing: false)
|
||||||
closed: Bool,
|
|
||||||
completion: @escaping (Bool) -> Void
|
|
||||||
) {
|
|
||||||
mutationTask?.cancel()
|
|
||||||
mutationTask = Task {
|
|
||||||
do {
|
|
||||||
try await context.core.setIssueClosed(
|
|
||||||
owner: owner,
|
|
||||||
repository: repository,
|
|
||||||
number: row.number,
|
|
||||||
closed: closed
|
|
||||||
)
|
|
||||||
guard !Task.isCancelled else {
|
|
||||||
completion(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(true)
|
|
||||||
loadIssues(refreshing: false)
|
|
||||||
} catch {
|
|
||||||
completion(false)
|
|
||||||
if !Task.isCancelled { show(error: error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func confirmDelete(_ row: IssueRow, completion: @escaping (Bool) -> Void) {
|
|
||||||
let alert = UIAlertController(
|
|
||||||
title: "Delete Issue #\(row.number)?",
|
|
||||||
message: "“\(row.title)” will be permanently deleted. This can’t be undone.",
|
|
||||||
preferredStyle: .alert
|
|
||||||
)
|
|
||||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) })
|
|
||||||
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
|
|
||||||
guard let self else {
|
|
||||||
completion(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.mutationTask?.cancel()
|
|
||||||
self.mutationTask = Task {
|
|
||||||
do {
|
|
||||||
try await self.context.core.deleteIssue(
|
|
||||||
owner: self.owner,
|
|
||||||
repository: self.repository,
|
|
||||||
number: row.number
|
|
||||||
)
|
|
||||||
guard !Task.isCancelled else {
|
|
||||||
completion(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(true)
|
|
||||||
self.loadIssues(refreshing: false)
|
|
||||||
} catch {
|
|
||||||
completion(false)
|
|
||||||
if !Task.isCancelled { self.show(error: error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateFilterMenu() {
|
private func updateFilterMenu() {
|
||||||
@@ -539,6 +557,7 @@ final class MilestonesViewController: RefreshingTableViewController {
|
|||||||
private let owner: String
|
private let owner: String
|
||||||
private let repository: String
|
private let repository: String
|
||||||
private var rows: [MilestoneRow] = []
|
private var rows: [MilestoneRow] = []
|
||||||
|
private var mutationTask: Task<Void, Never>?
|
||||||
private var currentPage: UInt32 = 0
|
private var currentPage: UInt32 = 0
|
||||||
|
|
||||||
init(context: AppContext, owner: String, repository: String) {
|
init(context: AppContext, owner: String, repository: String) {
|
||||||
@@ -552,6 +571,8 @@ final class MilestonesViewController: 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 { mutationTask?.cancel() }
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
|
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
|
||||||
@@ -648,15 +669,114 @@ final class MilestonesViewController: RefreshingTableViewController {
|
|||||||
animated: true
|
animated: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||||
|
) -> UISwipeActionsConfiguration? {
|
||||||
|
let row = rows[indexPath.row]
|
||||||
|
let close = row.state != "closed"
|
||||||
|
let stateAction = UIContextualAction(
|
||||||
|
style: .normal,
|
||||||
|
title: close ? "Close" : "Open"
|
||||||
|
) { [weak self] _, _, completion in
|
||||||
|
self?.setMilestone(row, closed: close, completion: completion) ?? completion(false)
|
||||||
|
}
|
||||||
|
stateAction.image = context.symbol(close ? "checkmark.circle" : "arrow.uturn.left.circle")
|
||||||
|
stateAction.backgroundColor = close ? .systemPurple : .systemGreen
|
||||||
|
|
||||||
|
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
|
||||||
|
[weak self] _, _, completion in
|
||||||
|
self?.confirmDelete(row, completion: completion) ?? completion(false)
|
||||||
|
}
|
||||||
|
deleteAction.image = context.symbol("trash")
|
||||||
|
|
||||||
|
let configuration = UISwipeActionsConfiguration(actions: [stateAction, deleteAction])
|
||||||
|
configuration.performsFirstActionWithFullSwipe = true
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setMilestone(
|
||||||
|
_ row: MilestoneRow,
|
||||||
|
closed: Bool,
|
||||||
|
completion: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
mutationTask?.cancel()
|
||||||
|
mutationTask = Task {
|
||||||
|
do {
|
||||||
|
try await context.core.setMilestoneClosed(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
id: row.id,
|
||||||
|
closed: closed
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(true)
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
completion(false)
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmDelete(_ row: MilestoneRow, completion: @escaping (Bool) -> Void) {
|
||||||
|
guard !row.hasIssues else {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Milestone Can’t Be Deleted",
|
||||||
|
message: "Remove all assigned issues and pull requests first.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completion(false) })
|
||||||
|
present(alert, animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Delete \(row.title)?",
|
||||||
|
message: "This milestone will be permanently deleted. This can’t be undone.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) })
|
||||||
|
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.mutationTask?.cancel()
|
||||||
|
self.mutationTask = Task {
|
||||||
|
do {
|
||||||
|
try await self.context.core.deleteMilestone(
|
||||||
|
owner: self.owner,
|
||||||
|
repository: self.repository,
|
||||||
|
id: row.id
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(true)
|
||||||
|
self.loadContent(refreshing: false)
|
||||||
|
} catch {
|
||||||
|
completion(false)
|
||||||
|
if !Task.isCancelled { self.show(error: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MilestoneViewController: RefreshingTableViewController {
|
final class MilestoneViewController: RefreshingTableViewController, IssueSwipeActionHost {
|
||||||
private let context: AppContext
|
fileprivate let context: AppContext
|
||||||
private let owner: String
|
fileprivate let owner: String
|
||||||
private let repository: String
|
fileprivate let repository: String
|
||||||
private let id: Int64
|
private let id: Int64
|
||||||
private var page: MilestonePage?
|
private var page: MilestonePage?
|
||||||
|
fileprivate var issueMutationTask: Task<Void, Never>?
|
||||||
private var currentPage: UInt32 = 0
|
private var currentPage: UInt32 = 0
|
||||||
|
|
||||||
init(context: AppContext, owner: String, repository: String, id: Int64) {
|
init(context: AppContext, owner: String, repository: String, id: Int64) {
|
||||||
@@ -671,6 +791,8 @@ final class MilestoneViewController: 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 { issueMutationTask?.cancel() }
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
|
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
|
||||||
@@ -820,6 +942,18 @@ final class MilestoneViewController: RefreshingTableViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||||
|
) -> UISwipeActionsConfiguration? {
|
||||||
|
guard indexPath.section == 1, let issue = page?.issues[indexPath.row] else { return nil }
|
||||||
|
return issueSwipeActions(for: issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func reloadIssuesAfterMutation() {
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class MilestoneCell: UITableViewCell {
|
final class MilestoneCell: UITableViewCell {
|
||||||
|
|||||||
Reference in New Issue
Block a user