gitbay/Views/Repos/RepoView.swift

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

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}