import SwiftUI /// One file's diff: a tappable header and, when expanded, its hunks. /// /// Works inside a `List` row and inside a `ScrollView`. Callers in a List /// should give the row zero insets so line backgrounds reach the edges: /// /// Section { DiffFileView(file: file).listRowInsets(EdgeInsets()) } struct DiffFileView: View { var file: FileDiff @State private var isExpanded: Bool /// Files with more lines than this start collapsed. static let autoExpandLimit = 400 init(file: FileDiff) { self.file = file let lineCount = file.hunks.reduce(0) { $0 + $1.lines.count } _isExpanded = State(initialValue: lineCount <= Self.autoExpandLimit) } var body: some View { VStack(spacing: 0) { header if isExpanded { Divider() content } } } // MARK: Header private var header: some View { Button { withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() } } label: { HStack(spacing: 10) { Image(systemName: changeIcon) .font(.body.weight(.medium)) .foregroundStyle(changeColor) Text(pathText) .font(.system(.subheadline, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) Spacer(minLength: 4) DiffStatText(additions: file.additions, deletions: file.deletions) Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(.tertiary) .rotationEffect(.degrees(isExpanded ? 90 : 0)) } .padding(.horizontal, 16) .padding(.vertical, 10) .contentShape(Rectangle()) } .buttonStyle(.plain) } private var changeIcon: String { switch file.change { case .added: "plus.square" case .deleted: "minus.square" case .renamed: "arrow.right.square" case .modified: "pencil" } } private var changeColor: Color { switch file.change { case .added: .green case .deleted: .red case .renamed: .blue case .modified: .orange } } private var pathText: String { file.change == .renamed ? "\(file.oldPath) → \(file.newPath)" : file.path } // MARK: Body @ViewBuilder private var content: some View { if file.isBinary { placeholder("Binary file not shown") } else if file.hunks.isEmpty { placeholder(file.change == .renamed ? "File renamed without content changes." : "No content changes.") } else { ScrollView(.horizontal, showsIndicators: true) { LazyVStack(alignment: .leading, spacing: 0) { ForEach(file.hunks) { hunk in HunkHeaderRow(text: hunk.header) ForEach(hunk.lines) { line in DiffLineRow(line: line) } } } } } } private func placeholder(_ text: String) -> some View { Text(text) .font(.footnote.italic()) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(16) } } // MARK: - Hunk header private struct HunkHeaderRow: View { var text: String var body: some View { Text(text) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) .fixedSize(horizontal: true, vertical: false) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.blue.opacity(0.1)) } } // MARK: - Line private struct DiffLineRow: View { var line: DiffLine var body: some View { HStack(spacing: 0) { gutter(line.oldNumber) gutter(line.newNumber) Text(marker) .font(.system(.caption, design: .monospaced)) .foregroundStyle(markerColor) .frame(width: 16) Text(line.text.isEmpty ? " " : line.text) .font(textFont) .foregroundStyle(line.kind == .note ? .secondary : .primary) .padding(.trailing, 12) } .fixedSize(horizontal: true, vertical: false) .frame(maxWidth: .infinity, alignment: .leading) .background(background) } private func gutter(_ number: Int?) -> some View { Text(number.map { String($0) } ?? "") .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) .frame(width: 40, alignment: .trailing) .padding(.trailing, 4) } private var marker: String { switch line.kind { case .addition: "+" case .deletion: "-" case .context: " " case .note: "" } } private var markerColor: Color { switch line.kind { case .addition: .green case .deletion: .red case .context, .note: .secondary } } private var textFont: Font { line.kind == .note ? .system(.caption, design: .monospaced).italic() : .system(.caption, design: .monospaced) } private var background: Color { switch line.kind { case .addition: Color.green.opacity(0.15) case .deletion: Color.red.opacity(0.15) case .context, .note: .clear } } }