gitbay/Views/Repos/RepoView.swift

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

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}