import UIKit @MainActor final class ActionsViewController: RefreshingTableViewController { private enum Mode: Int { case workflows, runs } private let context: AppContext private let owner: String private let repository: String private let defaultBranch: String private let modeControl = UISegmentedControl(items: ["Workflows", "Runs"]) private var mode = Mode.workflows private var workflows: [ActionWorkflowRow] = [] private var runs: [ActionRunRow] = [] private var currentPage: UInt32 = 0 private var autoRefreshTask: Task? private var loading = false private var requestGeneration = 0 init(context: AppContext, owner: String, repository: String, defaultBranch: String) { self.context = context self.owner = owner self.repository = repository self.defaultBranch = defaultBranch super.init() title = "Actions" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { autoRefreshTask?.cancel() } override func viewDidLoad() { super.viewDidLoad() modeControl.selectedSegmentIndex = mode.rawValue modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged) navigationItem.titleView = modeControl loadContent(refreshing: false) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) startAutoRefresh() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) autoRefreshTask?.cancel() } override func loadContent(refreshing: Bool) { loadPage(1, refreshing: refreshing) } override func loadMoreContent() { guard mode == .runs else { return } loadPage(currentPage + 1, refreshing: false) } @objc private func modeChanged() { guard let mode = Mode(rawValue: modeControl.selectedSegmentIndex) else { return } self.mode = mode loadingTask?.cancel() loading = false loadContent(refreshing: false) } private func loadPage(_ page: UInt32, refreshing: Bool) { guard !loading else { refreshControl?.endRefreshing() return } loading = true requestGeneration += 1 let generation = requestGeneration let requestedMode = mode if page == 1 { resetPagination() beginLoading(refreshing: refreshing) } loadingTask?.cancel() loadingTask = Task { defer { if requestGeneration == generation { loading = false if page == 1 { endLoading() } } } do { switch requestedMode { case .workflows: let result = try await context.core.actionWorkflows( owner: owner, repository: repository ) guard !Task.isCancelled, mode == requestedMode else { return } workflows = result currentPage = 1 finishPagination(hasMore: false) case .runs: let result = try await context.core.actionRuns( owner: owner, repository: repository, page: page ) guard !Task.isCancelled, mode == requestedMode else { return } runs = page == 1 ? result.rows : runs + result.rows currentPage = page finishPagination(hasMore: result.hasMore) } tableView.reloadData() updateEmptyState() } catch { if !Task.isCancelled { show(error: error) failPagination() } } } } private func startAutoRefresh() { autoRefreshTask?.cancel() autoRefreshTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(5)) guard let self, self.mode == .runs, !self.loading else { continue } self.loadPage(1, refreshing: false) } } } private func updateEmptyState() { let empty = mode == .workflows ? workflows.isEmpty : runs.isEmpty tableView.backgroundView = empty ? EmptyBackgroundView( title: mode == .workflows ? "No workflows" : "No workflow runs", detail: mode == .workflows ? "This repository has no Actions workflows." : "Dispatch a workflow to create the first run." ) : nil } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { mode == .workflows ? workflows.count : runs.count } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "action") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "action") if mode == .workflows { let row = workflows[indexPath.row] cell.accessoryView = nil configureTextCell( cell, title: row.name, detail: "\(row.state) · \(row.path)", image: context.symbol("play.square") ) cell.accessoryType = .disclosureIndicator } else { cell.accessoryType = .none let row = runs[indexPath.row] configureActionCell(cell, row: row, context: context) } return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) if mode == .workflows { let workflow = workflows[indexPath.row] navigationController?.pushViewController( WorkflowDispatchViewController( context: context, owner: owner, repository: repository, workflow: workflow, defaultBranch: defaultBranch ), animated: true ) } else { navigationController?.pushViewController( ActionRunViewController( context: context, owner: owner, repository: repository, run: runs[indexPath.row] ), animated: true ) } } } @MainActor final class WorkflowDispatchViewController: UITableViewController, UITextViewDelegate { private let context: AppContext private let owner: String private let repository: String private let workflow: ActionWorkflowRow private let referenceField = UITextField() private let inputsView = UITextView() private lazy var runButton = UIBarButtonItem( title: "Run Workflow", primaryAction: UIAction { [weak self] _ in self?.dispatch() } ) private var dispatchTask: Task? init( context: AppContext, owner: String, repository: String, workflow: ActionWorkflowRow, defaultBranch: String ) { self.context = context self.owner = owner self.repository = repository self.workflow = workflow referenceField.text = defaultBranch super.init(style: .insetGrouped) title = "Dispatch" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { dispatchTask?.cancel() } override func viewDidLoad() { super.viewDidLoad() referenceField.placeholder = "Branch or tag" referenceField.autocapitalizationType = .none referenceField.autocorrectionType = .no referenceField.clearButtonMode = .whileEditing referenceField.addTarget(self, action: #selector(referenceChanged), for: .editingChanged) inputsView.font = .preferredFont(forTextStyle: .body) inputsView.adjustsFontForContentSizeCategory = true inputsView.autocapitalizationType = .none inputsView.autocorrectionType = .no inputsView.accessibilityLabel = "Workflow dispatch inputs" navigationItem.rightBarButtonItem = runButton referenceChanged() } override func numberOfSections(in tableView: UITableView) -> Int { 3 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { [workflow.name, "Git Reference", "Inputs"][section] } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { section == 2 ? "Enter one workflow_dispatch input per line as KEY=VALUE. Leave blank when the workflow has no inputs." : nil } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = UITableViewCell(style: .default, reuseIdentifier: nil) if indexPath.section == 0 { var content = cell.defaultContentConfiguration() content.text = workflow.path content.secondaryText = workflow.state cell.contentConfiguration = content cell.selectionStyle = .none } else { let view = indexPath.section == 1 ? referenceField : inputsView view.translatesAutoresizingMaskIntoConstraints = false cell.contentView.addSubview(view) NSLayoutConstraint.activate([ view.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16), view.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16), view.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 8), view.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -8), ]) cell.selectionStyle = .none } return cell } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { indexPath.section == 2 ? 140 : UITableView.automaticDimension } private func dispatch() { navigationItem.rightBarButtonItem?.isEnabled = false dispatchTask?.cancel() dispatchTask = Task { do { try await context.core.dispatchActionWorkflow( owner: owner, repository: repository, workflow: workflow.id, reference: referenceField.text ?? "", inputs: [inputsView.text] ) navigationController?.popViewController(animated: true) } catch { if !Task.isCancelled { show(error: error) } navigationItem.rightBarButtonItem?.isEnabled = true } } } @objc private func referenceChanged() { runButton.isEnabled = !(referenceField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } @MainActor final class ActionRunViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private let runID: Int64 private var page: ActionRunPage? private var autoRefreshTask: Task? private var loading = false init(context: AppContext, owner: String, repository: String, run: ActionRunRow) { self.context = context self.owner = owner self.repository = repository runID = run.id super.init() title = "Run #\(run.number)" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { autoRefreshTask?.cancel() } override func viewDidLoad() { super.viewDidLoad() loadContent(refreshing: false) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) autoRefreshTask?.cancel() autoRefreshTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(5)) guard let self, !self.loading else { continue } self.loadContent(refreshing: false) } } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) autoRefreshTask?.cancel() } override func loadContent(refreshing: Bool) { guard !loading else { refreshControl?.endRefreshing() return } loading = true beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { defer { loading = false endLoading() } do { page = try await context.core.actionRun( owner: owner, repository: repository, run: runID ) tableView.reloadData() tableView.backgroundView = page?.jobs.isEmpty == true ? EmptyBackgroundView( title: "No jobs yet", detail: "Pull to refresh while the run is queued." ) : nil } catch { if !Task.isCancelled { show(error: error) } } } } override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { section == 0 ? 1 : page?.jobs.count ?? 0 } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { section == 0 ? "Run" : "Jobs" } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "actionDetail") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "actionDetail") if indexPath.section == 0 { let run = page!.run configureTextCell( cell, title: run.title, detail: "\(actionStateLabel(run.state)) · \(run.event) · \(run.branch)\n\(run.meta)", image: context.symbol(actionStateSymbolName(run.state)) ) tintActionIcon(in: cell, state: run.state) cell.selectionStyle = .none cell.accessoryType = .none } else { let job = page!.jobs[indexPath.row] configureTextCell( cell, title: job.name, detail: job.meta, image: context.symbol(actionStateSymbolName(job.state)) ) tintActionIcon(in: cell, state: job.state) cell.selectionStyle = .default cell.accessoryType = .disclosureIndicator } return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == 1 else { return } tableView.deselectRow(at: indexPath, animated: true) let job = page!.jobs[indexPath.row] navigationController?.pushViewController( ActionJobViewController( context: context, owner: owner, repository: repository, runID: runID, job: job ), animated: true ) } } @MainActor final class ActionJobViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private let runID: Int64 private let job: ActionJobRow private var page: ActionJobLogPage? init( context: AppContext, owner: String, repository: String, runID: Int64, job: ActionJobRow ) { self.context = context self.owner = owner self.repository = repository self.runID = runID self.job = job super.init() title = job.name } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { defer { endLoading() } do { page = try await context.core.actionJobLog( owner: owner, repository: repository, run: runID, job: job.id ) tableView.reloadData() tableView.backgroundView = page?.groups.isEmpty == true ? EmptyBackgroundView( title: "No log groups", detail: "This job has no log output yet. Pull to refresh." ) : nil } catch { if !Task.isCancelled { show(error: error) } } } } override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { section == 0 ? 1 : page?.groups.count ?? 0 } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { section == 0 ? "Job" : "Tasks" } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "jobDetail") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "jobDetail") if indexPath.section == 0 { configureTextCell( cell, title: page!.job.name, detail: page!.job.meta, image: context.symbol(actionStateSymbolName(page!.job.state)) ) tintActionIcon(in: cell, state: page!.job.state) cell.selectionStyle = .none cell.accessoryType = .none } else { let group = page!.groups[indexPath.row] configureTextCell( cell, title: group.name, detail: group.duration.isEmpty ? actionStateLabel(group.state) : "\(actionStateLabel(group.state)) · \(group.duration)", image: context.symbol(actionStateSymbolName(group.state)) ) tintActionIcon(in: cell, state: group.state) cell.selectionStyle = .default cell.accessoryType = .disclosureIndicator } return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == 1 else { return } tableView.deselectRow(at: indexPath, animated: true) let group = page!.groups[indexPath.row] navigationController?.pushViewController( ActionLogTextViewController(title: group.name, text: group.text), animated: true ) } } @MainActor final class ActionLogTextViewController: UIViewController { private let logTitle: String private let text: String init(title: String, text: String) { logTitle = title self.text = text super.init(nibName: nil, bundle: nil) self.title = title } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground let textView = UITextView() textView.isEditable = false textView.font = UIFontMetrics(forTextStyle: .body).scaledFont( for: .monospacedSystemFont(ofSize: 15, weight: .regular) ) textView.adjustsFontForContentSizeCategory = true textView.text = text textView.accessibilityLabel = "\(logTitle) log" textView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(textView) NSLayoutConstraint.activate([ textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), textView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) } } @MainActor private func configureActionCell(_ cell: UITableViewCell, row: ActionRunRow, context: AppContext) { configureTextCell( cell, title: "#\(row.number) \(row.title)", detail: "\(actionStateLabel(row.state)) · \(row.event) · \(row.branch)\n\(row.meta)", image: context.symbol(actionStateSymbolName(row.state)) ) tintActionIcon(in: cell, state: row.state) cell.accessoryView = nil cell.accessoryType = .disclosureIndicator } private func tintActionIcon(in cell: UITableViewCell, state: ActionState) { guard var content = cell.contentConfiguration as? UIListContentConfiguration else { return } content.imageProperties.tintColor = actionStateColor(state) cell.contentConfiguration = content } private func actionStateColor(_ state: ActionState) -> UIColor { switch state { case .succeeded: return .systemGreen case .failed: return .systemRed case .cancelled: return .systemGray case .skipped: return .tertiaryLabel case .queued: return .systemOrange case .waiting: return .systemOrange case .inProgress: return .systemBlue case .unknown: return .secondaryLabel } } private func actionStateLabel(_ state: ActionState) -> String { switch state { case .queued: return "Queued" case .waiting: return "Waiting" case .inProgress: return "In Progress" case .succeeded: return "Succeeded" case .failed: return "Failed" case .cancelled: return "Cancelled" case .skipped: return "Skipped" case .unknown: return "Unknown" } } private func actionStateSymbolName(_ state: ActionState) -> String { switch state { case .succeeded: return "checkmark.circle.fill" case .failed: return "xmark.circle.fill" case .cancelled: return "slash.circle.fill" case .skipped: return "minus.circle.fill" case .queued: return "clock.fill" case .waiting: return "hourglass.circle.fill" case .inProgress: return "circle.fill" case .unknown: return "questionmark.circle.fill" } }