import SwiftUI /// Read-only unified diff viewer for a single changed file. /// /// This Gitea instance does NOT return a per-file `patch` from /// `/pulls/{n}/files` (the field is absent), so the diff is fetched from the /// PR's `.diff` endpoint and the hunks belonging to this file are extracted. /// Added/deleted lines are colour-coded. struct DiffView: View { let repo: Repository let prNumber: Int let file: ChangedFile @State private var lines: [DiffLine] = [] @State private var isLoading = true @State private var errorMessage: String? var body: some View { Group { if isLoading { ProgressView("Loading diff…") } else if let errorMessage { ContentUnavailableView( "Couldn't load diff", systemImage: "doc.text", description: Text(errorMessage)) } else { ScrollView([.horizontal, .vertical]) { LazyVStack(alignment: .leading, spacing: 0) { ForEach(lines) { line in diffLine(line) } } .font(.caption.monospaced()) } } } .navigationTitle(file.filename) .navigationBarTitleDisplayMode(.inline) .task { await load() } } private func load() async { isLoading = true errorMessage = nil defer { isLoading = false } guard let client = SettingsStore.shared.client() else { errorMessage = "Not configured." return } do { let full = try await client.diff(owner: repo.owner.login, repo: repo.name, number: prNumber) let patch = extractSection(named: file.filename, from: full) lines = parse(patch.isEmpty ? full : patch) if lines.isEmpty { errorMessage = "No diff content for this file." } } catch { errorMessage = error.localizedDescription } } /// Extract the unified-diff block for `filename` from a multi-file diff. /// Falls back to the whole diff if the header can't be matched. private func extractSection(named filename: String, from diff: String) -> String { let marker = "+++ b/\(filename)" guard let idx = diff.range(of: marker)?.upperBound else { return "" } let tail = String(diff[idx...]) // Find the next "diff --git " after this file's header to delimit it. let rest = tail.dropFirst(marker.count) if let next = rest.range(of: "\ndiff --git ") { return String(tail[.. some View { Text(line.text) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 1) .background(background(for: line.type)) .foregroundColor(foreground(for: line.type)) } private func background(for type: DiffLineType) -> Color { switch type { case .addition: return Color.green.opacity(0.15) case .deletion: return Color.red.opacity(0.15) case .hunk, .meta: return Color(.secondarySystemBackground) case .context: return .clear } } private func foreground(for type: DiffLineType) -> Color { switch type { case .addition: return .green case .deletion: return .red case .hunk: return .secondary case .meta, .context: return .primary } } private func parse(_ patch: String) -> [DiffLine] { patch.components(separatedBy: "\n").map { line in if line.hasPrefix("@@") { return DiffLine(text: line, type: .hunk) } if line.hasPrefix("diff ") || line.hasPrefix("index ") || line.hasPrefix("--- ") || line.hasPrefix("+++ ") { return DiffLine(text: line, type: .meta) } if line.hasPrefix("+") { return DiffLine(text: line, type: .addition) } if line.hasPrefix("-") { return DiffLine(text: line, type: .deletion) } return DiffLine(text: line, type: .context) } } } private enum DiffLineType { case context, addition, deletion, hunk, meta } private struct DiffLine: Identifiable { // Stable id: a fresh UUID() on EVERY access (computed property) makes // SwiftUI's ForEach re-identify on every render, churning the main thread // and eventually crashing. Store it once. let id: UUID = UUID() let text: String let type: DiffLineType }