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 closedSwitch = UISwitch() private let dueSwitch = UISwitch() private let duePicker = UIDatePicker() private let spinner = UIActivityIndicatorView(style: .medium) private var page: IssueEditorPage? private var selectedLabels = Set() private var selectedMilestone: Int64? private var task: Task? 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 closedLabel = UILabel() closedLabel.text = "Closed" closedLabel.font = .preferredFont(forTextStyle: .body) closedLabel.adjustsFontForContentSizeCategory = true let closedRow = UIStackView(arrangedSubviews: [closedLabel, closedSwitch]) closedRow.alignment = .center closedSwitch.accessibilityLabel = "Closed issue" 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(closedRow) 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 closedSwitch.isOn = page.closed 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, closed: closedSwitch.isOn ) 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) } }