Files
ios-gitea-prs/GiteaPR/Detail/DiffView.swift
T
Hermes db694543c1
Build iOS App / Build .ipa and publish source (push) Successful in 22s
Fix diff screen hang/crash: stable DiffLine id + request timeout; bump to 1.5
DiffLine.id was a computed property returning a fresh UUID() on every access.
SwiftUI's ForEach requires stable identities; regenerating each read churned
the main thread, hanging the diff screen and eventually crashing. Store the
UUID once per line. Also add a 30s URLRequest timeout so a hung request can't
leave the 'Loading diff...' spinner forever. Bump to 1.5.
2026-08-12 21:36:34 +00:00

130 lines
4.7 KiB
Swift

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[..<next.lowerBound])
}
return tail
}
private func diffLine(_ line: DiffLine) -> 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
}