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:
Stefan Hausotte 2026-03-21 15:03:28 +01:00 committed by secana
parent 70a1cf010a
commit 10bbed5596
19 changed files with 1800 additions and 63 deletions

View file

@ -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

View 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
}
}

View file

@ -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 {

View file

@ -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

View 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
}

View file

@ -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)

View 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")
}
}

View file

@ -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

View file

@ -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)

View 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() }
}
}
}
}
}

View 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 }
}
}

View 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)
}
}

View 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 }
}
}

View 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)
}
}

View file

@ -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)

View 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)
}
}

View 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)
}
}

View 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) }
}
}

View file

@ -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}}'