import UIKit @MainActor final class IssuesViewController: RefreshingTableViewController, IssueSwipeActionHost { let context: AppContext let owner: String let repository: String private var rows: [IssueRow] = [] private var filterOptions: IssueFilterOptions? private var filterTask: Task? var issueMutationTask: 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() issueMutationTask?.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? { issueSwipeActions(for: rows[indexPath.row]) } func reloadIssuesAfterMutation() { loadIssues(refreshing: false) } 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()) } }