a native ios client for gitbay

client ios swift

https://gitbay.org

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}