import UIKit @MainActor final class IssuesViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private var rows: [IssueRow] = [] 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(IssueCell.self, forCellReuseIdentifier: "issue") updateFilterMenu() loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { rows = try await context.core.issues(owner: owner, repository: repository) tableView.reloadData() let status = context.core.settings().issueStatus tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView( title: "No \(status) issues", detail: "No issues match the selected status." ) : 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: "issue", for: indexPath) as! IssueCell cell.configure(rows[indexPath.row]) return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) navigationController?.pushViewController( IssueViewController( context: context, owner: owner, repository: repository, number: rows[indexPath.row].number ), animated: true ) } private func updateFilterMenu() { let current = context.core.settings().issueStatus navigationItem.rightBarButtonItem = UIBarButtonItem( image: context.symbol("line.3.horizontal.decrease.circle"), menu: UIMenu(children: ["open", "closed"].map { status in UIAction( title: status.capitalized, state: current == status ? .on : .off ) { [weak self] _ in guard let self else { return } do { try self.context.core.setIssueStatus(status: status) self.updateFilterMenu() self.loadContent(refreshing: false) } catch { self.show(error: error) } } }) ) } } final class IssueCell: UITableViewCell { private let titleLabel = UILabel() private let summaryLabel = UILabel() private let labels = UIStackView() private let metaLabel = UILabel() private let milestoneLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) accessoryType = .disclosureIndicator titleLabel.font = .preferredFont(forTextStyle: .headline) titleLabel.numberOfLines = 2 summaryLabel.font = .preferredFont(forTextStyle: .subheadline) summaryLabel.textColor = .secondaryLabel summaryLabel.numberOfLines = 2 labels.axis = .horizontal labels.spacing = 5 metaLabel.font = .preferredFont(forTextStyle: .caption1) metaLabel.textColor = .tertiaryLabel metaLabel.numberOfLines = 2 milestoneLabel.font = .preferredFont(forTextStyle: .caption1) milestoneLabel.adjustsFontForContentSizeCategory = true let stack = UIStackView( arrangedSubviews: [titleLabel, summaryLabel, labels, metaLabel, milestoneLabel] ) stack.axis = .vertical stack.spacing = 6 stack.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } func configure(_ row: IssueRow) { titleLabel.text = row.title summaryLabel.text = row.summary metaLabel.text = row.meta milestoneLabel.isHidden = row.milestone.isEmpty milestoneLabel.attributedText = symbolText( "flag.fill", text: row.milestone, font: milestoneLabel.font, color: .tertiaryLabel ) milestoneLabel.accessibilityLabel = row.milestone.isEmpty ? nil : "Milestone \(row.milestone)" labels.arrangedSubviews.forEach { $0.removeFromSuperview() } labels.isHidden = row.labels.isEmpty for label in row.labels.prefix(3) { let view = UILabel() view.text = " \(label.name) " view.font = .preferredFont(forTextStyle: .caption2) view.textColor = label.light ? .black : .white view.backgroundColor = UIColor(hex: label.color) view.layer.cornerRadius = 9 view.clipsToBounds = true labels.addArrangedSubview(view) } labels.addArrangedSubview(UIView()) } } @MainActor final class MilestonesViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private var rows: [MilestoneRow] = [] 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(MilestoneCell.self, forCellReuseIdentifier: "milestone") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 118 loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { rows = try await context.core.milestones(owner: owner, repository: repository) tableView.reloadData() tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView( title: "No milestones", detail: "This repository does not have any milestones." ) : 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: "milestone", for: indexPath ) as! MilestoneCell cell.configure(rows[indexPath.row], disclosure: true) return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) navigationController?.pushViewController( MilestoneViewController( context: context, owner: owner, repository: repository, id: rows[indexPath.row].id ), animated: true ) } } @MainActor final class MilestoneViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private let id: Int64 private var page: MilestonePage? init(context: AppContext, owner: String, repository: String, id: Int64) { self.context = context self.owner = owner self.repository = repository self.id = id super.init() title = "Milestone" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone") tableView.register(IssueCell.self, forCellReuseIdentifier: "issue") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 118 loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { page = try await context.core.milestone( owner: owner, repository: repository, id: id ) title = page?.milestone.title tableView.reloadData() } catch { if !Task.isCancelled { show(error: error) } } endLoading() } } override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { section == 0 ? 1 : page?.issues.count ?? 0 } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { section == 1 ? "Issues" : nil } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { section == 1 && page?.issues.isEmpty == true ? "No issues are assigned to this milestone." : nil } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { guard let page else { return UITableViewCell() } if indexPath.section == 0 { let cell = tableView.dequeueReusableCell( withIdentifier: "milestone", for: indexPath ) as! MilestoneCell cell.configure(page.milestone, disclosure: false) return cell } let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell cell.configure(page.issues[indexPath.row]) return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == 1, let issue = page?.issues[indexPath.row] else { return } tableView.deselectRow(at: indexPath, animated: true) navigationController?.pushViewController( IssueViewController( context: context, owner: owner, repository: repository, number: issue.number ), animated: true ) } } final class MilestoneCell: UITableViewCell { private let titleLabel = UILabel() private let descriptionLabel = UILabel() private let metaLabel = UILabel() private let progress = UIProgressView(progressViewStyle: .bar) override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) titleLabel.font = .preferredFont(forTextStyle: .headline) titleLabel.numberOfLines = 2 descriptionLabel.font = .preferredFont(forTextStyle: .subheadline) descriptionLabel.textColor = .secondaryLabel descriptionLabel.numberOfLines = 2 metaLabel.font = .preferredFont(forTextStyle: .caption1) metaLabel.textColor = .tertiaryLabel metaLabel.numberOfLines = 2 progress.progressTintColor = .systemGreen let stack = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel, progress, metaLabel]) stack.axis = .vertical stack.spacing = 7 stack.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12), ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } func configure(_ row: MilestoneRow, disclosure: Bool) { accessoryType = disclosure ? .disclosureIndicator : .none titleLabel.text = row.title descriptionLabel.text = row.description metaLabel.text = row.meta let total = row.openIssues + row.closedIssues progress.progress = total == 0 ? 0 : Float(row.closedIssues) / Float(total) progress.trackTintColor = total == 0 ? .systemGray5 : .systemOrange progress.accessibilityLabel = "Milestone progress" progress.accessibilityValue = "\(row.closedIssues) closed, \(row.openIssues) open" } } @MainActor final class PullsViewController: RefreshingTableViewController { private let context: AppContext private var rows: [PullRow] = [] init(context: AppContext) { self.context = context super.init() title = "Pull Requests" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() updateFilterMenu() loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { guard context.core.activeServerIndex() != nil else { tableView.backgroundView = EmptyBackgroundView( title: "No server selected", detail: "Open Issues or Repos to select a server or add your first one." ) refreshControl?.endRefreshing() return } beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { rows = try await context.core.pulls() tableView.reloadData() let status = context.core.settings().pullStatus tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView( title: "No \(status) pull requests", detail: "No pull requests match the selected status." ) : 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: "pull") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "pull") let row = rows[indexPath.row] configureTextCell( cell, title: "\(row.repository) #\(row.number)\n\(row.title)", detail: "\(row.summary)\n\(row.meta)" ) cell.accessoryType = .disclosureIndicator return cell } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 116 } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let row = rows[indexPath.row] navigationController?.pushViewController( PullViewController( context: context, owner: row.owner, repository: row.repository, number: row.number ), animated: true ) } private func updateFilterMenu() { let current = context.core.settings().pullStatus navigationItem.rightBarButtonItem = UIBarButtonItem( image: context.symbol("line.3.horizontal.decrease.circle"), menu: UIMenu(children: ["open", "closed"].map { status in UIAction(title: status.capitalized, state: current == status ? .on : .off) { [weak self] _ in guard let self else { return } do { try self.context.core.setPullStatus(status: status) self.updateFilterMenu() self.loadContent(refreshing: false) } catch { self.show(error: error) } } }) ) } } @MainActor final class CommitsViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private var page: CommitPage? private var branch: String? 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") updateBranchMenu() loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { page = try await context.core.commits( owner: owner, repository: repository, branch: branch ) updateBranchMenu() tableView.reloadData() tableView.backgroundView = page?.commits.isEmpty == true ? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.") : nil } catch { if !Task.isCancelled { show(error: error) } } endLoading() } } 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 row = page?.commits[indexPath.row] else { return } navigationController?.pushViewController( FilesViewController( context: context, owner: owner, repository: repository, sha: row.sha ), animated: true ) } 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 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 : min(72, CGFloat(laneCount) * 10 + 8) content.text = row.title content.secondaryText = row.refs.isEmpty ? "\(row.meta)\n\(row.sha.prefix(8))" : "\(row.refs)\n\(row.meta) ยท \(row.sha.prefix(8))" 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 spacing: CGFloat = 10 let centerY = bounds.midY for lane in 0.. 0 { context.move(to: CGPoint(x: x - spacing, y: centerY)) context.addLine(to: CGPoint(x: x, y: centerY)) context.strokePath() } if row.nodeLane == lane { context.setFillColor(color.cgColor) context.fillEllipse(in: CGRect(x: x - 5, y: centerY - 5, width: 10, height: 10)) } } } 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) } }