import SwiftUI /// A pull's diff: every file listed first, each file's rows asked for only as /// it comes on screen, as the pages do. struct FilesView: View { let repo: RepoRef let uri: String @Environment(Account.self) private var account @State private var reviewing = false var body: some View { Loading(load: { [repo, uri] in let thread: PullThread = try await Client.shared.query( "getThread", ["repo": repo.uri, "uri": uri, "filesOnly": "true"]) return thread }) { thread in let files = thread.changes?.files ?? [] ScrollView { LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { ForEach(files) { file in FileSection(repo: repo, uri: uri, file: file) } } } .safeAreaInset(edge: .bottom) { if account.signedIn, thread.item.state == "open" { ReviewButton { reviewing = true }.padding(.bottom, Space.s2) } } .sheet(isPresented: $reviewing) { ReviewSheet(repo: repo, uri: uri, author: thread.item.author.did) {} } .navigationSubtitleCompat(added: files.reduce(0) { $0 + $1.added }, removed: files.reduce(0) { $0 + $1.removed }) } .navigationTitle("Files changed") .navigationBarTitleDisplayMode(.inline) } } private extension View { func navigationSubtitleCompat(added: Int, removed: Int) -> some View { toolbar { ToolbarItem(placement: .topBarTrailing) { HStack(spacing: Space.s1) { Text("+\(added)").foregroundStyle(Color.success) Text("-\(removed)").foregroundStyle(Color.danger) } .font(.footnote.monospacedDigit()) .fixedSize() } } } } struct FileSection: View { let repo: RepoRef /// The pull whose rows to ask for, if they aren't in `file`. var uri: String? let file: DiffFile struct Line: Identifiable { let id: Int let row: DiffRow let text: AttributedString } @State private var open = true @State private var lines: [Line]? @State private var note: String? @State private var failed = false var body: some View { Section { if open { if let lines { if lines.isEmpty { Text(note ?? "No changes to show.").font(.footnote).foregroundStyle(.secondary).padding() } else { DiffLines(lines: lines) } } else if failed { VStack(alignment: .leading, spacing: Space.s2) { Text("This file's diff didn't load.").foregroundStyle(.secondary) Button("Try again") { failed = false } } .font(.footnote) .padding() } else { // A row that leaves the screen cancels this, and asks again // when it comes back. ProgressView().frame(maxWidth: .infinity).padding().task { await load() } } } } header: { Button { open.toggle() } label: { HStack(spacing: Space.s3) { Oct(name: "chevron-down", size: Icon.md) .rotationEffect(open ? .zero : .degrees(-90)) .foregroundStyle(.secondary) Text(file.path).font(.subheadline.monospaced()).lineLimit(1).truncationMode(.head) .foregroundStyle(.primary) Spacer(minLength: 4) Text("+\(file.added)").foregroundStyle(Color.success) Text("-\(file.removed)").foregroundStyle(Color.danger) } .font(.footnote.monospacedDigit()) .padding(.horizontal, Space.s4) .frame(minHeight: Size.fileHeader) .background(Color.raised) .overlay(alignment: .top) { Color.line.frame(height: 0.5) } .overlay(alignment: .bottom) { Color.line.frame(height: 0.5) } } .buttonStyle(.plain) .accessibilityValue(open ? "Open" : "Closed") } } private func load() async { var source = file if source.rows.isEmpty, source.note == nil, let uri { do { let answer: DiffAnswer = try await Client.shared.query( "getPullDiff", ["repo": repo.uri, "uri": uri, "paths": file.path]) guard let found = answer.files.first else { failed = true return } source = found } catch is CancellationError { return } catch let error as URLError where error.code == .cancelled { return } catch { failed = true return } } note = source.note let rows = source.rows let code = rows.filter(\.line).map(\.text) let painted = Highlight.paint(lines: code, ranges: source.ranges) var next = 0 var out: [Line] = [] out.reserveCapacity(rows.count) for (i, row) in rows.enumerated() { if row.line { out.append(Line(id: i, row: row, text: painted[next])) next += 1 } else { out.append(Line(id: i, row: row, text: AttributedString(row.text))) } } lines = out } } /// A file's diff, each line on one row whatever its length, its indent kept. The /// code scrolls sideways under numbers that stay put, as a file's lines do. /// /// Whole columns, not lazy ones: a lazy stack re-estimating a file's rows as they /// arrive leaves a gap above them. private struct DiffLines: View { let lines: [FileSection.Line] /// Every row a set height, so the two columns line up whatever a line holds. @ScaledMetric(relativeTo: .caption) private var codeHeight = 20 @ScaledMetric(relativeTo: .caption) private var hunkHeight = 28 @State private var width: CGFloat = 0 var body: some View { HStack(alignment: .top, spacing: 0) { VStack(spacing: 0) { ForEach(lines) { line in HStack(spacing: Space.s1_5) { Text(number(line.row)).frame(width: 34, alignment: .trailing).foregroundStyle(.secondary) Text(marker(line.row)).foregroundStyle(line.row.kind == "a" ? Color.success : Color.danger) .accessibilityHidden(true) } .padding(.horizontal, Space.s1_5) .frame(height: height(line.row)) .background(tint(line.row)) } } .fixedSize(horizontal: true, vertical: false) ScrollView(.horizontal) { VStack(alignment: .leading, spacing: 0) { ForEach(lines) { line in Text(line.text) .foregroundStyle(line.row.line ? Color.fg : Color.accent) .lineLimit(1) .fixedSize() .padding(.trailing, Space.s4) // The tint reaches the screen's edge on a short // line, and the widest line's end on a long one. .frame(minWidth: width, maxWidth: .infinity, minHeight: height(line.row), maxHeight: height(line.row), alignment: .leading) .background(tint(line.row)) } } .fixedSize(horizontal: true, vertical: false) } .onGeometryChange(for: CGFloat.self, of: \.size.width) { width = $0 } } .font(.caption.monospaced()) } private func height(_ row: DiffRow) -> CGFloat { row.line ? codeHeight : hunkHeight } private func tint(_ row: DiffRow) -> Color { switch row.kind { case "a": Color.success.opacity(0.14) case "d": Color.danger.opacity(0.12) case "h": Color.accent.opacity(0.12) default: .clear } } private func marker(_ row: DiffRow) -> String { switch row.kind { case "a": "+" case "d": "-" default: " " } } private func number(_ row: DiffRow) -> String { guard row.line else { return "" } return (row.kind == "d" ? row.old : row.new ?? row.old).map(String.init) ?? "" } }