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

File diff suppressed because it is too large Load Diff

View File

@@ -258,6 +258,31 @@ void uniffi_gotcha_core_fn_free_gotchacore(uint64_t handle, RustCallStatus *_Non
uint64_t uniffi_gotcha_core_fn_constructor_gotchacore_new(RustBuffer storage_directory, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_JOB_LOG
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_JOB_LOG
uint64_t uniffi_gotcha_core_fn_method_gotchacore_action_job_log(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t run, int64_t job
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_RUN
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_RUN
uint64_t uniffi_gotcha_core_fn_method_gotchacore_action_run(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t run
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_RUNS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_RUNS
uint64_t uniffi_gotcha_core_fn_method_gotchacore_action_runs(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_WORKFLOWS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_WORKFLOWS
uint64_t uniffi_gotcha_core_fn_method_gotchacore_action_workflows(uint64_t ptr, RustBuffer owner, RustBuffer repository
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DISPATCH_ACTION_WORKFLOW
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DISPATCH_ACTION_WORKFLOW
uint64_t uniffi_gotcha_core_fn_method_gotchacore_dispatch_action_workflow(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer workflow, RustBuffer reference, RustBuffer inputs
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS
uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_details(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer sha, RustBuffer branch
@@ -488,6 +513,11 @@ void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, Rust
void uniffi_gotcha_core_fn_method_gotchacore_set_notifications_enabled(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PRIMARY_DESTINATIONS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PRIMARY_DESTINATIONS
void uniffi_gotcha_core_fn_method_gotchacore_set_primary_destinations(uint64_t ptr, RustBuffer destinations, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
void uniffi_gotcha_core_fn_method_gotchacore_set_pull_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
@@ -776,6 +806,36 @@ void ffi_gotcha_core_rust_future_free_void(uint64_t handle
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID
#define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID
void ffi_gotcha_core_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_JOB_LOG
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_JOB_LOG
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_action_job_log(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_RUN
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_RUN
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_action_run(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_RUNS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_RUNS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_action_runs(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_WORKFLOWS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_WORKFLOWS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_action_workflows(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DISPATCH_ACTION_WORKFLOW
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DISPATCH_ACTION_WORKFLOW
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_dispatch_action_workflow(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DETAILS
@@ -1052,6 +1112,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_notifications_enabled(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PRIMARY_DESTINATIONS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PRIMARY_DESTINATIONS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_primary_destinations(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS

View File

@@ -19,6 +19,7 @@
4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */; };
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */; };
74626C144E9214BF89F821D2 /* WidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */; };
77576F6C61D65F12BB64A2E9 /* ActionsScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */; };
7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */; };
7A9D1ADD6623C89A7D5E016F /* GotchaWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */; };
@@ -31,6 +32,7 @@
A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */; };
BDFE5D7A54541A109B35BF45 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */; };
C4FA15BB48499D3EED99522E /* NavigationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */; };
CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */; };
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722405F999984C7CBE838711 /* IssueEditorViewController.swift */; };
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
@@ -77,6 +79,7 @@
52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = GotchaWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; };
64B529D84926EEEDC163A929 /* MilestoneScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneScreens.swift; sourceTree = "<group>"; };
6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSettingsViewController.swift; sourceTree = "<group>"; };
722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = "<group>"; };
7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitScreens.swift; sourceTree = "<group>"; };
77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIntents.swift; sourceTree = "<group>"; };
@@ -85,6 +88,7 @@
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = "<group>"; };
99B8279189276A084B69D7D0 /* PullScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullScreens.swift; sourceTree = "<group>"; };
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionsScreens.swift; sourceTree = "<group>"; };
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = "<group>"; };
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = "<group>"; };
C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityScreen.swift; sourceTree = "<group>"; };
@@ -124,6 +128,7 @@
710A50F51478401FC642E6E3 /* Sources */ = {
isa = PBXGroup;
children = (
ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */,
F61515849F6AACD721FE915C /* AppContext.swift */,
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */,
@@ -136,6 +141,7 @@
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */,
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */,
64B529D84926EEEDC163A929 /* MilestoneScreens.swift */,
6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */,
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */,
DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */,
99B8279189276A084B69D7D0 /* PullScreens.swift */,
@@ -348,6 +354,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
77576F6C61D65F12BB64A2E9 /* ActionsScreens.swift in Sources */,
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */,
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */,
@@ -360,6 +367,7 @@
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */,
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */,
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */,
C4FA15BB48499D3EED99522E /* NavigationSettingsViewController.swift in Sources */,
7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */,
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */,
33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */,

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"
}
}

View File

@@ -19,21 +19,20 @@ final class AppContext {
}
func makeRootController() -> UIViewController {
let roots: [UIViewController] = [
HomeViewController(context: self),
repositoryRoot(mode: .issues),
repositoryRoot(mode: .commits),
PullsViewController(context: self),
repositoryRoot(mode: .milestones),
]
let items = [
("Home", "house", "house.fill"),
("Issues", "exclamationmark.circle", "exclamationmark.circle.fill"),
("Repos", "books.vertical", "books.vertical.fill"),
("PRs", "arrow.triangle.pull", "arrow.triangle.pull"),
("Milestones", "flag", "flag.fill"),
]
navigationControllers = zip(roots, items).map { root, item in
rebuildTabs(preservingHomeStack: false)
return tabs
}
func applyPrimaryDestinations() {
rebuildTabs(preservingHomeStack: true)
}
private func rebuildTabs(preservingHomeStack: Bool) {
let destinations = core.settings().primaryDestinations
let home = preservingHomeStack ? navigationControllers.first : nil
let roots = destinations.map(destinationController)
let items = [("Home", "house", "house.fill")] + destinations.map(destinationItem)
let destinationNavigations = zip(roots, items.dropFirst()).map { root, item in
let navigation = UINavigationController(rootViewController: root)
navigation.tabBarItem = UITabBarItem(
title: item.0,
@@ -42,8 +41,17 @@ final class AppContext {
)
return navigation
}
let homeNavigation = home ?? UINavigationController(
rootViewController: HomeViewController(context: self)
)
homeNavigation.tabBarItem = UITabBarItem(
title: items[0].0,
image: UIImage(systemName: items[0].1),
selectedImage: UIImage(systemName: items[0].2)
)
navigationControllers = [homeNavigation] + destinationNavigations
tabs.viewControllers = navigationControllers
return tabs
(homeNavigation.viewControllers.first as? HomeViewController)?.refreshPrimaryDestinations()
}
func showStartupErrorIfNeeded() {
@@ -57,33 +65,50 @@ final class AppContext {
}
func reloadAfterServerChange() {
let replacements: [(Int, UIViewController)] = [
(0, HomeViewController(context: self)),
(1, repositoryRoot(mode: .issues)),
(2, repositoryRoot(mode: .commits)),
(3, PullsViewController(context: self)),
(4, repositoryRoot(mode: .milestones)),
]
for (index, root) in replacements {
navigationControllers[index].setViewControllers([root], animated: false)
}
rebuildTabs(preservingHomeStack: false)
WidgetCenter.shared.reloadAllTimelines()
}
@discardableResult
func route(widgetURL: URL) -> Bool {
guard widgetURL.scheme == "gotcha" else { return false }
let index: Int
switch widgetURL.host {
case "home": index = 0
case "pulls": index = 3
case "home":
tabs.selectedIndex = 0
navigationControllers[0].popToRootViewController(animated: false)
case "pulls":
if let index = primaryDestinations.firstIndex(of: .pullRequests).map({ $0 + 1 }) {
tabs.selectedIndex = index
navigationControllers[index].popToRootViewController(animated: false)
} else {
tabs.selectedIndex = 0
navigationControllers[0].pushViewController(
PullsViewController(context: self),
animated: false
)
}
default: return false
}
tabs.selectedIndex = index
navigationControllers[index].popToRootViewController(animated: false)
return true
}
var primaryDestinations: [PrimaryDestination] {
core.settings().primaryDestinations
}
var secondaryDestinations: [PrimaryDestination] {
PrimaryDestination.allCases.filter { !primaryDestinations.contains($0) }
}
func show(_ destination: PrimaryDestination, from navigation: UINavigationController?) {
if let index = primaryDestinations.firstIndex(of: destination).map({ $0 + 1 }) {
tabs.selectedIndex = index
navigationControllers[index].popToRootViewController(animated: false)
} else {
navigation?.pushViewController(destinationController(destination), animated: true)
}
}
func didAddServer(index: UInt32) throws {
try selectServer(index: index)
}
@@ -154,7 +179,7 @@ final class AppContext {
do {
try selectServer(index: UInt32(index))
} catch {
tabs.present(errorAlert(error.localizedDescription), animated: true)
tabs.present(errorAlert(errorMessage(error)), animated: true)
return
}
}
@@ -216,4 +241,54 @@ final class AppContext {
}
return RepositoriesViewController(context: self, mode: mode)
}
private func destinationController(_ destination: PrimaryDestination) -> UIViewController {
switch destination {
case .issues: return repositoryRoot(mode: .issues)
case .repositories: return repositoryRoot(mode: .commits)
case .pullRequests: return PullsViewController(context: self)
case .milestones: return repositoryRoot(mode: .milestones)
case .actions: return repositoryRoot(mode: .actions)
case .serverActivity: return ServerActivityViewController(context: self)
}
}
private func destinationItem(_ destination: PrimaryDestination) -> (String, String, String) {
switch destination {
case .issues: return ("Issues", "exclamationmark.circle", "exclamationmark.circle.fill")
case .repositories: return ("Repos", "books.vertical", "books.vertical.fill")
case .pullRequests: return ("PRs", "arrow.triangle.pull", "arrow.triangle.pull")
case .milestones: return ("Milestones", "flag", "flag.fill")
case .actions: return ("Actions", "play.square.stack", "play.square.stack.fill")
case .serverActivity: return ("Activity", "person.3", "person.3.fill")
}
}
}
extension PrimaryDestination: CaseIterable {
public static var allCases: [PrimaryDestination] {
[.issues, .repositories, .pullRequests, .milestones, .actions, .serverActivity]
}
var title: String {
switch self {
case .issues: return "Issues"
case .repositories: return "Repositories"
case .pullRequests: return "Pull Requests"
case .milestones: return "Milestones"
case .actions: return "Actions"
case .serverActivity: return "Server Activity"
}
}
var symbolName: String {
switch self {
case .issues: return "exclamationmark.circle"
case .repositories: return "books.vertical"
case .pullRequests: return "arrow.triangle.pull"
case .milestones: return "flag"
case .actions: return "play.square.stack"
case .serverActivity: return "person.3"
}
}
}

View File

@@ -2,25 +2,37 @@ import UIKit
@MainActor
final class HomeViewController: RefreshingTableViewController {
private enum ActivityFilter: Int {
case all
case issues
case pulls
var coreValue: HomeActivityFilter {
switch self {
case .all: return .all
case .issues: return .issues
case .pulls: return .pullRequests
}
}
private enum Timeline: Int {
case user
case allUsers
case notifications
}
private let context: AppContext
private var page: HomePage?
private var filter = ActivityFilter.all
private var nextPage: UInt32?
private var activities: [ActivityRow] { page?.activities ?? [] }
private var timeline = Timeline.user
private var userNextPage: UInt32?
private var serverActivities: [ActivityRow] = []
private var serverNextPage: UInt32?
private let notificationStatusControl = UISegmentedControl(items: ["Open", "Closed"])
private lazy var notificationHeaderView: UIView = {
let header = UIView()
notificationStatusControl.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(notificationStatusControl)
NSLayoutConstraint.activate([
notificationStatusControl.centerXAnchor.constraint(equalTo: header.centerXAnchor),
notificationStatusControl.centerYAnchor.constraint(equalTo: header.centerYAnchor),
])
return header
}()
private var notificationStatus = NotificationStatus.open
private var notificationRows: [NotificationRow] = []
private var notificationPage: UInt32 = 0
private var notificationsHaveMore = false
private var activities: [ActivityRow] {
timeline == .allUsers ? serverActivities : page?.activities ?? []
}
init(context: AppContext) {
self.context = context
@@ -33,6 +45,14 @@ final class HomeViewController: RefreshingTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(NotificationCell.self, forCellReuseIdentifier: "notification")
notificationStatusControl.selectedSegmentIndex = 0
notificationStatusControl.accessibilityLabel = "Notification status"
notificationStatusControl.addTarget(
self,
action: #selector(notificationStatusChanged),
for: .valueChanged
)
let servers = UIBarButtonItem(
image: context.symbol("server.rack"),
primaryAction: UIAction { [weak self] _ in
@@ -61,8 +81,11 @@ final class HomeViewController: RefreshingTableViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard page == nil else { return }
loadContent(refreshing: false)
if page == nil {
loadContent(refreshing: false)
} else if timeline == .notifications {
loadNotifications(page: 1, refreshing: false)
}
}
override func loadContent(refreshing: Bool) {
@@ -74,15 +97,31 @@ final class HomeViewController: RefreshingTableViewController {
refreshControl?.endRefreshing()
return
}
loadPage(1, refreshing: refreshing)
switch timeline {
case .user:
loadUserPage(1, refreshing: refreshing)
case .allUsers:
loadServerActivityPage(1, refreshing: refreshing)
case .notifications:
loadNotifications(page: 1, refreshing: refreshing)
}
}
override func loadMoreContent() {
guard let nextPage else { return }
loadPage(nextPage, refreshing: false)
switch timeline {
case .user:
guard let userNextPage else { return }
loadUserPage(userNextPage, refreshing: false)
case .allUsers:
guard let serverNextPage else { return }
loadServerActivityPage(serverNextPage, refreshing: false)
case .notifications:
guard notificationsHaveMore else { return }
loadNotifications(page: notificationPage + 1, refreshing: false)
}
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
private func loadUserPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
@@ -92,62 +131,106 @@ final class HomeViewController: RefreshingTableViewController {
do {
let result = try await context.core.home(
page: requestedPage,
filter: filter.coreValue
filter: .all
)
guard !Task.isCancelled, timeline == .user else { return }
if requestedPage == 1 {
page = result
} else {
page?.activities.append(contentsOf: result.activities)
page?.nextPage = result.nextPage
}
nextPage = result.nextPage
userNextPage = result.nextPage
finishPagination(hasMore: result.nextPage != nil)
title = page?.serverName
if requestedPage == 1 { tableView.tableHeaderView = page.map { page in
HeatmapView(
page: page,
selectedFilter: filter.rawValue,
onFilter: { [weak self] index in
guard let self, let filter = ActivityFilter(rawValue: index) else { return }
guard filter != self.filter else { return }
self.filter = filter
self.loadPage(1, refreshing: false)
},
onNotifications: { [weak self] in
guard let self else { return }
self.navigationController?.pushViewController(
NotificationsViewController(context: self.context),
animated: true
)
},
onServerActivity: { [weak self] in
guard let self else { return }
self.navigationController?.pushViewController(
ServerActivityViewController(context: self.context),
animated: true
)
}
)
} }
updateActivities()
setPanelTitle(self, "Home", server: page?.serverName)
if requestedPage == 1 { refreshPrimaryDestinations() }
updateRows()
} catch {
if !Task.isCancelled {
if !Task.isCancelled, timeline == .user {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
if requestedPage == 1, timeline == .user { endLoading() }
}
}
private func loadServerActivityPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.serverActivity(page: requestedPage)
guard !Task.isCancelled, timeline == .allUsers else { return }
if requestedPage == 1 {
serverActivities = result.rows
} else {
serverActivities.append(contentsOf: result.rows)
}
serverNextPage = result.hasMore ? requestedPage + 1 : nil
finishPagination(hasMore: result.hasMore)
updateRows()
} catch {
if !Task.isCancelled, timeline == .allUsers {
show(error: error)
failPagination()
}
}
if requestedPage == 1, timeline == .allUsers { endLoading() }
}
}
private func loadNotifications(page requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.notifications(
status: notificationStatus,
page: requestedPage
)
guard !Task.isCancelled, timeline == .notifications else { return }
if requestedPage == 1 {
notificationRows = result.rows
} else {
notificationRows.append(contentsOf: result.rows)
}
notificationPage = requestedPage
notificationsHaveMore = result.hasMore
finishPagination(hasMore: result.hasMore)
updateRows()
} catch {
if !Task.isCancelled, timeline == .notifications {
show(error: error)
failPagination()
}
}
if requestedPage == 1, timeline == .notifications { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
activities.count
timeline == .notifications ? notificationRows.count : activities.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
if timeline == .notifications {
let cell = tableView.dequeueReusableCell(
withIdentifier: "notification",
for: indexPath
) as! NotificationCell
cell.configure(notificationRows[indexPath.row])
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
let row = activities[indexPath.row]
@@ -162,22 +245,98 @@ final class HomeViewController: RefreshingTableViewController {
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
92
timeline == .notifications ? UITableView.automaticDimension : 92
}
override func tableView(
_ tableView: UITableView,
viewForHeaderInSection section: Int
) -> UIView? {
guard timeline == .notifications else { return nil }
return notificationHeaderView
}
override func tableView(
_ tableView: UITableView,
heightForHeaderInSection section: Int
) -> CGFloat {
timeline == .notifications ? 44 : .leastNormalMagnitude
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
context.route(activities[indexPath.row])
if timeline == .notifications {
openNotification(notificationRows[indexPath.row])
} else {
context.route(activities[indexPath.row])
}
}
private func updateActivities() {
private func updateRows() {
tableView.reloadData()
tableView.backgroundView = activities.isEmpty
? EmptyBackgroundView(
title: "No matching activity",
detail: "This server has no recent activity of the selected type."
let isEmpty = timeline == .notifications ? notificationRows.isEmpty : activities.isEmpty
guard isEmpty else {
tableView.backgroundView = nil
return
}
let message: (String, String) = switch timeline {
case .user:
("No activity", "This user has no recent activity.")
case .allUsers:
("No server activity", "No activity is visible to this server account.")
case .notifications:
notificationStatus == .open
? ("No open notifications", "This server has no open notifications.")
: ("No closed notifications", "This server has no closed notifications.")
}
tableView.backgroundView = EmptyBackgroundView(title: message.0, detail: message.1)
}
private func selectTimeline(_ rawValue: Int) {
guard let selected = Timeline(rawValue: rawValue), selected != timeline else { return }
timeline = selected
setPanelTitle(self, "Home", server: page?.serverName)
refreshPrimaryDestinations()
tableView.reloadData()
loadContent(refreshing: false)
}
@objc private func notificationStatusChanged() {
notificationStatus = notificationStatusControl.selectedSegmentIndex == 0 ? .open : .closed
loadNotifications(page: 1, refreshing: false)
}
private func openNotification(_ row: NotificationRow) {
guard row.target != .none else { return }
guard row.unread else {
context.route(row)
return
}
loadingTask?.cancel()
loadingTask = Task {
do {
try await context.core.markNotificationRead(serverId: row.serverId, id: row.id)
guard !Task.isCancelled else { return }
context.route(row)
} catch {
if !Task.isCancelled { show(error: error) }
}
}
}
func refreshPrimaryDestinations() {
tableView.tableHeaderView = page.map { page in
HeatmapView(
page: page,
selectedTimeline: timeline.rawValue,
destinations: context.secondaryDestinations.filter { $0 != .serverActivity },
onTimeline: { [weak self] timeline in self?.selectTimeline(timeline) },
onDestination: { [weak self] destination in
guard let self else { return }
self.context.show(destination, from: self.navigationController)
}
)
: nil
}
}
}
@@ -197,13 +356,14 @@ func activitySymbolName(for icon: ActivityIcon) -> String {
final class HeatmapView: UIView {
private let cells: [HeatCell]
private let calendar = Calendar(identifier: .gregorian)
private let controlsScroller = UIScrollView()
init(
page: HomePage,
selectedFilter: Int,
onFilter: @escaping (Int) -> Void,
onNotifications: @escaping () -> Void,
onServerActivity: @escaping () -> Void
selectedTimeline: Int,
destinations: [PrimaryDestination],
onTimeline: @escaping (Int) -> Void,
onDestination: @escaping (PrimaryDestination) -> Void
) {
cells = page.heatCells
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
@@ -218,16 +378,14 @@ final class HeatmapView: UIView {
total.font = .preferredFont(forTextStyle: .caption1)
total.textColor = .tertiaryLabel
addSubview(total)
let filters = UIStackView()
filters.axis = .horizontal
filters.spacing = 4
filters.translatesAutoresizingMaskIntoConstraints = false
let filterItems = [
("clock", "clock.fill", "All activity"),
("exclamationmark.circle", "exclamationmark.circle.fill", "Issues"),
("arrow.triangle.pull", "arrow.triangle.pull", "Pull requests"),
let timelinesView = UIStackView()
timelinesView.axis = .horizontal
let timelineItems = [
("person.crop.circle", "person.crop.circle.fill", "Your activity"),
("person.3", "person.3.fill", "All users activity"),
("bell", "bell.fill", "Notifications"),
]
for (index, item) in filterItems.enumerated() {
for (index, item) in timelineItems.enumerated() {
let button = UIButton(type: .custom, primaryAction: UIAction { action in
guard
let button = action.sender as? UIButton,
@@ -238,47 +396,77 @@ final class HeatmapView: UIView {
item.tintColor = item.isSelected ? .tintColor : .secondaryLabel
item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button
}
onFilter(button.tag)
onTimeline(button.tag)
})
button.tag = index
button.setImage(UIImage(systemName: item.0), for: .normal)
button.setImage(UIImage(systemName: item.1), for: .selected)
button.isSelected = index == selectedFilter
button.isSelected = index == selectedTimeline
button.tintColor = button.isSelected ? .tintColor : .secondaryLabel
button.accessibilityLabel = item.2
button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
filters.addArrangedSubview(button)
timelinesView.addArrangedSubview(button)
}
let serverActivity = UIButton(
type: .custom,
primaryAction: UIAction { _ in onServerActivity() }
)
serverActivity.setImage(UIImage(systemName: "person.3"), for: .normal)
serverActivity.tintColor = .secondaryLabel
serverActivity.accessibilityLabel = "Server Activity"
serverActivity.widthAnchor.constraint(equalToConstant: 44).isActive = true
filters.addArrangedSubview(serverActivity)
let notifications = UIButton(
type: .custom,
primaryAction: UIAction { _ in onNotifications() }
)
notifications.setImage(UIImage(systemName: "bell"), for: .normal)
notifications.tintColor = .secondaryLabel
notifications.accessibilityLabel = "Notifications"
notifications.widthAnchor.constraint(equalToConstant: 44).isActive = true
filters.addArrangedSubview(notifications)
addSubview(filters)
let destinationsView = UIStackView()
destinationsView.axis = .horizontal
for destination in destinations {
let button = UIButton(
type: .custom,
primaryAction: UIAction { _ in onDestination(destination) }
)
let symbol = destination == .milestones ? "flag.fill" : destination.symbolName
button.setImage(UIImage(systemName: symbol), for: .normal)
button.tintColor = .secondaryLabel
button.accessibilityLabel = destination.title
button.accessibilityHint = "Opens from Home"
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
destinationsView.addArrangedSubview(button)
}
let controls = UIStackView(arrangedSubviews: [timelinesView])
controls.axis = .horizontal
controls.alignment = .center
controls.translatesAutoresizingMaskIntoConstraints = false
if !destinations.isEmpty {
controls.addArrangedSubview(destinationsView)
}
controlsScroller.showsHorizontalScrollIndicator = false
controlsScroller.translatesAutoresizingMaskIntoConstraints = false
controlsScroller.addSubview(controls)
addSubview(controlsScroller)
NSLayoutConstraint.activate([
filters.centerXAnchor.constraint(equalTo: centerXAnchor),
filters.topAnchor.constraint(equalTo: topAnchor, constant: 132),
filters.heightAnchor.constraint(equalToConstant: 44),
controlsScroller.leadingAnchor.constraint(equalTo: leadingAnchor),
controlsScroller.trailingAnchor.constraint(equalTo: trailingAnchor),
controlsScroller.topAnchor.constraint(equalTo: topAnchor, constant: 132),
controlsScroller.heightAnchor.constraint(equalToConstant: 44),
controls.leadingAnchor.constraint(
equalTo: controlsScroller.contentLayoutGuide.leadingAnchor,
constant: 8
),
controls.trailingAnchor.constraint(
equalTo: controlsScroller.contentLayoutGuide.trailingAnchor,
constant: -8
),
controls.topAnchor.constraint(equalTo: controlsScroller.contentLayoutGuide.topAnchor),
controls.bottomAnchor.constraint(
equalTo: controlsScroller.contentLayoutGuide.bottomAnchor
),
controls.heightAnchor.constraint(equalTo: controlsScroller.frameLayoutGuide.heightAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func layoutSubviews() {
super.layoutSubviews()
let inset = max(0, (controlsScroller.bounds.width - controlsScroller.contentSize.width) / 2)
controlsScroller.contentInset.left = inset
controlsScroller.contentInset.right = inset
}
override func draw(_ rect: CGRect) {
guard
let first = cells.first,

View File

@@ -0,0 +1,128 @@
import UIKit
@MainActor
final class NavigationSettingsViewController: UITableViewController {
private let context: AppContext
private var selected: [PrimaryDestination]
init(context: AppContext) {
self.context = context
selected = context.primaryDestinations
super.init(style: .insetGrouped)
title = "Primary Navigation"
navigationItem.rightBarButtonItem = editButtonItem
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
private var available: [PrimaryDestination] {
PrimaryDestination.allCases.filter { !selected.contains($0) }
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? selected.count : available.count
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 0 ? "Shown After Home" : "Available on Home"
}
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
section == 0
? "Choose up to four destinations. Tap Edit to reorder them. Home is always first."
: "Destinations not in the tab bar remain available as buttons on Home."
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let destination = indexPath.section == 0 ? selected[indexPath.row] : available[indexPath.row]
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.text = destination.title
content.image = UIImage(systemName: destination.symbolName)
content.imageProperties.tintColor = .tintColor
if indexPath.section == 1 && selected.count == 4 {
content.textProperties.color = .secondaryLabel
content.imageProperties.tintColor = .tertiaryLabel
cell.selectionStyle = .none
} else if indexPath.section == 1 {
let add = UIImageView(image: UIImage(systemName: "plus.circle.fill"))
add.tintColor = .tintColor
cell.accessoryView = add
cell.accessibilityHint = "Adds this destination to the tab bar"
}
cell.contentConfiguration = content
return cell
}
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
indexPath.section == 0
}
override func tableView(
_ tableView: UITableView,
moveRowAt sourceIndexPath: IndexPath,
to destinationIndexPath: IndexPath
) {
guard sourceIndexPath.section == 0, destinationIndexPath.section == 0 else {
tableView.reloadData()
return
}
let destination = selected.remove(at: sourceIndexPath.row)
selected.insert(destination, at: destinationIndexPath.row)
save()
}
override func tableView(
_ tableView: UITableView,
targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath,
toProposedIndexPath proposedDestinationIndexPath: IndexPath
) -> IndexPath {
guard proposedDestinationIndexPath.section == 0 else {
return IndexPath(row: max(selected.count - 1, 0), section: 0)
}
return proposedDestinationIndexPath
}
override func tableView(
_ tableView: UITableView,
editingStyleForRowAt indexPath: IndexPath
) -> UITableViewCell.EditingStyle {
indexPath.section == 0 ? .delete : .none
}
override func tableView(
_ tableView: UITableView,
commit editingStyle: UITableViewCell.EditingStyle,
forRowAt indexPath: IndexPath
) {
guard editingStyle == .delete, indexPath.section == 0 else { return }
selected.remove(at: indexPath.row)
save()
tableView.reloadData()
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 1, selected.count < 4 else { return }
selected.append(available[indexPath.row])
save()
tableView.reloadData()
}
private func save() {
do {
try context.core.setPrimaryDestinations(destinations: selected)
context.applyPrimaryDestinations()
} catch {
selected = context.primaryDestinations
tableView.reloadData()
show(error: error)
}
}
}

View File

@@ -72,7 +72,7 @@ final class NotificationsViewController: RefreshingTableViewController {
init(context: AppContext) {
self.context = context
super.init()
title = "Notifications"
setPanelTitle(self, "Notifications", server: context.core.activeServerName())
}
@available(*, unavailable)

View File

@@ -62,7 +62,7 @@ final class PullsViewController: RefreshingTableViewController {
init(context: AppContext) {
self.context = context
super.init()
title = "Pull Requests"
setPanelTitle(self, "Pull Requests", server: context.core.activeServerName())
}
@available(*, unavailable)
@@ -75,20 +75,28 @@ final class PullsViewController: RefreshingTableViewController {
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 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(

View File

@@ -7,11 +7,20 @@ final class RepositoriesViewController: RefreshingTableViewController {
private var rows: [RepositoryRow] = []
private var currentPage: UInt32 = 0
private var panelTitle: String {
switch mode {
case .issues: return "Issues"
case .commits: return "Repositories"
case .milestones: return "Milestones"
case .actions: return "Actions"
}
}
init(context: AppContext, mode: RepositoryPane) {
self.context = context
self.mode = mode
super.init()
title = context.core.activeServerName() ?? "Repositories"
setPanelTitle(self, panelTitle, server: context.core.activeServerName())
}
@available(*, unavailable)
@@ -19,19 +28,27 @@ final class RepositoriesViewController: RefreshingTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
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
)
}
)
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) {
loadPage(1, refreshing: refreshing)
}
@@ -118,6 +135,13 @@ final class RepositoriesViewController: RefreshingTableViewController {
owner: row.owner,
repository: row.name
)
case .actions:
destination = ActionsViewController(
context: context,
owner: row.owner,
repository: row.name,
defaultBranch: row.defaultBranch
)
}
navigationController?.pushViewController(destination, animated: true)
}

View File

@@ -10,7 +10,7 @@ final class ServerActivityViewController: RefreshingTableViewController {
init(context: AppContext) {
self.context = context
super.init()
title = "Server Activity"
setPanelTitle(self, "Server Activity", server: context.core.activeServerName())
}
@available(*, unavailable)

View File

@@ -29,25 +29,29 @@ final class SettingsViewController: UITableViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
notificationSwitch.isOn = context.core.settings().notificationsEnabled
tableView.reloadSections(IndexSet(integer: 1), with: .none)
Task {
notificationStatus = await context.notifications.authorizationDescription()
updateNotificationSettingsCell()
}
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func numberOfSections(in tableView: UITableView) -> Int { 3 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 1 : 2
section == 2 ? 2 : 1
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 0 ? "Appearance" : "Notifications"
["Appearance", "Navigation", "Notifications"][section]
}
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
if section == 0 {
return "Follow iOS automatically or choose a fixed appearance."
}
if section == 1 {
return "Home is always present. Choose and order up to four additional destinations."
}
return "Gotcha checks periodically for server notifications. iOS decides when background refresh runs; delivery style, sounds, Focus, and summaries remain under your control in iOS Settings."
}
@@ -55,6 +59,15 @@ final class SettingsViewController: UITableViewController {
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
if indexPath.section == 1 {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
content.text = "Primary Destinations"
content.secondaryText = "\(context.primaryDestinations.count) selected"
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
return cell
}
guard indexPath.section == 0 else {
if indexPath.row == 0 {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
@@ -87,8 +100,14 @@ final class SettingsViewController: UITableViewController {
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 1, indexPath.row == 1 else { return }
context.notifications.openSystemSettings()
if indexPath.section == 1 {
navigationController?.pushViewController(
NavigationSettingsViewController(context: context),
animated: true
)
} else if indexPath.section == 2, indexPath.row == 1 {
context.notifications.openSystemSettings()
}
}
@objc private func appearanceChanged() {
@@ -119,7 +138,7 @@ final class SettingsViewController: UITableViewController {
}
private func updateNotificationSettingsCell() {
guard let cell = tableView.cellForRow(at: IndexPath(row: 1, section: 1)) else { return }
guard let cell = tableView.cellForRow(at: IndexPath(row: 1, section: 2)) else { return }
var content = cell.defaultContentConfiguration()
content.text = "Notification Settings"
content.secondaryText = notificationStatus

View File

@@ -46,9 +46,14 @@ func errorAlert(_ message: String) -> UIAlertController {
return alert
}
func errorMessage(_ error: Error) -> String {
if case let GotchaError.Message(message) = error { return message }
return error.localizedDescription
}
extension UIViewController {
func show(error: Error) {
present(errorAlert(error.localizedDescription), animated: true)
present(errorAlert(errorMessage(error)), animated: true)
}
func beginNavigationLoading(_ spinner: UIActivityIndicatorView) {
@@ -392,3 +397,31 @@ func separator() -> UIView {
line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
return line
}
@MainActor
func setPanelTitle(_ viewController: UIViewController, _ title: String, server: String?) {
viewController.title = title
viewController.navigationItem.titleView = nil
if #available(iOS 26.0, *) {
viewController.navigationItem.subtitle = server.flatMap { $0.isEmpty ? nil : $0 }
return
}
guard let server, !server.isEmpty else { return }
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.textAlignment = .center
let serverLabel = UILabel()
serverLabel.text = server
serverLabel.font = .preferredFont(forTextStyle: .caption1)
serverLabel.adjustsFontForContentSizeCategory = true
serverLabel.textColor = .secondaryLabel
serverLabel.textAlignment = .center
let labels = UIStackView(arrangedSubviews: [titleLabel, serverLabel])
labels.axis = .vertical
labels.spacing = 0
labels.alignment = .center
labels.accessibilityLabel = "\(title), \(server)"
viewController.navigationItem.titleView = labels
}