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