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. /// /// Rendering: a plain VStack inside nested vertical→horizontal ScrollViews. /// A LazyVStack inside a dual-axis `ScrollView([.horizontal, .vertical])` is a /// known-bad combination (rows collapse to ~zero width, so the background tint /// renders but the text disappears); VStack + nested scroll is the stable /// pattern for monospaced diff text. 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(.vertical) { ScrollView(.horizontal) { VStack(alignment: .leading, spacing: 0) { ForEach(lines) { line in diffLine(line) } } } } } } .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. 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...]) if let next = tail.range(of: "\ndiff --git ") { return String(tail[.. some View { HStack(spacing: 0) { Text(line.text) .font(.caption.monospaced()) .foregroundColor(foreground(for: line.type)) .fixedSize(horizontal: true, vertical: false) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 8) .padding(.vertical, 1) .background(background(for: line.type)) .frame(maxWidth: .infinity, alignment: .leading) } 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: generate once, not on every access (a fresh UUID each read // churns SwiftUI's ForEach on the main thread and can crash). let id: UUID = UUID() let text: String let type: DiffLineType }