gitbay/Views/MRs/DiffView.swift
163 lines · 4882 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 private let client: GitbayClient
8 private let repo: String
9 private let number: Int64
10 @State private var state: LoadState<UnifiedDiff> = .loading
11
12 init(client: GitbayClient, repo: String, number: Int64) {
13 self.client = client
14 self.repo = repo
15 self.number = number
16 }
17
18 var body: some View {
19 ZStack {
20 Color.clear
21 if let diff = state.value, !diff.files.isEmpty {
22 List {
23 ForEach(diff.files) { file in
24 FileDiffSection(file: file)
25 }
26 }
27 .listStyle(.plain)
28 } else if case .loaded = state {
29 ContentUnavailableView {
30 Label("No changes", systemImage: "plus.forwardslash.minus")
31 }
32 }
33 }
34 .overlay { LoadStateOverlay(state: state) }
35 .navigationTitle("Diff")
36 .navigationBarTitleDisplayMode(.inline)
37 .task {
38 do {
39 let text = try await client.readText(["mr", "diff", repo, String(number)])
40 state = .loaded(UnifiedDiff.parse(text))
41 } catch {
42 state = .from(error)
43 }
44 }
45 }
46}
47
48private struct FileDiffSection: View {
49
50 let file: UnifiedDiff.File
51 @State private var collapsed = false
52
53 var body: some View {
54 Section {
55 if !collapsed {
56 if file.isBinary {
57 Text("Binary file")
58 .font(.caption)
59 .foregroundStyle(.secondary)
60 } else {
61 ForEach(file.hunks) { hunk in
62 HunkView(hunk: hunk)
63 }
64 }
65 }
66 } header: {
67 Button {
68 collapsed.toggle()
69 } label: {
70 HStack(spacing: 6) {
71 Image(systemName: collapsed ? "chevron.right" : "chevron.down")
72 .font(.caption2)
73 Text(file.displayPath)
74 .font(.caption.monospaced().weight(.semibold))
75 .lineLimit(1)
76 .truncationMode(.head)
77 if file.isNew {
78 Text("new").font(.caption2).foregroundStyle(.green)
79 }
80 if file.isDeleted {
81 Text("deleted").font(.caption2).foregroundStyle(.red)
82 }
83 Spacer()
84 Text("+\(file.additions)").foregroundStyle(.green).font(.caption2)
85 Text("−\(file.deletions)").foregroundStyle(.red).font(.caption2)
86 }
87 }
88 .buttonStyle(.plain)
89 .textCase(nil)
90 }
91 }
92}
93
94private struct HunkView: View {
95
96 let hunk: UnifiedDiff.Hunk
97
98 var body: some View {
99 ScrollView(.horizontal) {
100 VStack(alignment: .leading, spacing: 0) {
101 Text(hunk.header)
102 .font(.caption2.monospaced())
103 .foregroundStyle(.blue)
104 .padding(.vertical, 2)
105 ForEach(hunk.lines) { line in
106 LineView(line: line)
107 }
108 }
109 }
110 .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8))
111 }
112}
113
114private struct LineView: View {
115
116 let line: UnifiedDiff.Line
117
118 var body: some View {
119 HStack(spacing: 0) {
120 Text(line.oldNumber.map(String.init) ?? "")
121 .frame(width: 34, alignment: .trailing)
122 .foregroundStyle(.tertiary)
123 Text(line.newNumber.map(String.init) ?? "")
124 .frame(width: 34, alignment: .trailing)
125 .foregroundStyle(.tertiary)
126 Text(marker)
127 .frame(width: 14)
128 .foregroundStyle(markerColor)
129 Text(line.text.isEmpty ? " " : line.text)
130 .foregroundStyle(textColor)
131 }
132 .font(.caption2.monospaced())
133 .background(background)
134 }
135
136 private var marker: String {
137 switch line.kind {
138 case .addition: "+"
139 case .deletion: "−"
140 case .context: " "
141 }
142 }
143
144 private var markerColor: Color {
145 switch line.kind {
146 case .addition: .green
147 case .deletion: .red
148 case .context: .clear
149 }
150 }
151
152 private var textColor: Color {
153 .primary
154 }
155
156 private var background: Color {
157 switch line.kind {
158 case .addition: .green.opacity(0.12)
159 case .deletion: .red.opacity(0.12)
160 case .context: .clear
161 }
162 }
163}