gitbay/Views/MRs/DiffView.swift
191 lines · 6681 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
104 var body: some View {
105 ScrollView(.horizontal) {
106 VStack(alignment: .leading, spacing: 0) {
107 // The stylesheet colors the hunk header with --accent on
108 // --fill-subtle, ruled above and below with --line.
109 Text(hunk.header)
110 .font(.gbMono(.caption2))
111 .foregroundStyle(Color.gbAccent)
112 .padding(.vertical, 3)
113 .frame(maxWidth: .infinity, alignment: .leading)
114 .background(Color.gbFillSubtle)
115 .overlay(alignment: .top) {
116 Rectangle().fill(Color.gbLine).frame(height: 1)
117 }
118 .overlay(alignment: .bottom) {
119 Rectangle().fill(Color.gbLine).frame(height: 1)
120 }
121 ForEach(hunk.lines) { line in
122 LineView(line: line)
123 if let model {
124 ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in
125 ReviewThreadView(thread: thread, model: model)
126 .padding(.vertical, 6)
127 .padding(.horizontal, 8)
128 .background(Color.gbFillSubtle)
129 }
130 }
131 }
132 }
133 }
134 .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8))
135 }
136}
137
138private struct LineView: View {
139
140 let line: UnifiedDiff.Line
141 @ScaledMetric(relativeTo: .caption2) private var numberWidth: CGFloat = 34
142 @ScaledMetric(relativeTo: .caption2) private var markerWidth: CGFloat = 14
143
144 var body: some View {
145 HStack(spacing: 0) {
146 Text(line.oldNumber.map(String.init) ?? "")
147 .frame(width: numberWidth, alignment: .trailing)
148 .foregroundStyle(.tertiary)
149 Text(line.newNumber.map(String.init) ?? "")
150 .frame(width: numberWidth, alignment: .trailing)
151 .foregroundStyle(.tertiary)
152 Text(marker)
153 .frame(width: markerWidth)
154 .foregroundStyle(markerColor)
155 Text(line.text.isEmpty ? " " : line.text)
156 .foregroundStyle(textColor)
157 }
158 .font(.gbMono(.caption2))
159 .background(background)
160 .accessibilityElement(children: .ignore)
161 .accessibilityLabel(line.accessibilityLabel)
162 }
163
164 private var marker: String {
165 switch line.kind {
166 case .addition: "+"
167 case .deletion: "−"
168 case .context: " "
169 }
170 }
171
172 private var markerColor: Color {
173 switch line.kind {
174 case .addition: .gbOK
175 case .deletion: .gbBad
176 case .context: .clear
177 }
178 }
179
180 private var textColor: Color {
181 .primary
182 }
183
184 private var background: Color {
185 switch line.kind {
186 case .addition: .gbDiffAdd
187 case .deletion: .gbDiffDel
188 case .context: .clear
189 }
190 }
191}