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