gitbay/Views/MRs/DiffView.swift
235 lines · 8767 bytes
1import SwiftUI
2
3/// The parsed unified diff, file by file, hunk by hunk. Loads its own
4/// text so the screen is reachable by value from anywhere.
5struct DiffView: View {
6
7 @State private var model: MRDetailViewModel
8
9 init(client: GitbayClient, repo: String, number: Int64) {
10 _model = State(initialValue: MRDetailViewModel(
11 client: client, repoPath: repo, number: number
12 ))
13 }
14
15 var body: some View {
16 ZStack {
17 Color.clear
18 if let diff = model.diff, !diff.files.isEmpty {
19 List {
20 ForEach(diff.files) { file in
21 DiffFileSection(file: file, model: model)
22 }
23 let detached = model.threads.filter { thread in
24 thread.stale || !diff.anchors(thread)
25 }
26 if !detached.isEmpty {
27 Section("Threads on earlier revisions") {
28 ForEach(detached) { thread in
29 ReviewThreadView(thread: thread, model: model)
30 }
31 }
32 }
33 }
34 .listStyle(.plain)
35 } else if model.state.value != nil {
36 ContentUnavailableView {
37 Label("No changes", systemImage: "plus.forwardslash.minus")
38 }
39 }
40 }
41 .overlay { LoadStateOverlay(state: model.state) }
42 .navigationTitle("Diff")
43 .navigationBarTitleDisplayMode(.inline)
44 .task { await model.load() }
45 .refreshable { await model.load() }
46 .referenceNavigation()
47 }
48}
49
50struct DiffFileSection: View {
51
52 let file: UnifiedDiff.File
53 /// Review threads belong to merge requests; a commit diff has none.
54 var model: MRDetailViewModel? = nil
55 @State private var collapsed = false
56
57 var body: some View {
58 Section {
59 if !collapsed {
60 if file.isBinary {
61 Text("Binary file")
62 .font(.gbSans(.caption))
63 .foregroundStyle(.secondary)
64 } else {
65 ForEach(file.hunks) { hunk in
66 HunkView(file: file, hunk: hunk, model: model)
67 }
68 }
69 }
70 } header: {
71 Button {
72 collapsed.toggle()
73 } label: {
74 HStack(spacing: 6) {
75 Image(systemName: collapsed ? "chevron.right" : "chevron.down")
76 .font(.gbSans(.caption2))
77 Text(file.displayPath)
78 .font(.gbMono(.caption).weight(.semibold))
79 .lineLimit(1)
80 .truncationMode(.head)
81 if file.isNew {
82 Text("new").font(.gbSans(.caption2)).foregroundStyle(Color.gbOK)
83 }
84 if file.isDeleted {
85 Text("deleted").font(.gbSans(.caption2)).foregroundStyle(Color.gbBad)
86 }
87 Spacer()
88 Text("+\(file.additions)").foregroundStyle(Color.gbOK).font(.gbSans(.caption2))
89 Text("−\(file.deletions)").foregroundStyle(Color.gbBad).font(.gbSans(.caption2))
90 }
91 }
92 .buttonStyle(.plain)
93 .textCase(nil)
94 }
95 }
96}
97
98private struct HunkView: View {
99
100 let file: UnifiedDiff.File
101 let hunk: UnifiedDiff.Hunk
102 let model: MRDetailViewModel?
103 @State private var composing: Compose?
104 /// The line tapped, whose actions are on offer.
105 @State private var picked: DiffCommentDraft?
106
107 /// A comment being written on a line, and whether it opened as a suggestion.
108 private struct Compose: Identifiable {
109 let draft: DiffCommentDraft
110 let suggesting: Bool
111 var id: String { draft.id + (suggesting ? ":s" : "") }
112 }
113
114 var body: some View {
115 ScrollView(.horizontal) {
116 VStack(alignment: .leading, spacing: 0) {
117 // The stylesheet colors the hunk header with --accent on
118 // --fill-subtle, ruled above and below with --line.
119 Text(hunk.header)
120 .font(.gbMono(.caption2))
121 .foregroundStyle(Color.gbAccent)
122 .padding(.vertical, 3)
123 .frame(maxWidth: .infinity, alignment: .leading)
124 .background(Color.gbFillSubtle)
125 .overlay(alignment: .top) {
126 Rectangle().fill(Color.gbLine).frame(height: 1)
127 }
128 .overlay(alignment: .bottom) {
129 Rectangle().fill(Color.gbLine).frame(height: 1)
130 }
131 ForEach(hunk.lines) { line in
132 if model != nil {
133 // A tap rather than a context menu: in a list row SwiftUI
134 // hoists the first line's menu to the whole hunk, so every
135 // line would offer line one's anchor.
136 LineView(line: line)
137 .contentShape(Rectangle())
138 .onTapGesture { pick(line) }
139 .accessibilityAddTraits(.isButton)
140 .accessibilityHint("Comment on this line")
141 .accessibilityAction { pick(line) }
142 } else {
143 LineView(line: line)
144 }
145 if let model {
146 ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in
147 ReviewThreadView(thread: thread, model: model)
148 .padding(.vertical, 6)
149 .padding(.horizontal, 8)
150 .background(Color.gbFillSubtle)
151 }
152 }
153 }
154 }
155 }
156 .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8))
157 .confirmationDialog(
158 picked.map { "\($0.path):\($0.line)\($0.old ? " (old)" : "")" } ?? "",
159 isPresented: Binding(get: { picked != nil }, set: { if !$0 { picked = nil } }),
160 titleVisibility: .visible,
161 presenting: picked
162 ) { draft in
163 Button("Comment") { composing = Compose(draft: draft, suggesting: false) }
164 if draft.canSuggest {
165 Button("Suggest a change") { composing = Compose(draft: draft, suggesting: true) }
166 }
167 Button("Cancel", role: .cancel) {}
168 }
169 .sheet(item: $composing) { compose in
170 if let model {
171 DiffCommentSheet(draft: compose.draft, model: model, suggesting: compose.suggesting)
172 }
173 }
174 }
175
176 private func pick(_ line: UnifiedDiff.Line) {
177 picked = DiffCommentDraft(file: file, hunk: hunk, line: line)
178 }
179}
180
181private struct LineView: View {
182
183 let line: UnifiedDiff.Line
184 @ScaledMetric(relativeTo: .caption2) private var numberWidth: CGFloat = 34
185 @ScaledMetric(relativeTo: .caption2) private var markerWidth: CGFloat = 14
186
187 var body: some View {
188 HStack(spacing: 0) {
189 Text(line.oldNumber.map(String.init) ?? "")
190 .frame(width: numberWidth, alignment: .trailing)
191 .foregroundStyle(.tertiary)
192 Text(line.newNumber.map(String.init) ?? "")
193 .frame(width: numberWidth, alignment: .trailing)
194 .foregroundStyle(.tertiary)
195 Text(marker)
196 .frame(width: markerWidth)
197 .foregroundStyle(markerColor)
198 Text(line.text.isEmpty ? " " : line.text)
199 .foregroundStyle(textColor)
200 }
201 .font(.gbMono(.caption2))
202 .background(background)
203 .accessibilityElement(children: .ignore)
204 .accessibilityLabel(line.accessibilityLabel)
205 .accessibilityIdentifier("diff-line-\(line.oldNumber ?? 0)-\(line.newNumber ?? 0)")
206 }
207
208 private var marker: String {
209 switch line.kind {
210 case .addition: "+"
211 case .deletion: "−"
212 case .context: " "
213 }
214 }
215
216 private var markerColor: Color {
217 switch line.kind {
218 case .addition: .gbOK
219 case .deletion: .gbBad
220 case .context: .clear
221 }
222 }
223
224 private var textColor: Color {
225 .primary
226 }
227
228 private var background: Color {
229 switch line.kind {
230 case .addition: .gbDiffAdd
231 case .deletion: .gbDiffDel
232 case .context: .clear
233 }
234 }
235}