gitbay/Views/Repos/CompareView.swift
134 lines · 4637 bytes
2 symbols in this file
1import SwiftUI
2
3/// Two refs, one diff — `repo diff`'s three-dot comparison, rendered the
4/// same way a commit's patch is. `base` and `head` are picked from
5/// `repo refs`; the request only fires when the user taps Compare.
6struct CompareView: View {
7
8 @State private var model: CompareViewModel
9
10 init(client: GitbayClient, repo: String, defaultBranch: String? = nil) {
11 let model = CompareViewModel(client: client, repoPath: repo)
12 if let defaultBranch {
13 model.base = defaultBranch
14 }
15 _model = State(initialValue: model)
16 }
17
18 private var isComparing: Bool {
19 if case .loading = model.state { return true }
20 return false
21 }
22
23 var body: some View {
24 List {
25 refsSection
26
27 Section {
28 Button("Compare") {
29 Task { await model.compare() }
30 }
31 .disabled(isComparing
32 || model.base.trimmingCharacters(in: .whitespaces).isEmpty
33 || model.head.trimmingCharacters(in: .whitespaces).isEmpty)
34 .accessibilityIdentifier("compare-submit")
35 }
36
37 if model.truncated {
38 Section {
39 GBNotice("The patch was too large and was cut short.", .gbWarn)
40 }
41 }
42
43 if let mergeBase = model.mergeBase {
44 Section {
45 HStack {
46 Text("Merge base")
47 Spacer()
48 Text(String(mergeBase.prefix(10)))
49 .font(.gbMono(.caption))
50 .foregroundStyle(.secondary)
51 }
52 }
53 }
54
55 // `.loading` is shown by the overlay below; `.empty` and
56 // `.failed` render inline so the pickers above stay reachable
57 // instead of being covered by a frame-filling overlay.
58 switch model.state {
59 case .empty(let message):
60 Section {
61 GBNotice(message, .gbWarn)
62 }
63 case .failed(let message):
64 Section {
65 GBNotice(message, .gbWarn)
66 }
67 case .loading, .loaded:
68 EmptyView()
69 }
70
71 if let diff = model.state.value, !diff.files.isEmpty {
72 ForEach(diff.files) { file in
73 DiffFileSection(file: file)
74 }
75 }
76 }
77 .overlay {
78 if isComparing {
79 LoadStateOverlay(state: model.state)
80 }
81 }
82 .navigationTitle("Compare")
83 .navigationBarTitleDisplayMode(.inline)
84 .task { await model.loadRefs() }
85 }
86
87 @ViewBuilder
88 private var refsSection: some View {
89 Section {
90 if let refs = model.refs {
91 if refs.branches.isEmpty && refs.tags.isEmpty {
92 GBNotice("This repository has no branches or tags to compare.", .gbWarn)
93 } else {
94 refPicker("Base", refs: refs, selection: Bindable(model).base)
95 refPicker("Head", refs: refs, selection: Bindable(model).head)
96 }
97 } else if let refsError = model.refsError {
98 GBNotice(refsError, .gbWarn)
99 } else {
100 ProgressView()
101 }
102 }
103 }
104
105 private func refPicker(_ title: String, refs: RepoRefs, selection: Binding<String>) -> some View {
106 Menu {
107 if !refs.branches.isEmpty {
108 Section("Branches") {
109 ForEach(refs.branches) { ref in
110 Button(ref.name) { selection.wrappedValue = ref.name }
111 }
112 }
113 }
114 if !refs.tags.isEmpty {
115 Section("Tags") {
116 ForEach(refs.tags) { ref in
117 Button(ref.name) { selection.wrappedValue = ref.name }
118 }
119 }
120 }
121 } label: {
122 HStack {
123 Text(title)
124 Spacer()
125 Text(selection.wrappedValue.isEmpty ? "Select a ref" : selection.wrappedValue)
126 .foregroundStyle(selection.wrappedValue.isEmpty ? .secondary : .primary)
127 Image(systemName: "chevron.up.chevron.down")
128 .font(.gbSans(.caption2))
129 .foregroundStyle(.secondary)
130 }
131 }
132 .accessibilityIdentifier("compare-\(title.lowercased())-menu")
133 }
134}