313 lines
11 KiB
Swift
313 lines
11 KiB
Swift
import UIKit
|
|
|
|
@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
|
|
private var currentPage: UInt32 = 0
|
|
|
|
init(context: AppContext, owner: String, repository: String) {
|
|
self.context = context
|
|
self.owner = owner
|
|
self.repository = repository
|
|
super.init()
|
|
title = repository
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
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)
|
|
}
|
|
|
|
override func loadContent(refreshing: Bool) {
|
|
loadPage(1, refreshing: refreshing)
|
|
}
|
|
|
|
override func loadMoreContent() {
|
|
guard mode == .history else { return }
|
|
loadPage(currentPage + 1, refreshing: false)
|
|
}
|
|
|
|
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
|
|
if requestedPage == 1 {
|
|
resetPagination()
|
|
beginLoading(refreshing: refreshing)
|
|
}
|
|
loadingTask?.cancel()
|
|
loadingTask = Task {
|
|
do {
|
|
switch mode {
|
|
case .history:
|
|
page = try await context.core.commits(
|
|
owner: owner,
|
|
repository: repository,
|
|
branch: branch,
|
|
path: "",
|
|
pages: requestedPage
|
|
)
|
|
currentPage = requestedPage
|
|
finishPagination(hasMore: page?.hasMore ?? false)
|
|
updateBranchMenu()
|
|
case .files:
|
|
contents = try await context.core.repositoryContents(
|
|
owner: owner,
|
|
repository: repository,
|
|
path: ""
|
|
)
|
|
}
|
|
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 == .history ? page?.commits.count ?? 0 : contents.count
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
cellForRowAt indexPath: IndexPath
|
|
) -> UITableViewCell {
|
|
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 {
|
|
mode == .history ? 86 : UITableView.automaticDimension
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
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,
|
|
branch: row.branchLabel ?? branch
|
|
),
|
|
animated: true
|
|
)
|
|
case .files:
|
|
showRepositoryContent(
|
|
contents[indexPath.row],
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository,
|
|
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() {
|
|
let branches = page?.branches ?? []
|
|
let choices: [String?] = [nil] + branches.map(Optional.some)
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
title: branch ?? "All",
|
|
menu: UIMenu(children: choices.map { choice in
|
|
UIAction(
|
|
title: choice ?? "All",
|
|
state: choice == branch ? .on : .off
|
|
) { [weak self] _ in
|
|
self?.branch = choice
|
|
self?.updateBranchMenu()
|
|
self?.loadContent(refreshing: false)
|
|
}
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
final class CommitCell: UITableViewCell {
|
|
private static let laneOrigin: CGFloat = 12
|
|
private static let laneSpacing: CGFloat = 12
|
|
|
|
private var row: CommitRow?
|
|
private var laneCount: UInt32 = 0
|
|
|
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
|
accessoryType = .disclosureIndicator
|
|
backgroundColor = .systemBackground
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
func configure(_ row: CommitRow, laneCount: UInt32) {
|
|
self.row = row
|
|
self.laneCount = laneCount
|
|
var content = defaultContentConfiguration()
|
|
content.directionalLayoutMargins.leading = laneCount == 0
|
|
? 0
|
|
: CGFloat(laneCount) * Self.laneSpacing + 10
|
|
content.text = row.title
|
|
if let branch = row.branchLabel {
|
|
let text = NSMutableAttributedString(string: "\(branch)\n\(row.detail)")
|
|
text.addAttribute(
|
|
.foregroundColor,
|
|
value: UIColor.tintColor,
|
|
range: NSRange(location: 0, length: (branch as NSString).length)
|
|
)
|
|
content.secondaryAttributedText = text
|
|
} else {
|
|
content.secondaryText = row.detail
|
|
}
|
|
content.secondaryTextProperties.numberOfLines = 2
|
|
contentConfiguration = content
|
|
setNeedsDisplay()
|
|
}
|
|
|
|
override func draw(_ rect: CGRect) {
|
|
super.draw(rect)
|
|
guard let row, laneCount > 0, let context = UIGraphicsGetCurrentContext() else { return }
|
|
let centerY = bounds.midY
|
|
let nodeX = row.nodeLane.map(laneX)
|
|
context.setLineWidth(3)
|
|
context.setLineCap(.round)
|
|
context.setLineJoin(.round)
|
|
|
|
for lane in 0..<laneCount {
|
|
if row.topLanes.contains(lane) {
|
|
let start = CGPoint(x: laneX(lane), y: 0)
|
|
if row.topConnections.contains(lane), let nodeX {
|
|
strokeCurve(
|
|
context,
|
|
from: start,
|
|
to: CGPoint(x: nodeX, y: centerY),
|
|
color: laneColor(lane)
|
|
)
|
|
} else {
|
|
strokeLine(
|
|
context,
|
|
from: start,
|
|
to: CGPoint(x: start.x, y: centerY),
|
|
color: laneColor(lane)
|
|
)
|
|
}
|
|
}
|
|
if row.bottomLanes.contains(lane) {
|
|
let end = CGPoint(x: laneX(lane), y: bounds.height)
|
|
if !row.bottomConnections.contains(lane) || row.topLanes.contains(lane) {
|
|
strokeLine(
|
|
context,
|
|
from: CGPoint(x: end.x, y: centerY),
|
|
to: end,
|
|
color: laneColor(lane)
|
|
)
|
|
}
|
|
if row.bottomConnections.contains(lane), let nodeX {
|
|
strokeCurve(
|
|
context,
|
|
from: CGPoint(x: nodeX, y: centerY),
|
|
to: end,
|
|
color: laneColor(lane)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if let lane = row.nodeLane, let nodeX {
|
|
context.setFillColor(laneColor(lane).cgColor)
|
|
context.fillEllipse(
|
|
in: CGRect(x: nodeX - 5, y: centerY - 5, width: 10, height: 10)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func laneX(_ lane: UInt32) -> CGFloat {
|
|
Self.laneOrigin + CGFloat(lane) * Self.laneSpacing
|
|
}
|
|
|
|
private func strokeLine(
|
|
_ context: CGContext,
|
|
from start: CGPoint,
|
|
to end: CGPoint,
|
|
color: UIColor
|
|
) {
|
|
context.setStrokeColor(color.cgColor)
|
|
context.move(to: start)
|
|
context.addLine(to: end)
|
|
context.strokePath()
|
|
}
|
|
|
|
private func strokeCurve(
|
|
_ context: CGContext,
|
|
from start: CGPoint,
|
|
to end: CGPoint,
|
|
color: UIColor
|
|
) {
|
|
let middleY = (start.y + end.y) / 2
|
|
context.setStrokeColor(color.cgColor)
|
|
context.move(to: start)
|
|
context.addCurve(
|
|
to: end,
|
|
control1: CGPoint(x: start.x, y: middleY),
|
|
control2: CGPoint(x: end.x, y: middleY)
|
|
)
|
|
context.strokePath()
|
|
}
|
|
|
|
private func laneColor(_ lane: UInt32) -> UIColor {
|
|
UIColor(hue: CGFloat((Double(lane) * 137.508).truncatingRemainder(dividingBy: 360)) / 360,
|
|
saturation: 0.78, brightness: 0.78, alpha: 1)
|
|
}
|
|
}
|