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