Present iPhone Git actions (#51)

This commit is contained in:
2026-08-14 17:24:41 +02:00
parent fcf649e56d
commit 65eb1da71c
8 changed files with 1054 additions and 90 deletions

View File

@@ -93,8 +93,8 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
TotpListViewController(shellPage: page, authentication: authentication)
case .preferences:
PreferencesViewController(page: page, authentication: authentication)
default:
ShellViewController(page: page)
case .home:
ShellViewController(page: page, authentication: authentication)
}
let navigation = UINavigationController(rootViewController: root)
navigation.navigationBar.prefersLargeTitles = true
@@ -182,6 +182,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
@MainActor
private final class ShellViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab: MobileTab
private let authentication: MobileAuthentication?
private var page: MobilePage
private var loadTask: Task<Void, Never>?
private var loadGeneration = 0
@@ -202,12 +203,20 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
private enum HomeRequest: Equatable {
case refreshIfStale
case refresh
case commit(String)
case fetch
case pull
case push
var changesLocalStore: Bool {
if case .pull = self { return true }
return false
}
}
init(page: MobilePage) {
init(page: MobilePage, authentication: MobileAuthentication?) {
shellTab = page.tab
self.authentication = authentication
self.page = page
super.init(style: .insetGrouped)
title = page.title
@@ -344,6 +353,43 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
)
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
let actions = homeActions(at: indexPath).map { action in
let contextual = UIContextualAction(style: .normal, title: action.title) {
[weak self] _, _, completion in
self?.perform(action)
completion(true)
}
contextual.image = UIImage(systemName: action.systemImage)
contextual.backgroundColor = actionColor(action.kind)
return contextual
}
guard !actions.isEmpty else { return nil }
let configuration = UISwipeActionsConfiguration(actions: actions)
configuration.performsFirstActionWithFullSwipe = actions.count == 1
return configuration
}
override func tableView(
_ tableView: UITableView,
contextMenuConfigurationForRowAt indexPath: IndexPath,
point: CGPoint
) -> UIContextMenuConfiguration? {
let actions = homeActions(at: indexPath)
guard !actions.isEmpty else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
UIMenu(children: actions.map { action in
UIAction(
title: action.title,
image: UIImage(systemName: action.systemImage)
) { [weak self] _ in self?.perform(action) }
})
}
}
@objc private func refreshRequested() {
if shellTab == .home, page.state == .ready {
guard !isHomeWorking else {
@@ -434,7 +480,13 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
}
@objc private func statusRefreshRequested() {
runHome(.refresh)
runHome(.fetch)
}
@objc private func cancelHomeRequested() {
homeOperation?.cancel()
navigationItem.leftBarButtonItem?.isEnabled = false
navigationItem.prompt = "Cancelling the current Git action…"
}
private var homeSections: [HomeSection] {
@@ -466,18 +518,25 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
content.text = row.title
content.secondaryText = row.detail
cell.selectionStyle = .none
cell.accessibilityCustomActions = accessibilityActions(row.actions)
case .incoming:
configureCommit(
homePage.incoming[indexPath.row],
content: &content,
cell: cell
)
cell.accessibilityCustomActions = accessibilityActions(
homePage.incoming[indexPath.row].actions
)
case .outgoing:
configureCommit(
homePage.outgoing[indexPath.row],
content: &content,
cell: cell
)
cell.accessibilityCustomActions = accessibilityActions(
homePage.outgoing[indexPath.row].actions
)
case .empty:
content.image = UIImage(systemName: "checkmark.circle")
content.text = "No Remote Activity"
@@ -533,7 +592,11 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
guard homePage == nil, homeTask == nil, !isHomeWorking else { return }
homeGeneration += 1
let current = homeGeneration
let operation = mobileHomeOperation()
guard let authentication else {
showHomeFailure(.unavailable)
return
}
let operation = mobileHomeOperation(authentication: authentication)
homeOperation = operation
isHomeWorking = true
navigationItem.rightBarButtonItem?.isEnabled = false
@@ -558,10 +621,14 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
private func runHome(_ request: HomeRequest) {
guard page.state == .ready, shellTab == .home, !isHomeWorking else { return }
guard let authentication else {
showHomeFailure(.unavailable)
return
}
cancelHomeWork()
homeGeneration += 1
let current = homeGeneration
let operation = mobileHomeOperation()
let operation = mobileHomeOperation(authentication: authentication)
homeOperation = operation
isHomeWorking = true
navigationItem.rightBarButtonItem?.isEnabled = false
@@ -582,8 +649,10 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
do {
let page: MobileHomePage = switch request {
case .refreshIfStale: try operation.refreshIfStale()
case .refresh: try operation.refresh()
case let .commit(message): try operation.commit(message: message)
case .fetch: try operation.fetch()
case .pull: try operation.pull()
case .push: try operation.push()
}
return Result<MobileHomePage, HomeFailure>.success(page)
} catch let error as MobileHomeFfiError {
@@ -609,24 +678,32 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
navigationItem.titleView = nil
navigationItem.prompt = nil
navigationItem.rightBarButtonItem?.isEnabled = true
navigationItem.leftBarButtonItem = nil
refreshControl?.endRefreshing()
switch result {
case let .success(homePage):
self.homePage = homePage
contentUnavailableConfiguration = nil
tableView.reloadData()
if request == .pull {
if request?.changesLocalStore == true {
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
if let notice = homePage.notice {
UIAccessibility.post(notification: .announcement, argument: notice.title)
}
NotificationCenter.default.post(
name: .ironStorageWatchSnapshotDidChange,
object: nil
)
}
if let notice = homePage.notice {
UIAccessibility.post(notification: .announcement, argument: notice.title)
}
case let .failure(failure):
tableView.reloadData()
if homePage == nil {
showHomeFailure(failure)
} else if failure.kind != .interrupted {
presentHomeFailure(failure)
presentHomeFailure(failure, retry: request ?? .fetch)
} else if failure.kind == .interrupted {
UIAccessibility.post(notification: .announcement, argument: failure.title)
} else {
presentHomeFailure(failure, retry: request ?? .fetch)
}
}
}
@@ -646,7 +723,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
contentUnavailableConfiguration = configuration
}
private func presentHomeFailure(_ failure: HomeFailure) {
private func presentHomeFailure(_ failure: HomeFailure, retry: HomeRequest) {
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
@@ -661,6 +738,9 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
} ?? tabs.selectedIndex
})
}
alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in
self?.runHome(retry)
})
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
@@ -677,6 +757,12 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
stack.spacing = 8
navigationItem.titleView = stack
navigationItem.prompt = progress.detail
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "Cancel",
style: .plain,
target: self,
action: #selector(cancelHomeRequested)
)
}
private func cancelHomeWork() {
@@ -690,10 +776,70 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
isHomeWorking = false
navigationItem.titleView = nil
navigationItem.prompt = nil
navigationItem.leftBarButtonItem = nil
refreshControl?.endRefreshing()
navigationItem.rightBarButtonItem?.isEnabled = true
}
private func homeActions(at indexPath: IndexPath) -> [MobileHomeAction] {
guard let homePage, homeSections.indices.contains(indexPath.section) else { return [] }
return switch homeSections[indexPath.section] {
case .summary where homePage.summaries.indices.contains(indexPath.row):
homePage.summaries[indexPath.row].actions
case .incoming where homePage.incoming.indices.contains(indexPath.row):
homePage.incoming[indexPath.row].actions
case .outgoing where homePage.outgoing.indices.contains(indexPath.row):
homePage.outgoing[indexPath.row].actions
default:
[]
}
}
private func perform(_ action: MobileHomeAction) {
switch action.kind {
case .commit: promptForCommitMessage()
case .fetch: runHome(.fetch)
case .pull: runHome(.pull)
case .push: runHome(.push)
}
}
private func promptForCommitMessage() {
let alert = UIAlertController(
title: "Commit Changes",
message: "Enter the message for all current password-store changes.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "Commit message"
field.autocapitalizationType = .sentences
field.returnKeyType = .done
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Commit", style: .default) { [weak self, weak alert] _ in
self?.runHome(.commit(alert?.textFields?.first?.text ?? ""))
})
present(alert, animated: true)
}
private func accessibilityActions(_ actions: [MobileHomeAction]) -> [UIAccessibilityCustomAction] {
actions.map { action in
UIAccessibilityCustomAction(name: action.title) { [weak self] _ in
self?.perform(action)
return true
}
}
}
private func actionColor(_ kind: MobileHomeActionKind) -> UIColor {
switch kind {
case .commit: .systemOrange
case .fetch: .systemGray
case .pull: .systemBlue
case .push: .systemGreen
}
}
private func stateImage(_ state: MobileShellState) -> String {
switch state {
case .loading: "hourglass"
@@ -5275,6 +5421,12 @@ private struct HomeFailure: Error, Sendable {
detail: "IronStorage could not load the storage-provided Home page."
)
static let unavailable = HomeFailure(
kind: .configuration,
title: "Home Is Unavailable",
detail: "Complete password-store setup before using Git actions."
)
private init(kind: MobileHomeErrorKind, title: String, detail: String) {
self.kind = kind
self.title = title