mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
302 lines
12 KiB
Swift
302 lines
12 KiB
Swift
import ForgejoKit
|
|
import SwiftUI
|
|
|
|
struct IssueDetailView: View {
|
|
let repository: Repository
|
|
let issueNumber: Int
|
|
@State private var authService: AuthenticationService
|
|
@State private var issue: Issue?
|
|
@State private var comments: [IssueComment] = []
|
|
@State private var isLoading = true
|
|
@State private var errorMessage: String?
|
|
@State private var showError = false
|
|
@State private var showEditSheet = false
|
|
@State private var isTogglingState = false
|
|
@State private var showCommentSheet = false
|
|
@State private var showActionsExpanded = false
|
|
|
|
private let issueService: IssueService?
|
|
|
|
init(repository: Repository, issueNumber: Int, authService: AuthenticationService) {
|
|
self.repository = repository
|
|
self.issueNumber = issueNumber
|
|
self.authService = authService
|
|
issueService = authService.client.map { IssueService(client: $0) }
|
|
}
|
|
|
|
private var owner: String {
|
|
repository.owner
|
|
}
|
|
|
|
private var repo: String {
|
|
repository.repoName
|
|
}
|
|
|
|
private var hasPushPermission: Bool {
|
|
repository.permissions?.push == true || repository.permissions?.admin == true
|
|
}
|
|
|
|
private var isAuthor: Bool {
|
|
guard let currentUser = authService.currentUser?.login else { return false }
|
|
return issue?.user.login == currentUser
|
|
}
|
|
|
|
private var canEditOrClose: Bool {
|
|
hasPushPermission || isAuthor
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
Group {
|
|
if isLoading, issue == nil {
|
|
ProgressView()
|
|
} else if let issue {
|
|
List {
|
|
// Header
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(issue.title)
|
|
.font(.title3)
|
|
.fontWeight(.bold)
|
|
.accessibilityIdentifier("issue-detail-title")
|
|
|
|
HStack(spacing: 8) {
|
|
HStack(spacing: 4) {
|
|
Image(
|
|
systemName: issue.stateValue == .open
|
|
? "circle.circle" : "checkmark.circle.fill",
|
|
)
|
|
Text(issue.stateValue == .open ? "Open" : "Closed")
|
|
}
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.foregroundStyle(.white)
|
|
.glassEffect(.regular.tint(issue.stateValue == .open ? .green : .purple))
|
|
|
|
Text("#\(issue.number)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Text("\(issue.user.login) opened \(formatRelativeDate(issue.createdAt))")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if !issue.labels.isEmpty {
|
|
FlowLayout(spacing: 4) {
|
|
ForEach(issue.labels) { label in
|
|
IssueLabelView(label: label)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
.listRowBackground(
|
|
(issue.stateValue == .open ? Color.green : Color.purple).opacity(0.08),
|
|
)
|
|
|
|
if let milestone = issue.milestone {
|
|
MilestoneDisplaySection(milestone: milestone)
|
|
}
|
|
|
|
if let assignees = issue.assignees, !assignees.isEmpty {
|
|
AssigneesDisplaySection(assignees: assignees)
|
|
}
|
|
|
|
// Body
|
|
if let body = issue.body, !body.isEmpty {
|
|
Section("Description") {
|
|
MarkdownPreview(text: body)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
// Comments
|
|
if !comments.isEmpty {
|
|
Section("Comments (\(comments.count))") {
|
|
ForEach(comments) { comment in
|
|
CommentView(
|
|
comment: comment,
|
|
currentUsername: authService.currentUser?.login,
|
|
) { newBody in
|
|
await editComment(commentId: comment.id, body: newBody)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spacer so content isn't hidden behind floating bar
|
|
Section {} footer: {
|
|
Spacer().frame(height: 60)
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
}
|
|
}
|
|
|
|
// Floating glass action cluster
|
|
if let currentIssue = issue {
|
|
ExpandableActionMenu(isExpanded: $showActionsExpanded) {
|
|
Button { showCommentSheet = true } label: {
|
|
Label("Comment", systemImage: "text.bubble")
|
|
}
|
|
.buttonStyle(.glassProminent)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.accessibilityIdentifier("issue-comment-button")
|
|
|
|
if canEditOrClose {
|
|
Button { showEditSheet = true } label: {
|
|
Label("Edit", systemImage: "pencil")
|
|
}
|
|
.buttonStyle(.glassProminent)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.accessibilityIdentifier("issue-edit-button")
|
|
|
|
Button { Task { await toggleIssueState() } } label: {
|
|
if isTogglingState {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else {
|
|
Label(
|
|
currentIssue.stateValue == .open ? "Close" : "Reopen",
|
|
systemImage: currentIssue.stateValue == .open
|
|
? "xmark.circle" : "arrow.uturn.left.circle",
|
|
)
|
|
}
|
|
}
|
|
.buttonStyle(.glassProminent)
|
|
.tint(currentIssue.stateValue == .open ? .purple : .green)
|
|
.disabled(isTogglingState)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.accessibilityIdentifier("issue-toggle-state")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("#\(issueNumber)")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.sheet(isPresented: $showEditSheet) {
|
|
if let issue {
|
|
IssueEditView(
|
|
repository: repository,
|
|
issue: issue,
|
|
authService: authService,
|
|
) { updatedIssue in
|
|
self.issue = updatedIssue
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showCommentSheet) {
|
|
CommentSheet(users: []) { body in
|
|
guard let issueService else { throw URLError(.userAuthenticationRequired) }
|
|
let comment = try await issueService.createComment(
|
|
owner: owner,
|
|
repo: repo,
|
|
index: issueNumber,
|
|
body: body,
|
|
)
|
|
comments.append(comment)
|
|
}
|
|
}
|
|
.task {
|
|
await loadData()
|
|
}
|
|
.errorAlert(message: $errorMessage, isPresented: $showError)
|
|
}
|
|
|
|
private func loadData() async {
|
|
guard let issueService else { return }
|
|
isLoading = true
|
|
do {
|
|
async let fetchedIssue = issueService.fetchIssue(owner: owner, repo: repo, index: issueNumber)
|
|
async let fetchedComments = issueService.fetchComments(owner: owner, repo: repo, index: issueNumber)
|
|
let loadedIssue = try await fetchedIssue
|
|
let loadedComments = try await fetchedComments
|
|
try Task.checkCancellation()
|
|
issue = loadedIssue
|
|
comments = loadedComments
|
|
} catch is CancellationError {
|
|
// Ignore cancellation
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
private func toggleIssueState() async {
|
|
guard let issueService, let issue else { return }
|
|
isTogglingState = true
|
|
|
|
do {
|
|
let newState = issue.stateValue == .open ? IssueState.closed.rawValue : IssueState.open.rawValue
|
|
let updated = try await issueService.editIssue(
|
|
owner: owner,
|
|
repo: repo,
|
|
index: issueNumber,
|
|
title: nil,
|
|
body: nil,
|
|
state: newState,
|
|
)
|
|
self.issue = updated
|
|
NotificationCenter.default.post(name: .issuesDidChange, object: nil)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
|
|
isTogglingState = false
|
|
}
|
|
|
|
private func editComment(commentId: Int, body: String) async -> Bool {
|
|
guard let issueService else { return false }
|
|
do {
|
|
let updated = try await issueService.editComment(
|
|
owner: owner,
|
|
repo: repo,
|
|
commentId: commentId,
|
|
body: body,
|
|
)
|
|
if let index = comments.firstIndex(where: { $0.id == commentId }) {
|
|
comments[index] = updated
|
|
}
|
|
return true
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
return false
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
init(preview _: Void, repository: Repository, issueNumber: Int, authService: AuthenticationService,
|
|
issue: Issue, comments: [IssueComment] = [])
|
|
{
|
|
self.repository = repository
|
|
self.issueNumber = issueNumber
|
|
self.authService = authService
|
|
issueService = nil
|
|
_issue = State(initialValue: issue)
|
|
_comments = State(initialValue: comments)
|
|
_isLoading = State(initialValue: false)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if DEBUG
|
|
#Preview {
|
|
NavigationStack {
|
|
IssueDetailView(
|
|
preview: (),
|
|
repository: .preview,
|
|
issueNumber: 1,
|
|
authService: .previewDefault,
|
|
issue: .preview,
|
|
comments: [.preview],
|
|
)
|
|
}
|
|
}
|
|
#endif
|