From 556c614011956c5757a0b93bdb292aaf5d8ff3e6 Mon Sep 17 00:00:00 2001 From: Stefan Hausotte Date: Sat, 21 Mar 2026 15:57:34 +0100 Subject: [PATCH] refactor: reduce code complexity (#19) Co-authored-by: Stefan Hausotte Co-committed-by: Stefan Hausotte --- Forji/Forji/Models/ForgejoInstance.swift | 8 + Forji/Forji/Views/HomeView.swift | 184 +++++-------- Forji/Forji/Views/InstanceListView.swift | 8 +- .../Views/MergedIssuesOverviewView.swift | 192 ++------------ .../MergedPullRequestsOverviewView.swift | 189 ++------------ .../Views/MergedRepositoryListView.swift | 83 ++---- .../Views/MergedSearchableOverviewView.swift | 242 ++++++++++++++++++ 7 files changed, 376 insertions(+), 530 deletions(-) create mode 100644 Forji/Forji/Views/MergedSearchableOverviewView.swift diff --git a/Forji/Forji/Models/ForgejoInstance.swift b/Forji/Forji/Models/ForgejoInstance.swift index 052a020..5098563 100644 --- a/Forji/Forji/Models/ForgejoInstance.swift +++ b/Forji/Forji/Models/ForgejoInstance.swift @@ -25,3 +25,11 @@ final class ForgejoInstance { self.useTokenAuth = useTokenAuth } } + +extension ForgejoInstance { + static func clearDefaults(in instances: [ForgejoInstance]) { + for instance in instances where instance.isDefault { + instance.isDefault = false + } + } +} diff --git a/Forji/Forji/Views/HomeView.swift b/Forji/Forji/Views/HomeView.swift index 5ece51e..0ef6398 100644 --- a/Forji/Forji/Views/HomeView.swift +++ b/Forji/Forji/Views/HomeView.swift @@ -161,63 +161,7 @@ struct SettingsTabView: View { .accessibilityIdentifier("home-logout-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) - } + AboutSection() } .navigationTitle("Settings") } @@ -279,9 +223,7 @@ struct MergedSettingsTabView: View { Toggle("Default on Launch", isOn: $defaultAllInstances) .onChange(of: defaultAllInstances) { _, isOn in if isOn { - for inst in allInstances where inst.isDefault { - inst.isDefault = false - } + ForgejoInstance.clearDefaults(in: allInstances) try? modelContext.save() } } @@ -299,68 +241,76 @@ struct MergedSettingsTabView: View { .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) - } + AboutSection() } .navigationTitle("Settings") } } +// MARK: - About Section + +private struct AboutSection: View { + var body: some View { + 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) + } + } +} + #if DEBUG #Preview { HomeView(authService: .previewDefault) diff --git a/Forji/Forji/Views/InstanceListView.swift b/Forji/Forji/Views/InstanceListView.swift index 074f9af..5e8a31d 100644 --- a/Forji/Forji/Views/InstanceListView.swift +++ b/Forji/Forji/Views/InstanceListView.swift @@ -230,9 +230,7 @@ struct InstanceListView: View { private func toggleDefaultAll() { defaultAllInstances.toggle() if defaultAllInstances { - for inst in instances where inst.isDefault { - inst.isDefault = false - } + ForgejoInstance.clearDefaults(in: instances) do { try modelContext.save() } catch { @@ -245,9 +243,7 @@ struct InstanceListView: View { if instance.isDefault { instance.isDefault = false } else { - for inst in instances where inst.isDefault { - inst.isDefault = false - } + ForgejoInstance.clearDefaults(in: instances) instance.isDefault = true defaultAllInstances = false } diff --git a/Forji/Forji/Views/MergedIssuesOverviewView.swift b/Forji/Forji/Views/MergedIssuesOverviewView.swift index 9a6561f..79c531d 100644 --- a/Forji/Forji/Views/MergedIssuesOverviewView.swift +++ b/Forji/Forji/Views/MergedIssuesOverviewView.swift @@ -4,186 +4,30 @@ 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 + MergedSearchableOverviewView( + manager: manager, + config: .issues, + row: { issue, instanceName in + IssueRow(issue: issue, instanceName: instanceName) + }, + detail: { repository, number, auth in + IssueDetailView( + repository: repository, + issueNumber: number, + authService: auth, + ) + }, + createView: { auth, onCreated in RepositoryPickerView(authService: auth) { repo in IssueCreateView( repository: repo, authService: auth, embeddedInNavigation: true, - ) { - showCreateFlow = false - reloadItems() - } + onCreated: onCreated, + ) } - } - } - .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.issueFlags { - group.addTask { - let service = IssueService(client: client) - return (index, try await resilientFetch { - try await service.searchIssues( - type: "issues", state: state, page: page, limit: limit, - assigned: flag == .assigned, created: flag == .created, - mentioned: flag == .mentioned - ) - }) - } - } - } else { - group.addTask { - let service = IssueService(client: client) - return (index, try await resilientFetch { - try await service.searchIssues( - type: "issues", state: state, query: query, - page: page, limit: limit - ) - }) - } - } - } - 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/MergedPullRequestsOverviewView.swift b/Forji/Forji/Views/MergedPullRequestsOverviewView.swift index a2a94ae..cd19ab5 100644 --- a/Forji/Forji/Views/MergedPullRequestsOverviewView.swift +++ b/Forji/Forji/Views/MergedPullRequestsOverviewView.swift @@ -4,183 +4,30 @@ 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 + MergedSearchableOverviewView( + manager: manager, + config: .pullRequests, + row: { issue, instanceName in + PullRequestOverviewRow(issue: issue, instanceName: instanceName) + }, + detail: { repository, number, auth in + PullRequestDetailView( + repository: repository, + prNumber: number, + authService: auth, + ) + }, + createView: { auth, onCreated in RepositoryPickerView(authService: auth) { repo in PullRequestCreateView( repository: repo, authService: auth, embeddedInNavigation: true, - ) { - showCreateFlow = false - reloadItems() - } + onCreated: onCreated, + ) } - } - } - .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) - return (index, try await resilientFetch { - try await service.searchIssues( - type: "pulls", state: state, page: page, limit: limit, - assigned: flag == .assigned, created: flag == .created, - mentioned: flag == .mentioned, reviewRequested: flag == .reviewRequested - ) - }) - } - } - } else { - group.addTask { - let service = IssueService(client: client) - return (index, try await resilientFetch { - try await service.searchIssues( - type: "pulls", state: state, query: query, - page: page, limit: limit - ) - }) - } - } - } - 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 index ad7c7c6..177420d 100644 --- a/Forji/Forji/Views/MergedRepositoryListView.swift +++ b/Forji/Forji/Views/MergedRepositoryListView.swift @@ -4,26 +4,19 @@ 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 pagination = PaginationState>() @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 { + @Bindable var pagination = pagination VStack(spacing: 0) { if !manager.failedInstances.isEmpty { MergedConnectionWarning(failedInstances: manager.failedInstances) } List { - if isLoading, repositories.isEmpty { + if pagination.isLoading, pagination.items.isEmpty { Section { HStack { Spacer() @@ -32,7 +25,7 @@ struct MergedRepositoryListView: View { } .listRowBackground(Color.clear) } - } else if repositories.isEmpty { + } else if pagination.items.isEmpty { ContentUnavailableView { Label("No Repositories", systemImage: "folder.badge.questionmark") .foregroundStyle(.secondary) @@ -45,7 +38,7 @@ struct MergedRepositoryListView: View { } } else { Section { - ForEach(repositories) { tagged in + ForEach(pagination.items) { tagged in NavigationLink { RepositoryDetailView(repository: tagged.item, authService: tagged.authService) } label: { @@ -53,13 +46,13 @@ struct MergedRepositoryListView: View { } } - if hasMore { + if pagination.hasMore { ProgressView() .frame(maxWidth: .infinity) .listRowBackground(Color.clear) .accessibilityIdentifier("load-more-indicator") .task { - await loadMoreRepositories() + await loadMore() } } } @@ -68,68 +61,36 @@ struct MergedRepositoryListView: View { .listStyle(.insetGrouped) .accessibilityIdentifier("repo-list") .refreshable { - currentPage = 1 - hasMore = true - await loadRepositories() + await reloadItems().value } } .navigationTitle("Repositories") .searchable(text: $searchText, prompt: "Search repositories") .task { - if !hasLoaded { - await loadRepositories() + if !pagination.hasLoaded { + reloadItems() } } .debouncedSearch(text: $searchText, task: $searchTask) { - currentPage = 1 - hasMore = true - await loadRepositories() + await reloadItems().value } - .errorAlert(message: $errorMessage, isPresented: $showError) + .errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError) } - private func loadRepositories() async { - isLoading = true - errorMessage = nil - - do { - let (items, anyFull) = try await fetchFromAllInstances(page: 1, limit: pageSize) - repositories = items - hasMore = anyFull - currentPage = 2 - hasLoaded = true - } catch is CancellationError { - // Ignore - } catch { - errorMessage = error.localizedDescription - showError = true + @discardableResult + private func reloadItems(clearItems: Bool = false) -> Task { + pagination.reload(clearItems: clearItems) { page, limit in + try await fetchFromAllInstances(page: page, limit: limit) } - - isLoading = false } - private func loadMoreRepositories() async { - guard hasMore, !isLoading else { return } - isLoading = true - - do { - let (items, anyFull) = try await fetchFromAllInstances(page: currentPage, limit: pageSize) - repositories.append(contentsOf: items) - hasMore = anyFull - currentPage += 1 - } catch is CancellationError { - // Ignore - } catch { - errorMessage = error.localizedDescription - showError = true + private func loadMore() async { + await pagination.loadMore { page, limit in + try await fetchFromAllInstances(page: page, limit: limit) } - - isLoading = false } - private func fetchFromAllInstances( - page: Int, limit: Int - ) async throws -> (items: [TaggedItem], anySourceFull: Bool) { + private func fetchFromAllInstances(page: Int, limit: Int) async throws -> [TaggedItem] { let query = searchText let sources = manager.connectionSources() @@ -150,10 +111,8 @@ struct MergedRepositoryListView: View { return try await group.reduce(into: [(Int, [Repository])]()) { $0.append($1) } } - let anySourceFull = batches.contains { $0.1.count >= limit } - let merged = TaggedItem.mergeAndDeduplicate(batches: batches, sources: sources) + return TaggedItem.mergeAndDeduplicate(batches: batches, sources: sources) .sorted { ($0.item.updatedAt ?? .distantPast) > ($1.item.updatedAt ?? .distantPast) } - return (merged, anySourceFull) } } diff --git a/Forji/Forji/Views/MergedSearchableOverviewView.swift b/Forji/Forji/Views/MergedSearchableOverviewView.swift new file mode 100644 index 0000000..7c13c36 --- /dev/null +++ b/Forji/Forji/Views/MergedSearchableOverviewView.swift @@ -0,0 +1,242 @@ +import ForgejoKit +import SwiftUI + +struct MergedSearchableConfig { + let issueType: String + let navigationTitle: String + let searchPrompt: String + let emptyTitle: String + let emptyOpenIcon: String + let emptyClosedIcon: String + let itemNoun: String + let createButtonId: String + let involvementFlags: [InvolvementScope] +} + +extension MergedSearchableConfig { + static let issues = MergedSearchableConfig( + issueType: "issues", + navigationTitle: "Issues", + searchPrompt: "Search issues", + emptyTitle: "No Issues", + emptyOpenIcon: "checkmark.circle", + emptyClosedIcon: "exclamationmark.circle", + itemNoun: "issues", + createButtonId: "issue-create-button", + involvementFlags: InvolvementScope.issueFlags, + ) + + static let pullRequests = MergedSearchableConfig( + issueType: "pulls", + navigationTitle: "Pull Requests", + searchPrompt: "Search pull requests", + emptyTitle: "No Pull Requests", + emptyOpenIcon: "arrow.triangle.pull", + emptyClosedIcon: "arrow.triangle.pull", + itemNoun: "pull requests", + createButtonId: "pr-create-button", + involvementFlags: InvolvementScope.pullRequestFlags, + ) +} + +struct MergedSearchableOverviewView: View { + let manager: MultiInstanceManager + let config: MergedSearchableConfig + + @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 + + private let rowContent: (Issue, String) -> Row + private let detailContent: (Repository, Int, AuthenticationService) -> Detail + private let createContent: (AuthenticationService, @escaping () -> Void) -> CreateView + + init( + manager: MultiInstanceManager, + config: MergedSearchableConfig, + @ViewBuilder row: @escaping (Issue, String) -> Row, + @ViewBuilder detail: @escaping (Repository, Int, AuthenticationService) -> Detail, + @ViewBuilder createView: @escaping (AuthenticationService, @escaping () -> Void) -> CreateView, + ) { + self.manager = manager + self.config = config + rowContent = row + detailContent = detail + createContent = createView + } + + 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( + config.emptyTitle, + systemImage: stateFilter == .open + ? config.emptyOpenIcon : config.emptyClosedIcon, + ) + .foregroundStyle(stateFilter == .open ? .green : .secondary) + } description: { + Text( + stateFilter == .open + ? "All clear \u{2014} no open \(config.itemNoun) to review." + : "No \(stateFilter.rawValue) \(config.itemNoun) found.", + ) + } + } else { + Section { + ForEach(pagination.items) { tagged in + if let repository = tagged.item.repository { + NavigationLink { + detailContent(repository, tagged.item.number, tagged.authService) + } label: { + rowContent(tagged.item, tagged.instanceName) + } + } else { + rowContent(tagged.item, 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(config.createButtonId) + } + .navigationTitle(config.navigationTitle) + .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: config.searchPrompt) + .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 + createContent(auth) { + 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 issueType = config.issueType + let flags = config.involvementFlags + 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 flags { + group.addTask { + let service = IssueService(client: client) + return (index, try await resilientFetch { + try await service.searchIssues( + type: issueType, state: state, page: page, limit: limit, + assigned: flag == .assigned, created: flag == .created, + mentioned: flag == .mentioned, + reviewRequested: flag == .reviewRequested, + ) + }) + } + } + } else { + group.addTask { + let service = IssueService(client: client) + return (index, try await resilientFetch { + try await service.searchIssues( + type: issueType, state: state, query: query, + page: page, limit: limit, + ) + }) + } + } + } + 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 } + } +}