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