gitbay/Views/MRs/DiffView.swift

v1.7.0
gitbay-ios/gitbay/Views/MRs/DiffView.swift history · blame · raw

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}