import SwiftUI /// The parsed unified diff, file by file, hunk by hunk. Loads its own /// text so the screen is reachable by value from anywhere. struct DiffView: View { @State private var model: MRDetailViewModel init(client: GitbayClient, repo: String, number: Int64) { _model = State(initialValue: MRDetailViewModel( client: client, repoPath: repo, number: number )) } var body: some View { ZStack { Color.clear if let diff = model.diff, !diff.files.isEmpty { List { ForEach(diff.files) { file in DiffFileSection(file: file, model: model) } let detached = model.threads.filter { thread in thread.stale || !diff.anchors(thread) } if !detached.isEmpty { Section("Threads on earlier revisions") { ForEach(detached) { thread in ReviewThreadView(thread: thread, model: model) } } } } .listStyle(.plain) } else if model.state.value != nil { ContentUnavailableView { Label("No changes", systemImage: "plus.forwardslash.minus") } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Diff") .navigationBarTitleDisplayMode(.inline) .task { await model.load() } .refreshable { await model.load() } } } struct DiffFileSection: View { let file: UnifiedDiff.File /// Review threads belong to merge requests; a commit diff has none. var model: MRDetailViewModel? = nil @State private var collapsed = false var body: some View { Section { if !collapsed { if file.isBinary { Text("Binary file") .font(.gbSans(.caption)) .foregroundStyle(.secondary) } else { ForEach(file.hunks) { hunk in HunkView(file: file, hunk: hunk, model: model) } } } } header: { Button { collapsed.toggle() } label: { HStack(spacing: 6) { Image(systemName: collapsed ? "chevron.right" : "chevron.down") .font(.gbSans(.caption2)) Text(file.displayPath) .font(.gbMono(.caption).weight(.semibold)) .lineLimit(1) .truncationMode(.head) if file.isNew { Text("new").font(.gbSans(.caption2)).foregroundStyle(Color.gbOK) } if file.isDeleted { Text("deleted").font(.gbSans(.caption2)).foregroundStyle(Color.gbBad) } Spacer() Text("+\(file.additions)").foregroundStyle(Color.gbOK).font(.gbSans(.caption2)) Text("−\(file.deletions)").foregroundStyle(Color.gbBad).font(.gbSans(.caption2)) } } .buttonStyle(.plain) .textCase(nil) } } } private struct HunkView: View { let file: UnifiedDiff.File let hunk: UnifiedDiff.Hunk let model: MRDetailViewModel? var body: some View { ScrollView(.horizontal) { VStack(alignment: .leading, spacing: 0) { // The stylesheet colors the hunk header with --accent on // --fill-subtle, ruled above and below with --line. Text(hunk.header) .font(.gbMono(.caption2)) .foregroundStyle(Color.gbAccent) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.gbFillSubtle) .overlay(alignment: .top) { Rectangle().fill(Color.gbLine).frame(height: 1) } .overlay(alignment: .bottom) { Rectangle().fill(Color.gbLine).frame(height: 1) } ForEach(hunk.lines) { line in LineView(line: line) if let model { ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in ReviewThreadView(thread: thread, model: model) .padding(.vertical, 6) .padding(.horizontal, 8) .background(Color.gbFillSubtle) } } } } } .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8)) } } private struct LineView: View { let line: UnifiedDiff.Line var body: some View { HStack(spacing: 0) { Text(line.oldNumber.map(String.init) ?? "") .frame(width: 34, alignment: .trailing) .foregroundStyle(.tertiary) Text(line.newNumber.map(String.init) ?? "") .frame(width: 34, alignment: .trailing) .foregroundStyle(.tertiary) Text(marker) .frame(width: 14) .foregroundStyle(markerColor) Text(line.text.isEmpty ? " " : line.text) .foregroundStyle(textColor) } .font(.gbMono(.caption2)) .background(background) } private var marker: String { switch line.kind { case .addition: "+" case .deletion: "−" case .context: " " } } private var markerColor: Color { switch line.kind { case .addition: .gbOK case .deletion: .gbBad case .context: .clear } } private var textColor: Color { .primary } private var background: Color { switch line.kind { case .addition: .gbDiffAdd case .deletion: .gbDiffDel case .context: .clear } } }