| @@ -0,0 +1,125 @@ |
| |
1 | import 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. |
| |
6 | struct CompareView: View { |
| |
7 | |
| |
8 | @State private var model: CompareViewModel |
| |
9 | /// `model.state` defaults to `.loading` before any comparison is |
| |
10 | /// requested, since `compare()` is manually triggered rather than |
| |
11 | /// run from `.task`. This tracks whether that default is a real |
| |
12 | /// request in flight, so the overlay doesn't spin before the user |
| |
13 | /// has picked anything. |
| |
14 | @State private var hasCompared = false |
| |
15 | |
| |
16 | init(client: GitbayClient, repo: String, defaultBranch: String? = nil) { |
| |
17 | let model = CompareViewModel(client: client, repoPath: repo) |
| |
18 | if let defaultBranch { |
| |
19 | model.base = defaultBranch |
| |
20 | } |
| |
21 | _model = State(initialValue: model) |
| |
22 | } |
| |
23 | |
| |
24 | private var isComparing: Bool { |
| |
25 | guard hasCompared, case .loading = model.state else { return false } |
| |
26 | return true |
| |
27 | } |
| |
28 | |
| |
29 | var body: some View { |
| |
30 | List { |
| |
31 | refsSection |
| |
32 | |
| |
33 | Section { |
| |
34 | Button("Compare") { |
| |
35 | hasCompared = true |
| |
36 | Task { await model.compare() } |
| |
37 | } |
| |
38 | .disabled(isComparing |
| |
39 | || model.base.trimmingCharacters(in: .whitespaces).isEmpty |
| |
40 | || model.head.trimmingCharacters(in: .whitespaces).isEmpty) |
| |
41 | .accessibilityIdentifier("compare-submit") |
| |
42 | } |
| |
43 | |
| |
44 | if model.truncated { |
| |
45 | Section { |
| |
46 | GBNotice("The patch was too large and was cut short.", .gbWarn) |
| |
47 | } |
| |
48 | } |
| |
49 | |
| |
50 | if let mergeBase = model.mergeBase { |
| |
51 | Section { |
| |
52 | HStack { |
| |
53 | Text("Merge base") |
| |
54 | Spacer() |
| |
55 | Text(String(mergeBase.prefix(10))) |
| |
56 | .font(.gbMono(.caption)) |
| |
57 | .foregroundStyle(.secondary) |
| |
58 | } |
| |
59 | } |
| |
60 | } |
| |
61 | |
| |
62 | if let diff = model.state.value, !diff.files.isEmpty { |
| |
63 | ForEach(diff.files) { file in |
| |
64 | DiffFileSection(file: file) |
| |
65 | } |
| |
66 | } |
| |
67 | } |
| |
68 | .overlay { |
| |
69 | if hasCompared { |
| |
70 | LoadStateOverlay(state: model.state) |
| |
71 | } |
| |
72 | } |
| |
73 | .navigationTitle("Compare") |
| |
74 | .navigationBarTitleDisplayMode(.inline) |
| |
75 | .task { await model.loadRefs() } |
| |
76 | } |
| |
77 | |
| |
78 | @ViewBuilder |
| |
79 | private var refsSection: some View { |
| |
80 | Section { |
| |
81 | if let refs = model.refs { |
| |
82 | if refs.branches.isEmpty && refs.tags.isEmpty { |
| |
83 | GBNotice("This repository has no branches or tags to compare.", .gbWarn) |
| |
84 | } else { |
| |
85 | refPicker("Base", refs: refs, selection: Bindable(model).base) |
| |
86 | refPicker("Head", refs: refs, selection: Bindable(model).head) |
| |
87 | } |
| |
88 | } else if let refsError = model.refsError { |
| |
89 | GBNotice(refsError, .gbWarn) |
| |
90 | } else { |
| |
91 | ProgressView() |
| |
92 | } |
| |
93 | } |
| |
94 | } |
| |
95 | |
| |
96 | private func refPicker(_ title: String, refs: RepoRefs, selection: Binding<String>) -> some View { |
| |
97 | Menu { |
| |
98 | if !refs.branches.isEmpty { |
| |
99 | Section("Branches") { |
| |
100 | ForEach(refs.branches) { ref in |
| |
101 | Button(ref.name) { selection.wrappedValue = ref.name } |
| |
102 | } |
| |
103 | } |
| |
104 | } |
| |
105 | if !refs.tags.isEmpty { |
| |
106 | Section("Tags") { |
| |
107 | ForEach(refs.tags) { ref in |
| |
108 | Button(ref.name) { selection.wrappedValue = ref.name } |
| |
109 | } |
| |
110 | } |
| |
111 | } |
| |
112 | } label: { |
| |
113 | HStack { |
| |
114 | Text(title) |
| |
115 | Spacer() |
| |
116 | Text(selection.wrappedValue.isEmpty ? "Select a ref" : selection.wrappedValue) |
| |
117 | .foregroundStyle(selection.wrappedValue.isEmpty ? .secondary : .primary) |
| |
118 | Image(systemName: "chevron.up.chevron.down") |
| |
119 | .font(.gbSans(.caption2)) |
| |
120 | .foregroundStyle(.secondary) |
| |
121 | } |
| |
122 | } |
| |
123 | .accessibilityIdentifier("compare-\(title.lowercased())-menu") |
| |
124 | } |
| |
125 | } |