import SwiftUI struct IssueView: View { @State private var model: IssueDetailViewModel @State private var commentText = "" @State private var editingLabel = "" @State private var editingAssignee = "" @State private var editing = false @State private var draftTitle = "" @State private var draftBody = "" init(client: GitbayClient, repo: String, number: Int64) { _model = State(initialValue: IssueDetailViewModel( client: client, repoPath: repo, number: number )) } var body: some View { List { if let issue = model.state.value { header(issue) if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } if let body = issue.body, !body.isEmpty { Section { MarkdownView(markdown: body) .padding(.vertical, 4) } } triageSection(issue) commentsSection(issue.comments ?? []) } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("#\(model.number)") .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .sheet(isPresented: $editing) { ComposeSheet( heading: "Edit #\(model.number)", submitLabel: "Save", working: model.working, errorMessage: model.actionError, title: $draftTitle, bodyText: $draftBody ) { Task { await model.edit(title: draftTitle, body: draftBody) if model.actionError == nil { editing = false } } } } .task { await model.load() } .refreshable { await model.load() } } @ViewBuilder private func header(_ issue: IssueDetail) -> some View { Section { VStack(alignment: .leading, spacing: 6) { Text(issue.title) .font(.gbSans(.headline)) HStack(spacing: 6) { Label(issue.state, systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill") .font(.gbSans(.caption).weight(.medium)) .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad) Text("by \(issue.author)") Text(issue.createdAt, format: .relative(presentation: .named)) .foregroundStyle(.tertiary) if let milestone = issue.milestone { Label(milestone, systemImage: "flag") } } .font(.gbSans(.caption)) .foregroundStyle(.secondary) } .padding(.vertical, 2) } } @ViewBuilder private func triageSection(_ issue: IssueDetail) -> some View { Section("Labels") { labelFlow(issue.labels ?? [], remove: { label in Task { await model.removeLabel(label) } }) HStack { TextField("Add label", text: $editingLabel) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button { let label = editingLabel.trimmingCharacters(in: .whitespaces) editingLabel = "" Task { await model.addLabel(label) } } label: { Image(systemName: "plus.circle.fill") } .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working) } } Section("Assignees") { labelFlow(issue.assignees ?? [], remove: { user in Task { await model.removeAssignee(user) } }) HStack { TextField("Assign user", text: $editingAssignee) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button { let user = editingAssignee.trimmingCharacters(in: .whitespaces) editingAssignee = "" Task { await model.addAssignee(user) } } label: { Image(systemName: "plus.circle.fill") } .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working) } } Section("Milestone") { Menu { Button("None") { Task { await model.setMilestone(nil) } } ForEach(model.availableMilestones ?? []) { milestone in Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") { Task { await model.setMilestone(milestone.title) } } } } label: { HStack { Label(issue.milestone ?? "None", systemImage: "flag") .font(.gbSans(.subheadline)) Spacer() Image(systemName: "chevron.up.chevron.down") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } .disabled(model.working) .task { await model.loadMilestones() } .accessibilityIdentifier("milestone-menu") } } @ViewBuilder private func labelFlow(_ items: [String], remove: @escaping (String) -> Void) -> some View { if !items.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(items, id: \.self) { item in HStack(spacing: 3) { Text(item) Button { remove(item) } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .disabled(model.working) } .font(.gbSans(.caption)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.secondary.opacity(0.07), in: gbChipShape) .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1)) } } } } } private func commentsSection(_ comments: [IssueDetail.Comment]) -> some View { Section("Comments") { ForEach(comments) { comment in VStack(alignment: .leading, spacing: 4) { HStack { Text(comment.author) .font(.gbSans(.caption).weight(.semibold)) Text(comment.createdAt, format: .relative(presentation: .named)) .font(.gbSans(.caption)) .foregroundStyle(.tertiary) } MarkdownView(markdown: comment.body) .font(.gbSans(.subheadline)) } .padding(.vertical, 2) } HStack { TextField("Comment", text: $commentText, axis: .vertical) .lineLimit(1...5) Button { let text = commentText commentText = "" Task { await model.comment(text) } } label: { Image(systemName: "arrow.up.circle.fill") } .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.working) } } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { if let issue = model.state.value { Menu { Button { draftTitle = issue.title draftBody = issue.body ?? "" editing = true } label: { Label("Edit", systemImage: "pencil") } Button(issue.isOpen ? "Close" : "Reopen") { Task { issue.isOpen ? await model.close() : await model.reopen() } } } label: { if model.working { ProgressView() } else { Image(systemName: "ellipsis.circle") } } .disabled(model.working) .accessibilityIdentifier("issue-actions-menu") } } } }