gitbay/Views/Repos/RepoView.swift
425 lines · 17610 bytes
1import SwiftUI
2
3struct RepoView: View {
4
5 @State private var model: RepoDetailViewModel
6 @State private var actionsModel: RepoActionsViewModel
7 private let client: GitbayClient
8 private let path: String
9 private let host: String
10
11 init(client: GitbayClient, path: String) {
12 self.client = client
13 self.path = path
14 self.host = client.instance.baseURL.host() ?? ""
15 _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
16 _actionsModel = State(initialValue: RepoActionsViewModel(client: client, repoPath: path))
17 }
18
19 @State private var confirmingArchive = false
20 @State private var confirmingFork = false
21 @State private var forkName = ""
22 @Environment(\.dynamicTypeSize) private var typeSize
23 @ScaledMetric(relativeTo: .title3) private var statValueHeight: CGFloat = 26
24 @Environment(\.pushRoute) private var pushRoute
25
26 /// Pushed from buttons rather than NavigationLinks: several share a
27 /// list row, and a link there claims the whole row and adds a chevron.
28 private enum Destination: Hashable {
29 case files, mergeRequests, issues, builds, owner, settings
30 case history, refs, milestones, labels, releases, wiki, grep, symbols
31 }
32
33 private func open(_ target: Destination) {
34 switch target {
35 case .files: pushRoute(RepoRoute.tree(repo: path, directory: "", ref: nil))
36 case .mergeRequests: pushRoute(MRRoute.list(repo: path))
37 case .issues: pushRoute(IssueRoute.list(repo: path))
38 case .builds: pushRoute(BuildRoute.list(repo: path))
39 case .owner: pushRoute(RepoRoute.profile(owner))
40 case .settings: pushRoute(RepoRoute.settings(repo: path))
41 case .history: pushRoute(RepoRoute.log(repo: path, ref: nil, path: nil))
42 case .refs: pushRoute(RepoRoute.refs(repo: path))
43 case .milestones: pushRoute(RepoRoute.milestones(repo: path))
44 case .labels: pushRoute(RepoRoute.labels(repo: path))
45 case .releases: pushRoute(ReleaseRoute.list(repo: path))
46 case .wiki: pushRoute(RepoRoute.wiki(repo: path))
47 case .grep: pushRoute(RepoRoute.grep(repo: path))
48 case .symbols: pushRoute(RepoRoute.symbols(repo: path))
49 }
50 }
51
52 var body: some View {
53 List {
54 if let detail = model.state.value {
55 if let error = model.actionError {
56 Section {
57 GBNotice(error, .gbWarn)
58 }
59 }
60 if let error = actionsModel.actionError {
61 Section {
62 GBNotice(error, .gbWarn)
63 }
64 }
65 if let notice = actionsModel.notice {
66 Section {
67 GBNotice(notice, .gbOK)
68 }
69 }
70 header(detail)
71
72 Section {
73 statStrip
74 .listRowInsets(EdgeInsets())
75 }
76
77 Section("More") {
78 morePills
79 .padding(.vertical, 4)
80 }
81
82 if let readme = model.readme {
83 Section("README") {
84 ReadmeView(
85 name: model.readmeName ?? "README.md",
86 content: readme,
87 host: host,
88 repoPath: path
89 )
90 .padding(.vertical, 4)
91 }
92 }
93 }
94 }
95 .overlay { LoadStateOverlay(state: model.state) }
96 .navigationTitle(String(path.split(separator: "/").last ?? ""))
97 .navigationBarTitleDisplayMode(.inline)
98 .toolbar { toolbar }
99 .task { await model.load() }
100 .refreshable { await model.load() }
101 .alert("Fork \(path)", isPresented: $confirmingFork) {
102 TextField("New name (optional)", text: $forkName)
103 .autocorrectionDisabled()
104 .textInputAutocapitalization(.never)
105 Button("Fork") {
106 Task {
107 if let result = await actionsModel.fork(named: forkName) {
108 pushRoute(RepoRoute.repo(result.path))
109 }
110 }
111 }
112 Button("Cancel", role: .cancel) {}
113 }
114 .confirmationDialog(
115 model.state.value?.isArchived == true
116 ? "Unarchive \(path)?"
117 : "Archive \(path)? Pushes and issue/MR writes will be refused.",
118 isPresented: $confirmingArchive
119 ) {
120 Button(
121 model.state.value?.isArchived == true ? "Unarchive" : "Archive",
122 role: model.state.value?.isArchived == true ? nil : .destructive
123 ) {
124 Task { await model.setArchived(!(model.state.value?.isArchived ?? false)) }
125 }
126 Button("Cancel", role: .cancel) {}
127 }
128 }
129
130 @ToolbarContentBuilder
131 private var toolbar: some ToolbarContent {
132 ToolbarItem(placement: .topBarTrailing) {
133 if let detail = model.state.value {
134 Menu {
135 if let pinned = model.isPinned {
136 Button {
137 Task { await model.setPinned(!pinned) }
138 } label: {
139 Label(pinned ? "Unpin" : "Pin",
140 systemImage: pinned ? "pin.slash" : "pin")
141 }
142 }
143 Button {
144 Task {
145 await actionsModel.setBookmarked(!detail.bookmarked)
146 await model.reloadDetail()
147 }
148 } label: {
149 Label(detail.bookmarked ? "Unbookmark" : "Bookmark",
150 systemImage: detail.bookmarked ? "bookmark.slash" : "bookmark")
151 }
152 Divider()
153 Section {
154 let watchState = WatchState(detail.watch)
155 switch watchState {
156 case .watching:
157 Label("Watching", systemImage: "eye")
158 case .muted:
159 Label("Muted", systemImage: "bell.slash")
160 case .default:
161 EmptyView()
162 }
163 if watchState.available.contains(.watch) {
164 Button {
165 Task {
166 await actionsModel.watch()
167 await model.reloadDetail()
168 }
169 } label: {
170 Label("Watch this repository", systemImage: "eye")
171 }
172 }
173 if watchState.available.contains(.unwatch) {
174 Button {
175 Task {
176 await actionsModel.unwatch()
177 await model.reloadDetail()
178 }
179 } label: {
180 if watchState == .muted {
181 Label("Unmute this repository", systemImage: "bell")
182 } else {
183 Label("Stop watching", systemImage: "eye.slash")
184 }
185 }
186 }
187 if watchState.available.contains(.mute) {
188 Button {
189 Task {
190 await actionsModel.mute()
191 await model.reloadDetail()
192 }
193 } label: {
194 Label("Mute this repository", systemImage: "bell.slash")
195 }
196 }
197 }
198 Divider()
199 Button {
200 forkName = ""
201 confirmingFork = true
202 } label: {
203 Label("Fork", systemImage: "tuningfork")
204 }
205 Divider()
206 Button {
207 open(.settings)
208 } label: {
209 Label("Settings", systemImage: "gearshape")
210 }
211 .accessibilityIdentifier("repo-settings-link")
212 Button(role: detail.isArchived ? nil : .destructive) {
213 confirmingArchive = true
214 } label: {
215 Label(detail.isArchived ? "Unarchive" : "Archive",
216 systemImage: "archivebox")
217 }
218 } label: {
219 if model.working || actionsModel.working {
220 ProgressView()
221 } else {
222 Image(systemName: "ellipsis.circle")
223 }
224 }
225 .accessibilityLabel("Actions")
226 .disabled(model.working || actionsModel.working)
227 .accessibilityIdentifier("repo-actions-menu")
228 }
229 }
230 }
231
232 @ViewBuilder
233 private func header(_ detail: RepoDetail) -> some View {
234 Section {
235 VStack(alignment: .leading, spacing: 8) {
236 HStack(spacing: 6) {
237 HStack(spacing: 0) {
238 Button(owner) { open(.owner) }
239 .buttonStyle(.borderless)
240 .accessibilityIdentifier("repo-owner-link")
241 Text("/" + (detail.path.split(separator: "/").last.map(String.init) ?? ""))
242 }
243 .font(.gbSans(.headline))
244 if detail.visibility == "private" {
245 Image(systemName: "lock.fill")
246 .font(.gbSans(.caption))
247 .foregroundStyle(.secondary)
248 }
249 if detail.isArchived {
250 GBChip("archived", .secondary)
251 }
252 }
253 if let description = detail.description, !description.isEmpty {
254 Text(description)
255 .font(.gbSans(.subheadline))
256 .foregroundStyle(.secondary)
257 }
258 if let website = detail.website, let url = URL(string: website) {
259 Link(website, destination: url)
260 .font(.gbSans(.caption))
261 .lineLimit(1)
262 }
263 HStack(spacing: 12) {
264 Label(detail.defaultBranch, systemImage: "arrow.triangle.branch")
265 if let topics = detail.topics, !topics.isEmpty {
266 Text(topics.joined(separator: " · "))
267 .lineLimit(1)
268 }
269 }
270 .font(.gbSans(.caption))
271 .foregroundStyle(.secondary)
272 if let forkOf = detail.forkOf {
273 NavigationLink(value: RepoRoute.repo(forkOf)) {
274 Text("Forked from \(forkOf)")
275 }
276 .font(.gbSans(.caption))
277 .foregroundStyle(.secondary)
278 }
279 }
280 .padding(.vertical, 4)
281 }
282 }
283
284 private var owner: String { String(path.split(separator: "/").first ?? "") }
285
286 /// Files, merge requests, issues and builds in one row; two rows at
287 /// accessibility text sizes.
288 private var statStrip: some View {
289 let columns = typeSize.isAccessibilitySize ? 2 : 4
290 return Grid(horizontalSpacing: 0, verticalSpacing: 0) {
291 ForEach(0..<(4 / columns), id: \.self) { row in
292 GridRow {
293 ForEach(0..<columns, id: \.self) { column in
294 stat(row * columns + column, first: column == 0)
295 .overlay(alignment: .top) {
296 if row > 0 { Rectangle().fill(Color.gbLine).frame(height: 0.5) }
297 }
298 }
299 }
300 }
301 }
302 }
303
304 @ViewBuilder
305 private func stat(_ index: Int, first: Bool) -> some View {
306 switch index {
307 case 0:
308 statCell("Files", label: "Files", id: "files", to: .files, first: first) {
309 Image(systemName: "folder").foregroundStyle(Color.gbAccent)
310 }
311 case 1:
312 statCell("MRs", label: "Merge requests", value: model.openMRs.map { "\($0) open" },
313 id: "merge-requests", to: .mergeRequests, first: first) {
314 count(model.openMRs, systemImage: "arrow.triangle.merge")
315 }
316 case 2:
317 statCell("Issues", label: "Issues", value: model.openIssues.map { "\($0) open" },
318 id: "issues", to: .issues, first: first) {
319 count(model.openIssues, systemImage: "smallcircle.filled.circle")
320 }
321 default:
322 statCell("Builds", label: "Builds", value: model.latestBuild?.status,
323 id: "builds", to: .builds, first: first) {
324 if let build = model.latestBuild {
325 Image(systemName: build.statusIcon).foregroundStyle(build.statusColor)
326 } else {
327 Image(systemName: "hammer").foregroundStyle(Color.gbAccent)
328 }
329 }
330 }
331 }
332
333 /// The count when it loaded, otherwise the icon.
334 @ViewBuilder
335 private func count(_ count: String?, systemImage: String) -> some View {
336 if let count {
337 Text(count).fontWeight(.medium).foregroundStyle(Color.gbAccent)
338 } else {
339 Image(systemName: systemImage).foregroundStyle(Color.gbAccent)
340 }
341 }
342
343 private func statCell(
344 _ title: String, label: String, value: String? = nil, id: String,
345 to target: Destination, first: Bool, @ViewBuilder top: () -> some View
346 ) -> some View {
347 Button { open(target) } label: {
348 VStack(spacing: 2) {
349 top()
350 .font(.gbSans(.title3))
351 .frame(height: statValueHeight)
352 Text(title)
353 .font(.gbSans(.caption))
354 .lineLimit(1)
355 }
356 .padding(.vertical, 10)
357 .frame(maxWidth: .infinity)
358 .contentShape(Rectangle())
359 }
360 .buttonStyle(.plain)
361 .overlay(alignment: .leading) {
362 if !first { Rectangle().fill(Color.gbLine).frame(width: 0.5) }
363 }
364 .accessibilityElement(children: .ignore)
365 .accessibilityLabel(label)
366 .accessibilityValue(value ?? "")
367 .accessibilityAddTraits(.isButton)
368 .accessibilityIdentifier("repo-link-\(id)")
369 }
370
371 /// The rest of the repository, always visible: two columns, one at
372 /// accessibility text sizes.
373 @ViewBuilder
374 private var morePills: some View {
375 let search = pill("Search in Files", systemImage: "text.magnifyingglass", to: .grep)
376 let symbols = pill("Definitions", systemImage: "function", to: .symbols)
377 if typeSize.isAccessibilitySize {
378 VStack(spacing: 8) {
379 pill("History", systemImage: "clock", to: .history)
380 pill("Branches & Tags", systemImage: "arrow.triangle.branch", to: .refs)
381 pill("Milestones", systemImage: "flag", to: .milestones)
382 pill("Labels", systemImage: "tag", to: .labels)
383 pill("Releases", systemImage: "shippingbox", to: .releases)
384 pill("Wiki", systemImage: "book", to: .wiki)
385 search
386 symbols
387 }
388 } else {
389 Grid(horizontalSpacing: 8, verticalSpacing: 8) {
390 GridRow {
391 pill("History", systemImage: "clock", to: .history)
392 pill("Branches & Tags", systemImage: "arrow.triangle.branch", to: .refs)
393 }
394 GridRow {
395 pill("Milestones", systemImage: "flag", to: .milestones)
396 pill("Labels", systemImage: "tag", to: .labels)
397 }
398 GridRow {
399 pill("Releases", systemImage: "shippingbox", to: .releases)
400 pill("Wiki", systemImage: "book", to: .wiki)
401 }
402 GridRow {
403 search
404 symbols
405 }
406 }
407 }
408 }
409
410 private func pill(_ title: String, systemImage: String, to target: Destination) -> some View {
411 Button { open(target) } label: {
412 Label {
413 Text(title).lineLimit(1).minimumScaleFactor(0.8)
414 } icon: {
415 Image(systemName: systemImage).foregroundStyle(Color.gbAccent)
416 }
417 .font(.gbSans(.subheadline))
418 .padding(.horizontal, 10)
419 .padding(.vertical, 8)
420 .frame(maxWidth: .infinity, alignment: .leading)
421 .contentShape(Rectangle())
422 }
423 .buttonStyle(.plain)
424 }
425}