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

View File

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

View File

@@ -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<PullFilterOptions, GotchaError> {
let server = self.server()?;
let filter = self

View File

@@ -208,18 +208,68 @@ impl Client {
.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<()> {
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);
}
}

View File

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

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
);
#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

View File

@@ -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<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 filterOptions: IssueFilterOptions?
private var filterTask: Task<Void, Never>?
private var mutationTask: Task<Void, Never>?
fileprivate var issueMutationTask: Task<Void, Never>?
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 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)
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<Void, Never>?
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 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
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<Void, Never>?
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 {