import UIKit @MainActor final class ServersViewController: UITableViewController { private let context: AppContext private var servers: [ServerRow] = [] private var mutationTask: Task? init(context: AppContext) { self.context = context super.init(style: .plain) title = "Servers" tableView.backgroundColor = .systemGroupedBackground tableView.separatorInset = .zero } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { mutationTask?.cancel() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) reloadServers() navigationItem.rightBarButtonItem = UIBarButtonItem( systemItem: .add, primaryAction: UIAction { [weak self] _ in self?.showServerEditor() } ) } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { servers.count } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "server") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "server") let server = servers[indexPath.row] configureTextCell(cell, title: server.name, detail: server.url) cell.accessoryType = .disclosureIndicator return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) do { try context.selectServer(index: UInt32(indexPath.row)) } catch { show(error: error) } } override func tableView( _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath ) -> UISwipeActionsConfiguration? { let server = servers[indexPath.row] let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completion in self?.confirmDelete(server, index: indexPath.row, completion: completion) ?? completion(false) } deleteAction.image = context.symbol("trash") let editAction = UIContextualAction(style: .normal, title: "Edit") { [weak self] _, _, completion in self?.showServerEditor(index: indexPath.row, completion: completion) ?? completion(false) } editAction.image = context.symbol("pencil") let configuration = UISwipeActionsConfiguration(actions: [deleteAction, editAction]) configuration.performsFirstActionWithFullSwipe = true return configuration } private func reloadServers() { servers = context.core.servers() tableView.reloadData() tableView.backgroundView = servers.isEmpty ? EmptyBackgroundView(title: "No servers", detail: "Add a code hosting server to get started.") : nil } private func showServerEditor( index: Int? = nil, completion swipeCompletion: ((Bool) -> Void)? = nil ) { do { let editor = try index.map { try context.core.serverEditor(index: UInt32($0)) } let controller = ServerEditorViewController( context: context, index: index.map(UInt32.init), editor: editor ) { [weak self] in self?.reloadServers() } present(UINavigationController(rootViewController: controller), animated: true) { swipeCompletion?(true) } } catch { swipeCompletion?(false) show(error: error) } } private func confirmDelete( _ server: ServerRow, index: Int, completion: @escaping (Bool) -> Void ) { let alert = UIAlertController( title: "Delete “\(server.name)”?", message: "This removes the server configuration and access token from this device. It doesn’t change anything on the server.", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) }) alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in guard let self else { completion(false) return } self.mutationTask?.cancel() self.mutationTask = Task { do { try await self.context.core.deleteServer(index: UInt32(index)) guard !Task.isCancelled else { completion(false) return } completion(true) self.reloadServers() self.context.reloadAfterServerChange() } catch { completion(false) if !Task.isCancelled { self.show(error: error) } } } }) present(alert, animated: true) } } @MainActor final class ServerEditorViewController: UITableViewController, UITextFieldDelegate { private let context: AppContext private let index: UInt32? private let editor: ServerEditor? private let completion: () -> Void private let nameField = UITextField() private let urlField = UITextField() private let tokenField = UITextField() private let providerButton = UIButton(type: .system) private var provider = ServerProvider.gitea private var saveButton: UIBarButtonItem! init( context: AppContext, index: UInt32?, editor: ServerEditor?, completion: @escaping () -> Void ) { self.context = context self.index = index self.editor = editor self.completion = completion provider = editor?.provider ?? .gitea super.init(style: .insetGrouped) title = index == nil ? "Add Server" : "Edit Server" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() navigationItem.leftBarButtonItem = UIBarButtonItem( systemItem: .cancel, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) } ) saveButton = UIBarButtonItem( title: index == nil ? "Add" : "Save", style: .done, target: self, action: #selector(save) ) navigationItem.rightBarButtonItem = saveButton configure(nameField, placeholder: "Work", contentType: .name) configure(urlField, placeholder: "https://gitea.example.com", contentType: .URL) urlField.keyboardType = .URL urlField.autocapitalizationType = .none configure(tokenField, placeholder: "Access token", contentType: nil) tokenField.isSecureTextEntry = true tokenField.autocapitalizationType = .none tokenField.returnKeyType = .done configureProviderButton() nameField.text = editor?.name urlField.text = editor?.url if editor != nil { tokenField.placeholder = "Leave unchanged" } } override func numberOfSections(in tableView: UITableView) -> Int { 4 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { ["API provider", "Name", "Server URL", "Access token"][section] } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = UITableViewCell(style: .default, reuseIdentifier: nil) let control: UIView = indexPath.section == 0 ? providerButton : [nameField, urlField, tokenField][indexPath.section - 1] control.translatesAutoresizingMaskIntoConstraints = false cell.contentView.addSubview(control) NSLayoutConstraint.activate([ control.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16), control.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16), control.topAnchor.constraint(equalTo: cell.contentView.topAnchor), control.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor), cell.contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48), ]) return cell } func textFieldShouldReturn(_ textField: UITextField) -> Bool { if textField === nameField { urlField.becomeFirstResponder() } else if textField === urlField { tokenField.becomeFirstResponder() } else { save() } return true } @objc private func save() { view.endEditing(true) saveButton.isEnabled = false let spinner = UIActivityIndicatorView(style: .medium) spinner.startAnimating() navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner) Task { do { if let index { try await context.core.updateServer( index: index, name: nameField.text ?? "", url: urlField.text ?? "", token: tokenField.text ?? "", provider: provider ) context.reloadAfterServerChange() } else { let index = try await context.core.addServer( name: nameField.text ?? "", url: urlField.text ?? "", token: tokenField.text ?? "", provider: provider ) try context.didAddServer(index: index) } completion() dismiss(animated: true) } catch { navigationItem.rightBarButtonItem = saveButton saveButton.isEnabled = true show(error: error) } } } private func configure( _ field: UITextField, placeholder: String, contentType: UITextContentType? ) { field.placeholder = placeholder field.textContentType = contentType field.clearButtonMode = .whileEditing field.delegate = self field.returnKeyType = .next field.adjustsFontForContentSizeCategory = true field.font = .preferredFont(forTextStyle: .body) } private func configureProviderButton() { providerButton.contentHorizontalAlignment = .leading providerButton.showsMenuAsPrimaryAction = true providerButton.changesSelectionAsPrimaryAction = true providerButton.accessibilityLabel = "API provider" providerButton.accessibilityValue = provider == .gitea ? "Gitea" : "Forgejo" providerButton.menu = UIMenu(options: .singleSelection, children: [ UIAction(title: "Gitea", state: provider == .gitea ? .on : .off) { [weak self] _ in self?.provider = .gitea self?.urlField.placeholder = "https://gitea.example.com" self?.providerButton.accessibilityValue = "Gitea" }, UIAction(title: "Forgejo", state: provider == .forgejo ? .on : .off) { [weak self] _ in self?.provider = .forgejo self?.urlField.placeholder = "https://forgejo.example.com" self?.providerButton.accessibilityValue = "Forgejo" }, ]) } }