gitbay/Views/Repos/RepoView.swift
430 lines · 17937 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 // Settings, pushed on top, may delete or move this repository;
102 // reloading turns this screen into the not-found it now is.
103 .onReceive(NotificationCenter.default.publisher(for: .gitbayRepositoryChanged)) { note in
104 if note.object as? String == path { Task { await model.load() } }
105 }
106 .alert("Fork \(path)", isPresented: $confirmingFork) {
107 TextField("New name (optional)", text: $forkName)
108 .autocorrectionDisabled()
109 .textInputAutocapitalization(.never)
110 Button("Fork") {
111 Task {
112 if let result = await actionsModel.fork(named: forkName) {
113 pushRoute(RepoRoute.repo(result.path))
114 }
115 }
116 }
117 Button("Cancel", role: .cancel) {}
118 }
119 .confirmationDialog(
120 model.state.value?.isArchived == true
121 ? "Unarchive \(path)?"
122 : "Archive \(path)? Pushes and issue/MR writes will be refused.",
123 isPresented: $confirmingArchive
124 ) {
125 Button(
126 model.state.value?.isArchived == true ? "Unarchive" : "Archive",
127 role: model.state.value?.isArchived == true ? nil : .destructive
128 ) {
129 Task { await model.setArchived(!(model.state.value?.isArchived ?? false)) }
130 }
131 Button("Cancel", role: .cancel) {}
132 }
133 }
134
135 @ToolbarContentBuilder
136 private var toolbar: some ToolbarContent {
137 ToolbarItem(placement: .topBarTrailing) {
138 if let detail = model.state.value {
139 Menu {
140 if let pinned = model.isPinned {
141 Button {
142 Task { await model.setPinned(!pinned) }
143 } label: {
144 Label(pinned ? "Unpin" : "Pin",
145 systemImage: pinned ? "pin.slash" : "pin")
146 }
147 }
148 Button {
149 Task {
150 await actionsModel.setBookmarked(!detail.bookmarked)
151 await model.reloadDetail()
152 }
153 } label: {
154 Label(detail.bookmarked ? "Unbookmark" : "Bookmark",
155 systemImage: detail.bookmarked ? "bookmark.slash" : "bookmark")
156 }
157 Divider()
158 Section {
159 let watchState = WatchState(detail.watch)
160 switch watchState {
161 case .watching:
162 Label("Watching", systemImage: "eye")
163 case .muted:
164 Label("Muted", systemImage: "bell.slash")
165 case .default:
166 EmptyView()
167 }
168 if watchState.available.contains(.watch) {
169 Button {
170 Task {
171 await actionsModel.watch()
172 await model.reloadDetail()
173 }
174 } label: {
175 Label("Watch this repository", systemImage: "eye")
176 }
177 }
178 if watchState.available.contains(.unwatch) {
179 Button {
180 Task {
181 await actionsModel.unwatch()
182 await model.reloadDetail()
183 }
184 } label: {
185 if watchState == .muted {
186 Label("Unmute this repository", systemImage: "bell")
187 } else {
188 Label("Stop watching", systemImage: "eye.slash")
189 }
190 }
191 }
192 if watchState.available.contains(.mute) {
193 Button {
194 Task {
195 await actionsModel.mute()
196 await model.reloadDetail()
197 }
198 } label: {
199 Label("Mute this repository", systemImage: "bell.slash")
200 }
201 }
202 }
203 Divider()
204 Button {
205 forkName = ""
206 confirmingFork = true
207 } label: {
208 Label("Fork", systemImage: "tuningfork")
209 }
210 Divider()
211 Button {
212 open(.settings)
213 } label: {
214 Label("Settings", systemImage: "gearshape")
215 }
216 .accessibilityIdentifier("repo-settings-link")
217 Button(role: detail.isArchived ? nil : .destructive) {
218 confirmingArchive = true
219 } label: {
220 Label(detail.isArchived ? "Unarchive" : "Archive",
221 systemImage: "archivebox")
222 }
223 } label: {
224 if model.working || actionsModel.working {
225 ProgressView()
226 } else {
227 Image(systemName: "ellipsis.circle")
228 }
229 }
230 .accessibilityLabel("Actions")
231 .disabled(model.working || actionsModel.working)
232 .accessibilityIdentifier("repo-actions-menu")
233 }
234 }
235 }
236
237 @ViewBuilder
238 private func header(_ detail: RepoDetail) -> some View {
239 Section {
240 VStack(alignment: .leading, spacing: 8) {
241 HStack(spacing: 6) {
242 HStack(spacing: 0) {
243 Button(owner) { open(.owner) }
244 .buttonStyle(.borderless)
245 .accessibilityIdentifier("repo-owner-link")
246 Text("/" + (detail.path.split(separator: "/").last.map(String.init) ?? ""))
247 }
248 .font(.gbSans(.headline))
249 if detail.visibility == "private" {
250 Image(systemName: "lock.fill")
251 .font(.gbSans(.caption))
252 .foregroundStyle(.secondary)
253 }
254 if detail.isArchived {
255 GBChip("archived", .secondary)
256 }
257 }
258 if let description = detail.description, !description.isEmpty {
259 Text(description)
260 .font(.gbSans(.subheadline))
261 .foregroundStyle(.secondary)
262 }
263 if let website = detail.website, let url = URL(string: website) {
264 Link(website, destination: url)
265 .font(.gbSans(.caption))
266 .lineLimit(1)
267 }
268 HStack(spacing: 12) {
269 Label(detail.defaultBranch, systemImage: "arrow.triangle.branch")
270 if let topics = detail.topics, !topics.isEmpty {
271 Text(topics.joined(separator: " · "))
272 .lineLimit(1)
273 }
274 }
275 .font(.gbSans(.caption))
276 .foregroundStyle(.secondary)
277 if let forkOf = detail.forkOf {
278 NavigationLink(value: RepoRoute.repo(forkOf)) {
279 Text("Forked from \(forkOf)")
280 }
281 .font(.gbSans(.caption))
282 .foregroundStyle(.secondary)
283 }
284 }
285 .padding(.vertical, 4)
286 }
287 }
288
289 private var owner: String { String(path.split(separator: "/").first ?? "") }
290
291 /// Files, merge requests, issues and builds in one row; two rows at
292 /// accessibility text sizes.
293 private var statStrip: some View {
294 let columns = typeSize.isAccessibilitySize ? 2 : 4
295 return Grid(horizontalSpacing: 0, verticalSpacing: 0) {
296 ForEach(0..<(4 / columns), id: \.self) { row in
297 GridRow {
298 ForEach(0..<columns, id: \.self) { column in
299 stat(row * columns + column, first: column == 0)
300 .overlay(alignment: .top) {
301 if row > 0 { Rectangle().fill(Color.gbLine).frame(height: 0.5) }
302 }
303 }
304 }
305 }
306 }
307 }
308
309 @ViewBuilder
310 private func stat(_ index: Int, first: Bool) -> some View {
311 switch index {
312 case 0:
313 statCell("Files", label: "Files", id: "files", to: .files, first: first) {
314 Image(systemName: "folder").foregroundStyle(Color.gbAccent)
315 }
316 case 1:
317 statCell("MRs", label: "Merge requests", value: model.openMRs.map { "\($0) open" },
318 id: "merge-requests", to: .mergeRequests, first: first) {
319 count(model.openMRs, systemImage: "arrow.triangle.merge")
320 }
321 case 2:
322 statCell("Issues", label: "Issues", value: model.openIssues.map { "\($0) open" },
323 id: "issues", to: .issues, first: first) {
324 count(model.openIssues, systemImage: "smallcircle.filled.circle")
325 }
326 default:
327 statCell("Builds", label: "Builds", value: model.latestBuild?.status,
328 id: "builds", to: .builds, first: first) {
329 if let build = model.latestBuild {
330 Image(systemName: build.statusIcon).foregroundStyle(build.statusColor)
331 } else {
332 Image(systemName: "hammer").foregroundStyle(Color.gbAccent)
333 }
334 }
335 }
336 }
337
338 /// The count when it loaded, otherwise the icon.
339 @ViewBuilder
340 private func count(_ count: String?, systemImage: String) -> some View {
341 if let count {
342 Text(count).fontWeight(.medium).foregroundStyle(Color.gbAccent)
343 } else {
344 Image(systemName: systemImage).foregroundStyle(Color.gbAccent)
345 }
346 }
347
348 private func statCell(
349 _ title: String, label: String, value: String? = nil, id: String,
350 to target: Destination, first: Bool, @ViewBuilder top: () -> some View
351 ) -> some View {
352 Button { open(target) } label: {
353 VStack(spacing: 2) {
354 top()
355 .font(.gbSans(.title3))
356 .frame(height: statValueHeight)
357 Text(title)
358 .font(.gbSans(.caption))
359 .lineLimit(1)
360 }
361 .padding(.vertical, 10)
362 .frame(maxWidth: .infinity)
363 .contentShape(Rectangle())
364 }
365 .buttonStyle(.plain)
366 .overlay(alignment: .leading) {
367 if !first { Rectangle().fill(Color.gbLine).frame(width: 0.5) }
368 }
369 .accessibilityElement(children: .ignore)
370 .accessibilityLabel(label)
371 .accessibilityValue(value ?? "")
372 .accessibilityAddTraits(.isButton)
373 .accessibilityIdentifier("repo-link-\(id)")
374 }
375
376 /// The rest of the repository, always visible: two columns, one at
377 /// accessibility text sizes.
378 @ViewBuilder
379 private var morePills: some View {
380 let search = pill("Search in Files", systemImage: "text.magnifyingglass", to: .grep)
381 let symbols = pill("Definitions", systemImage: "function", to: .symbols)
382 if typeSize.isAccessibilitySize {
383 VStack(spacing: 8) {
384 pill("History", systemImage: "clock", to: .history)
385 pill("Branches & Tags", systemImage: "arrow.triangle.branch", to: .refs)
386 pill("Milestones", systemImage: "flag", to: .milestones)
387 pill("Labels", systemImage: "tag", to: .labels)
388 pill("Releases", systemImage: "shippingbox", to: .releases)
389 pill("Wiki", systemImage: "book", to: .wiki)
390 search
391 symbols
392 }
393 } else {
394 Grid(horizontalSpacing: 8, verticalSpacing: 8) {
395 GridRow {
396 pill("History", systemImage: "clock", to: .history)
397 pill("Branches & Tags", systemImage: "arrow.triangle.branch", to: .refs)
398 }
399 GridRow {
400 pill("Milestones", systemImage: "flag", to: .milestones)
401 pill("Labels", systemImage: "tag", to: .labels)
402 }
403 GridRow {
404 pill("Releases", systemImage: "shippingbox", to: .releases)
405 pill("Wiki", systemImage: "book", to: .wiki)
406 }
407 GridRow {
408 search
409 symbols
410 }
411 }
412 }
413 }
414
415 private func pill(_ title: String, systemImage: String, to target: Destination) -> some View {
416 Button { open(target) } label: {
417 Label {
418 Text(title).lineLimit(1).minimumScaleFactor(0.8)
419 } icon: {
420 Image(systemName: systemImage).foregroundStyle(Color.gbAccent)
421 }
422 .font(.gbSans(.subheadline))
423 .padding(.horizontal, 10)
424 .padding(.vertical, 8)
425 .frame(maxWidth: .infinity, alignment: .leading)
426 .contentShape(Rectangle())
427 }
428 .buttonStyle(.plain)
429 }
430}