1219 lines
43 KiB
Swift
1219 lines
43 KiB
Swift
import Highlighter
|
|
import MarkdownUI
|
|
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,
|
|
issueState: page.state,
|
|
meta: page.meta,
|
|
body: page.body,
|
|
comments: page.comments,
|
|
editComment: { [weak self] comment in self?.editComment(comment) },
|
|
milestone: page.milestone
|
|
))
|
|
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,
|
|
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)
|
|
finishPagination(hasMore: page.hasMore)
|
|
}
|
|
} catch {
|
|
if !Task.isCancelled {
|
|
show(error: error)
|
|
failPagination()
|
|
}
|
|
}
|
|
if requestedPage == 1 { 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 enum Mode: Int { case files, history }
|
|
|
|
private let context: AppContext
|
|
private let owner: String
|
|
private let repository: String
|
|
private let path: String
|
|
private var mode = Mode.files
|
|
private var rows: [RepositoryContentRow] = []
|
|
private var history: CommitPage?
|
|
private var currentPage: UInt32 = 0
|
|
private var loadedFiles = false
|
|
|
|
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()
|
|
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
|
|
navigationItem.prompt = title
|
|
let modeControl = UISegmentedControl(items: ["Files", "History"])
|
|
modeControl.selectedSegmentIndex = mode.rawValue
|
|
modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
|
|
modeControl.accessibilityLabel = "Directory view"
|
|
navigationItem.titleView = modeControl
|
|
loadContent(refreshing: false)
|
|
}
|
|
|
|
override func loadContent(refreshing: Bool) {
|
|
switch mode {
|
|
case .files: loadFiles(refreshing: refreshing)
|
|
case .history: loadHistory(page: 1, refreshing: refreshing)
|
|
}
|
|
}
|
|
|
|
override func loadMoreContent() {
|
|
guard mode == .history else { return }
|
|
loadHistory(page: currentPage + 1, refreshing: false)
|
|
}
|
|
|
|
private func loadFiles(refreshing: Bool) {
|
|
resetPagination()
|
|
beginLoading(refreshing: refreshing)
|
|
loadingTask?.cancel()
|
|
loadingTask = Task {
|
|
do {
|
|
let rows = try await context.core.repositoryContents(
|
|
owner: owner,
|
|
repository: repository,
|
|
path: path
|
|
)
|
|
guard !Task.isCancelled, mode == .files else {
|
|
endLoading()
|
|
return
|
|
}
|
|
self.rows = rows
|
|
loadedFiles = true
|
|
tableView.reloadData()
|
|
updateEmptyView()
|
|
} catch {
|
|
if !Task.isCancelled { show(error: error) }
|
|
}
|
|
endLoading()
|
|
}
|
|
}
|
|
|
|
private func loadHistory(page requestedPage: UInt32, refreshing: Bool) {
|
|
if requestedPage == 1 {
|
|
resetPagination()
|
|
beginLoading(refreshing: refreshing)
|
|
}
|
|
loadingTask?.cancel()
|
|
loadingTask = Task {
|
|
do {
|
|
let history = try await context.core.commits(
|
|
owner: owner,
|
|
repository: repository,
|
|
branch: nil,
|
|
path: path,
|
|
pages: requestedPage
|
|
)
|
|
guard !Task.isCancelled, mode == .history else {
|
|
if requestedPage == 1 { endLoading() }
|
|
return
|
|
}
|
|
self.history = history
|
|
currentPage = requestedPage
|
|
finishPagination(hasMore: history.hasMore)
|
|
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 == .files ? rows.count : history?.commits.count ?? 0
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
cellForRowAt indexPath: IndexPath
|
|
) -> UITableViewCell {
|
|
switch mode {
|
|
case .files:
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
|
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
|
|
configureRepositoryContentCell(cell, row: rows[indexPath.row])
|
|
return cell
|
|
case .history:
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
|
|
if let history {
|
|
cell.configure(history.commits[indexPath.row], laneCount: history.laneCount)
|
|
}
|
|
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 .files:
|
|
showRepositoryContent(
|
|
rows[indexPath.row],
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository,
|
|
navigationController: navigationController
|
|
)
|
|
case .history:
|
|
guard let commit = history?.commits[indexPath.row] else { return }
|
|
navigationController?.pushViewController(
|
|
FilesViewController(
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository,
|
|
sha: commit.sha
|
|
),
|
|
animated: true
|
|
)
|
|
}
|
|
}
|
|
|
|
@objc private func modeChanged(_ sender: UISegmentedControl) {
|
|
guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return }
|
|
loadingTask?.cancel()
|
|
endLoading()
|
|
self.mode = mode
|
|
resetPagination()
|
|
tableView.backgroundView = nil
|
|
tableView.reloadData()
|
|
switch mode {
|
|
case .files:
|
|
if loadedFiles { updateEmptyView() } else { loadFiles(refreshing: false) }
|
|
case .history:
|
|
if let history {
|
|
finishPagination(hasMore: history.hasMore)
|
|
updateEmptyView()
|
|
} else {
|
|
loadHistory(page: 1, refreshing: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateEmptyView() {
|
|
switch mode {
|
|
case .files:
|
|
tableView.backgroundView = loadedFiles && rows.isEmpty
|
|
? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.")
|
|
: nil
|
|
case .history:
|
|
tableView.backgroundView = history?.commits.isEmpty == true
|
|
? EmptyBackgroundView(title: "No commits", detail: "No commits affect this folder.")
|
|
: nil
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class RepositoryHistoryViewController: RefreshingTableViewController {
|
|
private let context: AppContext
|
|
private let owner: String
|
|
private let repository: String
|
|
private let path: String
|
|
private let emptyDetail: String
|
|
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
|
private var page: CommitPage?
|
|
private var currentPage: UInt32 = 0
|
|
|
|
init(
|
|
context: AppContext,
|
|
owner: String,
|
|
repository: String,
|
|
path: String,
|
|
emptyDetail: String
|
|
) {
|
|
self.context = context
|
|
self.owner = owner
|
|
self.repository = repository
|
|
self.path = path
|
|
self.emptyDetail = emptyDetail
|
|
super.init()
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
|
|
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()
|
|
if !refreshing {
|
|
loadingIndicator.startAnimating()
|
|
tableView.backgroundView = loadingIndicator
|
|
}
|
|
}
|
|
loadingTask?.cancel()
|
|
loadingTask = Task {
|
|
do {
|
|
let page = try await context.core.commits(
|
|
owner: owner,
|
|
repository: repository,
|
|
branch: nil,
|
|
path: path,
|
|
pages: requestedPage
|
|
)
|
|
guard !Task.isCancelled else { return }
|
|
self.page = page
|
|
currentPage = requestedPage
|
|
finishPagination(hasMore: page.hasMore)
|
|
tableView.reloadData()
|
|
updateEmptyView()
|
|
} catch {
|
|
if !Task.isCancelled {
|
|
show(error: error)
|
|
failPagination()
|
|
}
|
|
}
|
|
if requestedPage == 1 {
|
|
loadingIndicator.stopAnimating()
|
|
refreshControl?.endRefreshing()
|
|
}
|
|
}
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
page?.commits.count ?? 0
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
cellForRowAt indexPath: IndexPath
|
|
) -> UITableViewCell {
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
|
|
if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) }
|
|
return cell
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
|
86
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
guard let commit = page?.commits[indexPath.row] else { return }
|
|
navigationController?.pushViewController(
|
|
FilesViewController(
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository,
|
|
sha: commit.sha
|
|
),
|
|
animated: true
|
|
)
|
|
}
|
|
|
|
private func updateEmptyView() {
|
|
tableView.backgroundView = page?.commits.isEmpty == true
|
|
? EmptyBackgroundView(title: "No commits", detail: emptyDetail)
|
|
: nil
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource {
|
|
private let context: AppContext
|
|
private let owner: String
|
|
private let repository: String
|
|
private let path: String
|
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
|
private let modeControl = UISegmentedControl()
|
|
private var loadingTask: Task<Void, Never>?
|
|
private var page: RepositoryFilePage?
|
|
private var previewURL: URL?
|
|
private var fileLanguage: String?
|
|
private var historyController: RepositoryHistoryViewController?
|
|
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
|
|
)
|
|
guard !Task.isCancelled else { return }
|
|
self.page = page
|
|
title = page.name
|
|
fileLanguage = page.language.isEmpty ? nil : page.language
|
|
configureModes(for: page)
|
|
} 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 configureModes(for page: RepositoryFilePage) {
|
|
navigationItem.prompt = title
|
|
modeControl.removeAllSegments()
|
|
let modes = page.kind == "markdown"
|
|
? ["Preview", "Source", "History"]
|
|
: ["Content", "History"]
|
|
for (index, mode) in modes.enumerated() {
|
|
modeControl.insertSegment(withTitle: mode, at: index, animated: false)
|
|
}
|
|
modeControl.selectedSegmentIndex = 0
|
|
modeControl.addTarget(self, action: #selector(fileModeChanged(_:)), for: .valueChanged)
|
|
modeControl.accessibilityLabel = "File view"
|
|
navigationItem.titleView = modeControl
|
|
showContent(page, mode: modes[0])
|
|
}
|
|
|
|
@objc private func fileModeChanged(_ sender: UISegmentedControl) {
|
|
guard let page, let mode = sender.titleForSegment(at: sender.selectedSegmentIndex) else { return }
|
|
showContent(page, mode: mode)
|
|
}
|
|
|
|
private func showContent(_ page: RepositoryFilePage, mode: String) {
|
|
switch mode {
|
|
case "Preview": showMarkdownPreview(page.text)
|
|
case "Source": showSource(page.text)
|
|
case "Content" where page.kind == "source": showSource(page.text)
|
|
case "Content":
|
|
do {
|
|
try showQuickLook(page.data, name: page.name)
|
|
} catch {
|
|
show(error: error)
|
|
}
|
|
case "History": showHistory()
|
|
default: break
|
|
}
|
|
}
|
|
|
|
private func showHistory() {
|
|
removeDisplayedView()
|
|
let history = historyController ?? RepositoryHistoryViewController(
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository,
|
|
path: path,
|
|
emptyDetail: "No commits affect this file."
|
|
)
|
|
historyController = history
|
|
addChild(history)
|
|
history.view.frame = view.bounds
|
|
history.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
displayedController = history
|
|
displayedView = history.view
|
|
view.addSubview(history.view)
|
|
history.didMove(toParent: self)
|
|
}
|
|
|
|
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()
|
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
scrollView.alwaysBounceHorizontal = true
|
|
scrollView.isDirectionalLockEnabled = true
|
|
scrollView.showsHorizontalScrollIndicator = true
|
|
scrollView.contentInsetAdjustmentBehavior = .never
|
|
|
|
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)
|
|
NSLayoutConstraint.activate([
|
|
scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
|
scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
|
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
|
scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
])
|
|
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 {
|
|
removeDisplayedView()
|
|
if previewURL == nil {
|
|
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]
|
|
displayedController = preview
|
|
displayedView = preview.view
|
|
view.addSubview(preview.view)
|
|
preview.didMove(toParent: self)
|
|
}
|
|
}
|
|
|
|
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],
|
|
editComment: ((CommentRow) -> Void)? = nil,
|
|
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, editComment: editComment)
|
|
}
|
|
|
|
private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView {
|
|
let icon = UIImageView()
|
|
configureIssueStateIcon(icon, state: state, textStyle: .title1)
|
|
let stack = UIStackView(arrangedSubviews: [icon, titleLabel])
|
|
stack.axis = .horizontal
|
|
stack.alignment = .firstBaseline
|
|
stack.spacing = 8
|
|
return stack
|
|
}
|
|
|
|
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 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
|
|
}
|