gitbay/Views/Issues/IssueView.swift
249 lines · 10277 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 Section {
33 if let body = issue.body, !body.isEmpty {
34 FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath)
35 .padding(.vertical, 4)
36 }
37 ReactionBar(reactions: issue.reactions ?? [], disabled: model.working,
38 idPrefix: "issue-reaction") { name in
39 Task { await model.react(name, comment: nil) }
40 }
41 }
42
43 triageSection(issue)
44 commentsSection(issue.comments ?? [])
45 }
46 }
47 .overlay { LoadStateOverlay(state: model.state) }
48 .referenceNavigation()
49 .scrollDismissesKeyboard(.interactively)
50 .navigationTitle("#\(model.number)")
51 .navigationBarTitleDisplayMode(.inline)
52 .toolbar { toolbar }
53 .sheet(isPresented: $editing) {
54 ComposeSheet(
55 heading: "Edit #\(model.number)",
56 submitLabel: "Save",
57 working: model.working,
58 errorMessage: model.actionError,
59 title: $draftTitle,
60 bodyText: $draftBody,
61 format: $draftFormat
62 ) {
63 Task {
64 await model.edit(title: draftTitle, body: draftBody, format: draftFormat)
65 if model.actionError == nil { editing = false }
66 }
67 }
68 }
69 .task { await model.load() }
70 .refreshable { await model.load() }
71 }
72
73 @ViewBuilder
74 private func header(_ issue: IssueDetail) -> some View {
75 Section {
76 VStack(alignment: .leading, spacing: 6) {
77 Text(issue.title)
78 .font(.gbSans(.headline))
79 HStack(spacing: 6) {
80 Label(stateLabel(issue.state), systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill")
81 .font(.gbSans(.caption).weight(.medium))
82 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
83 Text("by \(issue.author)")
84 Text(issue.createdAt, format: .relative(presentation: .named))
85 .foregroundStyle(.tertiary)
86 if let milestone = issue.milestone {
87 Label(milestone, systemImage: "flag")
88 }
89 }
90 .font(.gbSans(.caption))
91 .foregroundStyle(.secondary)
92 }
93 .padding(.vertical, 2)
94 }
95 }
96
97 @ViewBuilder
98 private func triageSection(_ issue: IssueDetail) -> some View {
99 Section("Labels") {
100 RemovableChips(items: issue.labels ?? [], idPrefix: "issue-label",
101 disabled: model.working, color: { model.colors.color($0) },
102 remove: { label in Task { await model.removeLabel(label) } })
103 HStack {
104 TextField("Add label", text: $editingLabel)
105 .autocorrectionDisabled()
106 .textInputAutocapitalization(.never)
107 Button {
108 let label = editingLabel.trimmingCharacters(in: .whitespaces)
109 editingLabel = ""
110 Task { await model.addLabel(label) }
111 } label: {
112 Image(systemName: "plus.circle.fill")
113 }
114 .accessibilityLabel("Add label")
115 .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
116 }
117 }
118 Section("Assignees") {
119 RemovableChips(items: issue.assignees ?? [], idPrefix: "issue-assignee",
120 disabled: model.working,
121 remove: { user in Task { await model.removeAssignee(user) } })
122 HStack {
123 TextField("Assign user", text: $editingAssignee)
124 .autocorrectionDisabled()
125 .textInputAutocapitalization(.never)
126 Button {
127 let user = editingAssignee.trimmingCharacters(in: .whitespaces)
128 editingAssignee = ""
129 Task { await model.addAssignee(user) }
130 } label: {
131 Image(systemName: "plus.circle.fill")
132 }
133 .accessibilityLabel("Add assignee")
134 .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
135 }
136 }
137 Section("Milestone") {
138 Menu {
139 Button("None") {
140 Task { await model.setMilestone(nil) }
141 }
142 ForEach(model.availableMilestones ?? []) { milestone in
143 Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
144 Task { await model.setMilestone(milestone.title) }
145 }
146 }
147 } label: {
148 HStack {
149 Label(issue.milestone ?? "None", systemImage: "flag")
150 .font(.gbSans(.subheadline))
151 Spacer()
152 Image(systemName: "chevron.up.chevron.down")
153 .font(.gbSans(.caption2))
154 .foregroundStyle(.secondary)
155 }
156 }
157 .disabled(model.working)
158 .task { await model.loadMilestones() }
159 .accessibilityIdentifier("milestone-menu")
160 }
161 }
162
163 private func commentsSection(_ comments: [IssueDetail.Comment]) -> some View {
164 Section("Comments") {
165 ForEach(comments) { comment in
166 VStack(alignment: .leading, spacing: 4) {
167 HStack {
168 Text(comment.author)
169 .font(.gbSans(.caption).weight(.semibold))
170 Text(comment.createdAt, format: .relative(presentation: .named))
171 .font(.gbSans(.caption))
172 .foregroundStyle(.tertiary)
173 }
174 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
175 .font(.gbSans(.subheadline))
176 if let id = comment.commentID, !comment.isSystem {
177 ReactionBar(reactions: comment.reactions ?? [], disabled: model.working,
178 idPrefix: "issue-comment-\(id)-reaction") { name in
179 Task { await model.react(name, comment: id) }
180 }
181 }
182 }
183 .padding(.vertical, 2)
184 }
185
186 HStack {
187 TextField("Comment", text: $commentText, axis: .vertical)
188 .lineLimit(1...5)
189 .accessibilityIdentifier("issue-comment-field")
190 Picker("Format", selection: $commentFormat) {
191 Text("Markdown").tag("md")
192 Text("Org").tag("org")
193 }
194 .pickerStyle(.segmented)
195 .labelsHidden()
196 .fixedSize()
197 .accessibilityIdentifier("issue-comment-format")
198 Button {
199 let text = commentText
200 commentText = ""
201 Task { await model.comment(text, format: commentFormat) }
202 } label: {
203 Image(systemName: "arrow.up.circle.fill")
204 .font(.title)
205 .frame(minWidth: 44, minHeight: 44)
206 .contentShape(Rectangle())
207 }
208 .accessibilityLabel("Send comment")
209 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
210 || model.working)
211 }
212 if !commentText.isEmpty {
213 MarkupPreview(text: commentText, format: commentFormat,
214 identifier: "issue-comment-preview")
215 .font(.gbSans(.subheadline))
216 }
217 }
218 }
219
220 @ToolbarContentBuilder
221 private var toolbar: some ToolbarContent {
222 ToolbarItem(placement: .topBarTrailing) {
223 if let issue = model.state.value {
224 Menu {
225 Button {
226 draftTitle = issue.title
227 draftBody = issue.body ?? ""
228 draftFormat = issue.bodyFormat ?? "md"
229 editing = true
230 } label: {
231 Label("Edit", systemImage: "pencil")
232 }
233 Button(issue.isOpen ? "Close" : "Reopen") {
234 Task { issue.isOpen ? await model.close() : await model.reopen() }
235 }
236 } label: {
237 if model.working {
238 ProgressView()
239 } else {
240 Image(systemName: "ellipsis.circle")
241 }
242 }
243 .accessibilityLabel("Actions")
244 .disabled(model.working)
245 .accessibilityIdentifier("issue-actions-menu")
246 }
247 }
248 }
249}