gitbay/Views/MRs/DiffView.swift
186 lines · 6363 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 }
47}
48
49struct DiffFileSection: View {
50
51 let file: UnifiedDiff.File
52 /// Review threads belong to merge requests; a commit diff has none.
53 var model: MRDetailViewModel? = nil
54 @State private var collapsed = false
55
56 var body: some View {
57 Section {
58 if !collapsed {
59 if file.isBinary {
60 Text("Binary file")
61 .font(.gbSans(.caption))
62 .foregroundStyle(.secondary)
63 } else {
64 ForEach(file.hunks) { hunk in
65 HunkView(file: file, hunk: hunk, model: model)
66 }
67 }
68 }
69 } header: {
70 Button {
71 collapsed.toggle()
72 } label: {
73 HStack(spacing: 6) {
74 Image(systemName: collapsed ? "chevron.right" : "chevron.down")
75 .font(.gbSans(.caption2))
76 Text(file.displayPath)
77 .font(.gbMono(.caption).weight(.semibold))
78 .lineLimit(1)
79 .truncationMode(.head)
80 if file.isNew {
81 Text("new").font(.gbSans(.caption2)).foregroundStyle(Color.gbOK)
82 }
83 if file.isDeleted {
84 Text("deleted").font(.gbSans(.caption2)).foregroundStyle(Color.gbBad)
85 }
86 Spacer()
87 Text("+\(file.additions)").foregroundStyle(Color.gbOK).font(.gbSans(.caption2))
88 Text("−\(file.deletions)").foregroundStyle(Color.gbBad).font(.gbSans(.caption2))
89 }
90 }
91 .buttonStyle(.plain)
92 .textCase(nil)
93 }
94 }
95}
96
97private struct HunkView: View {
98
99 let file: UnifiedDiff.File
100 let hunk: UnifiedDiff.Hunk
101 let model: MRDetailViewModel?
102
103 var body: some View {
104 ScrollView(.horizontal) {
105 VStack(alignment: .leading, spacing: 0) {
106 // The stylesheet colors the hunk header with --accent on
107 // --fill-subtle, ruled above and below with --line.
108 Text(hunk.header)
109 .font(.gbMono(.caption2))
110 .foregroundStyle(Color.gbAccent)
111 .padding(.vertical, 3)
112 .frame(maxWidth: .infinity, alignment: .leading)
113 .background(Color.gbFillSubtle)
114 .overlay(alignment: .top) {
115 Rectangle().fill(Color.gbLine).frame(height: 1)
116 }
117 .overlay(alignment: .bottom) {
118 Rectangle().fill(Color.gbLine).frame(height: 1)
119 }
120 ForEach(hunk.lines) { line in
121 LineView(line: line)
122 if let model {
123 ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in
124 ReviewThreadView(thread: thread, model: model)
125 .padding(.vertical, 6)
126 .padding(.horizontal, 8)
127 .background(Color.gbFillSubtle)
128 }
129 }
130 }
131 }
132 }
133 .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8))
134 }
135}
136
137private struct LineView: View {
138
139 let line: UnifiedDiff.Line
140
141 var body: some View {
142 HStack(spacing: 0) {
143 Text(line.oldNumber.map(String.init) ?? "")
144 .frame(width: 34, alignment: .trailing)
145 .foregroundStyle(.tertiary)
146 Text(line.newNumber.map(String.init) ?? "")
147 .frame(width: 34, alignment: .trailing)
148 .foregroundStyle(.tertiary)
149 Text(marker)
150 .frame(width: 14)
151 .foregroundStyle(markerColor)
152 Text(line.text.isEmpty ? " " : line.text)
153 .foregroundStyle(textColor)
154 }
155 .font(.gbMono(.caption2))
156 .background(background)
157 }
158
159 private var marker: String {
160 switch line.kind {
161 case .addition: "+"
162 case .deletion: "−"
163 case .context: " "
164 }
165 }
166
167 private var markerColor: Color {
168 switch line.kind {
169 case .addition: .gbOK
170 case .deletion: .gbBad
171 case .context: .clear
172 }
173 }
174
175 private var textColor: Color {
176 .primary
177 }
178
179 private var background: Color {
180 switch line.kind {
181 case .addition: .gbDiffAdd
182 case .deletion: .gbDiffDel
183 case .context: .clear
184 }
185 }
186}