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