Add repository file browser
This commit is contained in:
@@ -502,11 +502,15 @@ final class PullsViewController: RefreshingTableViewController {
|
||||
|
||||
@MainActor
|
||||
final class CommitsViewController: RefreshingTableViewController {
|
||||
private enum Mode: Int { case history, files }
|
||||
|
||||
private let context: AppContext
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private var page: CommitPage?
|
||||
private var contents: [RepositoryContentRow] = []
|
||||
private var branch: String?
|
||||
private var mode = Mode.history
|
||||
|
||||
init(context: AppContext, owner: String, repository: String) {
|
||||
self.context = context
|
||||
@@ -522,6 +526,11 @@ final class CommitsViewController: RefreshingTableViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
|
||||
let modeControl = UISegmentedControl(items: ["History", "Files"])
|
||||
modeControl.selectedSegmentIndex = mode.rawValue
|
||||
modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
|
||||
modeControl.accessibilityLabel = "Repository view"
|
||||
navigationItem.titleView = modeControl
|
||||
updateBranchMenu()
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
@@ -531,16 +540,23 @@ final class CommitsViewController: RefreshingTableViewController {
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
page = try await context.core.commits(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
branch: branch
|
||||
)
|
||||
updateBranchMenu()
|
||||
switch mode {
|
||||
case .history:
|
||||
page = try await context.core.commits(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
branch: branch
|
||||
)
|
||||
updateBranchMenu()
|
||||
case .files:
|
||||
contents = try await context.core.repositoryContents(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
path: ""
|
||||
)
|
||||
}
|
||||
tableView.reloadData()
|
||||
tableView.backgroundView = page?.commits.isEmpty == true
|
||||
? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.")
|
||||
: nil
|
||||
updateEmptyView()
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
@@ -549,34 +565,75 @@ final class CommitsViewController: RefreshingTableViewController {
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
page?.commits.count ?? 0
|
||||
mode == .history ? page?.commits.count ?? 0 : contents.count
|
||||
}
|
||||
|
||||
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
|
||||
switch mode {
|
||||
case .history:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
|
||||
if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) }
|
||||
return cell
|
||||
case .files:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
|
||||
configureRepositoryContentCell(cell, row: contents[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
86
|
||||
mode == .history ? 86 : UITableView.automaticDimension
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let row = page?.commits[indexPath.row] else { return }
|
||||
navigationController?.pushViewController(
|
||||
FilesViewController(
|
||||
switch mode {
|
||||
case .history:
|
||||
guard let row = page?.commits[indexPath.row] else { return }
|
||||
navigationController?.pushViewController(
|
||||
FilesViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
sha: row.sha
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
case .files:
|
||||
showRepositoryContent(
|
||||
contents[indexPath.row],
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
sha: row.sha
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
navigationController: navigationController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func modeChanged(_ sender: UISegmentedControl) {
|
||||
guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return }
|
||||
self.mode = mode
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
if mode == .history { updateBranchMenu() }
|
||||
tableView.reloadData()
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
private func updateEmptyView() {
|
||||
switch mode {
|
||||
case .history:
|
||||
tableView.backgroundView = page?.commits.isEmpty == true
|
||||
? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.")
|
||||
: nil
|
||||
case .files:
|
||||
tableView.backgroundView = contents.isEmpty
|
||||
? EmptyBackgroundView(title: "No files", detail: "This repository is empty.")
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
private func updateBranchMenu() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -124,6 +124,66 @@ func configureTextCell(_ cell: UITableViewCell, title: String, detail: String, i
|
||||
cell.backgroundColor = .systemBackground
|
||||
}
|
||||
|
||||
func configureRepositoryContentCell(_ cell: UITableViewCell, row: RepositoryContentRow) {
|
||||
let directory = row.kind == "dir"
|
||||
let detail = directory
|
||||
? "Folder"
|
||||
: ByteCountFormatter.string(fromByteCount: row.size, countStyle: .file)
|
||||
configureTextCell(
|
||||
cell,
|
||||
title: row.name,
|
||||
detail: detail,
|
||||
image: UIImage(systemName: directory ? "folder.fill" : "doc")
|
||||
)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func showRepositoryContent(
|
||||
_ row: RepositoryContentRow,
|
||||
context: AppContext,
|
||||
owner: String,
|
||||
repository: String,
|
||||
navigationController: UINavigationController?
|
||||
) {
|
||||
let destination: UIViewController = row.kind == "dir"
|
||||
? RepositoryDirectoryViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
path: row.path
|
||||
)
|
||||
: RepositoryFileViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
path: row.path
|
||||
)
|
||||
navigationController?.pushViewController(destination, animated: true)
|
||||
}
|
||||
|
||||
func sourceLanguage(_ path: String) -> String? {
|
||||
let filename = (path as NSString).lastPathComponent.lowercased()
|
||||
let filenames = [
|
||||
"cmakelists.txt": "cmake", "dockerfile": "dockerfile", "gemfile": "ruby",
|
||||
"makefile": "makefile", "podfile": "ruby",
|
||||
]
|
||||
if let language = filenames[filename] { return language }
|
||||
let languages = [
|
||||
"asm": "x86asm", "c": "c", "cc": "cpp", "clj": "clojure", "cpp": "cpp",
|
||||
"cs": "csharp", "css": "css", "dart": "dart", "ex": "elixir", "exs": "elixir",
|
||||
"fs": "fsharp", "go": "go", "groovy": "groovy", "h": "cpp", "hpp": "cpp",
|
||||
"hs": "haskell", "html": "xml", "java": "java", "jl": "julia", "js": "javascript",
|
||||
"json": "json", "jsx": "javascript", "kt": "kotlin", "kts": "kotlin", "lua": "lua",
|
||||
"m": "objectivec", "md": "markdown", "mm": "objectivec", "php": "php", "pl": "perl",
|
||||
"plist": "xml", "ps1": "powershell", "py": "python", "r": "r", "rb": "ruby",
|
||||
"rs": "rust", "scala": "scala", "scss": "scss", "sh": "bash", "sql": "sql",
|
||||
"swift": "swift", "toml": "ini", "ts": "typescript", "tsx": "typescript",
|
||||
"txt": "plaintext", "xml": "xml", "yaml": "yaml", "yml": "yaml",
|
||||
]
|
||||
return languages[(path as NSString).pathExtension.lowercased()]
|
||||
}
|
||||
|
||||
func symbolText(_ symbol: String, text: String, font: UIFont, color: UIColor) -> NSAttributedString {
|
||||
let output = NSMutableAttributedString()
|
||||
if let image = UIImage(systemName: symbol)?.withTintColor(color, renderingMode: .alwaysOriginal) {
|
||||
|
||||
Reference in New Issue
Block a user