Prepare Gotcha 1.0 for release
This commit is contained in:
383
ios/Sources/RepositoryFileScreens.swift
Normal file
383
ios/Sources/RepositoryFileScreens.swift
Normal file
@@ -0,0 +1,383 @@
|
||||
import Highlighter
|
||||
import QuickLook
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@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,
|
||||
branch: commit.branchLabel
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
private func updateEmptyView() {
|
||||
tableView.backgroundView = page?.commits.isEmpty == true
|
||||
? EmptyBackgroundView(title: "No commits", detail: emptyDetail)
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
final class CodeScrollView: UIScrollView {
|
||||
private let textView = UITextView(frame: .zero)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
alwaysBounceVertical = true
|
||||
alwaysBounceHorizontal = true
|
||||
isDirectionalLockEnabled = true
|
||||
showsHorizontalScrollIndicator = true
|
||||
contentInsetAdjustmentBehavior = .never
|
||||
textView.textContainer.lineBreakMode = .byClipping
|
||||
textView.textContainer.widthTracksTextView = false
|
||||
textView.isEditable = false
|
||||
textView.isScrollEnabled = false
|
||||
textView.isSelectable = true
|
||||
addSubview(textView)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
func display(_ text: NSAttributedString) {
|
||||
let text = NSMutableAttributedString(attributedString: text)
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineBreakMode = .byClipping
|
||||
text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length))
|
||||
textView.textStorage.setAttributedString(text)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
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 size = CGSize(
|
||||
width: max(
|
||||
bounds.width,
|
||||
ceil(textSize.width + textView.textContainerInset.left + textView.textContainerInset.right)
|
||||
),
|
||||
height: max(
|
||||
bounds.height,
|
||||
ceil(textSize.height + textView.textContainerInset.top + textView.textContainerInset.bottom)
|
||||
)
|
||||
)
|
||||
textView.frame = CGRect(origin: .zero, size: size)
|
||||
contentSize = size
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource {
|
||||
private enum Mode: String {
|
||||
case preview = "Preview"
|
||||
case source = "Source"
|
||||
case content = "Content"
|
||||
case history = "History"
|
||||
}
|
||||
|
||||
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 var modes: [Mode] = []
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
modes = page.kind == .markdown
|
||||
? [.preview, .source, .history]
|
||||
: [.content, .history]
|
||||
for (index, mode) in modes.enumerated() {
|
||||
modeControl.insertSegment(withTitle: mode.rawValue, 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, modes.indices.contains(sender.selectedSegmentIndex) else { return }
|
||||
showContent(page, mode: modes[sender.selectedSegmentIndex])
|
||||
}
|
||||
|
||||
private func showContent(_ page: RepositoryFilePage, mode: Mode) {
|
||||
switch mode {
|
||||
case .preview: showMarkdownPreview(page.text)
|
||||
case .source: showSource(page.text)
|
||||
case .content:
|
||||
if page.kind == .source {
|
||||
showSource(page.text)
|
||||
} else {
|
||||
do {
|
||||
try showQuickLook(page.data, name: page.name)
|
||||
} catch {
|
||||
show(error: error)
|
||||
}
|
||||
}
|
||||
case .history: showHistory()
|
||||
}
|
||||
}
|
||||
|
||||
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 codeView = CodeScrollView()
|
||||
codeView.display(highlighted)
|
||||
displayedView = codeView
|
||||
view.addSubview(codeView)
|
||||
NSLayoutConstraint.activate([
|
||||
codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func removeDisplayedView() {
|
||||
displayedController?.willMove(toParent: nil)
|
||||
displayedView?.removeFromSuperview()
|
||||
displayedController?.removeFromParent()
|
||||
displayedController = nil
|
||||
displayedView = nil
|
||||
}
|
||||
|
||||
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 {
|
||||
MarkdownContent(source: source)
|
||||
.padding()
|
||||
}
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user