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

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