Files
Gotcha/ios/Sources/ContentScreens.swift
2026-08-01 15:48:14 +02:00

1518 lines
54 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
private extension UIViewController {
func promptForSearchText(title: String, current: String, apply: @escaping (String) -> Void) {
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
alert.addTextField { field in
field.text = current
field.placeholder = "Search text"
field.accessibilityLabel = "Search text"
field.clearButtonMode = .whileEditing
field.returnKeyType = .search
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Apply", style: .default) { [weak alert] _ in
apply(alert?.textFields?.first?.text ?? "")
})
present(alert, animated: true)
}
}
@MainActor
final class IssuesViewController: RefreshingTableViewController {
private let context: AppContext
private let owner: String
private let repository: String
private var rows: [IssueRow] = []
private var filterOptions: IssueFilterOptions?
private var filterTask: Task<Void, Never>?
private var mutationTask: Task<Void, Never>?
private var currentPage: UInt32 = 0
private lazy var filterButton = UIBarButtonItem(
image: context.symbol("line.3.horizontal.decrease.circle")
)
init(context: AppContext, owner: String, repository: String) {
self.context = context
self.owner = owner
self.repository = repository
super.init()
title = repository
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
deinit {
filterTask?.cancel()
mutationTask?.cancel()
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
let addButton = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(createIssue)
)
addButton.accessibilityLabel = "New issue"
navigationItem.rightBarButtonItems = [addButton, filterButton]
updateFilterMenu()
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
loadFilterOptions()
loadIssues(refreshing: refreshing)
}
private func loadIssues(refreshing: Bool) {
loadIssues(page: 1, refreshing: refreshing)
}
override func loadMoreContent() {
loadIssues(page: currentPage + 1, refreshing: false)
}
private func loadIssues(page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.issues(
owner: owner,
repository: repository,
page: page
)
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
updateEmptyState()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if page == 1 { endLoading() }
}
}
private func loadFilterOptions() {
filterTask?.cancel()
filterTask = Task {
do {
let options = try await context.core.issueFilters(
owner: owner,
repository: repository
)
guard !Task.isCancelled else { return }
filterOptions = options
updateFilterMenu()
updateEmptyState()
} catch {
if !Task.isCancelled { show(error: error) }
}
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell
cell.configure(rows[indexPath.row])
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
navigationController?.pushViewController(
IssueViewController(
context: context,
owner: owner,
repository: repository,
number: rows[indexPath.row].number
),
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?.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(
_ 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)
}
private func updateFilterMenu() {
let current = context.core.settings().issueStatus
let status = UIMenu(
title: "Status",
image: filterMenuImage("circle.lefthalf.filled", active: current != "open"),
options: .singleSelection,
children: ["open", "closed"].map { status in
UIAction(
title: status.capitalized,
state: current == status ? .on : .off
) { [weak self] _ in
guard let self else { return }
do {
try self.context.core.setIssueStatus(status: status)
self.updateFilterMenu()
self.loadIssues(refreshing: false)
} catch {
self.show(error: error)
}
}
}
)
var children: [UIMenuElement] = [status]
if let filterOptions {
children.insert(searchAction(filterOptions), at: 0)
children.append(milestoneMenu(filterOptions))
children.append(labelMenu(filterOptions))
} else {
children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in })
}
children.append(clearFiltersMenu())
filterButton.menu = UIMenu(children: children)
filterButton.accessibilityLabel = "Filter issues"
updateFilterTint()
}
private func searchAction(_ options: IssueFilterOptions) -> UIAction {
UIAction(
title: "Search Text",
subtitle: options.searchText.isEmpty ? "Any text" : options.searchText,
image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty)
) { [weak self] _ in
self?.promptForSearchText(
title: "Search Issues",
current: options.searchText
) { [weak self] searchText in
self?.setSearchText(searchText)
}
}
}
private func milestoneMenu(_ options: IssueFilterOptions) -> UIMenu {
let selected = options.selectedMilestone
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
[weak self] _ in self?.selectMilestone("")
}
let actions = options.milestones.map { milestone in
UIAction(
title: milestone,
state: selected == milestone ? .on : .off
) { [weak self] _ in self?.selectMilestone(milestone) }
}
return UIMenu(
title: "Milestone",
image: filterMenuImage("flag", active: !selected.isEmpty),
options: .singleSelection,
children: [all] + actions
)
}
private func labelMenu(_ options: IssueFilterOptions) -> UIMenu {
let selected = Set(options.selectedLabels)
let actions = options.labels.map { label in
UIAction(
title: options.unavailableLabels.contains(label) ? "\(label) (Unavailable)" : label,
attributes: .keepsMenuPresented,
state: selected.contains(label) ? .on : .off
) { [weak self] action in
guard let self, let options = self.filterOptions else { return }
self.filterTask?.cancel()
var labels = Set(options.selectedLabels)
if labels.remove(label) == nil { labels.insert(label) }
do {
try self.saveFilters(
milestone: options.selectedMilestone,
labels: labels,
searchText: options.searchText
)
self.filterOptions?.selectedLabels = Array(labels)
action.state = labels.contains(label) ? .on : .off
self.updateFilterMenu()
self.loadIssues(refreshing: false)
} catch {
self.show(error: error)
}
}
}
return UIMenu(
title: "Labels",
image: filterMenuImage("tag", active: !selected.isEmpty),
children: actions.isEmpty
? [UIAction(title: "No labels", attributes: .disabled) { _ in }]
: actions
)
}
private func selectMilestone(_ milestone: String) {
guard let options = filterOptions else { return }
filterTask?.cancel()
do {
try saveFilters(
milestone: milestone,
labels: Set(options.selectedLabels),
searchText: options.searchText
)
filterOptions?.selectedMilestone = milestone
updateFilterMenu()
loadIssues(refreshing: false)
} catch {
show(error: error)
}
}
private func setSearchText(_ searchText: String) {
guard let options = filterOptions else { return }
filterTask?.cancel()
do {
try saveFilters(
milestone: options.selectedMilestone,
labels: Set(options.selectedLabels),
searchText: searchText
)
filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
updateFilterMenu()
loadIssues(refreshing: false)
} catch {
show(error: error)
}
}
private func saveFilters(
milestone: String,
labels: Set<String>,
searchText: String
) throws {
try context.core.setIssueFilters(
owner: owner,
repository: repository,
milestone: milestone,
labels: Array(labels),
searchText: searchText
)
}
private func clearFiltersMenu() -> UIMenu {
UIMenu(
options: .displayInline,
children: [
UIAction(
title: "Clear Filters",
image: context.symbol("xmark.circle"),
attributes: filtersActive ? [] : .disabled
) { [weak self] _ in self?.clearFilters() },
]
)
}
private func clearFilters() {
filterTask?.cancel()
do {
try context.core.clearIssueFilters(owner: owner, repository: repository)
filterOptions?.selectedMilestone = ""
filterOptions?.selectedLabels = []
filterOptions?.searchText = ""
updateFilterMenu()
loadIssues(refreshing: false)
} catch {
show(error: error)
}
}
private var filtersActive: Bool {
(try? context.core.issueFiltersActive(owner: owner, repository: repository)) ?? false
}
private func updateFilterTint() {
filterButton.tintColor = filtersActive ? .tintColor : .secondaryLabel
filterButton.accessibilityValue = filtersActive
? "Filters active"
: "Default filters"
}
@objc private func createIssue() {
let editor = IssueEditorViewController(
context: context,
owner: owner,
repository: repository
) { [weak self] number in
guard let self else { return }
self.dismiss(animated: true) {
self.loadContent(refreshing: false)
self.navigationController?.pushViewController(
IssueViewController(
context: self.context,
owner: self.owner,
repository: self.repository,
number: number
),
animated: true
)
}
}
present(UINavigationController(rootViewController: editor), animated: true)
}
private func updateEmptyState() {
guard rows.isEmpty else {
tableView.backgroundView = nil
return
}
let status = context.core.settings().issueStatus
tableView.backgroundView = EmptyBackgroundView(
title: "No \(status) issues",
detail: filtersActive
? "No issues match the selected filters."
: "This repository has no \(status) issues."
)
}
}
final class IssueCell: UITableViewCell {
private let stateIcon = UIImageView()
private let titleLabel = UILabel()
private let summaryLabel = UILabel()
private let labels = UIStackView()
private let metaLabel = UILabel()
private let milestoneLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
accessoryType = .disclosureIndicator
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.numberOfLines = 2
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
titleStack.alignment = .firstBaseline
titleStack.spacing = 8
summaryLabel.font = .preferredFont(forTextStyle: .subheadline)
summaryLabel.textColor = .secondaryLabel
summaryLabel.numberOfLines = 2
labels.axis = .horizontal
labels.spacing = 5
metaLabel.font = .preferredFont(forTextStyle: .caption1)
metaLabel.textColor = .tertiaryLabel
metaLabel.numberOfLines = 2
milestoneLabel.font = .preferredFont(forTextStyle: .caption1)
milestoneLabel.adjustsFontForContentSizeCategory = true
let stack = UIStackView(
arrangedSubviews: [titleStack, summaryLabel, labels, metaLabel, milestoneLabel]
)
stack.axis = .vertical
stack.spacing = 6
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func configure(_ row: IssueRow) {
configureIssueStateIcon(stateIcon, state: row.state, textStyle: .headline)
titleLabel.text = row.title
summaryLabel.text = row.summary
metaLabel.text = row.meta
milestoneLabel.isHidden = row.milestone.isEmpty
milestoneLabel.attributedText = symbolText(
"flag.fill",
text: row.milestone,
font: milestoneLabel.font,
color: .tertiaryLabel
)
milestoneLabel.accessibilityLabel = row.milestone.isEmpty
? nil
: "Milestone \(row.milestone)"
labels.arrangedSubviews.forEach { $0.removeFromSuperview() }
labels.isHidden = row.labels.isEmpty
for label in row.labels.prefix(3) {
labels.addArrangedSubview(issueLabelView(label))
}
labels.addArrangedSubview(UIView())
}
}
@MainActor
final class MilestonesViewController: RefreshingTableViewController {
private let context: AppContext
private let owner: String
private let repository: String
private var rows: [MilestoneRow] = []
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String) {
self.context = context
self.owner = owner
self.repository = repository
super.init()
title = repository
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 118
let addButton = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(createMilestone)
)
addButton.accessibilityLabel = "New milestone"
navigationItem.rightBarButtonItem = addButton
loadContent(refreshing: false)
}
@objc private func createMilestone() {
let editor = MilestoneEditorViewController(
context: context,
owner: owner,
repository: repository
) { [weak self] _ in
guard let self else { return }
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
}
present(UINavigationController(rootViewController: editor), animated: true)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.milestones(
owner: owner,
repository: repository,
page: page
)
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
tableView.backgroundView = rows.isEmpty
? EmptyBackgroundView(
title: "No milestones",
detail: "This repository does not have any milestones."
)
: nil
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if page == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: "milestone",
for: indexPath
) as! MilestoneCell
cell.configure(rows[indexPath.row], disclosure: true)
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
navigationController?.pushViewController(
MilestoneViewController(
context: context,
owner: owner,
repository: repository,
id: rows[indexPath.row].id
),
animated: true
)
}
}
@MainActor
final class MilestoneViewController: RefreshingTableViewController {
private let context: AppContext
private let owner: String
private let repository: String
private let id: Int64
private var page: MilestonePage?
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String, id: Int64) {
self.context = context
self.owner = owner
self.repository = repository
self.id = id
super.init()
title = "Milestone"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 118
let editButton = UIBarButtonItem(
image: context.symbol("pencil"),
style: .plain,
target: self,
action: #selector(editMilestone)
)
editButton.accessibilityLabel = "Edit milestone"
navigationItem.rightBarButtonItem = editButton
loadContent(refreshing: false)
}
@objc private func editMilestone() {
let editor = MilestoneEditorViewController(
context: context,
owner: owner,
repository: repository,
id: id
) { [weak self] _ in
guard let self else { return }
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
}
present(UINavigationController(rootViewController: editor), animated: true)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.milestone(
owner: owner,
repository: repository,
id: id,
page: requestedPage
)
if requestedPage == 1 {
page = result
} else {
page?.issues.append(contentsOf: result.issues)
page?.pulls.append(contentsOf: result.pulls)
page?.hasMore = result.hasMore
}
currentPage = requestedPage
finishPagination(hasMore: result.hasMore)
title = page?.milestone.title
tableView.reloadData()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 3 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0: 1
case 1: page?.issues.count ?? 0
default: page?.pulls.count ?? 0
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch section {
case 1: "Issues"
case 2: "Pull Requests"
default: nil
}
}
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
if section == 1, page?.issues.isEmpty == true {
return "No issues are assigned to this milestone."
}
if section == 2, page?.pulls.isEmpty == true {
return "No pull requests are assigned to this milestone."
}
return nil
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
guard let page else { return UITableViewCell() }
if indexPath.section == 0 {
let cell = tableView.dequeueReusableCell(
withIdentifier: "milestone",
for: indexPath
) as! MilestoneCell
cell.configure(page.milestone, disclosure: false)
return cell
}
if indexPath.section == 1 {
let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell
cell.configure(page.issues[indexPath.row])
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: "pull", for: indexPath) as! PullCell
cell.configure(page.pulls[indexPath.row])
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 1, let issue = page?.issues[indexPath.row] {
navigationController?.pushViewController(
IssueViewController(
context: context,
owner: owner,
repository: repository,
number: issue.number
),
animated: true
)
} else if indexPath.section == 2, let pull = page?.pulls[indexPath.row] {
navigationController?.pushViewController(
PullViewController(
context: context,
owner: pull.owner,
repository: pull.repository,
number: pull.number
),
animated: true
)
}
}
}
final class MilestoneCell: UITableViewCell {
private let stateIcon = UIImageView()
private let titleLabel = UILabel()
private let stack = UIStackView()
private var descriptionView: UIView?
private let metaLabel = UILabel()
private let progress = UIProgressView(progressViewStyle: .bar)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.numberOfLines = 2
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
titleStack.alignment = .firstBaseline
titleStack.spacing = 8
metaLabel.font = .preferredFont(forTextStyle: .caption1)
metaLabel.textColor = .tertiaryLabel
metaLabel.numberOfLines = 2
progress.progressTintColor = .systemGreen
stack.addArrangedSubview(titleStack)
stack.addArrangedSubview(progress)
stack.addArrangedSubview(metaLabel)
stack.axis = .vertical
stack.spacing = 7
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func configure(_ row: MilestoneRow, disclosure: Bool) {
accessoryType = disclosure ? .disclosureIndicator : .none
configureOpenClosedStateIcon(
stateIcon,
state: row.state,
subject: "milestone",
textStyle: .headline
)
titleLabel.text = row.title
descriptionView?.removeFromSuperview()
if !row.description.isEmpty {
let view = markdownView(row.description)
stack.insertArrangedSubview(view, at: 1)
descriptionView = view
}
metaLabel.text = row.meta
progress.progress = Float(row.progress)
progress.trackTintColor = row.hasIssues ? .systemOrange : .systemGray5
progress.accessibilityLabel = "Milestone progress"
progress.accessibilityValue = row.progressAccessibility
}
}
final class PullCell: UITableViewCell {
private let stateIcon = UIImageView()
private let titleLabel = UILabel()
private let summaryLabel = UILabel()
private let metaLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
accessoryType = .disclosureIndicator
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.numberOfLines = 2
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
titleStack.alignment = .firstBaseline
titleStack.spacing = 8
summaryLabel.font = .preferredFont(forTextStyle: .subheadline)
summaryLabel.textColor = .secondaryLabel
summaryLabel.numberOfLines = 2
metaLabel.font = .preferredFont(forTextStyle: .caption1)
metaLabel.textColor = .tertiaryLabel
metaLabel.numberOfLines = 2
[titleLabel, summaryLabel, metaLabel].forEach {
$0.adjustsFontForContentSizeCategory = true
}
let stack = UIStackView(arrangedSubviews: [titleStack, summaryLabel, metaLabel])
stack.axis = .vertical
stack.spacing = 6
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func configure(_ row: PullRow) {
configureOpenClosedStateIcon(
stateIcon,
state: row.state,
subject: "pull request",
textStyle: .headline
)
titleLabel.text = row.title
summaryLabel.text = row.summary
metaLabel.text = row.meta
}
}
@MainActor
final class PullsViewController: RefreshingTableViewController {
private let context: AppContext
private var rows: [PullRow] = []
private var filterOptions: PullFilterOptions?
private var filterTask: Task<Void, Never>?
private var currentPage: UInt32 = 0
init(context: AppContext) {
self.context = context
super.init()
title = "Pull Requests"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
deinit { filterTask?.cancel() }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 116
navigationItem.leftBarButtonItem = UIBarButtonItem(
image: context.symbol("server.rack"),
primaryAction: UIAction { [weak self] _ in
guard let self else { return }
self.navigationController?.pushViewController(
ServersViewController(context: self.context),
animated: true
)
}
)
updateFilterMenu()
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
guard context.core.activeServerIndex() != nil else {
tableView.backgroundView = EmptyBackgroundView(
title: "No server selected",
detail: "Open Issues or Repos to select a server or add your first one."
)
refreshControl?.endRefreshing()
return
}
loadFilterOptions()
loadPulls(refreshing: refreshing)
}
private func loadPulls(refreshing: Bool) {
loadPulls(page: 1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPulls(page: currentPage + 1, refreshing: false)
}
private func loadPulls(page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.pulls(page: page)
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
currentPage = page
finishPagination(hasMore: result.hasMore)
tableView.reloadData()
let status = context.core.settings().pullStatus
tableView.backgroundView = rows.isEmpty
? EmptyBackgroundView(
title: "No \(status) pull requests",
detail: filtersActive
? "No pull requests match the selected filters."
: "No pull requests match the selected status."
)
: nil
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if page == 1 { endLoading() }
}
}
private func loadFilterOptions() {
filterTask?.cancel()
filterTask = Task {
do {
filterOptions = try await context.core.pullFilters()
guard !Task.isCancelled else { return }
updateFilterMenu()
} catch {
if !Task.isCancelled { show(error: error) }
}
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "pull", for: indexPath) as! PullCell
cell.configure(rows[indexPath.row])
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let row = rows[indexPath.row]
navigationController?.pushViewController(
PullViewController(
context: context,
owner: row.owner,
repository: row.repository,
number: row.number
),
animated: true
)
}
private func updateFilterMenu() {
let current = context.core.settings().pullStatus
let status = UIMenu(
title: "Status",
image: filterMenuImage("circle.lefthalf.filled", active: current != "open"),
options: .singleSelection,
children: ["open", "closed"].map { status in
UIAction(title: status.capitalized, state: current == status ? .on : .off) {
[weak self] _ in
guard let self else { return }
do {
try self.context.core.setPullStatus(status: status)
self.updateFilterMenu()
self.loadPulls(refreshing: false)
} catch {
self.show(error: error)
}
}
}
)
let milestone: UIMenuElement = filterOptions.map(milestoneMenu)
?? UIAction(title: "Loading milestones…", attributes: .disabled) { _ in }
let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem(
image: context.symbol("line.3.horizontal.decrease.circle")
)
var children: [UIMenuElement] = [status]
if let filterOptions {
children.insert(searchAction(filterOptions), at: 0)
}
children.append(milestone)
children.append(clearFiltersMenu())
item.menu = UIMenu(children: children)
item.accessibilityLabel = "Filter pull requests"
navigationItem.rightBarButtonItem = item
updateFilterTint()
}
private func searchAction(_ options: PullFilterOptions) -> UIAction {
UIAction(
title: "Search Text",
subtitle: options.searchText.isEmpty ? "Any text" : options.searchText,
image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty)
) { [weak self] _ in
self?.promptForSearchText(
title: "Search Pull Requests",
current: options.searchText
) { [weak self] searchText in
self?.setSearchText(searchText)
}
}
}
private func milestoneMenu(_ options: PullFilterOptions) -> UIMenu {
let selected = options.selectedMilestone
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
[weak self] _ in self?.selectMilestone("")
}
let milestones = options.milestones.map { milestone in
UIAction(title: milestone, state: selected == milestone ? .on : .off) {
[weak self] _ in self?.selectMilestone(milestone)
}
}
return UIMenu(
title: "Milestone",
image: filterMenuImage("flag", active: !selected.isEmpty),
options: .singleSelection,
children: [all] + milestones
)
}
private func selectMilestone(_ milestone: String) {
guard let options = filterOptions else { return }
filterTask?.cancel()
do {
try context.core.setPullFilters(
milestone: milestone,
searchText: options.searchText
)
filterOptions?.selectedMilestone = milestone
updateFilterMenu()
loadPulls(refreshing: false)
} catch {
show(error: error)
}
}
private func setSearchText(_ searchText: String) {
guard let options = filterOptions else { return }
filterTask?.cancel()
do {
try context.core.setPullFilters(
milestone: options.selectedMilestone,
searchText: searchText
)
filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
updateFilterMenu()
loadPulls(refreshing: false)
} catch {
show(error: error)
}
}
private func clearFiltersMenu() -> UIMenu {
UIMenu(
options: .displayInline,
children: [
UIAction(
title: "Clear Filters",
image: context.symbol("xmark.circle"),
attributes: filtersActive ? [] : .disabled
) { [weak self] _ in self?.clearFilters() },
]
)
}
private func clearFilters() {
filterTask?.cancel()
do {
try context.core.clearPullFilters()
filterOptions?.selectedMilestone = ""
filterOptions?.searchText = ""
updateFilterMenu()
loadPulls(refreshing: false)
} catch {
show(error: error)
}
}
private var filtersActive: Bool {
(try? context.core.pullFiltersActive()) ?? false
}
private func updateFilterTint() {
navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel
navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive
? "Filters active"
: "Default filters"
}
}
@MainActor
final class CommitsViewController: RefreshingTableViewController {
private enum Mode: Int { case history, files }
private let context: AppContext
private let owner: String
private let repository: String
private var page: CommitPage?
private var contents: [RepositoryContentRow] = []
private var branch: String?
private var mode = Mode.history
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String) {
self.context = context
self.owner = owner
self.repository = repository
super.init()
title = repository
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
let modeControl = UISegmentedControl(items: ["History", "Files"])
modeControl.selectedSegmentIndex = mode.rawValue
modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
modeControl.accessibilityLabel = "Repository view"
navigationItem.titleView = modeControl
updateBranchMenu()
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
guard mode == .history else { return }
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
switch mode {
case .history:
page = try await context.core.commits(
owner: owner,
repository: repository,
branch: branch,
path: "",
pages: requestedPage
)
currentPage = requestedPage
finishPagination(hasMore: page?.hasMore ?? false)
updateBranchMenu()
case .files:
contents = try await context.core.repositoryContents(
owner: owner,
repository: repository,
path: ""
)
}
tableView.reloadData()
updateEmptyView()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
mode == .history ? page?.commits.count ?? 0 : contents.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
switch mode {
case .history:
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) }
return cell
case .files:
let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
configureRepositoryContentCell(cell, row: contents[indexPath.row])
return cell
}
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
mode == .history ? 86 : UITableView.automaticDimension
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
switch mode {
case .history:
guard let row = page?.commits[indexPath.row] else { return }
navigationController?.pushViewController(
FilesViewController(
context: context,
owner: owner,
repository: repository,
sha: row.sha,
branch: row.branchLabel ?? branch
),
animated: true
)
case .files:
showRepositoryContent(
contents[indexPath.row],
context: context,
owner: owner,
repository: repository,
navigationController: navigationController
)
}
}
@objc private func modeChanged(_ sender: UISegmentedControl) {
guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return }
self.mode = mode
navigationItem.rightBarButtonItem = nil
if mode == .history { updateBranchMenu() }
tableView.reloadData()
loadContent(refreshing: false)
}
private func updateEmptyView() {
switch mode {
case .history:
tableView.backgroundView = page?.commits.isEmpty == true
? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.")
: nil
case .files:
tableView.backgroundView = contents.isEmpty
? EmptyBackgroundView(title: "No files", detail: "This repository is empty.")
: nil
}
}
private func updateBranchMenu() {
let branches = page?.branches ?? []
let choices: [String?] = [nil] + branches.map(Optional.some)
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: branch ?? "All",
menu: UIMenu(children: choices.map { choice in
UIAction(
title: choice ?? "All",
state: choice == branch ? .on : .off
) { [weak self] _ in
self?.branch = choice
self?.updateBranchMenu()
self?.loadContent(refreshing: false)
}
})
)
}
}
final class CommitCell: UITableViewCell {
private static let laneOrigin: CGFloat = 12
private static let laneSpacing: CGFloat = 12
private var row: CommitRow?
private var laneCount: UInt32 = 0
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
accessoryType = .disclosureIndicator
backgroundColor = .systemBackground
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func configure(_ row: CommitRow, laneCount: UInt32) {
self.row = row
self.laneCount = laneCount
var content = defaultContentConfiguration()
content.directionalLayoutMargins.leading = laneCount == 0
? 0
: CGFloat(laneCount) * Self.laneSpacing + 10
content.text = row.title
if let branch = row.branchLabel {
let text = NSMutableAttributedString(string: "\(branch)\n\(row.detail)")
text.addAttribute(
.foregroundColor,
value: UIColor.tintColor,
range: NSRange(location: 0, length: (branch as NSString).length)
)
content.secondaryAttributedText = text
} else {
content.secondaryText = row.detail
}
content.secondaryTextProperties.numberOfLines = 2
contentConfiguration = content
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let row, laneCount > 0, let context = UIGraphicsGetCurrentContext() else { return }
let centerY = bounds.midY
let nodeX = row.nodeLane.map(laneX)
context.setLineWidth(3)
context.setLineCap(.round)
context.setLineJoin(.round)
for lane in 0..<laneCount {
if row.topLanes.contains(lane) {
let start = CGPoint(x: laneX(lane), y: 0)
if row.topConnections.contains(lane), let nodeX {
strokeCurve(
context,
from: start,
to: CGPoint(x: nodeX, y: centerY),
color: laneColor(lane)
)
} else {
strokeLine(
context,
from: start,
to: CGPoint(x: start.x, y: centerY),
color: laneColor(lane)
)
}
}
if row.bottomLanes.contains(lane) {
let end = CGPoint(x: laneX(lane), y: bounds.height)
if !row.bottomConnections.contains(lane) || row.topLanes.contains(lane) {
strokeLine(
context,
from: CGPoint(x: end.x, y: centerY),
to: end,
color: laneColor(lane)
)
}
if row.bottomConnections.contains(lane), let nodeX {
strokeCurve(
context,
from: CGPoint(x: nodeX, y: centerY),
to: end,
color: laneColor(lane)
)
}
}
}
if let lane = row.nodeLane, let nodeX {
context.setFillColor(laneColor(lane).cgColor)
context.fillEllipse(
in: CGRect(x: nodeX - 5, y: centerY - 5, width: 10, height: 10)
)
}
}
private func laneX(_ lane: UInt32) -> CGFloat {
Self.laneOrigin + CGFloat(lane) * Self.laneSpacing
}
private func strokeLine(
_ context: CGContext,
from start: CGPoint,
to end: CGPoint,
color: UIColor
) {
context.setStrokeColor(color.cgColor)
context.move(to: start)
context.addLine(to: end)
context.strokePath()
}
private func strokeCurve(
_ context: CGContext,
from start: CGPoint,
to end: CGPoint,
color: UIColor
) {
let middleY = (start.y + end.y) / 2
context.setStrokeColor(color.cgColor)
context.move(to: start)
context.addCurve(
to: end,
control1: CGPoint(x: start.x, y: middleY),
control2: CGPoint(x: end.x, y: middleY)
)
context.strokePath()
}
private func laneColor(_ lane: UInt32) -> UIColor {
UIColor(hue: CGFloat((Double(lane) * 137.508).truncatingRemainder(dividingBy: 360)) / 360,
saturation: 0.78, brightness: 0.78, alpha: 1)
}
}