Add issue comment editing

This commit is contained in:
Georg Bauer
2026-07-31 15:17:09 +02:00
parent 33f9eb809c
commit da0a3aecb4
10 changed files with 451 additions and 12 deletions

View File

@@ -649,6 +649,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?) async throws -> Int64
func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String) async throws
func selectServer(index: UInt32) throws
func servers() -> [ServerRow]
@@ -1073,6 +1075,22 @@ open func saveIssue(owner: String, repository: String, number: Int64?, title: St
)
}
open func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String)async throws {
return
try await uniffiRustCallAsync(
rustFutureFunc: {
uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterOptionInt64.lower(commentId),FfiConverterString.lower(body)
)
},
pollFunc: ffi_gotcha_core_rust_future_poll_void,
completeFunc: ffi_gotcha_core_rust_future_complete_void,
freeFunc: ffi_gotcha_core_rust_future_free_void,
liftFunc: { $0 },
errorHandler: FfiConverterTypeGotchaError_lift
)
}
open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_select_server(
@@ -1299,16 +1317,20 @@ public func FfiConverterTypeActivityRow_lower(_ value: ActivityRow) -> RustBuffe
public struct CommentRow: Equatable, Hashable {
public var id: Int64
public var author: String
public var body: String
public var meta: String
public var canEdit: Bool
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(author: String, body: String, meta: String) {
public init(id: Int64, author: String, body: String, meta: String, canEdit: Bool) {
self.id = id
self.author = author
self.body = body
self.meta = meta
self.canEdit = canEdit
}
@@ -1327,16 +1349,20 @@ public struct FfiConverterTypeCommentRow: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CommentRow {
return
try CommentRow(
id: FfiConverterInt64.read(from: &buf),
author: FfiConverterString.read(from: &buf),
body: FfiConverterString.read(from: &buf),
meta: FfiConverterString.read(from: &buf)
meta: FfiConverterString.read(from: &buf),
canEdit: FfiConverterBool.read(from: &buf)
)
}
public static func write(_ value: CommentRow, into buf: inout [UInt8]) {
FfiConverterInt64.write(value.id, into: &buf)
FfiConverterString.write(value.author, into: &buf)
FfiConverterString.write(value.body, into: &buf)
FfiConverterString.write(value.meta, into: &buf)
FfiConverterBool.write(value.canEdit, into: &buf)
}
}
@@ -3882,6 +3908,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue() != 55053) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) {
return InitializationResult.apiChecksumMismatch
}

View File

@@ -374,6 +374,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, R
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_SAVE_ISSUE_COMMENT
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer comment_id, RustBuffer body
);
#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
@@ -820,6 +825,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void
#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_SAVE_ISSUE_COMMENT
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER

View File

@@ -10,6 +10,7 @@
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E10787062D1175125BB4C /* ContentScreens.swift */; };
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; };
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.swift */; };
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; };
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 */; };
@@ -26,6 +27,7 @@
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>"; };
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.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>"; };
CE5E10787062D1175125BB4C /* ContentScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScreens.swift; sourceTree = "<group>"; };
@@ -53,6 +55,7 @@
children = (
F61515849F6AACD721FE915C /* AppContext.swift */,
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */,
CE5E10787062D1175125BB4C /* ContentScreens.swift */,
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */,
722405F999984C7CBE838711 /* IssueEditorViewController.swift */,
@@ -193,6 +196,7 @@
files = (
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */,
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */,
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */,
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */,
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */,

View File

@@ -0,0 +1,201 @@
import Highlighter
import SwiftUI
import UIKit
@MainActor
final class CommentEditorViewController: UIViewController, UITextViewDelegate {
private let context: AppContext
private let owner: String
private let repository: String
private let number: Int64
private let comment: CommentRow?
private let saved: () -> Void
private let scrollView = UIScrollView()
private let modeControl = UISegmentedControl(items: ["Write", "Preview"])
private let bodyView = UITextView()
private let previewView = UIView()
private let spinner = UIActivityIndicatorView(style: .medium)
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,
comment: CommentRow? = nil,
saved: @escaping () -> Void
) {
self.context = context
self.owner = owner
self.repository = repository
self.number = number
self.comment = comment
self.saved = saved
super.init(nibName: nil, bundle: nil)
title = comment == nil ? "New Comment" : "Edit Comment"
}
@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)
)
restoreSaveButton()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.keyboardDismissMode = .interactive
view.addSubview(scrollView)
let stack = UIStackView(arrangedSubviews: [modeControl, bodyView, previewView])
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stack)
modeControl.selectedSegmentIndex = 0
modeControl.accessibilityLabel = "Comment editor mode"
modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged)
bodyView.delegate = self
bodyView.text = comment?.body ?? ""
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 = "Comment in Markdown"
previewView.backgroundColor = .secondarySystemGroupedBackground
previewView.layer.cornerRadius = 10
previewView.clipsToBounds = true
previewView.accessibilityLabel = "Comment preview"
previewView.isHidden = true
highlightBody()
updateSaveButton()
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),
])
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if comment == nil { bodyView.becomeFirstResponder() }
}
func textViewDidChange(_ textView: UITextView) {
highlightBody()
updateSaveButton()
}
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
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 save() {
view.endEditing(true)
navigationItem.leftBarButtonItem?.isEnabled = false
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
spinner.startAnimating()
task?.cancel()
task = Task {
do {
try await context.core.saveIssueComment(
owner: owner,
repository: repository,
number: number,
commentId: comment?.id,
body: bodyView.text ?? ""
)
guard !Task.isCancelled else { return }
saved()
} 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)
)
updateSaveButton()
}
private func updateSaveButton() {
navigationItem.rightBarButtonItem?.isEnabled = !(bodyView.text ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@objc private func cancel() { dismiss(animated: true) }
}

View File

@@ -146,15 +146,41 @@ final class IssueViewController: MarkdownPageViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.rightBarButtonItem = UIBarButtonItem(
let addComment = UIBarButtonItem(
image: context.symbol("plus.bubble"),
style: .plain,
target: self,
action: #selector(addComment)
)
addComment.accessibilityLabel = "Add comment"
let editIssue = UIBarButtonItem(
title: "Edit",
style: .plain,
target: self,
action: #selector(editIssue)
)
navigationItem.rightBarButtonItems = [addComment, editIssue]
loadContent(refreshing: false)
}
@objc private func addComment() { presentCommentEditor() }
private func editComment(_ comment: CommentRow) { presentCommentEditor(comment) }
private func presentCommentEditor(_ comment: CommentRow? = nil) {
let editor = CommentEditorViewController(
context: context,
owner: owner,
repository: repository,
number: number,
comment: comment
) { [weak self] in
guard let self else { return }
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
}
present(UINavigationController(rootViewController: editor), animated: true)
}
@objc private func editIssue() {
let editor = IssueEditorViewController(
context: context,
@@ -204,6 +230,7 @@ final class IssueViewController: MarkdownPageViewController {
meta: page.meta,
body: page.body,
comments: page.comments,
editComment: { [weak self] comment in self?.editComment(comment) },
milestone: page.milestone
))
finishPagination(hasMore: page.hasMore)
@@ -791,6 +818,7 @@ private func detailViews(
meta: String,
body: String,
comments: [CommentRow],
editComment: ((CommentRow) -> Void)? = nil,
milestone: String = ""
) -> [UIView] {
let titleLabel = UILabel()
@@ -815,7 +843,7 @@ private func detailViews(
milestoneLabel.accessibilityLabel = "Milestone \(milestone)"
views.append(milestoneLabel)
}
return views + [separator(), bodyView] + commentViews(comments)
return views + [separator(), bodyView] + commentViews(comments, editComment: editComment)
}
private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView {
@@ -843,18 +871,37 @@ private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView {
return stack
}
private func commentViews(_ comments: [CommentRow]) -> [UIView] {
private func commentViews(
_ comments: [CommentRow],
editComment: ((CommentRow) -> Void)? = nil
) -> [UIView] {
guard !comments.isEmpty else { return [] }
var views: [UIView] = [sectionLabel("Comments")]
for comment in comments {
let author = UILabel()
author.text = comment.author
author.font = .preferredFont(forTextStyle: .headline)
var headerViews: [UIView] = [author, UIView()]
if comment.canEdit, let editComment {
var configuration = UIButton.Configuration.plain()
configuration.title = "Edit"
configuration.image = UIImage(systemName: "pencil")
configuration.imagePadding = 5
configuration.contentInsets = .zero
let button = UIButton(
configuration: configuration,
primaryAction: UIAction { _ in editComment(comment) }
)
button.accessibilityLabel = "Edit comment by \(comment.author)"
headerViews.append(button)
}
let header = UIStackView(arrangedSubviews: headerViews)
header.alignment = .center
let date = UILabel()
date.text = comment.meta
date.font = .preferredFont(forTextStyle: .caption1)
date.textColor = .tertiaryLabel
let stack = UIStackView(arrangedSubviews: [author, markdownView(comment.body), date, separator()])
let stack = UIStackView(arrangedSubviews: [header, markdownView(comment.body), date, separator()])
stack.axis = .vertical
stack.spacing = 6
views.append(stack)