gitbay/Views/Issues/IssueView.swift
240 lines · 9070 bytes
1import SwiftUI
2
3struct IssueView: View {
4
5 @State private var model: IssueDetailViewModel
6 @State private var commentText = ""
7 @State private var editingLabel = ""
8 @State private var editingAssignee = ""
9 @State private var editing = false
10 @State private var draftTitle = ""
11 @State private var draftBody = ""
12
13 init(client: GitbayClient, repo: String, number: Int64) {
14 _model = State(initialValue: IssueDetailViewModel(
15 client: client, repoPath: repo, number: number
16 ))
17 }
18
19 var body: some View {
20 List {
21 if let issue = model.state.value {
22 header(issue)
23
24 if let error = model.actionError {
25 Section {
26 Label(error, systemImage: "hand.raised")
27 .foregroundStyle(.orange)
28 .font(.subheadline)
29 }
30 }
31
32 if let body = issue.body, !body.isEmpty {
33 Section {
34 MarkdownView(markdown: body)
35 .padding(.vertical, 4)
36 }
37 }
38
39 triageSection(issue)
40 commentsSection(issue.comments ?? [])
41 }
42 }
43 .overlay { LoadStateOverlay(state: model.state) }
44 .navigationTitle("#\(model.number)")
45 .navigationBarTitleDisplayMode(.inline)
46 .toolbar { toolbar }
47 .sheet(isPresented: $editing) {
48 ComposeSheet(
49 heading: "Edit #\(model.number)",
50 submitLabel: "Save",
51 working: model.working,
52 errorMessage: model.actionError,
53 title: $draftTitle,
54 bodyText: $draftBody
55 ) {
56 Task {
57 await model.edit(title: draftTitle, body: draftBody)
58 if model.actionError == nil { editing = false }
59 }
60 }
61 }
62 .task { await model.load() }
63 .refreshable { await model.load() }
64 }
65
66 @ViewBuilder
67 private func header(_ issue: IssueDetail) -> some View {
68 Section {
69 VStack(alignment: .leading, spacing: 6) {
70 Text(issue.title)
71 .font(.headline)
72 HStack(spacing: 6) {
73 Label(issue.state, systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill")
74 .font(.caption.weight(.medium))
75 .foregroundStyle(issue.isOpen ? .green : .purple)
76 Text("by \(issue.author)")
77 Text(issue.createdAt, format: .relative(presentation: .named))
78 .foregroundStyle(.tertiary)
79 if let milestone = issue.milestone {
80 Label(milestone, systemImage: "flag")
81 }
82 }
83 .font(.caption)
84 .foregroundStyle(.secondary)
85 }
86 .padding(.vertical, 2)
87 }
88 }
89
90 @ViewBuilder
91 private func triageSection(_ issue: IssueDetail) -> some View {
92 Section("Labels") {
93 labelFlow(issue.labels ?? [], remove: { label in
94 Task { await model.removeLabel(label) }
95 })
96 HStack {
97 TextField("Add label", text: $editingLabel)
98 .autocorrectionDisabled()
99 .textInputAutocapitalization(.never)
100 Button {
101 let label = editingLabel.trimmingCharacters(in: .whitespaces)
102 editingLabel = ""
103 Task { await model.addLabel(label) }
104 } label: {
105 Image(systemName: "plus.circle.fill")
106 }
107 .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
108 }
109 }
110 Section("Assignees") {
111 labelFlow(issue.assignees ?? [], remove: { user in
112 Task { await model.removeAssignee(user) }
113 })
114 HStack {
115 TextField("Assign user", text: $editingAssignee)
116 .autocorrectionDisabled()
117 .textInputAutocapitalization(.never)
118 Button {
119 let user = editingAssignee.trimmingCharacters(in: .whitespaces)
120 editingAssignee = ""
121 Task { await model.addAssignee(user) }
122 } label: {
123 Image(systemName: "plus.circle.fill")
124 }
125 .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
126 }
127 }
128 Section("Milestone") {
129 Menu {
130 Button("None") {
131 Task { await model.setMilestone(nil) }
132 }
133 ForEach(model.availableMilestones ?? []) { milestone in
134 Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
135 Task { await model.setMilestone(milestone.title) }
136 }
137 }
138 } label: {
139 HStack {
140 Label(issue.milestone ?? "None", systemImage: "flag")
141 .font(.subheadline)
142 Spacer()
143 Image(systemName: "chevron.up.chevron.down")
144 .font(.caption2)
145 .foregroundStyle(.secondary)
146 }
147 }
148 .disabled(model.working)
149 .task { await model.loadMilestones() }
150 .accessibilityIdentifier("milestone-menu")
151 }
152 }
153
154 @ViewBuilder
155 private func labelFlow(_ items: [String], remove: @escaping (String) -> Void) -> some View {
156 if !items.isEmpty {
157 ScrollView(.horizontal, showsIndicators: false) {
158 HStack(spacing: 6) {
159 ForEach(items, id: \.self) { item in
160 HStack(spacing: 3) {
161 Text(item)
162 Button {
163 remove(item)
164 } label: {
165 Image(systemName: "xmark.circle.fill")
166 .foregroundStyle(.tertiary)
167 }
168 .disabled(model.working)
169 }
170 .font(.caption)
171 .padding(.horizontal, 8)
172 .padding(.vertical, 3)
173 .background(.quaternary, in: Capsule())
174 }
175 }
176 }
177 }
178 }
179
180 private func commentsSection(_ comments: [IssueDetail.Comment]) -> some View {
181 Section("Comments") {
182 ForEach(comments) { comment in
183 VStack(alignment: .leading, spacing: 4) {
184 HStack {
185 Text(comment.author)
186 .font(.caption.weight(.semibold))
187 Text(comment.createdAt, format: .relative(presentation: .named))
188 .font(.caption)
189 .foregroundStyle(.tertiary)
190 }
191 MarkdownView(markdown: comment.body)
192 .font(.subheadline)
193 }
194 .padding(.vertical, 2)
195 }
196
197 HStack {
198 TextField("Comment", text: $commentText, axis: .vertical)
199 .lineLimit(1...5)
200 Button {
201 let text = commentText
202 commentText = ""
203 Task { await model.comment(text) }
204 } label: {
205 Image(systemName: "arrow.up.circle.fill")
206 }
207 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
208 || model.working)
209 }
210 }
211 }
212
213 @ToolbarContentBuilder
214 private var toolbar: some ToolbarContent {
215 ToolbarItem(placement: .topBarTrailing) {
216 if let issue = model.state.value {
217 Menu {
218 Button {
219 draftTitle = issue.title
220 draftBody = issue.body ?? ""
221 editing = true
222 } label: {
223 Label("Edit", systemImage: "pencil")
224 }
225 Button(issue.isOpen ? "Close" : "Reopen") {
226 Task { issue.isOpen ? await model.close() : await model.reopen() }
227 }
228 } label: {
229 if model.working {
230 ProgressView()
231 } else {
232 Image(systemName: "ellipsis.circle")
233 }
234 }
235 .disabled(model.working)
236 .accessibilityIdentifier("issue-actions-menu")
237 }
238 }
239 }
240}