mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
refactor: reduce code complexity
This commit is contained in:
parent
0bcca64d3a
commit
d61851b9e9
7 changed files with 376 additions and 530 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
242
Forji/Forji/Views/MergedSearchableOverviewView.swift
Normal file
242
Forji/Forji/Views/MergedSearchableOverviewView.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue