import UIKit @MainActor class MarkdownPageViewController: UIViewController { let context: AppContext let scrollView = UIScrollView() let stack = UIStackView() private let spinner = UIActivityIndicatorView(style: .medium) var loadingTask: Task? 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.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 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) } @objc private func refreshRequested() { loadContent(refreshing: true) } } @MainActor final class IssueViewController: MarkdownPageViewController { private let owner: String private let repository: String private let number: Int64 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() loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { let page = try await context.core.issue( owner: owner, repository: repository, number: number ) replaceContent(detailViews( title: page.title, meta: page.meta, body: page.body, comments: page.comments, milestone: page.milestone )) } catch { if !Task.isCancelled { show(error: error) } } endLoading() } } } @MainActor final class PullViewController: MarkdownPageViewController { private let owner: String private let repository: String private let number: Int64 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) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { let page = try await context.core.pull( owner: owner, repository: repository, number: number ) var views = detailViews( title: page.title, meta: page.meta, body: page.body, comments: [] ) if !page.files.isEmpty { views.append(sectionLabel(page.filesRef)) views.append(contentsOf: page.files.map { file in detailButton(title: file.path, detail: file.status) { [weak self] 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) } catch { if !Task.isCancelled { show(error: error) } } endLoading() } } } @MainActor final class FilesViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private let sha: String private var rows: [FileRow] = [] init(context: AppContext, owner: String, repository: String, sha: String) { self.context = context self.owner = owner self.repository = repository self.sha = sha super.init() title = "Changed Files" } @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) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { rows = try await context.core.commitFiles( owner: owner, repository: repository, sha: sha ) tableView.reloadData() tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView(title: "No changed files", detail: "This commit does not contain file changes.") : nil } catch { if !Task.isCancelled { show(error: error) } } 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: "file") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "file") let row = rows[indexPath.row] configureTextCell(cell, title: row.path, detail: row.status) cell.accessoryType = .disclosureIndicator return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let row = rows[indexPath.row] navigationController?.pushViewController( DiffViewController( context: context, source: .commit( owner: owner, repository: repository, sha: sha, path: row.path ) ), animated: true ) } } enum DiffSource { case commit(owner: String, repository: String, sha: String, path: String) case pull(owner: String, repository: String, number: Int64, path: String) } @MainActor final class DiffViewController: UIViewController { private let context: AppContext private let source: DiffSource private let textView = UITextView() private let spinner = UIActivityIndicatorView(style: .medium) private var loadingTask: Task? init(context: AppContext, source: DiffSource) { self.context = context self.source = source 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 = .systemBackground textView.translatesAutoresizingMaskIntoConstraints = false textView.isEditable = false textView.isSelectable = true textView.alwaysBounceVertical = true textView.alwaysBounceHorizontal = true textView.showsHorizontalScrollIndicator = true textView.textContainer.widthTracksTextView = false textView.textContainer.lineFragmentPadding = 8 textView.refreshControl = UIRefreshControl() textView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged) view.addSubview(textView) NSLayoutConstraint.activate([ textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), textView.topAnchor.constraint(equalTo: view.topAnchor), textView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) beginNavigationLoading(spinner) reload() } deinit { loadingTask?.cancel() } @objc private func reload() { loadingTask?.cancel() loadingTask = Task { do { let page: DiffPage switch source { case let .commit(owner, repository, sha, path): page = try await context.core.commitDiff( owner: owner, repository: repository, sha: sha, path: path ) case let .pull(owner, repository, number, path): page = try await context.core.pullDiff( owner: owner, repository: repository, number: number, path: path ) } title = page.title textView.attributedText = diffText(page) } catch { if !Task.isCancelled { show(error: error) } } endNavigationLoading(spinner) textView.refreshControl?.endRefreshing() } } private func diffText(_ page: DiffPage) -> NSAttributedString { let output = NSMutableAttributedString() let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) for line in page.lines { let text = String(format: "%4@ %4@ %@\n", line.oldNumber, line.newNumber, line.text) let color: UIColor switch line.kind { case "addition": color = UIColor.systemGreen.withAlphaComponent(0.16) case "removal": color = UIColor.systemRed.withAlphaComponent(0.16) case "hunk": color = UIColor.systemBlue.withAlphaComponent(0.14) case "header": color = UIColor.systemGray.withAlphaComponent(0.14) default: color = .clear } output.append(NSAttributedString(string: text, attributes: [ .font: font, .foregroundColor: UIColor.label, .backgroundColor: color, ])) } return output } } private func detailViews( title: String, meta: String, body: String, comments: [CommentRow], milestone: String = "" ) -> [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] = [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) } return views + [separator(), bodyView] + commentViews(comments) } private func commentViews(_ comments: [CommentRow]) -> [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) let date = UILabel() date.text = comment.meta date.font = .preferredFont(forTextStyle: .caption1) date.textColor = .tertiaryLabel let stack = UIStackView(arrangedSubviews: [author, markdownView(comment.body), date, separator()]) stack.axis = .vertical stack.spacing = 6 views.append(stack) } return views } private func markdownView(_ source: String) -> UITextView { let view = UITextView() view.attributedText = markdown(source) view.isEditable = false view.isSelectable = true view.isScrollEnabled = false view.backgroundColor = .clear view.textContainerInset = .zero view.textContainer.lineFragmentPadding = 0 view.adjustsFontForContentSizeCategory = true return view } private func sectionLabel(_ text: String) -> UILabel { let label = UILabel() label.text = text label.font = .preferredFont(forTextStyle: .headline) return label } private func separator() -> UIView { let line = UIView() line.backgroundColor = .separator line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true return line } private func detailButton(title: String, detail: String, action: @escaping () -> Void) -> UIButton { var configuration = UIButton.Configuration.plain() configuration.title = title configuration.subtitle = detail configuration.image = UIImage(systemName: "chevron.right") configuration.imagePlacement = .trailing configuration.imagePadding = 8 configuration.contentInsets = .init(top: 10, leading: 0, bottom: 10, trailing: 0) let button = UIButton(configuration: configuration, primaryAction: UIAction { _ in action() }) button.contentHorizontalAlignment = .fill return button }