feat(ios): extend swipe actions to milestones

This commit is contained in:
Georg Bauer
2026-08-01 20:57:16 +02:00
parent 5a48456333
commit c0b03f26ac
7 changed files with 413 additions and 96 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 cant 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 cant 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 Cant 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 cant 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 {