diff --git a/Forji/Forji/Views/SearchableOverviewView.swift b/Forji/Forji/Views/SearchableOverviewView.swift index 14ff638..97f9a75 100644 --- a/Forji/Forji/Views/SearchableOverviewView.swift +++ b/Forji/Forji/Views/SearchableOverviewView.swift @@ -9,7 +9,7 @@ struct SearchableOverviewView: View { @State private var searchText = "" @State private var searchTask: Task? @State private var showCreateFlow = false - @State private var involvementScope: InvolvementScope = .created + @State private var involvementScope: InvolvementScope = .involved private let issueService: IssueService? private let issueType: String @@ -70,7 +70,7 @@ struct SearchableOverviewView: View { } private var hasNonDefaultFilters: Bool { - stateFilter != .open || involvementScope != .created + stateFilter != .open || involvementScope != .involved } private var filterSummaryText: String { @@ -88,7 +88,7 @@ struct SearchableOverviewView: View { private func scopeDisplayLabel(_ scope: InvolvementScope) -> String { switch scope { - case .involved: "All" + case .involved: "All involvement" case .created: "Created by you" case .assigned: "Assigned to you" case .mentioned: "Mentioned" @@ -242,12 +242,7 @@ struct SearchableOverviewView: View { private func searchIssues(service: IssueService, page: Int, limit: Int) async throws -> [Issue] { switch involvementScope { case .involved: - // No involvement flags — the API uses AND logic when multiple are set, - // so we omit all flags to show everything visible to the user. - try await service.searchIssues( - type: issueType, state: stateFilter.rawValue, query: searchText, - page: page, limit: limit, - ) + try await searchInvolved(service: service, page: page, limit: limit) case .created: try await service.searchIssues( type: issueType, state: stateFilter.rawValue, query: searchText, @@ -271,6 +266,60 @@ struct SearchableOverviewView: View { } } + /// Fires parallel requests for each involvement type and merges results. + /// The API uses AND logic when multiple flags are set, so we need separate + /// calls to get OR-union semantics for "All" involvement. + /// Individual requests that time out return empty so others still appear. + private func searchInvolved(service: IssueService, page: Int, limit: Int) async throws -> [Issue] { + let type = issueType + let state = stateFilter.rawValue + let query = searchText + + let batches = try await withThrowingTaskGroup(of: [Issue].self) { group in + for flag in involvedFlags { + group.addTask { + try await resilientFetch { + try await service.searchIssues( + type: type, state: state, query: query, page: page, limit: limit, + assigned: flag == .assigned, created: flag == .created, + mentioned: flag == .mentioned, reviewRequested: flag == .reviewRequested, + ) + } + } + } + return try await group.reduce(into: [[Issue]]()) { $0.append($1) } + } + + return Self.deduplicateAndSort(batches) + } + + private var involvedFlags: [InvolvementScope] { + var flags: [InvolvementScope] = [.created, .assigned, .mentioned] + if showReviewRequested { flags.append(.reviewRequested) } + return flags + } + + /// Runs a fetch; returns `[]` on timeout so one slow request does not + /// block the rest. All other errors (auth, decoding, etc.) propagate. + private func resilientFetch(_ fetch: () async throws -> [Issue]) async throws -> [Issue] { + do { + return try await fetch() + } catch let urlError as URLError where urlError.code == .timedOut { + return [] + } + } + + /// Merges multiple batches of issues, removing duplicates by ID and + /// sorting by most recently updated first. + static func deduplicateAndSort(_ batches: [[Issue]]) -> [Issue] { + var seen = Set() + var merged = [Issue]() + for issue in batches.flatMap({ $0 }) where seen.insert(issue.id).inserted { + merged.append(issue) + } + return merged.sorted { $0.updatedAt > $1.updatedAt } + } + @discardableResult private func reloadItems(clearItems: Bool = false) -> Task { guard let issueService else { return Task {} } diff --git a/Forji/ForjiTests/SearchableOverviewTests.swift b/Forji/ForjiTests/SearchableOverviewTests.swift new file mode 100644 index 0000000..862bbe5 --- /dev/null +++ b/Forji/ForjiTests/SearchableOverviewTests.swift @@ -0,0 +1,60 @@ +import ForgejoKit +import Foundation +import SwiftUI +import Testing + +@testable import Forji + +private func makeIssue(id: Int, updatedAt: Date = Date()) -> ForgejoKit.Issue { + ForgejoKit.Issue( + id: id, number: id, + title: "Issue \(id)", body: nil, state: "open", + user: User(id: 1, login: "u", fullName: "U", email: "u@e", avatarUrl: nil, isAdmin: false, created: Date()), + createdAt: Date(), updatedAt: updatedAt, comments: 0, + ) +} + +private typealias SUT = SearchableOverviewView + +@Suite("deduplicateAndSort") +struct DeduplicateAndSortTests { + @Test func emptyBatchesReturnEmpty() { + let result = SUT.deduplicateAndSort([[], []]) + #expect(result.isEmpty) + } + + @Test func singleBatchPreserved() { + let now = Date() + let issues = [ + makeIssue(id: 1, updatedAt: now), + makeIssue(id: 2, updatedAt: now.addingTimeInterval(-10)), + ] + let result = SUT.deduplicateAndSort([issues]) + #expect(result.map { $0.id } == [1, 2]) + } + + @Test func duplicatesRemovedKeepsFirst() { + let a = makeIssue(id: 1, updatedAt: Date()) + let b = makeIssue(id: 1, updatedAt: Date().addingTimeInterval(-100)) + let result = SUT.deduplicateAndSort([[a], [b]]) + #expect(result.count == 1) + #expect(result[0].id == 1) + } + + @Test func sortedByUpdatedAtDescending() { + let old = makeIssue(id: 1, updatedAt: Date().addingTimeInterval(-200)) + let mid = makeIssue(id: 2, updatedAt: Date().addingTimeInterval(-100)) + let recent = makeIssue(id: 3, updatedAt: Date()) + let result = SUT.deduplicateAndSort([[old], [recent], [mid]]) + #expect(result.map { $0.id } == [3, 2, 1]) + } + + @Test func duplicatesAcrossMultipleBatches() { + let now = Date() + let a = makeIssue(id: 1, updatedAt: now) + let b = makeIssue(id: 2, updatedAt: now.addingTimeInterval(-10)) + let c = makeIssue(id: 3, updatedAt: now.addingTimeInterval(-20)) + let result = SUT.deduplicateAndSort([[a, b], [b, c], [a, c]]) + #expect(result.map { $0.id } == [1, 2, 3]) + } +}