312 lines
12 KiB
Swift
312 lines
12 KiB
Swift
import UIKit
|
||
|
||
@MainActor
|
||
final class ServersViewController: UITableViewController {
|
||
private let context: AppContext
|
||
private var servers: [ServerRow] = []
|
||
private var mutationTask: Task<Void, Never>?
|
||
|
||
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"
|
||
},
|
||
])
|
||
}
|
||
}
|