import UIKit 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() setPanelTitle(self, "Pull Requests", server: context.core.activeServerName()) } @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 updateFilterMenu() loadContent(refreshing: false) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if navigationController?.viewControllers.first === self { 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 ) } ) } else { navigationItem.leftBarButtonItem = nil } } 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" } }