gitbay/Views/MRs/DiffView.swift

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

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}