Prepare Gotcha 1.0 for release

This commit is contained in:
Georg Bauer
2026-08-03 17:54:07 +02:00
parent 1c4a6efd13
commit 46cca8fdd2
60 changed files with 7916 additions and 7173 deletions

View 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))
}
}