import UIKit private extension UIViewController { func promptForSearchText(title: String, current: String, apply: @escaping (String) -> Void) { let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) alert.addTextField { field in field.text = current field.placeholder = "Search text" field.accessibilityLabel = "Search text" field.clearButtonMode = .whileEditing field.returnKeyType = .search } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Apply", style: .default) { [weak alert] _ in apply(alert?.textFields?.first?.text ?? "") }) present(alert, animated: true) } } @MainActor final class IssuesViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private var rows: [IssueRow] = [] private var filterOptions: IssueFilterOptions? private var filterTask: Task? private var mutationTask: Task? private var currentPage: UInt32 = 0 private lazy var filterButton = UIBarButtonItem( image: context.symbol("line.3.horizontal.decrease.circle") ) 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") } deinit { filterTask?.cancel() mutationTask?.cancel() } override func viewDidLoad() { super.viewDidLoad() tableView.register(IssueCell.self, forCellReuseIdentifier: "issue") let addButton = UIBarButtonItem( barButtonSystemItem: .add, target: self, action: #selector(createIssue) ) addButton.accessibilityLabel = "New issue" navigationItem.rightBarButtonItems = [addButton, filterButton] updateFilterMenu() loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { loadFilterOptions() loadIssues(refreshing: refreshing) } private func loadIssues(refreshing: Bool) { loadIssues(page: 1, refreshing: refreshing) } override func loadMoreContent() { loadIssues(page: currentPage + 1, refreshing: false) } private func loadIssues(page: UInt32, refreshing: Bool) { if page == 1 { resetPagination() beginLoading(refreshing: refreshing) } loadingTask?.cancel() loadingTask = Task { do { let result = try await context.core.issues( owner: owner, repository: repository, page: page ) if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) } currentPage = page finishPagination(hasMore: result.hasMore) tableView.reloadData() updateEmptyState() } catch { if !Task.isCancelled { show(error: error) failPagination() } } if page == 1 { endLoading() } } } private func loadFilterOptions() { filterTask?.cancel() filterTask = Task { do { let options = try await context.core.issueFilters( owner: owner, repository: repository ) guard !Task.isCancelled else { return } filterOptions = options updateFilterMenu() updateEmptyState() } catch { if !Task.isCancelled { show(error: error) } } } } 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 ) } override func tableView( _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath ) -> UISwipeActionsConfiguration? { let row = rows[indexPath.row] let close = row.state != "closed" let stateAction = UIContextualAction( style: .normal, title: close ? "Close" : "Open" ) { [weak self] _, _, completion in self?.setIssue(row, closed: close, completion: completion) ?? completion(false) } stateAction.image = context.symbol(close ? "checkmark.circle" : "arrow.uturn.left.circle") stateAction.backgroundColor = close ? .systemPurple : .systemGreen let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completion in self?.confirmDelete(row, completion: completion) ?? completion(false) } deleteAction.image = context.symbol("trash") let configuration = UISwipeActionsConfiguration(actions: [stateAction, deleteAction]) configuration.performsFirstActionWithFullSwipe = true return configuration } private func setIssue( _ row: IssueRow, closed: Bool, completion: @escaping (Bool) -> Void ) { mutationTask?.cancel() mutationTask = Task { do { try await context.core.setIssueClosed( owner: owner, repository: repository, number: row.number, closed: closed ) guard !Task.isCancelled else { completion(false) return } completion(true) loadIssues(refreshing: false) } catch { completion(false) if !Task.isCancelled { show(error: error) } } } } private func confirmDelete(_ row: IssueRow, completion: @escaping (Bool) -> Void) { let alert = UIAlertController( title: "Delete Issue #\(row.number)?", message: "“\(row.title)” will be permanently deleted. This can’t be undone.", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) }) alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in guard let self else { completion(false) return } self.mutationTask?.cancel() self.mutationTask = Task { do { try await self.context.core.deleteIssue( owner: self.owner, repository: self.repository, number: row.number ) guard !Task.isCancelled else { completion(false) return } completion(true) self.loadIssues(refreshing: false) } catch { completion(false) if !Task.isCancelled { self.show(error: error) } } } }) present(alert, animated: true) } private func updateFilterMenu() { let current = context.core.settings().issueStatus let status = UIMenu( title: "Status", image: filterMenuImage("circle.lefthalf.filled", active: current != "open"), options: .singleSelection, 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.loadIssues(refreshing: false) } catch { self.show(error: error) } } } ) var children: [UIMenuElement] = [status] if let filterOptions { children.insert(searchAction(filterOptions), at: 0) children.append(milestoneMenu(filterOptions)) children.append(labelMenu(filterOptions)) } else { children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in }) } children.append(clearFiltersMenu()) filterButton.menu = UIMenu(children: children) filterButton.accessibilityLabel = "Filter issues" updateFilterTint() } private func searchAction(_ options: IssueFilterOptions) -> UIAction { UIAction( title: "Search Text", subtitle: options.searchText.isEmpty ? "Any text" : options.searchText, image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty) ) { [weak self] _ in self?.promptForSearchText( title: "Search Issues", current: options.searchText ) { [weak self] searchText in self?.setSearchText(searchText) } } } private func milestoneMenu(_ options: IssueFilterOptions) -> UIMenu { let selected = options.selectedMilestone let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) { [weak self] _ in self?.selectMilestone("") } let actions = options.milestones.map { milestone in UIAction( title: milestone, state: selected == milestone ? .on : .off ) { [weak self] _ in self?.selectMilestone(milestone) } } return UIMenu( title: "Milestone", image: filterMenuImage("flag", active: !selected.isEmpty), options: .singleSelection, children: [all] + actions ) } private func labelMenu(_ options: IssueFilterOptions) -> UIMenu { let selected = Set(options.selectedLabels) let actions = options.labels.map { label in UIAction( title: options.unavailableLabels.contains(label) ? "\(label) (Unavailable)" : label, attributes: .keepsMenuPresented, state: selected.contains(label) ? .on : .off ) { [weak self] action in guard let self, let options = self.filterOptions else { return } self.filterTask?.cancel() var labels = Set(options.selectedLabels) if labels.remove(label) == nil { labels.insert(label) } do { try self.saveFilters( milestone: options.selectedMilestone, labels: labels, searchText: options.searchText ) self.filterOptions?.selectedLabels = Array(labels) action.state = labels.contains(label) ? .on : .off self.updateFilterMenu() self.loadIssues(refreshing: false) } catch { self.show(error: error) } } } return UIMenu( title: "Labels", image: filterMenuImage("tag", active: !selected.isEmpty), children: actions.isEmpty ? [UIAction(title: "No labels", attributes: .disabled) { _ in }] : actions ) } private func selectMilestone(_ milestone: String) { guard let options = filterOptions else { return } filterTask?.cancel() do { try saveFilters( milestone: milestone, labels: Set(options.selectedLabels), searchText: options.searchText ) filterOptions?.selectedMilestone = milestone updateFilterMenu() loadIssues(refreshing: false) } catch { show(error: error) } } private func setSearchText(_ searchText: String) { guard let options = filterOptions else { return } filterTask?.cancel() do { try saveFilters( milestone: options.selectedMilestone, labels: Set(options.selectedLabels), searchText: searchText ) filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines) updateFilterMenu() loadIssues(refreshing: false) } catch { show(error: error) } } private func saveFilters( milestone: String, labels: Set, searchText: String ) throws { try context.core.setIssueFilters( owner: owner, repository: repository, milestone: milestone, labels: Array(labels), searchText: searchText ) } private func clearFiltersMenu() -> UIMenu { UIMenu( options: .displayInline, children: [ UIAction( title: "Clear Filters", image: context.symbol("xmark.circle"), attributes: filtersActive ? [] : .disabled ) { [weak self] _ in self?.clearFilters() }, ] ) } private func clearFilters() { filterTask?.cancel() do { try context.core.clearIssueFilters(owner: owner, repository: repository) filterOptions?.selectedMilestone = "" filterOptions?.selectedLabels = [] filterOptions?.searchText = "" updateFilterMenu() loadIssues(refreshing: false) } catch { show(error: error) } } private var filtersActive: Bool { (try? context.core.issueFiltersActive(owner: owner, repository: repository)) ?? false } private func updateFilterTint() { filterButton.tintColor = filtersActive ? .tintColor : .secondaryLabel filterButton.accessibilityValue = filtersActive ? "Filters active" : "Default filters" } @objc private func createIssue() { let editor = IssueEditorViewController( context: context, owner: owner, repository: repository ) { [weak self] number in guard let self else { return } self.dismiss(animated: true) { self.loadContent(refreshing: false) self.navigationController?.pushViewController( IssueViewController( context: self.context, owner: self.owner, repository: self.repository, number: number ), animated: true ) } } present(UINavigationController(rootViewController: editor), animated: true) } private func updateEmptyState() { guard rows.isEmpty else { tableView.backgroundView = nil return } let status = context.core.settings().issueStatus tableView.backgroundView = EmptyBackgroundView( title: "No \(status) issues", detail: filtersActive ? "No issues match the selected filters." : "This repository has no \(status) issues." ) } } final class IssueCell: UITableViewCell { private let stateIcon = UIImageView() 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 let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel]) titleStack.alignment = .firstBaseline titleStack.spacing = 8 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: [titleStack, 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) { configureIssueStateIcon(stateIcon, state: row.state, textStyle: .headline) 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) { labels.addArrangedSubview(issueLabelView(label)) } labels.addArrangedSubview(UIView()) } } @MainActor final class MilestonesViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private var rows: [MilestoneRow] = [] 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(MilestoneCell.self, forCellReuseIdentifier: "milestone") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 118 let addButton = UIBarButtonItem( barButtonSystemItem: .add, target: self, action: #selector(createMilestone) ) addButton.accessibilityLabel = "New milestone" navigationItem.rightBarButtonItem = addButton loadContent(refreshing: false) } @objc private func createMilestone() { let editor = MilestoneEditorViewController( context: context, owner: owner, repository: repository ) { [weak self] _ in guard let self else { return } self.dismiss(animated: true) { self.loadContent(refreshing: false) } } present(UINavigationController(rootViewController: editor), animated: true) } override func loadContent(refreshing: Bool) { loadPage(1, refreshing: refreshing) } override func loadMoreContent() { loadPage(currentPage + 1, refreshing: false) } private func loadPage(_ page: UInt32, refreshing: Bool) { if page == 1 { resetPagination() beginLoading(refreshing: refreshing) } loadingTask?.cancel() loadingTask = Task { do { let result = try await context.core.milestones( owner: owner, repository: repository, page: page ) if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) } currentPage = page finishPagination(hasMore: result.hasMore) 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) failPagination() } } if page == 1 { 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? private var currentPage: UInt32 = 0 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.register(PullCell.self, forCellReuseIdentifier: "pull") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 118 let editButton = UIBarButtonItem( image: context.symbol("pencil"), style: .plain, target: self, action: #selector(editMilestone) ) editButton.accessibilityLabel = "Edit milestone" navigationItem.rightBarButtonItem = editButton loadContent(refreshing: false) } @objc private func editMilestone() { let editor = MilestoneEditorViewController( context: context, owner: owner, repository: repository, id: id ) { [weak self] _ in guard let self else { return } self.dismiss(animated: true) { self.loadContent(refreshing: false) } } present(UINavigationController(rootViewController: editor), animated: true) } override func loadContent(refreshing: Bool) { loadPage(1, refreshing: refreshing) } override func loadMoreContent() { loadPage(currentPage + 1, refreshing: false) } private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { if requestedPage == 1 { resetPagination() beginLoading(refreshing: refreshing) } loadingTask?.cancel() loadingTask = Task { do { let result = try await context.core.milestone( owner: owner, repository: repository, id: id, page: requestedPage ) if requestedPage == 1 { page = result } else { page?.issues.append(contentsOf: result.issues) page?.pulls.append(contentsOf: result.pulls) page?.hasMore = result.hasMore } currentPage = requestedPage finishPagination(hasMore: result.hasMore) title = page?.milestone.title tableView.reloadData() } catch { if !Task.isCancelled { show(error: error) failPagination() } } if requestedPage == 1 { endLoading() } } } override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 3 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0: 1 case 1: page?.issues.count ?? 0 default: page?.pulls.count ?? 0 } } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch section { case 1: "Issues" case 2: "Pull Requests" default: nil } } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { if section == 1, page?.issues.isEmpty == true { return "No issues are assigned to this milestone." } if section == 2, page?.pulls.isEmpty == true { return "No pull requests are assigned to this milestone." } return 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 } if indexPath.section == 1 { let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell cell.configure(page.issues[indexPath.row]) return cell } let cell = tableView.dequeueReusableCell(withIdentifier: "pull", for: indexPath) as! PullCell cell.configure(page.pulls[indexPath.row]) return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) if indexPath.section == 1, let issue = page?.issues[indexPath.row] { navigationController?.pushViewController( IssueViewController( context: context, owner: owner, repository: repository, number: issue.number ), animated: true ) } else if indexPath.section == 2, let pull = page?.pulls[indexPath.row] { navigationController?.pushViewController( PullViewController( context: context, owner: pull.owner, repository: pull.repository, number: pull.number ), animated: true ) } } } final class MilestoneCell: UITableViewCell { private let stateIcon = UIImageView() private let titleLabel = UILabel() private let stack = UIStackView() private var descriptionView: UIView? 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 let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel]) titleStack.alignment = .firstBaseline titleStack.spacing = 8 metaLabel.font = .preferredFont(forTextStyle: .caption1) metaLabel.textColor = .tertiaryLabel metaLabel.numberOfLines = 2 progress.progressTintColor = .systemGreen stack.addArrangedSubview(titleStack) stack.addArrangedSubview(progress) stack.addArrangedSubview(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 configureOpenClosedStateIcon( stateIcon, state: row.state, subject: "milestone", textStyle: .headline ) titleLabel.text = row.title descriptionView?.removeFromSuperview() if !row.description.isEmpty { let view = markdownView(row.description) stack.insertArrangedSubview(view, at: 1) descriptionView = view } metaLabel.text = row.meta progress.progress = Float(row.progress) progress.trackTintColor = row.hasIssues ? .systemOrange : .systemGray5 progress.accessibilityLabel = "Milestone progress" progress.accessibilityValue = row.progressAccessibility } } final class PullCell: UITableViewCell { private let stateIcon = UIImageView() private let titleLabel = UILabel() private let summaryLabel = UILabel() private let metaLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) accessoryType = .disclosureIndicator titleLabel.font = .preferredFont(forTextStyle: .headline) titleLabel.numberOfLines = 2 let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel]) titleStack.alignment = .firstBaseline titleStack.spacing = 8 summaryLabel.font = .preferredFont(forTextStyle: .subheadline) summaryLabel.textColor = .secondaryLabel summaryLabel.numberOfLines = 2 metaLabel.font = .preferredFont(forTextStyle: .caption1) metaLabel.textColor = .tertiaryLabel metaLabel.numberOfLines = 2 [titleLabel, summaryLabel, metaLabel].forEach { $0.adjustsFontForContentSizeCategory = true } let stack = UIStackView(arrangedSubviews: [titleStack, summaryLabel, metaLabel]) 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: PullRow) { configureOpenClosedStateIcon( stateIcon, state: row.state, subject: "pull request", textStyle: .headline ) titleLabel.text = row.title summaryLabel.text = row.summary metaLabel.text = row.meta } } @MainActor final class PullsViewController: RefreshingTableViewController { private let context: AppContext private var rows: [PullRow] = [] private var filterOptions: PullFilterOptions? private var filterTask: Task? private var currentPage: UInt32 = 0 init(context: AppContext) { self.context = context super.init() title = "Pull Requests" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { filterTask?.cancel() } override func viewDidLoad() { super.viewDidLoad() tableView.register(PullCell.self, forCellReuseIdentifier: "pull") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 116 navigationItem.leftBarButtonItem = UIBarButtonItem( image: context.symbol("server.rack"), primaryAction: UIAction { [weak self] _ in guard let self else { return } self.navigationController?.pushViewController( ServersViewController(context: self.context), animated: true ) } ) 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 } loadFilterOptions() loadPulls(refreshing: refreshing) } private func loadPulls(refreshing: Bool) { loadPulls(page: 1, refreshing: refreshing) } override func loadMoreContent() { loadPulls(page: currentPage + 1, refreshing: false) } private func loadPulls(page: UInt32, refreshing: Bool) { if page == 1 { resetPagination() beginLoading(refreshing: refreshing) } loadingTask?.cancel() loadingTask = Task { do { let result = try await context.core.pulls(page: page) if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) } currentPage = page finishPagination(hasMore: result.hasMore) tableView.reloadData() let status = context.core.settings().pullStatus tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView( title: "No \(status) pull requests", detail: filtersActive ? "No pull requests match the selected filters." : "No pull requests match the selected status." ) : nil } catch { if !Task.isCancelled { show(error: error) failPagination() } } if page == 1 { endLoading() } } } private func loadFilterOptions() { filterTask?.cancel() filterTask = Task { do { filterOptions = try await context.core.pullFilters() guard !Task.isCancelled else { return } updateFilterMenu() } catch { if !Task.isCancelled { show(error: error) } } } } 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", for: indexPath) as! PullCell cell.configure(rows[indexPath.row]) return cell } 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 let status = UIMenu( title: "Status", image: filterMenuImage("circle.lefthalf.filled", active: current != "open"), options: .singleSelection, 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.loadPulls(refreshing: false) } catch { self.show(error: error) } } } ) let milestone: UIMenuElement = filterOptions.map(milestoneMenu) ?? UIAction(title: "Loading milestones…", attributes: .disabled) { _ in } let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem( image: context.symbol("line.3.horizontal.decrease.circle") ) var children: [UIMenuElement] = [status] if let filterOptions { children.insert(searchAction(filterOptions), at: 0) } children.append(milestone) children.append(clearFiltersMenu()) item.menu = UIMenu(children: children) item.accessibilityLabel = "Filter pull requests" navigationItem.rightBarButtonItem = item updateFilterTint() } private func searchAction(_ options: PullFilterOptions) -> UIAction { UIAction( title: "Search Text", subtitle: options.searchText.isEmpty ? "Any text" : options.searchText, image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty) ) { [weak self] _ in self?.promptForSearchText( title: "Search Pull Requests", current: options.searchText ) { [weak self] searchText in self?.setSearchText(searchText) } } } private func milestoneMenu(_ options: PullFilterOptions) -> UIMenu { let selected = options.selectedMilestone let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) { [weak self] _ in self?.selectMilestone("") } let milestones = options.milestones.map { milestone in UIAction(title: milestone, state: selected == milestone ? .on : .off) { [weak self] _ in self?.selectMilestone(milestone) } } return UIMenu( title: "Milestone", image: filterMenuImage("flag", active: !selected.isEmpty), options: .singleSelection, children: [all] + milestones ) } private func selectMilestone(_ milestone: String) { guard let options = filterOptions else { return } filterTask?.cancel() do { try context.core.setPullFilters( milestone: milestone, searchText: options.searchText ) filterOptions?.selectedMilestone = milestone updateFilterMenu() loadPulls(refreshing: false) } catch { show(error: error) } } private func setSearchText(_ searchText: String) { guard let options = filterOptions else { return } filterTask?.cancel() do { try context.core.setPullFilters( milestone: options.selectedMilestone, searchText: searchText ) filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines) updateFilterMenu() loadPulls(refreshing: false) } catch { show(error: error) } } private func clearFiltersMenu() -> UIMenu { UIMenu( options: .displayInline, children: [ UIAction( title: "Clear Filters", image: context.symbol("xmark.circle"), attributes: filtersActive ? [] : .disabled ) { [weak self] _ in self?.clearFilters() }, ] ) } private func clearFilters() { filterTask?.cancel() do { try context.core.clearPullFilters() filterOptions?.selectedMilestone = "" filterOptions?.searchText = "" updateFilterMenu() loadPulls(refreshing: false) } catch { show(error: error) } } private var filtersActive: Bool { (try? context.core.pullFiltersActive()) ?? false } private func updateFilterTint() { navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive ? "Filters active" : "Default filters" } } @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) } }