gitbay/Views/Repos/RepoView.swift

v1.9.0
gitbay-ios/gitbay/Views/Repos/RepoView.swift history · blame · raw

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}