Files
Gotcha/ios/Sources/WorkItemDetailScreens.swift
2026-08-03 17:54:07 +02:00

496 lines
18 KiB
Swift

import Highlighter
import QuickLook
import SwiftUI
import UIKit
@MainActor
class MarkdownPageViewController: UIViewController, UIScrollViewDelegate {
let context: AppContext
let scrollView = UIScrollView()
let stack = UIStackView()
private let spinner = UIActivityIndicatorView(style: .medium)
var loadingTask: Task<Void, Never>?
private lazy var moreButton = UIButton(
configuration: .plain(),
primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() }
)
private var hasMoreContent = false
private var loadingMore = false
init(context: AppContext) {
self.context = context
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGroupedBackground
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceVertical = true
scrollView.delegate = self
scrollView.refreshControl = UIRefreshControl()
scrollView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
view.addSubview(scrollView)
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stack)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 18),
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -18),
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 18),
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -18),
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -36),
])
}
deinit { loadingTask?.cancel() }
func loadContent(refreshing: Bool) {}
func loadMoreContent() {}
func beginLoading(refreshing: Bool) {
if !refreshing { beginNavigationLoading(spinner) }
}
func endLoading() {
endNavigationLoading(spinner)
scrollView.refreshControl?.endRefreshing()
}
func replaceContent(_ views: [UIView]) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
views.forEach(stack.addArrangedSubview)
}
func resetPagination() {
hasMoreContent = false
loadingMore = false
stack.removeArrangedSubview(moreButton)
moreButton.removeFromSuperview()
}
func finishPagination(hasMore: Bool) {
hasMoreContent = hasMore
loadingMore = false
guard hasMore else { return }
var configuration = moreButton.configuration
configuration?.title = "Pull up or tap to load more"
configuration?.showsActivityIndicator = false
moreButton.configuration = configuration
moreButton.accessibilityLabel = "Load more results"
if moreButton.superview !== stack { stack.addArrangedSubview(moreButton) }
if moreButton.constraints.isEmpty {
moreButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
}
}
func failPagination() {
loadingMore = false
finishPagination(hasMore: hasMoreContent)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isDragging, hasMoreContent, !loadingMore else { return }
let bottom = max(
-scrollView.adjustedContentInset.top,
scrollView.contentSize.height
+ scrollView.adjustedContentInset.bottom
- scrollView.bounds.height
)
if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() }
}
private func requestMoreContent() {
guard hasMoreContent, !loadingMore else { return }
loadingMore = true
var configuration = moreButton.configuration
configuration?.title = "Loading more…"
configuration?.showsActivityIndicator = true
moreButton.configuration = configuration
loadMoreContent()
}
@objc private func refreshRequested() {
loadContent(refreshing: true)
}
}
@MainActor
final class IssueViewController: MarkdownPageViewController {
private let owner: String
private let repository: String
private let number: Int64
private var page: IssuePage?
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String, number: Int64) {
self.owner = owner
self.repository = repository
self.number = number
super.init(context: context)
title = "Issue"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
let addComment = UIBarButtonItem(
image: context.symbol("plus.bubble"),
style: .plain,
target: self,
action: #selector(addComment)
)
addComment.accessibilityLabel = "Add comment"
let editIssue = UIBarButtonItem(
image: context.symbol("pencil"),
style: .plain,
target: self,
action: #selector(editIssue)
)
editIssue.accessibilityLabel = "Edit issue"
navigationItem.rightBarButtonItems = [addComment, editIssue]
loadContent(refreshing: false)
}
@objc private func addComment() { presentCommentEditor() }
private func editComment(_ comment: CommentRow) { presentCommentEditor(comment) }
private func presentCommentEditor(_ comment: CommentRow? = nil) {
let editor = CommentEditorViewController(
context: context,
owner: owner,
repository: repository,
number: number,
comment: comment
) { [weak self] in
guard let self else { return }
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
}
present(UINavigationController(rootViewController: editor), animated: true)
}
@objc private func editIssue() {
let editor = IssueEditorViewController(
context: context,
owner: owner,
repository: repository,
number: number
) { [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.issue(
owner: owner,
repository: repository,
number: number,
page: requestedPage
)
if requestedPage == 1 {
page = result
} else {
page?.comments.append(contentsOf: result.comments)
page?.hasMore = result.hasMore
}
currentPage = requestedPage
if let page {
replaceContent(detailViews(
title: page.title,
state: page.state,
stateSubject: "issue",
meta: page.meta,
body: page.body,
comments: page.comments,
editComment: { [weak self] comment in self?.editComment(comment) },
milestone: page.milestone,
labels: page.labels
))
finishPagination(hasMore: page.hasMore)
}
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
}
@MainActor
final class PullViewController: MarkdownPageViewController {
private let owner: String
private let repository: String
private let number: Int64
private var page: PullPage?
private var currentPage: UInt32 = 0
init(context: AppContext, owner: String, repository: String, number: Int64) {
self.owner = owner
self.repository = repository
self.number = number
super.init(context: context)
title = "Pull Request"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
loadContent(refreshing: false)
}
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.pull(
owner: owner,
repository: repository,
number: number,
page: requestedPage
)
if requestedPage == 1 {
page = result
} else {
page?.files.append(contentsOf: result.files)
page?.comments.append(contentsOf: result.comments)
page?.hasMore = result.hasMore
}
currentPage = requestedPage
if let page {
var views = detailViews(
title: page.title,
state: page.state,
stateSubject: "pull request",
meta: page.meta,
body: page.body,
comments: []
)
if !page.files.isEmpty {
views.append(sectionLabel(page.filesRef))
views.append(FileListView(rows: page.files) { [weak self] file in
guard let self else { return }
self.navigationController?.pushViewController(
DiffViewController(
context: self.context,
source: .pull(
owner: self.owner,
repository: self.repository,
number: self.number,
path: file.path
)
),
animated: true
)
})
}
views.append(contentsOf: commentViews(page.comments))
replaceContent(views)
finishPagination(hasMore: page.hasMore)
}
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
}
private final class FileListView: UITableView, UITableViewDataSource, UITableViewDelegate {
private let rows: [FileRow]
private let select: (FileRow) -> Void
private var contentHeight: NSLayoutConstraint!
init(rows: [FileRow], select: @escaping (FileRow) -> Void) {
self.rows = rows
self.select = select
super.init(frame: .zero, style: .plain)
translatesAutoresizingMaskIntoConstraints = false
dataSource = self
delegate = self
isScrollEnabled = false
rowHeight = UITableView.automaticDimension
estimatedRowHeight = 55
separatorInset = .zero
backgroundColor = .systemGroupedBackground
contentHeight = heightAnchor.constraint(equalToConstant: CGFloat(rows.count) * estimatedRowHeight)
contentHeight.isActive = true
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func layoutSubviews() {
super.layoutSubviews()
if abs(contentHeight.constant - contentSize.height) > 0.5 {
contentHeight.constant = contentSize.height
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "file")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "file")
let row = rows[indexPath.row]
configureTextCell(cell, title: row.path, detail: row.status)
cell.accessoryType = .disclosureIndicator
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
select(rows[indexPath.row])
}
}
@MainActor
private func detailViews(
title: String,
state: WorkItemState? = nil,
stateSubject: String = "",
meta: String,
body: String,
comments: [CommentRow],
editComment: ((CommentRow) -> Void)? = nil,
milestone: String = "",
labels: [LabelRow] = []
) -> [UIView] {
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .preferredFont(forTextStyle: .title1)
titleLabel.numberOfLines = 0
let metaLabel = UILabel()
metaLabel.text = meta
metaLabel.font = .preferredFont(forTextStyle: .subheadline)
metaLabel.textColor = .secondaryLabel
metaLabel.numberOfLines = 0
let bodyView = markdownView(body)
var views: [UIView] = [
state.map { stateTitle(titleLabel, state: $0, subject: stateSubject) } ?? titleLabel,
metaLabel,
]
if !milestone.isEmpty {
let milestoneLabel = UILabel()
milestoneLabel.attributedText = symbolText(
"flag.fill",
text: milestone,
font: .preferredFont(forTextStyle: .subheadline),
color: .secondaryLabel
)
milestoneLabel.accessibilityLabel = "Milestone \(milestone)"
views.append(milestoneLabel)
}
if !labels.isEmpty { views.append(IssueLabelsView(labels)) }
return views + [separator(), bodyView] + commentViews(comments, editComment: editComment)
}
private func stateTitle(_ titleLabel: UILabel, state: WorkItemState, subject: String) -> UIView {
let icon = UIImageView()
configureOpenClosedStateIcon(icon, state: state, subject: subject, textStyle: .title1)
let stack = UIStackView(arrangedSubviews: [icon, titleLabel])
stack.axis = .horizontal
stack.alignment = .firstBaseline
stack.spacing = 8
return stack
}
@MainActor
private func commentViews(
_ comments: [CommentRow],
editComment: ((CommentRow) -> Void)? = nil
) -> [UIView] {
guard !comments.isEmpty else { return [] }
var views: [UIView] = [sectionLabel("Comments")]
for comment in comments {
let author = UILabel()
author.text = comment.author
author.font = .preferredFont(forTextStyle: .headline)
var headerViews: [UIView] = [author, UIView()]
if comment.canEdit, let editComment {
var configuration = UIButton.Configuration.plain()
configuration.image = UIImage(systemName: "pencil")
configuration.contentInsets = .zero
let button = UIButton(
configuration: configuration,
primaryAction: UIAction { _ in editComment(comment) }
)
button.accessibilityLabel = "Edit comment by \(comment.author)"
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
button.heightAnchor.constraint(equalToConstant: 44).isActive = true
headerViews.append(button)
}
let header = UIStackView(arrangedSubviews: headerViews)
header.alignment = .center
let date = UILabel()
date.text = comment.meta
date.font = .preferredFont(forTextStyle: .caption1)
date.textColor = .tertiaryLabel
let stack = UIStackView(arrangedSubviews: [header, markdownView(comment.body), date, separator()])
stack.axis = .vertical
stack.spacing = 6
views.append(stack)
}
return views
}
private func sectionLabel(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .preferredFont(forTextStyle: .headline)
return label
}