Add issue creation and editing

This commit is contained in:
Georg Bauer
2026-07-31 14:39:17 +02:00
parent 86200b4668
commit 228879da15
11 changed files with 1175 additions and 40 deletions

View File

@@ -619,6 +619,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
func issue(owner: String, repository: String, number: Int64) async throws -> IssuePage
func issueEditor(owner: String, repository: String, number: Int64?) async throws -> IssueEditorPage
func issueFilters(owner: String, repository: String) async throws -> IssueFilterOptions
func issueFiltersActive(owner: String, repository: String) throws -> Bool
@@ -645,6 +647,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
func repositoryFile(owner: String, repository: String, path: String) async throws -> RepositoryFilePage
func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?) async throws -> Int64
func selectServer(index: UInt32) throws
func servers() -> [ServerRow]
@@ -841,6 +845,22 @@ open func issue(owner: String, repository: String, number: Int64)async throws -
)
}
open func issueEditor(owner: String, repository: String, number: Int64?)async throws -> IssueEditorPage {
return
try await uniffiRustCallAsync(
rustFutureFunc: {
uniffi_gotcha_core_fn_method_gotchacore_issue_editor(
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(number)
)
},
pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer,
completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer,
freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer,
liftFunc: FfiConverterTypeIssueEditorPage_lift,
errorHandler: FfiConverterTypeGotchaError_lift
)
}
open func issueFilters(owner: String, repository: String)async throws -> IssueFilterOptions {
return
try await uniffiRustCallAsync(
@@ -1037,6 +1057,22 @@ open func repositoryFile(owner: String, repository: String, path: String)async t
)
}
open func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?)async throws -> Int64 {
return
try await uniffiRustCallAsync(
rustFutureFunc: {
uniffi_gotcha_core_fn_method_gotchacore_save_issue(
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(number),FfiConverterString.lower(title),FfiConverterString.lower(body),FfiConverterSequenceInt64.lower(labelIds),FfiConverterOptionInt64.lower(milestoneId),FfiConverterOptionInt64.lower(dueDate)
)
},
pollFunc: ffi_gotcha_core_rust_future_poll_i64,
completeFunc: ffi_gotcha_core_rust_future_complete_i64,
freeFunc: ffi_gotcha_core_rust_future_free_i64,
liftFunc: FfiConverterInt64.lift,
errorHandler: FfiConverterTypeGotchaError_lift
)
}
open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_select_server(
@@ -1750,6 +1786,188 @@ public func FfiConverterTypeHomePage_lower(_ value: HomePage) -> RustBuffer {
}
public struct IssueEditorLabel: Equatable, Hashable {
public var id: Int64
public var name: String
public var selected: Bool
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(id: Int64, name: String, selected: Bool) {
self.id = id
self.name = name
self.selected = selected
}
}
#if compiler(>=6)
extension IssueEditorLabel: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeIssueEditorLabel: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueEditorLabel {
return
try IssueEditorLabel(
id: FfiConverterInt64.read(from: &buf),
name: FfiConverterString.read(from: &buf),
selected: FfiConverterBool.read(from: &buf)
)
}
public static func write(_ value: IssueEditorLabel, into buf: inout [UInt8]) {
FfiConverterInt64.write(value.id, into: &buf)
FfiConverterString.write(value.name, into: &buf)
FfiConverterBool.write(value.selected, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeIssueEditorLabel_lift(_ buf: RustBuffer) throws -> IssueEditorLabel {
return try FfiConverterTypeIssueEditorLabel.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeIssueEditorLabel_lower(_ value: IssueEditorLabel) -> RustBuffer {
return FfiConverterTypeIssueEditorLabel.lower(value)
}
public struct IssueEditorMilestone: Equatable, Hashable {
public var id: Int64
public var title: String
public var selected: Bool
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(id: Int64, title: String, selected: Bool) {
self.id = id
self.title = title
self.selected = selected
}
}
#if compiler(>=6)
extension IssueEditorMilestone: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeIssueEditorMilestone: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueEditorMilestone {
return
try IssueEditorMilestone(
id: FfiConverterInt64.read(from: &buf),
title: FfiConverterString.read(from: &buf),
selected: FfiConverterBool.read(from: &buf)
)
}
public static func write(_ value: IssueEditorMilestone, into buf: inout [UInt8]) {
FfiConverterInt64.write(value.id, into: &buf)
FfiConverterString.write(value.title, into: &buf)
FfiConverterBool.write(value.selected, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeIssueEditorMilestone_lift(_ buf: RustBuffer) throws -> IssueEditorMilestone {
return try FfiConverterTypeIssueEditorMilestone.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeIssueEditorMilestone_lower(_ value: IssueEditorMilestone) -> RustBuffer {
return FfiConverterTypeIssueEditorMilestone.lower(value)
}
public struct IssueEditorPage: Equatable, Hashable {
public var title: String
public var body: String
public var dueDate: Int64?
public var labels: [IssueEditorLabel]
public var milestones: [IssueEditorMilestone]
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(title: String, body: String, dueDate: Int64?, labels: [IssueEditorLabel], milestones: [IssueEditorMilestone]) {
self.title = title
self.body = body
self.dueDate = dueDate
self.labels = labels
self.milestones = milestones
}
}
#if compiler(>=6)
extension IssueEditorPage: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeIssueEditorPage: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueEditorPage {
return
try IssueEditorPage(
title: FfiConverterString.read(from: &buf),
body: FfiConverterString.read(from: &buf),
dueDate: FfiConverterOptionInt64.read(from: &buf),
labels: FfiConverterSequenceTypeIssueEditorLabel.read(from: &buf),
milestones: FfiConverterSequenceTypeIssueEditorMilestone.read(from: &buf)
)
}
public static func write(_ value: IssueEditorPage, into buf: inout [UInt8]) {
FfiConverterString.write(value.title, into: &buf)
FfiConverterString.write(value.body, into: &buf)
FfiConverterOptionInt64.write(value.dueDate, into: &buf)
FfiConverterSequenceTypeIssueEditorLabel.write(value.labels, into: &buf)
FfiConverterSequenceTypeIssueEditorMilestone.write(value.milestones, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeIssueEditorPage_lift(_ buf: RustBuffer) throws -> IssueEditorPage {
return try FfiConverterTypeIssueEditorPage.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeIssueEditorPage_lower(_ value: IssueEditorPage) -> RustBuffer {
return FfiConverterTypeIssueEditorPage.lower(value)
}
public struct IssueFilterOptions: Equatable, Hashable {
public var milestones: [String]
public var labels: [String]
@@ -2744,6 +2962,30 @@ fileprivate struct FfiConverterOptionUInt32: FfiConverterRustBuffer {
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterOptionInt64: FfiConverterRustBuffer {
typealias SwiftType = Int64?
public static func write(_ value: SwiftType, into buf: inout [UInt8]) {
guard let value = value else {
writeInt(&buf, Int8(0))
return
}
writeInt(&buf, Int8(1))
FfiConverterInt64.write(value, into: &buf)
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType {
switch try readInt(&buf) as Int8 {
case 0: return nil
case 1: return try FfiConverterInt64.read(from: &buf)
default: throw UniffiInternalError.unexpectedOptionalTag
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -2793,6 +3035,31 @@ fileprivate struct FfiConverterSequenceUInt32: FfiConverterRustBuffer {
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterSequenceInt64: FfiConverterRustBuffer {
typealias SwiftType = [Int64]
public static func write(_ value: [Int64], into buf: inout [UInt8]) {
let len = Int32(value.count)
writeInt(&buf, len)
for item in value {
FfiConverterInt64.write(item, into: &buf)
}
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [Int64] {
let len: Int32 = try readInt(&buf)
var seq = [Int64]()
seq.reserveCapacity(Int(len))
for _ in 0 ..< len {
seq.append(try FfiConverterInt64.read(from: &buf))
}
return seq
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -2968,6 +3235,56 @@ fileprivate struct FfiConverterSequenceTypeHeatCell: FfiConverterRustBuffer {
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterSequenceTypeIssueEditorLabel: FfiConverterRustBuffer {
typealias SwiftType = [IssueEditorLabel]
public static func write(_ value: [IssueEditorLabel], into buf: inout [UInt8]) {
let len = Int32(value.count)
writeInt(&buf, len)
for item in value {
FfiConverterTypeIssueEditorLabel.write(item, into: &buf)
}
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [IssueEditorLabel] {
let len: Int32 = try readInt(&buf)
var seq = [IssueEditorLabel]()
seq.reserveCapacity(Int(len))
for _ in 0 ..< len {
seq.append(try FfiConverterTypeIssueEditorLabel.read(from: &buf))
}
return seq
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterSequenceTypeIssueEditorMilestone: FfiConverterRustBuffer {
typealias SwiftType = [IssueEditorMilestone]
public static func write(_ value: [IssueEditorMilestone], into buf: inout [UInt8]) {
let len = Int32(value.count)
writeInt(&buf, len)
for item in value {
FfiConverterTypeIssueEditorMilestone.write(item, into: &buf)
}
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [IssueEditorMilestone] {
let len: Int32 = try readInt(&buf)
var seq = [IssueEditorMilestone]()
seq.reserveCapacity(Int(len))
for _ in 0 ..< len {
seq.append(try FfiConverterTypeIssueEditorMilestone.read(from: &buf))
}
return seq
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -3230,6 +3547,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 56735) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_issue_editor() != 10078) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters() != 24054) {
return InitializationResult.apiChecksumMismatch
}
@@ -3269,6 +3589,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_repository_file() != 44948) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue() != 55053) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) {
return InitializationResult.apiChecksumMismatch
}

View File

@@ -299,6 +299,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_EDITOR
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_EDITOR
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue_editor(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository
@@ -364,6 +369,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_contents(uint64_t pt
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number, RustBuffer title, RustBuffer body, RustBuffer label_ids, RustBuffer milestone_id, RustBuffer due_date
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
@@ -720,6 +730,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_EDITOR
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_EDITOR
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue_editor(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_FILTERS
@@ -798,6 +814,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_contents(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER

View File

@@ -13,6 +13,7 @@
7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; };
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; };
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.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 */; };
D78A121E9ADA0B72E2CF94DC /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8AD463F3AC817A44306C2297 /* MarkdownUI */; };
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; };
@@ -23,6 +24,7 @@
/* Begin PBXFileReference section */
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; };
722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = "<group>"; };
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = "<group>"; };
@@ -53,6 +55,7 @@
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
CE5E10787062D1175125BB4C /* ContentScreens.swift */,
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */,
722405F999984C7CBE838711 /* IssueEditorViewController.swift */,
AC828923A1E11A58AE348A74 /* ListScreens.swift */,
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */,
F75B3E4FFB9C9992517C4D69 /* Support.swift */,
@@ -192,6 +195,7 @@
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */,
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */,
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */,
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */,
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */,
F55A89489B2758D694F3B27D /* Support.swift in Sources */,

View File

@@ -8,6 +8,9 @@ final class IssuesViewController: RefreshingTableViewController {
private var rows: [IssueRow] = []
private var filterOptions: IssueFilterOptions?
private var filterTask: Task<Void, Never>?
private lazy var filterButton = UIBarButtonItem(
image: context.symbol("line.3.horizontal.decrease.circle")
)
init(context: AppContext, owner: String, repository: String) {
self.context = context
@@ -25,6 +28,13 @@ final class IssuesViewController: RefreshingTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
let addButton = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(createIssue)
)
addButton.accessibilityLabel = "New issue"
navigationItem.rightBarButtonItems = [addButton, filterButton]
updateFilterMenu()
loadContent(refreshing: false)
}
@@ -122,14 +132,8 @@ final class IssuesViewController: RefreshingTableViewController {
} else {
children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in })
}
let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem(
image: context.symbol("line.3.horizontal.decrease.circle")
)
item.menu = UIMenu(children: children)
item.accessibilityLabel = "Filter issues"
if navigationItem.rightBarButtonItem == nil {
navigationItem.rightBarButtonItem = item
}
filterButton.menu = UIMenu(children: children)
filterButton.accessibilityLabel = "Filter issues"
updateFilterTint()
}
@@ -211,12 +215,35 @@ final class IssuesViewController: RefreshingTableViewController {
}
private func updateFilterTint() {
navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel
navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive
filterButton.tintColor = filtersActive ? .tintColor : .secondaryLabel
filterButton.accessibilityValue = filtersActive
? "Filters active"
: "Default filters"
}
@objc private func createIssue() {
let editor = IssueEditorViewController(
context: context,
owner: owner,
repository: repository
) { [weak self] number in
guard let self else { return }
self.dismiss(animated: true) {
self.loadContent(refreshing: false)
self.navigationController?.pushViewController(
IssueViewController(
context: self.context,
owner: self.owner,
repository: self.repository,
number: number
),
animated: true
)
}
}
present(UINavigationController(rootViewController: editor), animated: true)
}
private func updateEmptyState() {
guard rows.isEmpty else {
tableView.backgroundView = nil

View File

@@ -87,9 +87,28 @@ final class IssueViewController: MarkdownPageViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Edit",
style: .plain,
target: self,
action: #selector(editIssue)
)
loadContent(refreshing: false)
}
@objc private func editIssue() {
let editor = IssueEditorViewController(
context: context,
owner: owner,
repository: repository,
number: number
) { [weak self] _ in
guard let self else { return }
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
}
present(UINavigationController(rootViewController: editor), animated: true)
}
override func loadContent(refreshing: Bool) {
beginLoading(refreshing: refreshing)
loadingTask?.cancel()
@@ -538,7 +557,7 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD
}
}
private struct RepositoryMarkdownPreview: View {
struct RepositoryMarkdownPreview: View {
let source: String
var body: some View {

View File

@@ -0,0 +1,353 @@
import Highlighter
import SwiftUI
import UIKit
@MainActor
final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UITextViewDelegate {
private let context: AppContext
private let owner: String
private let repository: String
private let number: Int64?
private let saved: (Int64) -> Void
private let scrollView = UIScrollView()
private let stack = UIStackView()
private let titleField = UITextField()
private let modeControl = UISegmentedControl(items: ["Write", "Preview"])
private let bodyView = UITextView()
private let previewView = UIView()
private let labelsButton = UIButton(type: .system)
private let milestoneButton = UIButton(type: .system)
private let dueSwitch = UISwitch()
private let duePicker = UIDatePicker()
private let spinner = UIActivityIndicatorView(style: .medium)
private var page: IssueEditorPage?
private var selectedLabels = Set<Int64>()
private var selectedMilestone: Int64?
private var task: Task<Void, Never>?
private var previewController: UIViewController?
private var editorFont: UIFont {
UIFontMetrics(forTextStyle: .body).scaledFont(
for: .monospacedSystemFont(ofSize: 15, weight: .regular)
)
}
init(
context: AppContext,
owner: String,
repository: String,
number: Int64? = nil,
saved: @escaping (Int64) -> Void
) {
self.context = context
self.owner = owner
self.repository = repository
self.number = number
self.saved = saved
super.init(nibName: nil, bundle: nil)
title = number == nil ? "New Issue" : "Edit Issue"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
deinit { task?.cancel() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGroupedBackground
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .cancel,
target: self,
action: #selector(cancel)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Save",
style: .done,
target: self,
action: #selector(save)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
spinner.startAnimating()
configureForm()
task = Task { await load() }
}
private func configureForm() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.keyboardDismissMode = .interactive
view.addSubview(scrollView)
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
stack.isHidden = true
scrollView.addSubview(stack)
titleField.placeholder = "Title"
titleField.delegate = self
titleField.font = .preferredFont(forTextStyle: .headline)
titleField.adjustsFontForContentSizeCategory = true
titleField.borderStyle = .roundedRect
titleField.autocapitalizationType = .sentences
titleField.returnKeyType = .next
titleField.accessibilityLabel = "Issue title"
titleField.addTarget(self, action: #selector(titleChanged), for: .editingChanged)
[labelsButton, milestoneButton].forEach { button in
var configuration = UIButton.Configuration.tinted()
configuration.imagePlacement = .leading
configuration.imagePadding = 8
button.configuration = configuration
button.showsMenuAsPrimaryAction = true
button.contentHorizontalAlignment = .leading
}
labelsButton.configuration?.image = context.symbol("tag")
milestoneButton.configuration?.image = context.symbol("flag")
let dueLabel = UILabel()
dueLabel.text = "Due Date"
dueLabel.font = .preferredFont(forTextStyle: .body)
dueLabel.adjustsFontForContentSizeCategory = true
let dueRow = UIStackView(arrangedSubviews: [dueLabel, dueSwitch])
dueRow.alignment = .center
dueSwitch.accessibilityLabel = "Set due date"
dueSwitch.addTarget(self, action: #selector(dueChanged), for: .valueChanged)
duePicker.datePickerMode = .date
duePicker.preferredDatePickerStyle = .inline
duePicker.isHidden = true
modeControl.selectedSegmentIndex = 0
modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged)
bodyView.delegate = self
bodyView.font = editorFont
bodyView.adjustsFontForContentSizeCategory = true
bodyView.backgroundColor = .secondarySystemGroupedBackground
bodyView.layer.cornerRadius = 10
bodyView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
bodyView.autocapitalizationType = .sentences
bodyView.accessibilityLabel = "Issue description in Markdown"
previewView.backgroundColor = .secondarySystemGroupedBackground
previewView.layer.cornerRadius = 10
previewView.accessibilityLabel = "Issue description preview"
previewView.isHidden = true
stack.addArrangedSubview(titleField)
stack.addArrangedSubview(labelsButton)
stack.addArrangedSubview(milestoneButton)
stack.addArrangedSubview(dueRow)
stack.addArrangedSubview(duePicker)
stack.addArrangedSubview(modeControl)
stack.addArrangedSubview(bodyView)
stack.addArrangedSubview(previewView)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32),
bodyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
previewView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
])
}
private func load() async {
do {
let page = try await context.core.issueEditor(
owner: owner,
repository: repository,
number: number
)
guard !Task.isCancelled else { return }
self.page = page
titleField.text = page.title
bodyView.text = page.body
selectedLabels = Set(page.labels.filter(\.selected).map(\.id))
selectedMilestone = page.milestones.first(where: \.selected)?.id
if let timestamp = page.dueDate {
dueSwitch.isOn = true
duePicker.date = localDate(forUTCTimestamp: timestamp)
duePicker.isHidden = false
}
updateLabelsMenu()
updateMilestoneMenu()
highlightBody()
stack.isHidden = false
navigationItem.rightBarButtonItem?.isEnabled = !page.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} catch {
if !Task.isCancelled { show(error: error) }
}
restoreSaveButton()
}
private func updateLabelsMenu() {
guard let page else { return }
labelsButton.configuration?.title = selectedLabels.isEmpty
? "Labels"
: "Labels (\(selectedLabels.count))"
labelsButton.accessibilityValue = selectedLabels.isEmpty
? "None"
: page.labels.filter { selectedLabels.contains($0.id) }.map(\.name).joined(separator: ", ")
let actions = page.labels.map { label in
UIAction(
title: label.name,
attributes: .keepsMenuPresented,
state: selectedLabels.contains(label.id) ? .on : .off
) { [weak self] action in
guard let self else { return }
if self.selectedLabels.remove(label.id) == nil { self.selectedLabels.insert(label.id) }
action.state = self.selectedLabels.contains(label.id) ? .on : .off
self.updateLabelsMenu()
}
}
labelsButton.menu = UIMenu(children: actions.isEmpty
? [UIAction(title: "No labels", attributes: .disabled) { _ in }]
: actions)
}
private func updateMilestoneMenu() {
guard let page else { return }
milestoneButton.configuration?.title = page.milestones
.first(where: { $0.id == selectedMilestone })?.title ?? "Milestone"
milestoneButton.accessibilityValue = page.milestones
.first(where: { $0.id == selectedMilestone })?.title ?? "None"
let none = UIAction(title: "No Milestone", state: selectedMilestone == nil ? .on : .off) {
[weak self] _ in
self?.selectedMilestone = nil
self?.updateMilestoneMenu()
}
milestoneButton.menu = UIMenu(options: .singleSelection, children: [none] + page.milestones.map { milestone in
UIAction(
title: milestone.title,
state: milestone.id == selectedMilestone ? .on : .off
) { [weak self] _ in
self?.selectedMilestone = milestone.id
self?.updateMilestoneMenu()
}
})
}
func textViewDidChange(_ textView: UITextView) {
highlightBody()
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
bodyView.becomeFirstResponder()
return true
}
private func highlightBody() {
let source = bodyView.text ?? ""
let selection = bodyView.selectedRange
let highlighter = Highlighter()
highlighter?.setTheme(traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light")
highlighter?.theme.setCodeFont(editorFont)
bodyView.attributedText = highlighter?.highlight(source, as: "markdown")
?? NSAttributedString(string: source, attributes: [
.font: editorFont,
.foregroundColor: UIColor.label,
])
bodyView.selectedRange = selection
bodyView.typingAttributes = [
.font: editorFont,
.foregroundColor: UIColor.label,
]
}
@objc private func modeChanged() {
let preview = modeControl.selectedSegmentIndex == 1
if preview {
previewController?.willMove(toParent: nil)
previewController?.view.removeFromSuperview()
previewController?.removeFromParent()
let controller = UIHostingController(
rootView: RepositoryMarkdownPreview(source: bodyView.text ?? "")
)
addChild(controller)
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.layer.cornerRadius = 10
controller.view.clipsToBounds = true
previewView.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: previewView.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: previewView.trailingAnchor),
controller.view.topAnchor.constraint(equalTo: previewView.topAnchor),
controller.view.bottomAnchor.constraint(equalTo: previewView.bottomAnchor),
])
controller.didMove(toParent: self)
previewController = controller
}
bodyView.isHidden = preview
previewView.isHidden = !preview
view.endEditing(true)
}
@objc private func dueChanged() {
duePicker.isHidden = !dueSwitch.isOn
if dueSwitch.isOn, page?.dueDate == nil { duePicker.date = Date() }
}
@objc private func titleChanged() {
navigationItem.rightBarButtonItem?.isEnabled = !(titleField.text ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@objc private func save() {
view.endEditing(true)
navigationItem.leftBarButtonItem?.isEnabled = false
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
spinner.startAnimating()
task?.cancel()
task = Task {
do {
let number = try await context.core.saveIssue(
owner: owner,
repository: repository,
number: number,
title: titleField.text ?? "",
body: bodyView.text ?? "",
labelIds: Array(selectedLabels),
milestoneId: selectedMilestone,
dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil
)
guard !Task.isCancelled else { return }
saved(number)
} catch {
if !Task.isCancelled {
show(error: error)
restoreSaveButton()
}
}
}
}
private func restoreSaveButton() {
spinner.stopAnimating()
navigationItem.leftBarButtonItem?.isEnabled = true
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Save",
style: .done,
target: self,
action: #selector(save)
)
titleChanged()
}
@objc private func cancel() { dismiss(animated: true) }
private func localDate(forUTCTimestamp timestamp: Int64) -> Date {
var utc = Calendar(identifier: .gregorian)
utc.timeZone = TimeZone(secondsFromGMT: 0)!
let components = utc.dateComponents([.year, .month, .day], from: Date(timeIntervalSince1970: TimeInterval(timestamp)))
return Calendar.current.date(from: components) ?? Date()
}
private func utcTimestamp(forLocalDate date: Date) -> Int64 {
let components = Calendar.current.dateComponents([.year, .month, .day], from: date)
var utc = Calendar(identifier: .gregorian)
utc.timeZone = TimeZone(secondsFromGMT: 0)!
return Int64((utc.date(from: components) ?? date).timeIntervalSince1970)
}
}