import SwiftUI struct RepoView: View { @State private var model: RepoDetailViewModel @State private var actionsModel: RepoActionsViewModel private let client: GitbayClient private let path: String private let host: String init(client: GitbayClient, path: String) { self.client = client self.path = path self.host = client.instance.baseURL.host() ?? "" _model = State(initialValue: RepoDetailViewModel(client: client, path: path)) _actionsModel = State(initialValue: RepoActionsViewModel(client: client, repoPath: path)) } @State private var confirmingArchive = false @State private var confirmingFork = false @State private var forkName = "" @Environment(\.dynamicTypeSize) private var typeSize @ScaledMetric(relativeTo: .title3) private var statValueHeight: CGFloat = 26 @Environment(\.pushRoute) private var pushRoute /// Pushed from buttons rather than NavigationLinks: several share a /// list row, and a link there claims the whole row and adds a chevron. private enum Destination: Hashable { case files, mergeRequests, issues, builds, owner, settings case history, refs, milestones, labels, releases, wiki, grep } private func open(_ target: Destination) { switch target { case .files: pushRoute(RepoRoute.tree(repo: path, directory: "", ref: nil)) case .mergeRequests: pushRoute(MRRoute.list(repo: path)) case .issues: pushRoute(IssueRoute.list(repo: path)) case .builds: pushRoute(BuildRoute.list(repo: path)) case .owner: pushRoute(RepoRoute.profile(owner)) case .settings: pushRoute(RepoRoute.settings(repo: path)) case .history: pushRoute(RepoRoute.log(repo: path, ref: nil, path: nil)) case .refs: pushRoute(RepoRoute.refs(repo: path)) case .milestones: pushRoute(RepoRoute.milestones(repo: path)) case .labels: pushRoute(RepoRoute.labels(repo: path)) case .releases: pushRoute(ReleaseRoute.list(repo: path)) case .wiki: pushRoute(RepoRoute.wiki(repo: path)) case .grep: pushRoute(RepoRoute.grep(repo: path)) } } var body: some View { List { if let detail = model.state.value { if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } if let error = actionsModel.actionError { Section { GBNotice(error, .gbWarn) } } if let notice = actionsModel.notice { Section { GBNotice(notice, .gbOK) } } header(detail) Section { statStrip .listRowInsets(EdgeInsets()) } Section("More") { morePills .padding(.vertical, 4) } if let readme = model.readme { Section("README") { ReadmeView( name: model.readmeName ?? "README.md", content: readme, host: host, repoPath: path ) .padding(.vertical, 4) } } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle(String(path.split(separator: "/").last ?? "")) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .task { await model.load() } .refreshable { await model.load() } .alert("Fork \(path)", isPresented: $confirmingFork) { TextField("New name (optional)", text: $forkName) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Fork") { Task { if let result = await actionsModel.fork(named: forkName) { pushRoute(RepoRoute.repo(result.path)) } } } Button("Cancel", role: .cancel) {} } .confirmationDialog( model.state.value?.isArchived == true ? "Unarchive \(path)?" : "Archive \(path)? Pushes and issue/MR writes will be refused.", isPresented: $confirmingArchive ) { Button( model.state.value?.isArchived == true ? "Unarchive" : "Archive", role: model.state.value?.isArchived == true ? nil : .destructive ) { Task { await model.setArchived(!(model.state.value?.isArchived ?? false)) } } Button("Cancel", role: .cancel) {} } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { if let detail = model.state.value { Menu { if let pinned = model.isPinned { Button { Task { await model.setPinned(!pinned) } } label: { Label(pinned ? "Unpin" : "Pin", systemImage: pinned ? "pin.slash" : "pin") } } Button { Task { await actionsModel.setBookmarked(!detail.bookmarked) await model.reloadDetail() } } label: { Label(detail.bookmarked ? "Unbookmark" : "Bookmark", systemImage: detail.bookmarked ? "bookmark.slash" : "bookmark") } Divider() Section { let watchState = WatchState(detail.watch) switch watchState { case .watching: Label("Watching", systemImage: "eye") case .muted: Label("Muted", systemImage: "bell.slash") case .default: EmptyView() } if watchState.available.contains(.watch) { Button { Task { await actionsModel.watch() await model.reloadDetail() } } label: { Label("Watch this repository", systemImage: "eye") } } if watchState.available.contains(.unwatch) { Button { Task { await actionsModel.unwatch() await model.reloadDetail() } } label: { if watchState == .muted { Label("Unmute this repository", systemImage: "bell") } else { Label("Stop watching", systemImage: "eye.slash") } } } if watchState.available.contains(.mute) { Button { Task { await actionsModel.mute() await model.reloadDetail() } } label: { Label("Mute this repository", systemImage: "bell.slash") } } } Divider() Button { forkName = "" confirmingFork = true } label: { Label("Fork", systemImage: "tuningfork") } Divider() Button { open(.settings) } label: { Label("Settings", systemImage: "gearshape") } .accessibilityIdentifier("repo-settings-link") Button(role: detail.isArchived ? nil : .destructive) { confirmingArchive = true } label: { Label(detail.isArchived ? "Unarchive" : "Archive", systemImage: "archivebox") } } label: { if model.working || actionsModel.working { ProgressView() } else { Image(systemName: "ellipsis.circle") } } .accessibilityLabel("Actions") .disabled(model.working || actionsModel.working) .accessibilityIdentifier("repo-actions-menu") } } } @ViewBuilder private func header(_ detail: RepoDetail) -> some View { Section { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { HStack(spacing: 0) { Button(owner) { open(.owner) } .buttonStyle(.borderless) .accessibilityIdentifier("repo-owner-link") Text("/" + (detail.path.split(separator: "/").last.map(String.init) ?? "")) } .font(.gbSans(.headline)) if detail.visibility == "private" { Image(systemName: "lock.fill") .font(.gbSans(.caption)) .foregroundStyle(.secondary) } if detail.isArchived { GBChip("archived", .secondary) } } if let description = detail.description, !description.isEmpty { Text(description) .font(.gbSans(.subheadline)) .foregroundStyle(.secondary) } if let website = detail.website, let url = URL(string: website) { Link(website, destination: url) .font(.gbSans(.caption)) .lineLimit(1) } HStack(spacing: 12) { Label(detail.defaultBranch, systemImage: "arrow.triangle.branch") if let topics = detail.topics, !topics.isEmpty { Text(topics.joined(separator: " ยท ")) .lineLimit(1) } } .font(.gbSans(.caption)) .foregroundStyle(.secondary) if let forkOf = detail.forkOf { NavigationLink(value: RepoRoute.repo(forkOf)) { Text("Forked from \(forkOf)") } .font(.gbSans(.caption)) .foregroundStyle(.secondary) } } .padding(.vertical, 4) } } private var owner: String { String(path.split(separator: "/").first ?? "") } /// Files, merge requests, issues and builds in one row; two rows at /// accessibility text sizes. private var statStrip: some View { let columns = typeSize.isAccessibilitySize ? 2 : 4 return Grid(horizontalSpacing: 0, verticalSpacing: 0) { ForEach(0..<(4 / columns), id: \.self) { row in GridRow { ForEach(0.. 0 { Rectangle().fill(Color.gbLine).frame(height: 0.5) } } } } } } } @ViewBuilder private func stat(_ index: Int, first: Bool) -> some View { switch index { case 0: statCell("Files", label: "Files", id: "files", to: .files, first: first) { Image(systemName: "folder").foregroundStyle(Color.gbAccent) } case 1: statCell("MRs", label: "Merge requests", value: model.openMRs.map { "\($0) open" }, id: "merge-requests", to: .mergeRequests, first: first) { count(model.openMRs, systemImage: "arrow.triangle.merge") } case 2: statCell("Issues", label: "Issues", value: model.openIssues.map { "\($0) open" }, id: "issues", to: .issues, first: first) { count(model.openIssues, systemImage: "smallcircle.filled.circle") } default: statCell("Builds", label: "Builds", value: model.latestBuild?.status, id: "builds", to: .builds, first: first) { if let build = model.latestBuild { Image(systemName: build.statusIcon).foregroundStyle(build.statusColor) } else { Image(systemName: "hammer").foregroundStyle(Color.gbAccent) } } } } /// The count when it loaded, otherwise the icon. @ViewBuilder private func count(_ count: String?, systemImage: String) -> some View { if let count { Text(count).fontWeight(.medium).foregroundStyle(Color.gbAccent) } else { Image(systemName: systemImage).foregroundStyle(Color.gbAccent) } } private func statCell( _ title: String, label: String, value: String? = nil, id: String, to target: Destination, first: Bool, @ViewBuilder top: () -> some View ) -> some View { Button { open(target) } label: { VStack(spacing: 2) { top() .font(.gbSans(.title3)) .frame(height: statValueHeight) Text(title) .font(.gbSans(.caption)) .lineLimit(1) } .padding(.vertical, 10) .frame(maxWidth: .infinity) .contentShape(Rectangle()) } .buttonStyle(.plain) .overlay(alignment: .leading) { if !first { Rectangle().fill(Color.gbLine).frame(width: 0.5) } } .accessibilityElement(children: .ignore) .accessibilityLabel(label) .accessibilityValue(value ?? "") .accessibilityAddTraits(.isButton) .accessibilityIdentifier("repo-link-\(id)") } /// The rest of the repository, always visible: two columns, one at /// accessibility text sizes. @ViewBuilder private var morePills: some View { let search = pill("Search in Files", systemImage: "text.magnifyingglass", to: .grep) if typeSize.isAccessibilitySize { VStack(spacing: 8) { pill("History", systemImage: "clock", to: .history) pill("Branches & Tags", systemImage: "arrow.triangle.branch", to: .refs) pill("Milestones", systemImage: "flag", to: .milestones) pill("Labels", systemImage: "tag", to: .labels) pill("Releases", systemImage: "shippingbox", to: .releases) pill("Wiki", systemImage: "book", to: .wiki) search } } else { Grid(horizontalSpacing: 8, verticalSpacing: 8) { GridRow { pill("History", systemImage: "clock", to: .history) pill("Branches & Tags", systemImage: "arrow.triangle.branch", to: .refs) } GridRow { pill("Milestones", systemImage: "flag", to: .milestones) pill("Labels", systemImage: "tag", to: .labels) } GridRow { pill("Releases", systemImage: "shippingbox", to: .releases) pill("Wiki", systemImage: "book", to: .wiki) } GridRow { search.gridCellColumns(2) } } } } private func pill(_ title: String, systemImage: String, to target: Destination) -> some View { Button { open(target) } label: { Label { Text(title).lineLimit(1).minimumScaleFactor(0.8) } icon: { Image(systemName: systemImage).foregroundStyle(Color.gbAccent) } .font(.gbSans(.subheadline)) .padding(.horizontal, 10) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) } }