diff --git a/TESTING.md b/TESTING.md index de5276f..b2f45d7 100644 --- a/TESTING.md +++ b/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 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. -- [ ] In the issue list, partially swipe an open issue to reveal **Close** and - **Delete**. Cancel the destructive Delete confirmation and verify nothing - changes. Full-swipe the same row to close it, switch to Closed issues, and - full-swipe it again to reopen it; each row leaves the current filtered list - only after the server mutation succeeds. +- [ ] In both the repository issue list and the issue section below a milestone, + partially swipe an open issue to reveal **Close** and **Delete**. Cancel + the destructive Delete confirmation and verify nothing changes. Full-swipe + the same row to close it, then full-swipe the closed row to reopen it; each + list refreshes only after the server mutation succeeds. - [ ] Cancel both new and edited issues, including with the keyboard and date picker visible. Nothing is saved, the modal dismisses normally, and the 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. - [ ] Open a milestone and verify every assigned issue and pull request is 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, title and multiline description fields, a **Closed** switch that defaults off, and an optional inline date picker. Save is disabled for an empty or diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index e2605c0..22d6007 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -148,6 +148,32 @@ pub async fn save_milestone( .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( server: &Server, status: &str, diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index f8ff428..1582589 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -586,6 +586,35 @@ impl GotchaCore { .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 { let server = self.server()?; let filter = self diff --git a/crates/gitea/src/milestones.rs b/crates/gitea/src/milestones.rs index 672e859..90355ad 100644 --- a/crates/gitea/src/milestones.rs +++ b/crates/gitea/src/milestones.rs @@ -208,18 +208,68 @@ impl Client { .map_err(Into::into) } + pub async fn set_milestone_closed( + &self, + repository: &RepositoryId, + id: i64, + closed: bool, + ) -> Result { + 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<()> { + 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( &self.configuration(), &repository.owner, &repository.repository, - id, + &id.to_string(), ) .await .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)] struct MilestoneRequest { title: String, @@ -244,4 +294,13 @@ mod tests { assert!(value.get("due_on").unwrap().is_null()); 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); + } } diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index b95fb5a..97d79ad 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -621,6 +621,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { 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 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 setMilestoneClosed(owner: String, repository: String, id: Int64, closed: Bool) async throws + func setPullFilters(milestone: String, searchText: 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 { return 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) { uniffiCallStatus in 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) { 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) { return InitializationResult.apiChecksumMismatch } @@ -4422,6 +4461,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status() != 44445) { 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) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index c7dc322..7b5ca2d 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -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 ); #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 #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 @@ -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 ); #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 #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 @@ -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 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 #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 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 #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_FILTERS diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index 5d36593..407d576 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -19,14 +19,109 @@ private extension UIViewController { } @MainActor -final class IssuesViewController: RefreshingTableViewController { - private let context: AppContext - private let owner: String - private let repository: String +private protocol IssueSwipeActionHost: AnyObject { + var context: AppContext { get } + var owner: String { get } + var repository: String { get } + var issueMutationTask: Task? { 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 filterOptions: IssueFilterOptions? private var filterTask: Task? - private var mutationTask: Task? + fileprivate var issueMutationTask: Task? private var currentPage: UInt32 = 0 private lazy var filterButton = UIBarButtonItem( image: context.symbol("line.3.horizontal.decrease.circle") @@ -45,7 +140,7 @@ final class IssuesViewController: RefreshingTableViewController { deinit { filterTask?.cancel() - mutationTask?.cancel() + issueMutationTask?.cancel() } override func viewDidLoad() { @@ -151,88 +246,11 @@ final class IssuesViewController: RefreshingTableViewController { _ 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?.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 + issueSwipeActions(for: rows[indexPath.row]) } - private func setIssue( - _ row: IssueRow, - 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) + fileprivate func reloadIssuesAfterMutation() { + loadIssues(refreshing: false) } private func updateFilterMenu() { @@ -539,6 +557,7 @@ final class MilestonesViewController: RefreshingTableViewController { private let owner: String private let repository: String private var rows: [MilestoneRow] = [] + private var mutationTask: Task? private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String) { @@ -552,6 +571,8 @@ final class MilestonesViewController: RefreshingTableViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + deinit { mutationTask?.cancel() } + override func viewDidLoad() { super.viewDidLoad() tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone") @@ -648,15 +669,114 @@ final class MilestonesViewController: RefreshingTableViewController { 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 -final class MilestoneViewController: RefreshingTableViewController { - private let context: AppContext - private let owner: String - private let repository: String +final class MilestoneViewController: RefreshingTableViewController, IssueSwipeActionHost { + fileprivate let context: AppContext + fileprivate let owner: String + fileprivate let repository: String private let id: Int64 private var page: MilestonePage? + fileprivate var issueMutationTask: Task? private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String, id: Int64) { @@ -671,6 +791,8 @@ final class MilestoneViewController: RefreshingTableViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + deinit { issueMutationTask?.cancel() } + override func viewDidLoad() { super.viewDidLoad() 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 {