Files
Gotcha/ios/Sources/DetailScreens.swift
2026-07-31 14:10:00 +02:00

766 lines
27 KiB
Swift

import Highlighter
import MarkdownUI
import QuickLook
import SwiftUI
import UIKit
@MainActor
class MarkdownPageViewController: UIViewController {
let context: AppContext
let scrollView = UIScrollView()
let stack = UIStackView()
private let spinner = UIActivityIndicatorView(style: .medium)
var loadingTask: Task<Void, Never>?
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,
issueState: page.state,
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
)
}
}
@MainActor
final class RepositoryDirectoryViewController: RefreshingTableViewController {
private let context: AppContext
private let owner: String
private let repository: String
private let path: String
private var rows: [RepositoryContentRow] = []
init(context: AppContext, owner: String, repository: String, path: String, name: String) {
self.context = context
self.owner = owner
self.repository = repository
self.path = path
super.init()
title = name
}
@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.repositoryContents(
owner: owner,
repository: repository,
path: path
)
tableView.reloadData()
tableView.backgroundView = rows.isEmpty
? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.")
: 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: "repository-content")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
configureRepositoryContentCell(cell, row: rows[indexPath.row])
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
showRepositoryContent(
rows[indexPath.row],
context: context,
owner: owner,
repository: repository,
navigationController: navigationController
)
}
}
@MainActor
final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource {
private enum MarkdownMode: Int { case preview, source }
private let context: AppContext
private let owner: String
private let repository: String
private let path: String
private let spinner = UIActivityIndicatorView(style: .medium)
private var loadingTask: Task<Void, Never>?
private var previewURL: URL?
private var markdownSource: String?
private var fileLanguage: String?
private var displayedController: UIViewController?
private var displayedView: UIView?
private weak var sourceScrollView: UIScrollView?
private weak var sourceTextView: UITextView?
init(context: AppContext, owner: String, repository: String, path: String, name: String) {
self.context = context
self.owner = owner
self.repository = repository
self.path = path
super.init(nibName: nil, bundle: nil)
title = name
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
beginNavigationLoading(spinner)
loadingTask = Task {
do {
let page = try await context.core.repositoryFile(
owner: owner,
repository: repository,
path: path
)
title = page.name
fileLanguage = page.language.isEmpty ? nil : page.language
switch page.kind {
case "markdown": showMarkdown(page.text)
case "source": showSource(page.text)
default: try showQuickLook(page.data, name: page.name)
}
} catch {
if !Task.isCancelled { show(error: error) }
}
endNavigationLoading(spinner)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateSourceLayout()
}
deinit {
loadingTask?.cancel()
if let previewURL { try? FileManager.default.removeItem(at: previewURL) }
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
previewURL == nil ? 0 : 1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
previewURL! as NSURL
}
private func showMarkdown(_ source: String) {
markdownSource = source
navigationItem.prompt = title
let modeControl = UISegmentedControl(items: ["Preview", "Source"])
modeControl.selectedSegmentIndex = MarkdownMode.preview.rawValue
modeControl.addTarget(self, action: #selector(markdownModeChanged(_:)), for: .valueChanged)
modeControl.accessibilityLabel = "Markdown view"
navigationItem.titleView = modeControl
showMarkdownPreview(source)
}
@objc private func markdownModeChanged(_ sender: UISegmentedControl) {
guard let mode = MarkdownMode(rawValue: sender.selectedSegmentIndex),
let source = markdownSource else { return }
switch mode {
case .preview: showMarkdownPreview(source)
case .source: showSource(source)
}
}
private func showMarkdownPreview(_ source: String) {
removeDisplayedView()
let preview = UIHostingController(rootView: RepositoryMarkdownPreview(source: source))
addChild(preview)
preview.view.frame = view.bounds
preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
displayedController = preview
displayedView = preview.view
view.addSubview(preview.view)
preview.didMove(toParent: self)
}
private func showSource(_ source: String) {
removeDisplayedView()
let highlighter = Highlighter()
highlighter?.setTheme(
traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light"
)
highlighter?.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular))
let highlighted = highlighter?.highlight(source, as: fileLanguage)
?? NSAttributedString(string: source, attributes: [
.font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular),
.foregroundColor: UIColor.label,
])
let text = NSMutableAttributedString(attributedString: highlighted)
let paragraph = NSMutableParagraphStyle()
paragraph.lineBreakMode = .byClipping
text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length))
let scrollView = UIScrollView(frame: view.bounds)
scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
scrollView.alwaysBounceHorizontal = true
scrollView.isDirectionalLockEnabled = true
scrollView.showsHorizontalScrollIndicator = true
let textView = UITextView(frame: .zero)
textView.textContainer.size = CGSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.textContainer.lineBreakMode = .byClipping
textView.textContainer.widthTracksTextView = false
textView.textStorage.setAttributedString(text)
textView.isEditable = false
textView.isScrollEnabled = false
textView.isSelectable = true
sourceScrollView = scrollView
sourceTextView = textView
scrollView.addSubview(textView)
displayedView = scrollView
view.addSubview(scrollView)
updateSourceLayout()
}
private func removeDisplayedView() {
displayedController?.willMove(toParent: nil)
displayedView?.removeFromSuperview()
displayedController?.removeFromParent()
displayedController = nil
displayedView = nil
sourceScrollView = nil
sourceTextView = nil
}
private func updateSourceLayout() {
guard let scrollView = sourceScrollView, let textView = sourceTextView else { return }
textView.textContainer.widthTracksTextView = false
textView.textContainer.size = CGSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.layoutManager.ensureLayout(for: textView.textContainer)
let textSize = textView.layoutManager.usedRect(for: textView.textContainer).size
let contentSize = CGSize(
width: max(
scrollView.bounds.width,
ceil(textSize.width + textView.textContainerInset.left + textView.textContainerInset.right)
),
height: max(
scrollView.bounds.height,
ceil(textSize.height + textView.textContainerInset.top + textView.textContainerInset.bottom)
)
)
textView.frame = CGRect(origin: .zero, size: contentSize)
scrollView.contentSize = contentSize
}
private func showQuickLook(_ data: Data, name: String) throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("\(UUID().uuidString)-\(name)")
try data.write(to: url, options: .atomic)
previewURL = url
let preview = QLPreviewController()
preview.dataSource = self
addChild(preview)
preview.view.frame = view.bounds
preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(preview.view)
preview.didMove(toParent: self)
}
}
private struct RepositoryMarkdownPreview: View {
let source: String
var body: some View {
ScrollView {
Markdown(source)
.markdownTheme(.gitHub)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.background(Color(uiColor: .systemBackground))
}
}
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<Void, Never>?
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,
issueState: String? = nil,
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] = [issueState.map { issueTitle(titleLabel, state: $0) } ?? 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 issueTitle(_ titleLabel: UILabel, state: String) -> UIView {
let symbol: String
let color: UIColor
let accessibilityLabel: String
switch state {
case "open":
(symbol, color, accessibilityLabel) = ("exclamationmark.circle.fill", .systemGreen, "Open issue")
case "closed":
(symbol, color, accessibilityLabel) = ("checkmark.circle.fill", .systemPurple, "Closed issue")
default:
(symbol, color, accessibilityLabel) = ("questionmark.circle.fill", .systemGray, "Unknown issue state")
}
let icon = UIImageView(image: UIImage(systemName: symbol))
icon.tintColor = color
icon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .title1)
icon.setContentHuggingPriority(.required, for: .horizontal)
icon.isAccessibilityElement = true
icon.accessibilityLabel = accessibilityLabel
let stack = UIStackView(arrangedSubviews: [icon, titleLabel])
stack.axis = .horizontal
stack.alignment = .firstBaseline
stack.spacing = 8
return stack
}
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
}