mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
feat: combined instance view (#18)
Co-authored-by: Stefan Hausotte <stefan.hausotte@gmx.de> Co-committed-by: Stefan Hausotte <stefan.hausotte@gmx.de>
This commit is contained in:
parent
70a1cf010a
commit
10bbed5596
19 changed files with 1800 additions and 63 deletions
|
|
@ -18,8 +18,11 @@ enum AppAppearance: Int, CaseIterable {
|
|||
|
||||
struct ContentView: View {
|
||||
@AppStorage("appearance") private var appearance: AppAppearance = .system
|
||||
@AppStorage("defaultAllInstances") private var defaultAllInstances = false
|
||||
@State private var authService = AuthenticationService()
|
||||
@State private var multiInstanceManager: MultiInstanceManager?
|
||||
@Query(filter: #Predicate<ForgejoInstance> { $0.isDefault }) private var defaultInstances: [ForgejoInstance]
|
||||
@Query private var allInstances: [ForgejoInstance]
|
||||
@State private var hasAttemptedAutoLogin = false
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
|
|
@ -29,17 +32,23 @@ struct ContentView: View {
|
|||
@AppStorage("dev_username") private var devUsername = ""
|
||||
@AppStorage("dev_password") private var devPassword = ""
|
||||
@AppStorage("dev_skipAutoLogin") private var devSkipAutoLogin = false
|
||||
@AppStorage("dev_resetData") private var devResetData = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if authService.isAuthenticated, authService.client != nil {
|
||||
if let manager = multiInstanceManager {
|
||||
HomeView(multiInstanceManager: manager) {
|
||||
multiInstanceManager?.disconnect()
|
||||
multiInstanceManager = nil
|
||||
}
|
||||
} else if authService.isAuthenticated, authService.client != nil {
|
||||
HomeView(authService: authService)
|
||||
} else if !hasAttemptedAutoLogin {
|
||||
ProgressView("Connecting...")
|
||||
.task { await attemptAutoLogin() }
|
||||
} else {
|
||||
InstanceListView(authService: authService)
|
||||
InstanceListView(authService: authService, multiInstanceManager: $multiInstanceManager)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
|
|
@ -47,51 +56,76 @@ struct ContentView: View {
|
|||
|
||||
private func attemptAutoLogin() async {
|
||||
#if DEBUG
|
||||
if devResetData {
|
||||
do {
|
||||
try modelContext.delete(model: ForgejoInstance.self)
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
assertionFailure("dev_resetData failed: \(error)")
|
||||
}
|
||||
devResetData = false
|
||||
}
|
||||
if devSkipAutoLogin {
|
||||
hasAttemptedAutoLogin = true
|
||||
return
|
||||
}
|
||||
// Dev auto-login: launch arguments set dev_ UserDefaults
|
||||
if !devServerURL.isEmpty, !devUsername.isEmpty, !devPassword.isEmpty {
|
||||
do {
|
||||
try await authService.login(serverURL: devServerURL, username: devUsername, password: devPassword)
|
||||
let descriptor = FetchDescriptor<ForgejoInstance>(predicate: #Predicate {
|
||||
$0.serverURL == devServerURL && $0.username == devUsername
|
||||
})
|
||||
let existing = (try? modelContext.fetch(descriptor)) ?? []
|
||||
let instance: ForgejoInstance
|
||||
if let found = existing.first {
|
||||
instance = found
|
||||
} else {
|
||||
instance = ForgejoInstance(serverURL: devServerURL, username: devUsername, name: "Dev")
|
||||
modelContext.insert(instance)
|
||||
}
|
||||
authService.currentInstance = instance
|
||||
hasAttemptedAutoLogin = true
|
||||
return
|
||||
} catch {
|
||||
// Fall through to normal flow
|
||||
}
|
||||
if await attemptDevAutoLogin() {
|
||||
hasAttemptedAutoLogin = true
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
guard let defaultInstance = defaultInstances.first else {
|
||||
if defaultAllInstances, allInstances.count > 1 {
|
||||
let manager = MultiInstanceManager()
|
||||
await manager.connect(instances: allInstances)
|
||||
if manager.isConnected {
|
||||
multiInstanceManager = manager
|
||||
}
|
||||
hasAttemptedAutoLogin = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await authService.restoreSession(instance: defaultInstance)
|
||||
defaultInstance.lastUsed = Date()
|
||||
|
||||
if let defaultInstance = defaultInstances.first {
|
||||
do {
|
||||
try modelContext.save()
|
||||
try await authService.restoreSession(instance: defaultInstance)
|
||||
defaultInstance.lastUsed = Date()
|
||||
try? modelContext.save()
|
||||
} catch {
|
||||
assertionFailure("SwiftData save failed: \(error)")
|
||||
// Auto-login failed — fall through to instance list
|
||||
}
|
||||
} catch {
|
||||
// Auto-login failed — fall through to instance list
|
||||
}
|
||||
hasAttemptedAutoLogin = true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func attemptDevAutoLogin() async -> Bool {
|
||||
guard !devServerURL.isEmpty, !devUsername.isEmpty, !devPassword.isEmpty else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
try await authService.login(
|
||||
serverURL: devServerURL, username: devUsername, password: devPassword,
|
||||
)
|
||||
let descriptor = FetchDescriptor<ForgejoInstance>(predicate: #Predicate {
|
||||
$0.serverURL == devServerURL && $0.username == devUsername
|
||||
})
|
||||
let existing = (try? modelContext.fetch(descriptor)) ?? []
|
||||
let instance: ForgejoInstance
|
||||
if let found = existing.first {
|
||||
instance = found
|
||||
} else {
|
||||
instance = ForgejoInstance(
|
||||
serverURL: devServerURL, username: devUsername, name: "Dev",
|
||||
)
|
||||
modelContext.insert(instance)
|
||||
}
|
||||
authService.currentInstance = instance
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
|
|||
33
Forji/Forji/Helpers/TaggedItem.swift
Normal file
33
Forji/Forji/Helpers/TaggedItem.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import Foundation
|
||||
|
||||
struct TaggedItem<T: Identifiable>: Identifiable {
|
||||
let id: String
|
||||
let item: T
|
||||
let instanceName: String
|
||||
let authService: AuthenticationService
|
||||
|
||||
init(item: T, instanceName: String, authService: AuthenticationService) {
|
||||
id = "\(instanceName):\(item.id)"
|
||||
self.item = item
|
||||
self.instanceName = instanceName
|
||||
self.authService = authService
|
||||
}
|
||||
|
||||
static func mergeAndDeduplicate(
|
||||
batches: [(Int, [T])],
|
||||
sources: [ConnectionSource],
|
||||
) -> [TaggedItem<T>] {
|
||||
var seen = Set<String>()
|
||||
var merged = [TaggedItem<T>]()
|
||||
for (index, items) in batches {
|
||||
let source = sources[index]
|
||||
for item in items {
|
||||
let tagged = TaggedItem(item: item, instanceName: source.name, authService: source.authService)
|
||||
if seen.insert(tagged.id).inserted {
|
||||
merged.append(tagged)
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,9 @@ enum InvolvementScope: String, CaseIterable {
|
|||
case assigned
|
||||
case mentioned
|
||||
case reviewRequested = "review_requested"
|
||||
|
||||
static let issueFlags: [InvolvementScope] = [.created, .assigned, .mentioned]
|
||||
static let pullRequestFlags: [InvolvementScope] = [.created, .assigned, .mentioned, .reviewRequested]
|
||||
}
|
||||
|
||||
extension NotificationSubject {
|
||||
|
|
|
|||
|
|
@ -108,25 +108,45 @@ class AuthenticationService {
|
|||
|
||||
func restoreSession(instance: ForgejoInstance) async throws {
|
||||
let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL)
|
||||
try await restoreWithCredentials(
|
||||
serverURL: normalizedURL,
|
||||
username: instance.username,
|
||||
allowSelfSigned: instance.allowSelfSignedCertificates,
|
||||
useTokenAuth: instance.useTokenAuth,
|
||||
)
|
||||
currentInstance = instance
|
||||
}
|
||||
|
||||
func restoreFromSnapshot(_ snapshot: InstanceSnapshot) async throws {
|
||||
let normalizedURL = ForgejoClient.normalizeServerURL(snapshot.serverURL)
|
||||
try await restoreWithCredentials(
|
||||
serverURL: normalizedURL,
|
||||
username: snapshot.username,
|
||||
allowSelfSigned: snapshot.allowSelfSigned,
|
||||
useTokenAuth: snapshot.useTokenAuth,
|
||||
)
|
||||
}
|
||||
|
||||
private func restoreWithCredentials(
|
||||
serverURL: String, username: String, allowSelfSigned: Bool, useTokenAuth: Bool
|
||||
) async throws {
|
||||
// Try restoring from stored API token first (avoids 2FA prompt)
|
||||
if let token = try? await KeychainManager.shared.getToken(for: normalizedURL, username: instance.username) {
|
||||
if let token = try? await KeychainManager.shared.getToken(for: serverURL, username: username) {
|
||||
let tokenClient = ForgejoClient(
|
||||
serverURL: normalizedURL,
|
||||
username: instance.username,
|
||||
serverURL: serverURL,
|
||||
username: username,
|
||||
token: token,
|
||||
allowSelfSignedCertificates: instance.allowSelfSignedCertificates,
|
||||
allowSelfSignedCertificates: allowSelfSigned,
|
||||
)
|
||||
do {
|
||||
let user = try await tokenClient.fetchCurrentUser()
|
||||
client = tokenClient
|
||||
currentUser = user
|
||||
isAuthenticated = true
|
||||
currentInstance = instance
|
||||
return
|
||||
} catch {
|
||||
// Token is invalid/expired — only fall through to password if this is not a token-only instance
|
||||
if instance.useTokenAuth {
|
||||
if useTokenAuth {
|
||||
throw SessionRestoreError.tokenExpired
|
||||
}
|
||||
// Otherwise fall through to password-based login below
|
||||
|
|
@ -134,16 +154,14 @@ class AuthenticationService {
|
|||
}
|
||||
|
||||
// Fall back to password-based login (creates a new token)
|
||||
guard !instance.useTokenAuth else {
|
||||
guard !useTokenAuth else {
|
||||
throw SessionRestoreError.tokenExpired
|
||||
}
|
||||
let password = try await KeychainManager.shared.getPassword(for: normalizedURL, username: instance.username)
|
||||
let password = try await KeychainManager.shared.getPassword(for: serverURL, username: username)
|
||||
try await login(
|
||||
serverURL: normalizedURL, username: instance.username,
|
||||
password: password,
|
||||
allowSelfSigned: instance.allowSelfSignedCertificates,
|
||||
serverURL: serverURL, username: username,
|
||||
password: password, allowSelfSigned: allowSelfSigned,
|
||||
)
|
||||
currentInstance = instance
|
||||
}
|
||||
|
||||
// Stub factories for SwiftUI previews
|
||||
|
|
|
|||
122
Forji/Forji/Services/MultiInstanceManager.swift
Normal file
122
Forji/Forji/Services/MultiInstanceManager.swift
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import ForgejoKit
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MultiInstanceManager {
|
||||
private(set) var connections: [(instance: ForgejoInstance, authService: AuthenticationService)] = []
|
||||
private(set) var failedInstances: [(instance: ForgejoInstance, error: String)] = []
|
||||
private(set) var isConnecting = false
|
||||
|
||||
var isConnected: Bool {
|
||||
!connections.isEmpty
|
||||
}
|
||||
|
||||
func connect(instances: [ForgejoInstance]) async {
|
||||
isConnecting = true
|
||||
connections = []
|
||||
failedInstances = []
|
||||
|
||||
// Extract Sendable data before entering the task group
|
||||
let snapshots = instances.map { instance in
|
||||
InstanceSnapshot(
|
||||
serverURL: instance.serverURL,
|
||||
username: instance.username,
|
||||
allowSelfSigned: instance.allowSelfSignedCertificates,
|
||||
useTokenAuth: instance.useTokenAuth,
|
||||
)
|
||||
}
|
||||
|
||||
// Create AuthenticationService instances on the main actor before the task group
|
||||
let authServices = snapshots.map { _ in AuthenticationService() }
|
||||
|
||||
let results = await withTaskGroup(
|
||||
of: (Int, Bool, String?).self,
|
||||
) { group in
|
||||
for (index, snapshot) in snapshots.enumerated() {
|
||||
let auth = authServices[index]
|
||||
group.addTask {
|
||||
do {
|
||||
try await auth.restoreFromSnapshot(snapshot)
|
||||
return (index, true, nil)
|
||||
} catch {
|
||||
return (index, false, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
var collected = [(Int, Bool, String?)]()
|
||||
for await result in group {
|
||||
collected.append(result)
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
for (index, success, error) in results {
|
||||
let instance = instances[index]
|
||||
if success {
|
||||
connections.append((instance: instance, authService: authServices[index]))
|
||||
} else {
|
||||
failedInstances.append((instance: instance, error: error ?? "Unknown error"))
|
||||
}
|
||||
}
|
||||
|
||||
isConnecting = false
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
for connection in connections {
|
||||
connection.authService.disconnect()
|
||||
}
|
||||
connections = []
|
||||
failedInstances = []
|
||||
}
|
||||
|
||||
var totalUnreadCount: Int {
|
||||
get async {
|
||||
let clients = connections.compactMap(\.authService.client)
|
||||
return await withTaskGroup(of: Int.self) { group in
|
||||
for client in clients {
|
||||
group.addTask {
|
||||
let service = NotificationService(client: client)
|
||||
return (try? await service.fetchUnreadCount()) ?? 0
|
||||
}
|
||||
}
|
||||
var total = 0
|
||||
for await count in group {
|
||||
total += count
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connectionSources() -> [ConnectionSource] {
|
||||
connections.compactMap { connection in
|
||||
guard let client = connection.authService.client else { return nil }
|
||||
return ConnectionSource(
|
||||
name: displayName(for: connection.instance),
|
||||
client: client,
|
||||
authService: connection.authService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func displayName(for instance: ForgejoInstance) -> String {
|
||||
instance.name.isEmpty ? instance.serverURL : instance.name
|
||||
}
|
||||
}
|
||||
|
||||
/// Sendable snapshot of ForgejoInstance data needed for session restore.
|
||||
struct InstanceSnapshot: Sendable {
|
||||
let serverURL: String
|
||||
let username: String
|
||||
let allowSelfSigned: Bool
|
||||
let useTokenAuth: Bool
|
||||
}
|
||||
|
||||
struct ConnectionSource {
|
||||
let name: String
|
||||
let client: ForgejoClient
|
||||
let authService: AuthenticationService
|
||||
}
|
||||
|
|
@ -1,48 +1,77 @@
|
|||
import ForgejoKit
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct HomeView: View {
|
||||
@State private var authService: AuthenticationService
|
||||
@State private var authService: AuthenticationService?
|
||||
@State private var multiInstanceManager: MultiInstanceManager?
|
||||
@State private var unreadCount = 0
|
||||
@State private var selectedTab = 0
|
||||
|
||||
private let notificationService: NotificationService?
|
||||
private var onDisconnectMerged: (() -> Void)?
|
||||
|
||||
init(authService: AuthenticationService) {
|
||||
self.authService = authService
|
||||
notificationService = authService.client.map { NotificationService(client: $0) }
|
||||
}
|
||||
|
||||
init(multiInstanceManager: MultiInstanceManager, onDisconnect: @escaping () -> Void) {
|
||||
self.multiInstanceManager = multiInstanceManager
|
||||
notificationService = nil
|
||||
onDisconnectMerged = onDisconnect
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
Tab("Repositories", systemImage: "folder.fill", value: 0) {
|
||||
NavigationStack {
|
||||
RepositoryListView(authService: authService)
|
||||
if let manager = multiInstanceManager {
|
||||
MergedRepositoryListView(manager: manager)
|
||||
} else if let authService {
|
||||
RepositoryListView(authService: authService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Tab("Issues", systemImage: "exclamationmark.circle.fill", value: 1) {
|
||||
NavigationStack {
|
||||
IssuesOverviewView(authService: authService)
|
||||
if let manager = multiInstanceManager {
|
||||
MergedIssuesOverviewView(manager: manager)
|
||||
} else if let authService {
|
||||
IssuesOverviewView(authService: authService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Tab("Pull Requests", systemImage: "arrow.triangle.pull", value: 2) {
|
||||
NavigationStack {
|
||||
PullRequestsOverviewView(authService: authService)
|
||||
if let manager = multiInstanceManager {
|
||||
MergedPullRequestsOverviewView(manager: manager)
|
||||
} else if let authService {
|
||||
PullRequestsOverviewView(authService: authService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Tab("Notifications", systemImage: "bell.fill", value: 3) {
|
||||
NavigationStack {
|
||||
NotificationsOverviewView(authService: authService)
|
||||
if let manager = multiInstanceManager {
|
||||
MergedNotificationsOverviewView(manager: manager)
|
||||
} else if let authService {
|
||||
NotificationsOverviewView(authService: authService)
|
||||
}
|
||||
}
|
||||
}
|
||||
.badge(unreadCount)
|
||||
|
||||
Tab("Settings", systemImage: "gearshape", value: 4) {
|
||||
NavigationStack {
|
||||
SettingsTabView(authService: authService)
|
||||
if let manager = multiInstanceManager {
|
||||
MergedSettingsTabView(manager: manager, onDisconnect: onDisconnectMerged)
|
||||
} else if let authService {
|
||||
SettingsTabView(authService: authService)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -56,11 +85,14 @@ struct HomeView: View {
|
|||
}
|
||||
|
||||
private func loadUnreadCount() async {
|
||||
guard let notificationService else { return }
|
||||
do {
|
||||
unreadCount = try await notificationService.fetchUnreadCount()
|
||||
} catch {
|
||||
// Silently ignore — badge is non-critical
|
||||
if let multiInstanceManager {
|
||||
unreadCount = await multiInstanceManager.totalUnreadCount
|
||||
} else if let notificationService {
|
||||
do {
|
||||
unreadCount = try await notificationService.fetchUnreadCount()
|
||||
} catch {
|
||||
// Silently ignore — badge is non-critical
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +223,144 @@ struct SettingsTabView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Merged Settings Tab
|
||||
|
||||
struct MergedSettingsTabView: View {
|
||||
@AppStorage("appearance") private var appearance: AppAppearance = .system
|
||||
@AppStorage("defaultAllInstances") private var defaultAllInstances = false
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var allInstances: [ForgejoInstance]
|
||||
let manager: MultiInstanceManager
|
||||
var onDisconnect: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Appearance") {
|
||||
Picker("Theme", selection: $appearance) {
|
||||
Text("System").tag(AppAppearance.system)
|
||||
Text("Light").tag(AppAppearance.light)
|
||||
Text("Dark").tag(AppAppearance.dark)
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Section("Connected Instances") {
|
||||
ForEach(manager.connections, id: \.instance.id) { connection in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(manager.displayName(for: connection.instance))
|
||||
.font(.headline)
|
||||
Text(connection.instance.serverURL)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("@\(connection.instance.username)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if !manager.failedInstances.isEmpty {
|
||||
Section("Failed Connections") {
|
||||
ForEach(manager.failedInstances, id: \.instance.id) { failed in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(manager.displayName(for: failed.instance))
|
||||
.font(.headline)
|
||||
Text(failed.error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Default on Launch", isOn: $defaultAllInstances)
|
||||
.onChange(of: defaultAllInstances) { _, isOn in
|
||||
if isOn {
|
||||
for inst in allInstances where inst.isDefault {
|
||||
inst.isDefault = false
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Automatically connect to all instances when the app launches.")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
onDisconnect?()
|
||||
} label: {
|
||||
Label("Switch Instance", systemImage: "arrow.triangle.swap")
|
||||
}
|
||||
.accessibilityIdentifier("home-switch-instance-button")
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Copyright", value: "Stefan Hausotte")
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("License")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
// swiftlint:disable:next line_length
|
||||
Text("Forji is free software licensed under the GNU General Public License v3.0 or later (GPLv3+). You are free to use, modify, and redistribute it under the terms of that license.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Logo")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("The Forji logo is based on the Forgejo logo by Caesar Schinas, licensed under CC BY-SA 4.0.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Used Libraries")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Link("ForgejoKit (MIT)", destination: URL(string: "https://codeberg.org/secana/ForgejoKit")!)
|
||||
Link("Textual (MIT)", destination: URL(string: "https://github.com/gonzalezreal/textual")!)
|
||||
Link(
|
||||
"HighlightSwift (MIT)",
|
||||
destination: URL(string: "https://github.com/appstefan/HighlightSwift")!,
|
||||
)
|
||||
Link(
|
||||
"mermaid (MIT)",
|
||||
destination: URL(string: "https://github.com/mermaid-js/mermaid")!,
|
||||
)
|
||||
Link(
|
||||
"marked (MIT)",
|
||||
destination: URL(string: "https://github.com/markedjs/marked")!,
|
||||
)
|
||||
Link(
|
||||
"DOMPurify (Apache 2.0/MPL 2.0)",
|
||||
destination: URL(string: "https://github.com/cure53/DOMPurify")!,
|
||||
)
|
||||
Link(
|
||||
"github-markdown-css (MIT)",
|
||||
destination: URL(string: "https://github.com/sindresorhus/github-markdown-css")!,
|
||||
)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
HomeView(authService: .previewDefault)
|
||||
|
|
|
|||
15
Forji/Forji/Views/InstanceBadge.swift
Normal file
15
Forji/Forji/Views/InstanceBadge.swift
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import SwiftUI
|
||||
|
||||
struct InstanceBadge: View {
|
||||
let name: String
|
||||
|
||||
var body: some View {
|
||||
Text(name)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.glassEffect(.regular.tint(colorForLanguage(name)))
|
||||
.accessibilityIdentifier("instance-badge")
|
||||
}
|
||||
}
|
||||
|
|
@ -5,15 +5,19 @@ import SwiftUI
|
|||
struct InstanceListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \ForgejoInstance.lastUsed, order: .reverse) private var instances: [ForgejoInstance]
|
||||
@AppStorage("defaultAllInstances") private var defaultAllInstances = false
|
||||
@State private var authService: AuthenticationService
|
||||
@Binding var multiInstanceManager: MultiInstanceManager?
|
||||
@State private var showAddSheet = false
|
||||
@State private var editingInstance: ForgejoInstance?
|
||||
@State private var connectingInstance: ForgejoInstance?
|
||||
@State private var connectingAll = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
|
||||
init(authService: AuthenticationService) {
|
||||
init(authService: AuthenticationService, multiInstanceManager: Binding<MultiInstanceManager?>) {
|
||||
self.authService = authService
|
||||
_multiInstanceManager = multiInstanceManager
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -28,6 +32,20 @@ struct InstanceListView: View {
|
|||
}
|
||||
} else {
|
||||
List {
|
||||
if instances.count > 1 {
|
||||
allInstancesRow
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
Button {
|
||||
toggleDefaultAll()
|
||||
} label: {
|
||||
Label(
|
||||
defaultAllInstances ? "Unset Default" : "Set Default",
|
||||
systemImage: defaultAllInstances ? "star.slash" : "star.fill",
|
||||
)
|
||||
}
|
||||
.tint(.yellow)
|
||||
}
|
||||
}
|
||||
ForEach(instances) { instance in
|
||||
instanceRow(instance)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
|
|
@ -119,6 +137,57 @@ struct InstanceListView: View {
|
|||
.accessibilityIdentifier("instance-row")
|
||||
}
|
||||
|
||||
private var allInstancesRow: some View {
|
||||
Button {
|
||||
connectAll()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text("All Instances")
|
||||
.font(.headline)
|
||||
if defaultAllInstances {
|
||||
Text("Default")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.glassEffect(.regular.tint(.yellow))
|
||||
}
|
||||
}
|
||||
Text("\(instances.count) instances")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if connectingAll {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "rectangle.stack.fill")
|
||||
.foregroundStyle(.blue)
|
||||
.font(.title3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(connectingInstance != nil || connectingAll)
|
||||
.accessibilityIdentifier("all-instances-row")
|
||||
}
|
||||
|
||||
private func connectAll() {
|
||||
connectingAll = true
|
||||
let manager = MultiInstanceManager()
|
||||
Task {
|
||||
await manager.connect(instances: instances)
|
||||
if manager.isConnected {
|
||||
multiInstanceManager = manager
|
||||
} else {
|
||||
errorMessage = "Could not connect to any instance"
|
||||
showError = true
|
||||
}
|
||||
connectingAll = false
|
||||
}
|
||||
}
|
||||
|
||||
private func connect(to instance: ForgejoInstance) {
|
||||
connectingInstance = instance
|
||||
Task {
|
||||
|
|
@ -158,6 +227,20 @@ struct InstanceListView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func toggleDefaultAll() {
|
||||
defaultAllInstances.toggle()
|
||||
if defaultAllInstances {
|
||||
for inst in instances where inst.isDefault {
|
||||
inst.isDefault = false
|
||||
}
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
assertionFailure("SwiftData save failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleDefault(_ instance: ForgejoInstance) {
|
||||
if instance.isDefault {
|
||||
instance.isDefault = false
|
||||
|
|
@ -166,6 +249,7 @@ struct InstanceListView: View {
|
|||
inst.isDefault = false
|
||||
}
|
||||
instance.isDefault = true
|
||||
defaultAllInstances = false
|
||||
}
|
||||
do {
|
||||
try modelContext.save()
|
||||
|
|
@ -177,7 +261,7 @@ struct InstanceListView: View {
|
|||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
InstanceListView(authService: .previewDefault)
|
||||
InstanceListView(authService: .previewDefault, multiInstanceManager: .constant(nil))
|
||||
.modelContainer(for: ForgejoInstance.self, inMemory: true)
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ struct IssueListView: View {
|
|||
|
||||
struct IssueRow: View {
|
||||
let issue: Issue
|
||||
var instanceName: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
|
|
@ -173,9 +174,14 @@ struct IssueRow: View {
|
|||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(issue.title)
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
HStack(spacing: 6) {
|
||||
Text(issue.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if let instanceName {
|
||||
InstanceBadge(name: instanceName)
|
||||
}
|
||||
}
|
||||
|
||||
if let repository = issue.repository {
|
||||
Text(repository.fullName)
|
||||
|
|
|
|||
59
Forji/Forji/Views/MergedHelpers.swift
Normal file
59
Forji/Forji/Views/MergedHelpers.swift
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MergedConnectionWarning: View {
|
||||
let failedInstances: [(instance: ForgejoInstance, error: String)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Some instances failed to connect", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.orange)
|
||||
ForEach(failedInstances, id: \.instance.id) { failed in
|
||||
let name = failed.instance.name.isEmpty ? failed.instance.serverURL : failed.instance.name
|
||||
Text("\(name): \(failed.error)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.ultraThinMaterial)
|
||||
.accessibilityIdentifier("merged-connection-status")
|
||||
}
|
||||
}
|
||||
|
||||
struct MergedCreatePickerView<Content: View>: View {
|
||||
let manager: MultiInstanceManager
|
||||
@ViewBuilder let content: (ForgejoInstance, AuthenticationService) -> Content
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("Select Instance") {
|
||||
ForEach(manager.connections, id: \.instance.id) { connection in
|
||||
NavigationLink {
|
||||
content(connection.instance, connection.authService)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(manager.displayName(for: connection.instance))
|
||||
.font(.headline)
|
||||
Text(connection.instance.serverURL)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Choose Instance")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
188
Forji/Forji/Views/MergedIssuesOverviewView.swift
Normal file
188
Forji/Forji/Views/MergedIssuesOverviewView.swift
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import ForgejoKit
|
||||
import SwiftUI
|
||||
|
||||
struct MergedIssuesOverviewView: View {
|
||||
let manager: MultiInstanceManager
|
||||
|
||||
@State private var pagination = PaginationState<TaggedItem<Issue>>()
|
||||
@State private var stateFilter: IssueFilterState = .open
|
||||
@State private var searchText = ""
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
@State private var showCreateFlow = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var pagination = pagination
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
VStack(spacing: 0) {
|
||||
if !manager.failedInstances.isEmpty {
|
||||
MergedConnectionWarning(failedInstances: manager.failedInstances)
|
||||
}
|
||||
|
||||
List {
|
||||
if pagination.isLoading, pagination.items.isEmpty {
|
||||
LoadingListSection()
|
||||
} else if pagination.items.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label(
|
||||
"No Issues",
|
||||
systemImage: stateFilter == .open ? "checkmark.circle" : "exclamationmark.circle",
|
||||
)
|
||||
.foregroundStyle(stateFilter == .open ? .green : .secondary)
|
||||
} description: {
|
||||
Text(
|
||||
stateFilter == .open
|
||||
? "All clear — no open issues to review."
|
||||
: "No \(stateFilter.rawValue) issues found.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(pagination.items) { tagged in
|
||||
if let repository = tagged.item.repository {
|
||||
NavigationLink {
|
||||
IssueDetailView(
|
||||
repository: repository,
|
||||
issueNumber: tagged.item.number,
|
||||
authService: tagged.authService,
|
||||
)
|
||||
} label: {
|
||||
IssueRow(issue: tagged.item, instanceName: tagged.instanceName)
|
||||
}
|
||||
} else {
|
||||
IssueRow(issue: tagged.item, instanceName: tagged.instanceName)
|
||||
}
|
||||
}
|
||||
|
||||
if pagination.hasMore {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
.accessibilityIdentifier("load-more-indicator")
|
||||
.task {
|
||||
await loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
FloatingCreateButton {
|
||||
showCreateFlow = true
|
||||
}
|
||||
.accessibilityIdentifier("issue-create-button")
|
||||
}
|
||||
.navigationTitle("Issues")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Section("State") {
|
||||
filterButton("Open", isSelected: stateFilter == .open) { stateFilter = .open }
|
||||
filterButton("Closed", isSelected: stateFilter == .closed) { stateFilter = .closed }
|
||||
filterButton("All", isSelected: stateFilter == .all) { stateFilter = .all }
|
||||
}
|
||||
} label: {
|
||||
Image(
|
||||
systemName: stateFilter != .open
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle",
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("filter-menu-button")
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search issues")
|
||||
.refreshable {
|
||||
await reloadItems().value
|
||||
}
|
||||
.task {
|
||||
if !pagination.hasLoaded {
|
||||
reloadItems()
|
||||
}
|
||||
}
|
||||
.onChange(of: stateFilter) {
|
||||
reloadItems(clearItems: true)
|
||||
}
|
||||
.debouncedSearch(text: $searchText, task: $searchTask) {
|
||||
await reloadItems().value
|
||||
}
|
||||
.sheet(isPresented: $showCreateFlow) {
|
||||
MergedCreatePickerView(manager: manager) { _, auth in
|
||||
RepositoryPickerView(authService: auth) { repo in
|
||||
IssueCreateView(
|
||||
repository: repo,
|
||||
authService: auth,
|
||||
embeddedInNavigation: true,
|
||||
) {
|
||||
showCreateFlow = false
|
||||
reloadItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError)
|
||||
}
|
||||
|
||||
private func filterButton(_ label: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
if isSelected {
|
||||
Label(label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func reloadItems(clearItems: Bool = false) -> Task<Void, Never> {
|
||||
pagination.reload(clearItems: clearItems) { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
await pagination.loadMore { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchFromAllInstances(page: Int, limit: Int) async throws -> [TaggedItem<Issue>] {
|
||||
let state = stateFilter.rawValue
|
||||
let query = searchText
|
||||
let sources = manager.connectionSources()
|
||||
|
||||
let batches = try await withThrowingTaskGroup(of: (Int, [Issue]).self) { group in
|
||||
for (index, source) in sources.enumerated() {
|
||||
let client = source.client
|
||||
if query.isEmpty {
|
||||
// Fire separate requests per involvement flag for OR-union semantics
|
||||
for flag in InvolvementScope.issueFlags {
|
||||
group.addTask {
|
||||
let service = IssueService(client: client)
|
||||
let issues = (try? await service.searchIssues(
|
||||
type: "issues", state: state, page: page, limit: limit,
|
||||
assigned: flag == .assigned, created: flag == .created,
|
||||
mentioned: flag == .mentioned
|
||||
)) ?? []
|
||||
return (index, issues)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
group.addTask {
|
||||
let service = IssueService(client: client)
|
||||
let issues = (try? await service.searchIssues(
|
||||
type: "issues", state: state, query: query,
|
||||
page: page, limit: limit
|
||||
)) ?? []
|
||||
return (index, issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try await group.reduce(into: [(Int, [Issue])]()) { $0.append($1) }
|
||||
}
|
||||
|
||||
return TaggedItem.mergeAndDeduplicate(batches: batches, sources: sources)
|
||||
.sorted { $0.item.updatedAt > $1.item.updatedAt }
|
||||
}
|
||||
}
|
||||
221
Forji/Forji/Views/MergedNotificationsOverviewView.swift
Normal file
221
Forji/Forji/Views/MergedNotificationsOverviewView.swift
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import ForgejoKit
|
||||
import SwiftUI
|
||||
|
||||
struct MergedNotificationsOverviewView: View {
|
||||
let manager: MultiInstanceManager
|
||||
|
||||
@State private var pagination = PaginationState<TaggedItem<NotificationThread>>()
|
||||
@State private var statusFilter: String = "unread"
|
||||
|
||||
private var statusTypes: [String] {
|
||||
switch statusFilter {
|
||||
case "unread": ["unread"]
|
||||
case "read": ["read"]
|
||||
default: ["unread", "read"]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var pagination = pagination
|
||||
VStack(spacing: 0) {
|
||||
if !manager.failedInstances.isEmpty {
|
||||
MergedConnectionWarning(failedInstances: manager.failedInstances)
|
||||
}
|
||||
|
||||
SegmentedPickerSection(
|
||||
title: "Status",
|
||||
selection: $statusFilter,
|
||||
options: [("Unread", "unread"), ("Read", "read"), ("All", "all")],
|
||||
accessibilityIdentifier: "notification-status-picker",
|
||||
)
|
||||
|
||||
List {
|
||||
if pagination.isLoading, pagination.items.isEmpty {
|
||||
LoadingListSection()
|
||||
} else if pagination.items.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Notifications", systemImage: "bell.slash")
|
||||
.foregroundStyle(statusFilter == "unread" ? .green : .secondary)
|
||||
} description: {
|
||||
Text(
|
||||
statusFilter == "unread"
|
||||
? "You're all caught up!"
|
||||
: "There are no \(statusFilter) notifications",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(pagination.items) { tagged in
|
||||
notificationRow(tagged)
|
||||
}
|
||||
|
||||
if pagination.hasMore {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
.accessibilityIdentifier("load-more-indicator")
|
||||
.task {
|
||||
await loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
.navigationTitle("Notifications")
|
||||
.refreshable {
|
||||
await reloadNotifications().value
|
||||
}
|
||||
.task {
|
||||
if !pagination.hasLoaded {
|
||||
reloadNotifications()
|
||||
}
|
||||
}
|
||||
.onChange(of: statusFilter) {
|
||||
reloadNotifications(clearItems: true)
|
||||
}
|
||||
.errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func notificationRow(_ tagged: TaggedItem<NotificationThread>) -> some View {
|
||||
let notification = tagged.item
|
||||
let destination = navigationDestination(for: tagged)
|
||||
if let destination {
|
||||
NavigationLink { destination } label: {
|
||||
MergedNotificationRow(notification: notification, instanceName: tagged.instanceName)
|
||||
}
|
||||
} else {
|
||||
MergedNotificationRow(notification: notification, instanceName: tagged.instanceName)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func navigationDestination(for tagged: TaggedItem<NotificationThread>) -> (some View)? {
|
||||
let notification = tagged.item
|
||||
switch notification.subject.type {
|
||||
case "Issue":
|
||||
if let number = subjectNumber(from: notification) {
|
||||
IssueDetailView(
|
||||
repository: notification.repository,
|
||||
issueNumber: number,
|
||||
authService: tagged.authService,
|
||||
)
|
||||
}
|
||||
case "Pull":
|
||||
if let number = subjectNumber(from: notification) {
|
||||
PullRequestDetailView(
|
||||
repository: notification.repository,
|
||||
prNumber: number,
|
||||
authService: tagged.authService,
|
||||
)
|
||||
}
|
||||
default:
|
||||
nil as EmptyView?
|
||||
}
|
||||
}
|
||||
|
||||
private func subjectNumber(from notification: NotificationThread) -> Int? {
|
||||
notificationSubjectNumber(from: notification.subject.url)
|
||||
?? notificationSubjectNumber(from: notification.subject.htmlUrl)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func reloadNotifications(clearItems: Bool = false) -> Task<Void, Never> {
|
||||
pagination.reload(clearItems: clearItems) { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
await pagination.loadMore { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchFromAllInstances(page: Int, limit: Int) async throws -> [TaggedItem<NotificationThread>] {
|
||||
let types = statusTypes
|
||||
|
||||
let sources = manager.connectionSources()
|
||||
|
||||
let batches = try await withThrowingTaskGroup(of: (Int, [NotificationThread]).self) { group in
|
||||
for (index, source) in sources.enumerated() {
|
||||
let client = source.client
|
||||
group.addTask {
|
||||
let service = NotificationService(client: client)
|
||||
let notifications = (try? await service.fetchNotifications(
|
||||
statusTypes: types, page: page, limit: limit
|
||||
)) ?? []
|
||||
return (index, notifications)
|
||||
}
|
||||
}
|
||||
return try await group.reduce(into: [(Int, [NotificationThread])]()) { $0.append($1) }
|
||||
}
|
||||
|
||||
return TaggedItem.mergeAndDeduplicate(batches: batches, sources: sources)
|
||||
.sorted { $0.item.updatedAt > $1.item.updatedAt }
|
||||
}
|
||||
}
|
||||
|
||||
private struct MergedNotificationRow: View {
|
||||
let notification: NotificationThread
|
||||
var instanceName: String?
|
||||
|
||||
private var typeIcon: String {
|
||||
switch notification.subject.type {
|
||||
case "Issue": "exclamationmark.circle"
|
||||
case "Pull": "arrow.triangle.pull"
|
||||
case "Commit": "circle.dotted.circle"
|
||||
case "Repository": "folder"
|
||||
default: "bell"
|
||||
}
|
||||
}
|
||||
|
||||
private var stateColor: Color {
|
||||
switch notification.subject.stateValue {
|
||||
case .open: .green
|
||||
case .closed, .merged: .purple
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: typeIcon)
|
||||
.foregroundStyle(stateColor)
|
||||
.font(.body)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(notification.subject.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if let instanceName {
|
||||
InstanceBadge(name: instanceName)
|
||||
}
|
||||
}
|
||||
|
||||
Text(notification.repository.fullName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(formatRelativeDate(notification.updatedAt))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if notification.unread {
|
||||
Circle()
|
||||
.fill(.blue)
|
||||
.frame(width: 10, height: 10)
|
||||
.glassEffect(.regular.tint(.blue))
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
184
Forji/Forji/Views/MergedPullRequestsOverviewView.swift
Normal file
184
Forji/Forji/Views/MergedPullRequestsOverviewView.swift
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import ForgejoKit
|
||||
import SwiftUI
|
||||
|
||||
struct MergedPullRequestsOverviewView: View {
|
||||
let manager: MultiInstanceManager
|
||||
|
||||
@State private var pagination = PaginationState<TaggedItem<Issue>>()
|
||||
@State private var stateFilter: IssueFilterState = .open
|
||||
@State private var searchText = ""
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
@State private var showCreateFlow = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var pagination = pagination
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
VStack(spacing: 0) {
|
||||
if !manager.failedInstances.isEmpty {
|
||||
MergedConnectionWarning(failedInstances: manager.failedInstances)
|
||||
}
|
||||
|
||||
List {
|
||||
if pagination.isLoading, pagination.items.isEmpty {
|
||||
LoadingListSection()
|
||||
} else if pagination.items.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Pull Requests", systemImage: "arrow.triangle.pull")
|
||||
.foregroundStyle(stateFilter == .open ? .green : .secondary)
|
||||
} description: {
|
||||
Text(
|
||||
stateFilter == .open
|
||||
? "All clear — no open pull requests to review."
|
||||
: "No \(stateFilter.rawValue) pull requests found.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(pagination.items) { tagged in
|
||||
if let repository = tagged.item.repository {
|
||||
NavigationLink {
|
||||
PullRequestDetailView(
|
||||
repository: repository,
|
||||
prNumber: tagged.item.number,
|
||||
authService: tagged.authService,
|
||||
)
|
||||
} label: {
|
||||
PullRequestOverviewRow(issue: tagged.item, instanceName: tagged.instanceName)
|
||||
}
|
||||
} else {
|
||||
PullRequestOverviewRow(issue: tagged.item, instanceName: tagged.instanceName)
|
||||
}
|
||||
}
|
||||
|
||||
if pagination.hasMore {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
.accessibilityIdentifier("load-more-indicator")
|
||||
.task {
|
||||
await loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
FloatingCreateButton {
|
||||
showCreateFlow = true
|
||||
}
|
||||
.accessibilityIdentifier("pr-create-button")
|
||||
}
|
||||
.navigationTitle("Pull Requests")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Section("State") {
|
||||
filterButton("Open", isSelected: stateFilter == .open) { stateFilter = .open }
|
||||
filterButton("Closed", isSelected: stateFilter == .closed) { stateFilter = .closed }
|
||||
filterButton("All", isSelected: stateFilter == .all) { stateFilter = .all }
|
||||
}
|
||||
} label: {
|
||||
Image(
|
||||
systemName: stateFilter != .open
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle",
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("filter-menu-button")
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search pull requests")
|
||||
.refreshable {
|
||||
await reloadItems().value
|
||||
}
|
||||
.task {
|
||||
if !pagination.hasLoaded {
|
||||
reloadItems()
|
||||
}
|
||||
}
|
||||
.onChange(of: stateFilter) {
|
||||
reloadItems(clearItems: true)
|
||||
}
|
||||
.debouncedSearch(text: $searchText, task: $searchTask) {
|
||||
await reloadItems().value
|
||||
}
|
||||
.sheet(isPresented: $showCreateFlow) {
|
||||
MergedCreatePickerView(manager: manager) { _, auth in
|
||||
RepositoryPickerView(authService: auth) { repo in
|
||||
PullRequestCreateView(
|
||||
repository: repo,
|
||||
authService: auth,
|
||||
embeddedInNavigation: true,
|
||||
) {
|
||||
showCreateFlow = false
|
||||
reloadItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError)
|
||||
}
|
||||
|
||||
private func filterButton(_ label: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
if isSelected {
|
||||
Label(label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func reloadItems(clearItems: Bool = false) -> Task<Void, Never> {
|
||||
pagination.reload(clearItems: clearItems) { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
await pagination.loadMore { page, limit in
|
||||
try await fetchFromAllInstances(page: page, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchFromAllInstances(page: Int, limit: Int) async throws -> [TaggedItem<Issue>] {
|
||||
let state = stateFilter.rawValue
|
||||
let query = searchText
|
||||
let sources = manager.connectionSources()
|
||||
|
||||
let batches = try await withThrowingTaskGroup(of: (Int, [Issue]).self) { group in
|
||||
for (index, source) in sources.enumerated() {
|
||||
let client = source.client
|
||||
if query.isEmpty {
|
||||
for flag in InvolvementScope.pullRequestFlags {
|
||||
group.addTask {
|
||||
let service = IssueService(client: client)
|
||||
let issues = (try? await service.searchIssues(
|
||||
type: "pulls", state: state, page: page, limit: limit,
|
||||
assigned: flag == .assigned, created: flag == .created,
|
||||
mentioned: flag == .mentioned, reviewRequested: flag == .reviewRequested
|
||||
)) ?? []
|
||||
return (index, issues)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
group.addTask {
|
||||
let service = IssueService(client: client)
|
||||
let issues = (try? await service.searchIssues(
|
||||
type: "pulls", state: state, query: query,
|
||||
page: page, limit: limit
|
||||
)) ?? []
|
||||
return (index, issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try await group.reduce(into: [(Int, [Issue])]()) { $0.append($1) }
|
||||
}
|
||||
|
||||
return TaggedItem.mergeAndDeduplicate(batches: batches, sources: sources)
|
||||
.sorted { $0.item.updatedAt > $1.item.updatedAt }
|
||||
}
|
||||
}
|
||||
219
Forji/Forji/Views/MergedRepositoryListView.swift
Normal file
219
Forji/Forji/Views/MergedRepositoryListView.swift
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import ForgejoKit
|
||||
import SwiftUI
|
||||
|
||||
struct MergedRepositoryListView: View {
|
||||
let manager: MultiInstanceManager
|
||||
|
||||
@State private var repositories: [TaggedItem<Repository>] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
@State private var searchText = ""
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
@State private var hasMore = true
|
||||
@State private var currentPage = 1
|
||||
@State private var hasLoaded = false
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if !manager.failedInstances.isEmpty {
|
||||
MergedConnectionWarning(failedInstances: manager.failedInstances)
|
||||
}
|
||||
|
||||
List {
|
||||
if isLoading, repositories.isEmpty {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
} else if repositories.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Repositories", systemImage: "folder.badge.questionmark")
|
||||
.foregroundStyle(.secondary)
|
||||
} description: {
|
||||
Text(
|
||||
searchText.isEmpty
|
||||
? "No repositories yet."
|
||||
: "No repositories matching your search.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(repositories) { tagged in
|
||||
NavigationLink {
|
||||
RepositoryDetailView(repository: tagged.item, authService: tagged.authService)
|
||||
} label: {
|
||||
MergedRepositoryRow(repository: tagged.item, instanceName: tagged.instanceName)
|
||||
}
|
||||
}
|
||||
|
||||
if hasMore {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
.accessibilityIdentifier("load-more-indicator")
|
||||
.task {
|
||||
await loadMoreRepositories()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.accessibilityIdentifier("repo-list")
|
||||
.refreshable {
|
||||
currentPage = 1
|
||||
hasMore = true
|
||||
await loadRepositories()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Repositories")
|
||||
.searchable(text: $searchText, prompt: "Search repositories")
|
||||
.task {
|
||||
if !hasLoaded {
|
||||
await loadRepositories()
|
||||
}
|
||||
}
|
||||
.debouncedSearch(text: $searchText, task: $searchTask) {
|
||||
currentPage = 1
|
||||
hasMore = true
|
||||
await loadRepositories()
|
||||
}
|
||||
.errorAlert(message: $errorMessage, isPresented: $showError)
|
||||
}
|
||||
|
||||
private func loadRepositories() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let items = try await fetchFromAllInstances(page: 1, limit: pageSize)
|
||||
repositories = items
|
||||
hasMore = items.count >= pageSize * manager.connections.count
|
||||
currentPage = 2
|
||||
hasLoaded = true
|
||||
} catch is CancellationError {
|
||||
// Ignore
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadMoreRepositories() async {
|
||||
guard hasMore, !isLoading else { return }
|
||||
isLoading = true
|
||||
|
||||
do {
|
||||
let items = try await fetchFromAllInstances(page: currentPage, limit: pageSize)
|
||||
repositories.append(contentsOf: items)
|
||||
hasMore = items.count >= pageSize
|
||||
currentPage += 1
|
||||
} catch is CancellationError {
|
||||
// Ignore
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func fetchFromAllInstances(page: Int, limit: Int) async throws -> [TaggedItem<Repository>] {
|
||||
let query = searchText
|
||||
|
||||
let sources = manager.connectionSources()
|
||||
|
||||
let batches = try await withThrowingTaskGroup(of: (Int, [Repository]).self) { group in
|
||||
for (index, source) in sources.enumerated() {
|
||||
let client = source.client
|
||||
group.addTask {
|
||||
let service = RepositoryService(client: client)
|
||||
let repos: [Repository]
|
||||
if query.isEmpty {
|
||||
repos = (try? await service.fetchUserRepositories(page: page, limit: limit)) ?? []
|
||||
} else {
|
||||
repos = (try? await service.searchRepositories(query: query, page: page, limit: limit)) ?? []
|
||||
}
|
||||
return (index, repos)
|
||||
}
|
||||
}
|
||||
return try await group.reduce(into: [(Int, [Repository])]()) { $0.append($1) }
|
||||
}
|
||||
|
||||
return TaggedItem.mergeAndDeduplicate(batches: batches, sources: sources)
|
||||
.sorted { ($0.item.updatedAt ?? .distantPast) > ($1.item.updatedAt ?? .distantPast) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct MergedRepositoryRow: View {
|
||||
let repository: Repository
|
||||
var instanceName: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(repository.name)
|
||||
.font(.title3.bold())
|
||||
if let instanceName {
|
||||
InstanceBadge(name: instanceName)
|
||||
}
|
||||
}
|
||||
|
||||
if let description = repository.description {
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if repository.private ?? false {
|
||||
RepoBadge(title: "Private", systemImage: "lock.fill")
|
||||
}
|
||||
|
||||
if repository.archived ?? false {
|
||||
RepoBadge(title: "Archived", systemImage: "archivebox.fill")
|
||||
}
|
||||
|
||||
if repository.mirror ?? false {
|
||||
RepoBadge(title: "Mirror", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
if let language = repository.language {
|
||||
Text(language)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.glassEffect(.regular.tint(colorForLanguage(language)))
|
||||
}
|
||||
|
||||
Label("\(repository.starsCount ?? 0)", systemImage: "star.fill")
|
||||
Label("\(repository.forksCount ?? 0)", systemImage: "tuningfork")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
if let updatedAt = repository.updatedAt {
|
||||
Text(formatRelativeDate(updatedAt))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ struct PullRequestsOverviewView: View {
|
|||
|
||||
struct PullRequestOverviewRow: View {
|
||||
let issue: Issue
|
||||
var instanceName: String?
|
||||
|
||||
private var status: PRStatusStyle {
|
||||
PRStatusStyle(state: issue.pullRequestStateValue, merged: issue.pullRequest?.merged, draft: nil)
|
||||
|
|
@ -63,9 +64,14 @@ struct PullRequestOverviewRow: View {
|
|||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(issue.title)
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
HStack(spacing: 6) {
|
||||
Text(issue.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if let instanceName {
|
||||
InstanceBadge(name: instanceName)
|
||||
}
|
||||
}
|
||||
|
||||
if let repository = issue.repository {
|
||||
Text(repository.fullName)
|
||||
|
|
|
|||
71
Forji/ForjiTests/MultiInstanceManagerTests.swift
Normal file
71
Forji/ForjiTests/MultiInstanceManagerTests.swift
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import ForgejoKit
|
||||
import Testing
|
||||
@testable import Forji
|
||||
|
||||
@MainActor
|
||||
struct MultiInstanceManagerTests {
|
||||
|
||||
// MARK: - Initial State
|
||||
|
||||
@Test func initialStateIsDisconnected() {
|
||||
let manager = MultiInstanceManager()
|
||||
#expect(!manager.isConnected)
|
||||
#expect(manager.connections.isEmpty)
|
||||
#expect(manager.failedInstances.isEmpty)
|
||||
#expect(!manager.isConnecting)
|
||||
}
|
||||
|
||||
// MARK: - Display Name
|
||||
|
||||
@Test func displayNameReturnsNameWhenNotEmpty() {
|
||||
let manager = MultiInstanceManager()
|
||||
let instance = ForgejoInstance(serverURL: "https://forgejo.example.com", username: "user", name: "Work")
|
||||
#expect(manager.displayName(for: instance) == "Work")
|
||||
}
|
||||
|
||||
@Test func displayNameFallsBackToServerURL() {
|
||||
let manager = MultiInstanceManager()
|
||||
let instance = ForgejoInstance(serverURL: "https://forgejo.example.com", username: "user", name: "")
|
||||
#expect(manager.displayName(for: instance) == "https://forgejo.example.com")
|
||||
}
|
||||
|
||||
// MARK: - Connect with no valid credentials returns empty
|
||||
|
||||
@Test func connectWithNoStoredCredentialsFails() async {
|
||||
let manager = MultiInstanceManager()
|
||||
let instance = ForgejoInstance(
|
||||
serverURL: "https://nonexistent.invalid", username: "nobody"
|
||||
)
|
||||
|
||||
await manager.connect(instances: [instance])
|
||||
|
||||
#expect(!manager.isConnected)
|
||||
#expect(manager.connections.isEmpty)
|
||||
#expect(manager.failedInstances.count == 1)
|
||||
#expect(!manager.isConnecting)
|
||||
}
|
||||
|
||||
// MARK: - Disconnect
|
||||
|
||||
@Test func disconnectAfterFailedConnectClearsState() async {
|
||||
let manager = MultiInstanceManager()
|
||||
let instance = ForgejoInstance(
|
||||
serverURL: "https://nonexistent.invalid", username: "nobody"
|
||||
)
|
||||
|
||||
await manager.connect(instances: [instance])
|
||||
#expect(!manager.failedInstances.isEmpty)
|
||||
|
||||
manager.disconnect()
|
||||
#expect(manager.connections.isEmpty)
|
||||
#expect(manager.failedInstances.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Connection Sources
|
||||
|
||||
@Test func connectionSourcesReturnsEmptyWhenNoConnections() {
|
||||
let manager = MultiInstanceManager()
|
||||
let sources = manager.connectionSources()
|
||||
#expect(sources.isEmpty)
|
||||
}
|
||||
}
|
||||
104
Forji/ForjiTests/TaggedItemTests.swift
Normal file
104
Forji/ForjiTests/TaggedItemTests.swift
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Forji
|
||||
|
||||
private typealias FIssue = ForgejoKit.Issue
|
||||
|
||||
struct TaggedItemTests {
|
||||
|
||||
private func makeAuth() -> AuthenticationService {
|
||||
AuthenticationService()
|
||||
}
|
||||
|
||||
private func makeIssue(id: Int, title: String = "Test", updatedAt: Date = Date()) -> FIssue {
|
||||
FIssue(
|
||||
id: id, number: id, title: title, state: "open",
|
||||
user: User(id: 1, login: "test", avatarUrl: nil),
|
||||
createdAt: Date(), updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - ID Generation
|
||||
|
||||
@Test func idCombinesInstanceNameAndItemId() {
|
||||
let auth = makeAuth()
|
||||
let issue = makeIssue(id: 42)
|
||||
let tagged = TaggedItem(item: issue, instanceName: "work", authService: auth)
|
||||
#expect(tagged.id == "work:42")
|
||||
}
|
||||
|
||||
@Test func sameItemDifferentInstancesHaveDifferentIds() {
|
||||
let auth = makeAuth()
|
||||
let issue = makeIssue(id: 1)
|
||||
let taggedA = TaggedItem(item: issue, instanceName: "work", authService: auth)
|
||||
let taggedB = TaggedItem(item: issue, instanceName: "personal", authService: auth)
|
||||
#expect(taggedA.id != taggedB.id)
|
||||
}
|
||||
|
||||
@Test func sameInstanceSameItemIdProducesSameId() {
|
||||
let auth = makeAuth()
|
||||
let issueA = makeIssue(id: 5)
|
||||
let issueB = makeIssue(id: 5, title: "Different title")
|
||||
let taggedA = TaggedItem(item: issueA, instanceName: "srv", authService: auth)
|
||||
let taggedB = TaggedItem(item: issueB, instanceName: "srv", authService: auth)
|
||||
#expect(taggedA.id == taggedB.id)
|
||||
}
|
||||
|
||||
// MARK: - Merge and Deduplicate
|
||||
|
||||
@Test func mergeAndDeduplicateRemovesDuplicateIds() {
|
||||
let auth = makeAuth()
|
||||
let source = ConnectionSource(
|
||||
name: "srv",
|
||||
client: ForgejoClient(serverURL: "https://example.com", username: "u", token: "t"),
|
||||
authService: auth
|
||||
)
|
||||
let issue = makeIssue(id: 1)
|
||||
let batches: [(Int, [FIssue])] = [(0, [issue]), (0, [issue])]
|
||||
|
||||
let result = TaggedItem.mergeAndDeduplicate(batches: batches, sources: [source])
|
||||
#expect(result.count == 1)
|
||||
}
|
||||
|
||||
@Test func mergeAndDeduplicateKeepsItemsFromDifferentSources() {
|
||||
let auth = makeAuth()
|
||||
let sourceA = ConnectionSource(
|
||||
name: "work",
|
||||
client: ForgejoClient(serverURL: "https://work.example.com", username: "u", token: "t"),
|
||||
authService: auth
|
||||
)
|
||||
let sourceB = ConnectionSource(
|
||||
name: "personal",
|
||||
client: ForgejoClient(serverURL: "https://personal.example.com", username: "u", token: "t"),
|
||||
authService: auth
|
||||
)
|
||||
let issue = makeIssue(id: 1)
|
||||
let batches: [(Int, [FIssue])] = [(0, [issue]), (1, [issue])]
|
||||
|
||||
let result = TaggedItem.mergeAndDeduplicate(batches: batches, sources: [sourceA, sourceB])
|
||||
#expect(result.count == 2)
|
||||
}
|
||||
|
||||
@Test func mergeAndDeduplicatePreservesAllFields() {
|
||||
let auth = makeAuth()
|
||||
let source = ConnectionSource(
|
||||
name: "myserver",
|
||||
client: ForgejoClient(serverURL: "https://example.com", username: "u", token: "t"),
|
||||
authService: auth
|
||||
)
|
||||
let issue = makeIssue(id: 7, title: "Important")
|
||||
let batches: [(Int, [FIssue])] = [(0, [issue])]
|
||||
|
||||
let result = TaggedItem.mergeAndDeduplicate(batches: batches, sources: [source])
|
||||
#expect(result.count == 1)
|
||||
#expect(result[0].item.title == "Important")
|
||||
#expect(result[0].instanceName == "myserver")
|
||||
#expect(result[0].id == "myserver:7")
|
||||
}
|
||||
|
||||
@Test func mergeAndDeduplicateReturnsEmptyForEmptyBatches() {
|
||||
let result: [TaggedItem<FIssue>] = TaggedItem.mergeAndDeduplicate(batches: [], sources: [])
|
||||
#expect(result.isEmpty)
|
||||
}
|
||||
}
|
||||
184
Forji/ForjiUITests/MergedInstanceUITests.swift
Normal file
184
Forji/ForjiUITests/MergedInstanceUITests.swift
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import XCTest
|
||||
|
||||
final class MergedInstanceUITests: ForgejoUITestBase {
|
||||
|
||||
private var serverURL2: String?
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
serverURL2 = resolveSecondServerURL()
|
||||
// Clear SwiftData instances from previous test methods
|
||||
app.launchArguments = ["-dev_skipAutoLogin", "true", "-dev_resetData", "true"]
|
||||
app.launch()
|
||||
app.terminate()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Launches with skipAutoLogin, pre-filling the form with the given dev credentials.
|
||||
/// After tapping + then Login, the app goes to HomeView. We then relaunch with
|
||||
/// skipAutoLogin again so the instance list re-appears with the saved instance.
|
||||
private func addInstanceViaRelaunch(
|
||||
serverURL url: String,
|
||||
username: String = "testadmin",
|
||||
password: String = "admin1234",
|
||||
) {
|
||||
// Launch with form pre-fill + skip auto-login
|
||||
app.launchArguments = [
|
||||
"-dev_serverURL", url,
|
||||
"-dev_username", username,
|
||||
"-dev_password", password,
|
||||
"-dev_skipAutoLogin", "true",
|
||||
]
|
||||
app.launch()
|
||||
|
||||
let addButton = app.buttons["instance-add-button"]
|
||||
XCTAssertTrue(addButton.waitForExistence(timeout: 15), "Instance list did not appear")
|
||||
addButton.tap()
|
||||
|
||||
// Form is pre-filled from dev launch args — scroll and tap login
|
||||
app.swipeUp()
|
||||
|
||||
let loginButton = app.buttons["login-button"]
|
||||
XCTAssertTrue(loginButton.waitForExistence(timeout: 5), "Login button not found")
|
||||
loginButton.tap()
|
||||
|
||||
// Wait for HomeView to appear (login succeeded, instance saved to SwiftData)
|
||||
let reposTab = app.tabBars.buttons["Repositories"]
|
||||
XCTAssertTrue(reposTab.waitForExistence(timeout: 20), "HomeView did not appear after login")
|
||||
}
|
||||
|
||||
/// Relaunches the app skipping auto-login to land on the instance list.
|
||||
private func relaunchToInstanceList() {
|
||||
app.launchArguments = ["-dev_skipAutoLogin", "true"]
|
||||
app.launch()
|
||||
|
||||
let addButton = app.buttons["instance-add-button"]
|
||||
XCTAssertTrue(
|
||||
addButton.waitForExistence(timeout: 15)
|
||||
|| app.collectionViews["instance-list"].waitForExistence(timeout: 5),
|
||||
"Instance list did not appear",
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets up two instances and relaunches to instance list.
|
||||
private func setupTwoInstances() throws {
|
||||
guard let url2 = serverURL2 else {
|
||||
throw XCTSkip("Second integration server not available")
|
||||
}
|
||||
|
||||
// Add first instance
|
||||
addInstanceViaRelaunch(serverURL: serverURL)
|
||||
|
||||
// Add second instance (relaunch adds to SwiftData)
|
||||
addInstanceViaRelaunch(serverURL: url2)
|
||||
|
||||
// Relaunch to instance list with both instances persisted
|
||||
relaunchToInstanceList()
|
||||
}
|
||||
|
||||
/// Enters merged mode from the instance list.
|
||||
private func enterMergedMode() {
|
||||
let allRow = app.buttons["all-instances-row"]
|
||||
XCTAssertTrue(allRow.waitForExistence(timeout: 10), "All Instances row not found")
|
||||
allRow.tap()
|
||||
|
||||
let reposTab = app.tabBars.buttons["Repositories"]
|
||||
XCTAssertTrue(reposTab.waitForExistence(timeout: 20), "Merged HomeView did not load")
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
func testAllInstancesRowAppearsWithMultipleInstances() throws {
|
||||
try setupTwoInstances()
|
||||
|
||||
let allRow = app.buttons["all-instances-row"]
|
||||
XCTAssertTrue(allRow.waitForExistence(timeout: 5), "All Instances row should appear with multiple instances")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testAllInstancesRowHiddenWithSingleInstance() throws {
|
||||
addInstanceViaRelaunch(serverURL: serverURL)
|
||||
relaunchToInstanceList()
|
||||
|
||||
let allRow = app.buttons["all-instances-row"]
|
||||
XCTAssertFalse(allRow.exists, "All Instances row should not appear with single instance")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testMergedViewLoads() throws {
|
||||
try setupTwoInstances()
|
||||
enterMergedMode()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testMergedIssuesShowInstanceBadges() throws {
|
||||
try setupTwoInstances()
|
||||
enterMergedMode()
|
||||
|
||||
app.tabBars.buttons["Issues"].tap()
|
||||
|
||||
let badge = app.staticTexts.matching(identifier: "instance-badge")
|
||||
XCTAssertTrue(
|
||||
badge.firstMatch.waitForExistence(timeout: 15),
|
||||
"Instance badges should appear in merged issues view",
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testMergedPullRequestsShowInstanceBadges() throws {
|
||||
try setupTwoInstances()
|
||||
enterMergedMode()
|
||||
|
||||
app.tabBars.buttons["Pull Requests"].tap()
|
||||
|
||||
let badge = app.staticTexts.matching(identifier: "instance-badge")
|
||||
XCTAssertTrue(
|
||||
badge.firstMatch.waitForExistence(timeout: 15),
|
||||
"Instance badges should appear in merged PRs view",
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testMergedNotificationsShowItems() throws {
|
||||
try setupTwoInstances()
|
||||
enterMergedMode()
|
||||
|
||||
app.tabBars.buttons["Notifications"].tap()
|
||||
|
||||
let navTitle = app.navigationBars["Notifications"]
|
||||
XCTAssertTrue(navTitle.waitForExistence(timeout: 10), "Notifications tab should load in merged mode")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDisconnectFromMergedMode() throws {
|
||||
try setupTwoInstances()
|
||||
enterMergedMode()
|
||||
|
||||
app.tabBars.buttons["Settings"].tap()
|
||||
|
||||
// Scroll down to reveal Switch Instance button
|
||||
app.swipeUp()
|
||||
|
||||
let switchButton = app.buttons["home-switch-instance-button"]
|
||||
XCTAssertTrue(switchButton.waitForExistence(timeout: 5))
|
||||
switchButton.tap()
|
||||
|
||||
let instanceList = app.collectionViews["instance-list"]
|
||||
XCTAssertTrue(instanceList.waitForExistence(timeout: 10), "Should return to instance list after disconnect")
|
||||
}
|
||||
|
||||
// MARK: - Server URL Helpers
|
||||
|
||||
private func resolveSecondServerURL() -> String? {
|
||||
let deviceName = (ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] ?? "")
|
||||
.replacingOccurrences(of: " ", with: "_")
|
||||
let specificPath = "/tmp/forgejo_test_url2_\(deviceName).txt"
|
||||
let genericPath = "/tmp/forgejo_test_url2.txt"
|
||||
|
||||
return ((try? String(contentsOfFile: specificPath, encoding: .utf8))
|
||||
?? (try? String(contentsOfFile: genericPath, encoding: .utf8)))
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
}
|
||||
}
|
||||
18
justfile
18
justfile
|
|
@ -6,7 +6,7 @@ compose_file := "integration/docker-compose.yml"
|
|||
base_url_1 := "http://localhost:13001"
|
||||
base_url_2 := "http://localhost:13002"
|
||||
readonly_classes := "LoginUITests CommitHistoryUITests PaginationUITests RepositoryUITests IssueUITests PullRequestUITests OverviewCreateUITests"
|
||||
mutating_classes := "RepositoryMutatingUITests IssueMutatingUITests PullRequestMutatingUITests OverviewCreateMutatingUITests HomeScreenUITests NotificationsUITests PermissionUITests"
|
||||
mutating_classes := "RepositoryMutatingUITests IssueMutatingUITests PullRequestMutatingUITests OverviewCreateMutatingUITests HomeScreenUITests NotificationsUITests PermissionUITests MergedInstanceUITests"
|
||||
|
||||
# List all available tasks
|
||||
default:
|
||||
|
|
@ -75,12 +75,14 @@ docker-up:
|
|||
docker-down:
|
||||
docker compose -f {{compose_file}} down -v 2>/dev/null || true
|
||||
rm -f /tmp/forgejo_test_url.txt /tmp/forgejo_test_url_*.txt
|
||||
rm -f /tmp/forgejo_test_url2.txt /tmp/forgejo_test_url2_*.txt
|
||||
rm -f /tmp/forgejo_test_token.txt /tmp/forgejo_test_token_*.txt
|
||||
|
||||
# Clear integration test caches (seed snapshots, temp files)
|
||||
clean-integration:
|
||||
rm -f integration/.forgejo-seed-snapshot.tar.gz integration/.forgejo-seed-hash
|
||||
rm -f /tmp/forgejo_test_url.txt /tmp/forgejo_test_url_*.txt
|
||||
rm -f /tmp/forgejo_test_url2.txt /tmp/forgejo_test_url2_*.txt
|
||||
rm -f /tmp/forgejo_test_token.txt /tmp/forgejo_test_token_*.txt
|
||||
rm -rf integration/forgejo-seed/.build
|
||||
@echo "Integration test caches cleared."
|
||||
|
|
@ -235,6 +237,7 @@ test-one filter="" destination=default_destination:
|
|||
wait
|
||||
just seed
|
||||
just _write-url-file '{{destination}}' '{{base_url_1}}' true
|
||||
just _write-url2-file '{{destination}}' '{{base_url_2}}' true
|
||||
xcodebuild test-without-building \
|
||||
-project Forji/Forji.xcodeproj \
|
||||
-scheme Forji \
|
||||
|
|
@ -270,7 +273,20 @@ _write-url-file destination url fallback="false":
|
|||
echo "{{url}}" > /tmp/forgejo_test_url.txt
|
||||
fi
|
||||
|
||||
[private]
|
||||
_write-url2-file destination url fallback="false":
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
SIM="$(echo '{{destination}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')"
|
||||
SIM_SAFE="${SIM// /_}"
|
||||
echo "{{url}}" > "/tmp/forgejo_test_url2_${SIM_SAFE}.txt"
|
||||
if [ "{{fallback}}" = "true" ]; then
|
||||
echo "{{url}}" > /tmp/forgejo_test_url2.txt
|
||||
fi
|
||||
|
||||
[private]
|
||||
_write-url-files destination_a=default_destination destination_b=default_destination_b:
|
||||
just _write-url-file '{{destination_a}}' '{{base_url_1}}' true
|
||||
just _write-url-file '{{destination_b}}' '{{base_url_2}}'
|
||||
just _write-url2-file '{{destination_a}}' '{{base_url_2}}'
|
||||
just _write-url2-file '{{destination_b}}' '{{base_url_1}}'
|
||||
|
|
|
|||
Loading…
Reference in a new issue