refactor: reduce code complexity (#19)

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:57:34 +01:00 committed by secana
parent 0bcca64d3a
commit 556c614011
7 changed files with 376 additions and 530 deletions

View file

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

View file

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

View file

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

View file

@ -4,186 +4,30 @@ 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
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<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.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 }
},
)
}
}

View file

@ -4,183 +4,30 @@ 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
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<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)
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 }
},
)
}
}

View file

@ -4,26 +4,19 @@ 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 pagination = PaginationState<TaggedItem<Repository>>()
@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 {
@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<Void, Never> {
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<Repository>], anySourceFull: Bool) {
private func fetchFromAllInstances(page: Int, limit: Int) async throws -> [TaggedItem<Repository>] {
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)
}
}

View file

@ -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<Row: View, Detail: View, CreateView: View>: View {
let manager: MultiInstanceManager
let config: MergedSearchableConfig
@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
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<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 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 }
}
}