diff --git a/Forji/Forji/App/ContentView.swift b/Forji/Forji/App/ContentView.swift index d29d6ee..0ebd07b 100644 --- a/Forji/Forji/App/ContentView.swift +++ b/Forji/Forji/App/ContentView.swift @@ -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 { $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(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(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 diff --git a/Forji/Forji/Helpers/TaggedItem.swift b/Forji/Forji/Helpers/TaggedItem.swift new file mode 100644 index 0000000..2d64cb3 --- /dev/null +++ b/Forji/Forji/Helpers/TaggedItem.swift @@ -0,0 +1,33 @@ +import Foundation + +struct TaggedItem: 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] { + var seen = Set() + var merged = [TaggedItem]() + 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 + } +} diff --git a/Forji/Forji/Models/State.swift b/Forji/Forji/Models/State.swift index 9542cca..ca29cb2 100644 --- a/Forji/Forji/Models/State.swift +++ b/Forji/Forji/Models/State.swift @@ -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 { diff --git a/Forji/Forji/Services/AuthenticationService.swift b/Forji/Forji/Services/AuthenticationService.swift index b95f2e3..4549f0b 100644 --- a/Forji/Forji/Services/AuthenticationService.swift +++ b/Forji/Forji/Services/AuthenticationService.swift @@ -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 diff --git a/Forji/Forji/Services/MultiInstanceManager.swift b/Forji/Forji/Services/MultiInstanceManager.swift new file mode 100644 index 0000000..6e8db2b --- /dev/null +++ b/Forji/Forji/Services/MultiInstanceManager.swift @@ -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 +} diff --git a/Forji/Forji/Views/HomeView.swift b/Forji/Forji/Views/HomeView.swift index 7ba7f10..5ece51e 100644 --- a/Forji/Forji/Views/HomeView.swift +++ b/Forji/Forji/Views/HomeView.swift @@ -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) diff --git a/Forji/Forji/Views/InstanceBadge.swift b/Forji/Forji/Views/InstanceBadge.swift new file mode 100644 index 0000000..f176241 --- /dev/null +++ b/Forji/Forji/Views/InstanceBadge.swift @@ -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") + } +} diff --git a/Forji/Forji/Views/InstanceListView.swift b/Forji/Forji/Views/InstanceListView.swift index c8d4189..074f9af 100644 --- a/Forji/Forji/Views/InstanceListView.swift +++ b/Forji/Forji/Views/InstanceListView.swift @@ -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) { 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 diff --git a/Forji/Forji/Views/IssueListView.swift b/Forji/Forji/Views/IssueListView.swift index fb93a63..fe3763c 100644 --- a/Forji/Forji/Views/IssueListView.swift +++ b/Forji/Forji/Views/IssueListView.swift @@ -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) diff --git a/Forji/Forji/Views/MergedHelpers.swift b/Forji/Forji/Views/MergedHelpers.swift new file mode 100644 index 0000000..06a3e8a --- /dev/null +++ b/Forji/Forji/Views/MergedHelpers.swift @@ -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: 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() } + } + } + } + } +} diff --git a/Forji/Forji/Views/MergedIssuesOverviewView.swift b/Forji/Forji/Views/MergedIssuesOverviewView.swift new file mode 100644 index 0000000..7ad1931 --- /dev/null +++ b/Forji/Forji/Views/MergedIssuesOverviewView.swift @@ -0,0 +1,188 @@ +import ForgejoKit +import SwiftUI + +struct MergedIssuesOverviewView: View { + let manager: MultiInstanceManager + + @State private var pagination = PaginationState>() + @State private var stateFilter: IssueFilterState = .open + @State private var searchText = "" + @State private var searchTask: Task? + @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 { + 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] { + 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 } + } +} diff --git a/Forji/Forji/Views/MergedNotificationsOverviewView.swift b/Forji/Forji/Views/MergedNotificationsOverviewView.swift new file mode 100644 index 0000000..84a8698 --- /dev/null +++ b/Forji/Forji/Views/MergedNotificationsOverviewView.swift @@ -0,0 +1,221 @@ +import ForgejoKit +import SwiftUI + +struct MergedNotificationsOverviewView: View { + let manager: MultiInstanceManager + + @State private var pagination = PaginationState>() + @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) -> 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) -> (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 { + 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] { + 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) + } +} diff --git a/Forji/Forji/Views/MergedPullRequestsOverviewView.swift b/Forji/Forji/Views/MergedPullRequestsOverviewView.swift new file mode 100644 index 0000000..504170e --- /dev/null +++ b/Forji/Forji/Views/MergedPullRequestsOverviewView.swift @@ -0,0 +1,184 @@ +import ForgejoKit +import SwiftUI + +struct MergedPullRequestsOverviewView: View { + let manager: MultiInstanceManager + + @State private var pagination = PaginationState>() + @State private var stateFilter: IssueFilterState = .open + @State private var searchText = "" + @State private var searchTask: Task? + @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 { + 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] { + 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 } + } +} diff --git a/Forji/Forji/Views/MergedRepositoryListView.swift b/Forji/Forji/Views/MergedRepositoryListView.swift new file mode 100644 index 0000000..1da4ada --- /dev/null +++ b/Forji/Forji/Views/MergedRepositoryListView.swift @@ -0,0 +1,219 @@ +import ForgejoKit +import SwiftUI + +struct MergedRepositoryListView: View { + let manager: MultiInstanceManager + + @State private var repositories: [TaggedItem] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showError = false + @State private var searchText = "" + @State private var searchTask: Task? + @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] { + 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) + } +} diff --git a/Forji/Forji/Views/PullRequestsOverviewView.swift b/Forji/Forji/Views/PullRequestsOverviewView.swift index f8db475..105b2b9 100644 --- a/Forji/Forji/Views/PullRequestsOverviewView.swift +++ b/Forji/Forji/Views/PullRequestsOverviewView.swift @@ -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) diff --git a/Forji/ForjiTests/MultiInstanceManagerTests.swift b/Forji/ForjiTests/MultiInstanceManagerTests.swift new file mode 100644 index 0000000..8bd3959 --- /dev/null +++ b/Forji/ForjiTests/MultiInstanceManagerTests.swift @@ -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) + } +} diff --git a/Forji/ForjiTests/TaggedItemTests.swift b/Forji/ForjiTests/TaggedItemTests.swift new file mode 100644 index 0000000..3b60586 --- /dev/null +++ b/Forji/ForjiTests/TaggedItemTests.swift @@ -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] = TaggedItem.mergeAndDeduplicate(batches: [], sources: []) + #expect(result.isEmpty) + } +} diff --git a/Forji/ForjiUITests/MergedInstanceUITests.swift b/Forji/ForjiUITests/MergedInstanceUITests.swift new file mode 100644 index 0000000..3d428b0 --- /dev/null +++ b/Forji/ForjiUITests/MergedInstanceUITests.swift @@ -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) } + } +} diff --git a/justfile b/justfile index e10bff1..8376789 100644 --- a/justfile +++ b/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}}'