Add first-class Actions management (#63)

This commit is contained in:
Georg Bauer
2026-08-15 20:06:52 +02:00
parent f84ab774e6
commit 59bcddfadf
32 changed files with 4039 additions and 275 deletions

View File

@@ -0,0 +1,661 @@
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<Void, Never>?
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<Void, Never>?
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<Void, Never>?
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"
}
}