Add repository file browser

This commit is contained in:
Georg Bauer
2026-07-31 11:25:46 +02:00
parent 4e4dfb0db9
commit 7b2b431dfd
11 changed files with 669 additions and 21 deletions

View File

@@ -1,4 +1,7 @@
import Highlighter
import QuickLook
import UIKit
import UniformTypeIdentifiers
@MainActor
class MarkdownPageViewController: UIViewController {
@@ -259,6 +262,192 @@ final class FilesViewController: RefreshingTableViewController {
}
}
@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) {
self.context = context
self.owner = owner
self.repository = repository
self.path = path
super.init()
title = path.split(separator: "/").last.map(String.init) ?? repository
}
@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 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?
init(context: AppContext, owner: String, repository: String, path: String) {
self.context = context
self.owner = owner
self.repository = repository
self.path = path
super.init(nibName: nil, bundle: nil)
title = path.split(separator: "/").last.map(String.init)
}
@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 data = try await context.core.repositoryFile(
owner: owner,
repository: repository,
path: path
)
if !isMediaFile(path), let source = String(data: data, encoding: .utf8) {
showSource(source)
} else {
try showQuickLook(data)
}
} catch {
if !Task.isCancelled { show(error: error) }
}
endNavigationLoading(spinner)
}
}
private func isMediaFile(_ path: String) -> Bool {
guard let type = UTType(filenameExtension: (path as NSString).pathExtension) else {
return false
}
return type.conforms(to: .image)
|| type.conforms(to: .audio)
|| type.conforms(to: .audiovisualContent)
|| type.conforms(to: .pdf)
}
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 showSource(_ source: String) {
let textView = UITextView(frame: view.bounds)
textView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
textView.isEditable = false
textView.isSelectable = true
textView.alwaysBounceHorizontal = true
textView.isDirectionalLockEnabled = true
textView.showsHorizontalScrollIndicator = true
textView.textContainer.widthTracksTextView = false
textView.textContainer.size = CGSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
let highlighter = Highlighter()
highlighter?.setTheme(
traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light"
)
highlighter?.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular))
textView.attributedText = highlighter?.highlight(source, as: sourceLanguage(path))
?? NSAttributedString(string: source, attributes: [
.font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular),
.foregroundColor: UIColor.label,
])
textView.layoutManager.ensureLayout(for: textView.textContainer)
textView.contentSize.width = textView.layoutManager.usedRect(for: textView.textContainer).width
+ textView.textContainerInset.left
+ textView.textContainerInset.right
view.addSubview(textView)
}
private func showQuickLook(_ data: Data) throws {
let name = path.split(separator: "/").last.map(String.init) ?? "Preview"
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)
}
}
enum DiffSource {
case commit(owner: String, repository: String, sha: String, path: String)
case pull(owner: String, repository: String, number: Int64, path: String)