import SwiftUI struct RepoView: View { let repo: RepoRef @Environment(Account.self) private var account @State private var reporting: Reporting? private struct Answer: Sendable { let detail: RepoDetail; let tree: Tree?; let readme: FileAnswer? } var body: some View { Loading(load: { [repo] in let detail: RepoDetail = try await Client.shared.query("getRepo", ["repo": repo.uri]) let tree: Tree? = try? await Client.shared.query("getTree", ["repo": repo.uri]) var readme: FileAnswer? if let name = tree?.readme?.name { readme = try? await Client.shared.query("getFile", ["repo": repo.uri, "path": name]) } return Answer(detail: detail, tree: tree, readme: readme) }) { answer in RepoPage(repo: repo, detail: answer.detail, checks: answer.tree?.checks ?? [], readmeName: answer.tree?.readme?.name, readme: answer.readme?.text, readmeHTML: answer.readme?.html) } .navigationTitle(repo.name) .navigationBarTitleDisplayMode(.inline) .toolbar { if let url = URL(string: "https://knotless.dev/\(repo.handle)/\(repo.name)") { ToolbarItem(placement: .topBarTrailing) { ShareLink(item: url) } } if account.signedIn, account.person?.did != repo.did { ToolbarItem(placement: .topBarTrailing) { Menu { Button("Report this repository") { reporting = Reporting(subject: "knotless://\(repo.did)/\(repo.name)", what: "this repository") } } label: { SwiftUI.Label("More", systemImage: "ellipsis") } } } } .sheet(item: $reporting) { ReportSheet(reporting: $0) } } } private struct RepoPage: View { let repo: RepoRef let detail: RepoDetail let checks: [Check] let readmeName: String? let readme: String? let readmeHTML: String? /// The branch being looked at; the default until another is chosen. @State private var branch: String? @State private var choosing = false private var shown: String { branch ?? detail.defaultBranch } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { RepoHeader(repo: repo, detail: detail) Sections { PageRow(.items(repo, kind: "issue"), "Issues", "issue-opened", .success, detail.openIssues) RowDivider() PageRow(.items(repo, kind: "pull"), "Pulls", "git-pull-request", .accent, detail.openPulls) if detail.private != true { RowDivider() NavigationLink(value: Route.runs(repo)) { PageRowLabel("Checks", "play", .attention) { if !checks.isEmpty { let summary = CheckSummary(checks) Oct(name: summary.symbol).foregroundStyle(summary.color).accessibilityLabel(summary.text) } } } .buttonStyle(RowPress()) } if let releases = detail.releases, releases.count > 0 { RowDivider() PageRow(.releases(repo), "Releases", "tag", .neutral, releases.count) if let latest = releases.release { LatestRelease(release: latest, repo: repo) } } } if !detail.defaultBranch.isEmpty { SectionTitle(icon: "git-branch", title: shown, mono: true) { Button("Change branch") { choosing = true }.font(.subheadline) } Sections { PageRow(.tree(repo, ref: shown, path: ""), "Code", "code", .neutral, nil) RowDivider() PageRow(.history(repo, ref: shown), "Commits", "git-commit", .neutral, nil) } if let branch, branch != detail.defaultBranch { BranchReadme(repo: repo, ref: branch).id(branch) } else if let readme { ReadmeSection(repo: repo, ref: detail.defaultBranch, name: readmeName ?? "Readme", text: readme, html: readmeHTML) } } } .padding(.bottom, Space.s6) } .background(Color.groupedPage) .sheet(isPresented: $choosing) { BranchPicker(repo: repo, chosen: shown) { branch = $0 } } } } /// What a repository says of itself, and what someone can do with it. private struct RepoHeader: View { @Environment(Account.self) private var account let repo: RepoRef let detail: RepoDetail var body: some View { VStack(alignment: .leading, spacing: Space.s3) { NavigationLink(value: Route.profile(detail.owner.handle)) { HStack(spacing: Space.s2) { Avatar(url: detail.owner.avatar, name: detail.owner.handle, did: detail.owner.did, size: Size.Avatar.small) Text(detail.owner.handle).foregroundStyle(.secondary).lineLimit(1) } } .buttonStyle(.plain) .accessibilityIdentifier("repo-owner") HStack(spacing: Space.s2) { Text(detail.name).font(.largeTitle.bold()) if detail.private == true { Image(systemName: "lock.fill").foregroundStyle(.secondary).accessibilityLabel("Private") } } if let about = detail.description, !about.isEmpty { Text(about) } if let fork = detail.forkOf { NavigationLink(value: Route.repo(fork.ref)) { HStack(spacing: Space.s1_5) { Oct(name: "repo-forked").foregroundStyle(.secondary) Text("Forked from \(fork.handle)/\(fork.name)").foregroundStyle(.secondary).lineLimit(1) } } .buttonStyle(.plain) .font(.subheadline) } if let link = detail.links?.first { LinkLine(link: link) } if detail.private != true { RepoActions(repo: repo, detail: detail, mine: account.person?.did == detail.owner.did) } } .padding(.horizontal, Space.s4) .padding(.top, Space.s2) .padding(.bottom, Space.s5) .frame(maxWidth: .infinity, alignment: .leading) } } struct LinkLine: View { let link: WebLink var body: some View { if let url = URL(string: link.url) { Link(destination: url) { HStack(spacing: Space.s2) { Oct(name: "link").foregroundStyle(.secondary) Text(link.label).fontWeight(.semibold).foregroundStyle(.primary).lineLimit(1) } } .buttonStyle(.plain) } } } /// A repository's stars, and to someone signed in the buttons to star it, fork /// it and choose what to hear of it. private struct RepoActions: View { @Environment(Account.self) private var account let repo: RepoRef let mine: Bool @State private var starred: Bool @State private var count: Int @State private var level: String? @State private var failure: String? @State private var busy = false @State private var asking = false @State private var forking = false @State private var forked: RepoRef? init(repo: RepoRef, detail: RepoDetail, mine: Bool) { self.repo = repo self.mine = mine _starred = State(initialValue: detail.viewer?.star != nil) _count = State(initialValue: detail.starCount ?? 0) _level = State(initialValue: detail.viewer?.subscription) } var body: some View { VStack(alignment: .leading, spacing: Space.s3_5) { HStack(spacing: Space.s1_5) { Oct(name: "star").foregroundStyle(.secondary) Text("\(Text("\(count)").fontWeight(.semibold)) \(Text(count == 1 ? "star" : "stars").foregroundStyle(.secondary))") } if account.signedIn { HStack(spacing: Space.s2) { Button { Task { await toggle() } } label: { HStack(spacing: Space.s1_5) { Oct(name: starred ? "star-fill" : "star").foregroundStyle(starred ? Color.attention : .primary) Text(starred ? "Starred" : "Star").fontWeight(.semibold) } .frame(maxWidth: .infinity) } .buttonStyle(HeaderButton()) .disabled(busy) .accessibilityValue(starred ? "Starred" : "Not starred") if !mine { Button { asking = true } label: { if forking { ProgressView() } else { Oct(name: "repo-forked") } } .buttonStyle(HeaderButton()) .disabled(forking) .accessibilityLabel("Fork") .confirmationDialog("Fork \(repo.name)?", isPresented: $asking, titleVisibility: .visible) { Button("Fork") { Task { await fork() } } } message: { Text("This copies every branch and tag to your account.") } } HearMenu(repo: repo, level: $level) { failure = $0 } } } } .navigationDestination(item: $forked) { RepoView(repo: $0) } .alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { Button("OK", role: .cancel) {} } message: { Text(failure ?? "") } } private func toggle() async { busy = true defer { busy = false } do { try await Client.shared.send(Writes.star(repo: repo, undo: starred)) count += starred ? -1 : 1 starred.toggle() } catch { failure = error.localizedDescription } } private func fork() async { guard let person = account.person else { return } forking = true defer { forking = false } do { let made: RepoMade = try await Client.shared.answer(to: Writes.fork(repo: repo)) forked = RepoRef(did: person.did, handle: person.handle, name: made.name) } catch { failure = error.localizedDescription } } } private struct HearMenu: View { let repo: RepoRef @Binding var level: String? let failed: (String) -> Void private var listening: Bool { level == "all" || level == "releases" } var body: some View { Menu { choice(nil, "Only what I take part in") choice("all", "All activity") choice("releases", "Releases only") choice("ignore", "Ignore") } label: { Oct(name: level == "ignore" ? "bell-slash" : listening ? "bell-fill" : "bell") .foregroundStyle(listening ? Color.accent : .primary) } .buttonStyle(HeaderButton()) .accessibilityLabel("Notifications") } private func choice(_ value: String?, _ title: String) -> some View { Button { let before = level level = value Task { do { try await Client.shared.send(Writes.hear(repo: repo, level: value ?? "participating")) } catch { level = before failed(error.localizedDescription) } } } label: { if level == value { SwiftUI.Label(title, systemImage: "checkmark") } else { Text(title) } } } } /// A filled button in a repository's or profile's header, those beside each /// other the same height. struct HeaderButton: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .frame(minWidth: 26, minHeight: 24) .padding(.horizontal, Space.s3) .padding(.vertical, Space.s3) .background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.large)) .opacity(configuration.isPressed ? 0.6 : 1) .contentShape(RoundedRectangle(cornerRadius: Radius.large)) } } /// A band of rows across the page. struct Sections: View { @ViewBuilder let content: Content var body: some View { VStack(spacing: 0) { content } .background(Color.card) .overlay(alignment: .top) { Divider() } .overlay(alignment: .bottom) { Divider() } } } /// The words over a band, with what can be done about it at the end. struct SectionTitle: View { let icon: String let title: String var mono = false @ViewBuilder let trailing: Trailing var body: some View { HStack(spacing: Space.s2_5) { Oct(name: icon).foregroundStyle(.secondary) Text(title) .font(mono ? .subheadline.monospaced() : .subheadline) .foregroundStyle(.secondary) .lineLimit(1) Spacer() trailing } .padding(.horizontal, Space.s4) .padding(.top, Space.s7) .padding(.bottom, Space.s2_5) } } /// A row's coloured square, its words and whatever ends it, before its chevron. struct PageRowLabel: View { let title: String let icon: String let color: Color @ViewBuilder let trailing: Trailing init(_ title: String, _ icon: String, _ color: Color, @ViewBuilder trailing: () -> Trailing) { self.title = title self.icon = icon self.color = color self.trailing = trailing() } var body: some View { HStack(spacing: Space.s3) { IconSquare(image: Image("oct-" + icon), color: color) Text(title) Spacer() trailing RowChevron(name: "chevron-right") } .padding(.horizontal, Space.s4) .padding(.vertical, Space.s2) .frame(minHeight: Size.row) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } } /// A row that opens a screen, with how many it holds. struct PageRow: View { let route: Route let title: String let icon: String let color: Color let count: Int? init(_ route: Route, _ title: String, _ icon: String, _ color: Color, _ count: Int?) { self.route = route self.title = title self.icon = icon self.color = color self.count = count } var body: some View { NavigationLink(value: route) { PageRowLabel(title, icon, color) { if let count { Text("\(count)").foregroundStyle(.secondary) } } } .buttonStyle(RowPress()) } } private struct LatestRelease: View { let release: RepoDetail.Releases.Latest let repo: RepoRef var body: some View { NavigationLink(value: Route.releases(repo)) { VStack(alignment: .leading, spacing: Space.s0_5) { Text("\(release.package) \(release.version)").lineLimit(1) HStack(spacing: Space.s1) { if let when = release.createdAt { Text(When.short(when)).foregroundStyle(.secondary) } if release.latest == true { if release.createdAt != nil { Text("·").foregroundStyle(.secondary) } Text("Latest").foregroundStyle(Color.success) } } .font(.subheadline) } .padding(Space.s3) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.inset, in: RoundedRectangle(cornerRadius: Radius.medium)) .overlay(RoundedRectangle(cornerRadius: Radius.medium).stroke(Color.line)) } .buttonStyle(.plain) .padding(.leading, Size.rowInset) .padding(.trailing, Space.s4) .padding(.bottom, Space.s3) } } private struct ReadmeSection: View { let repo: RepoRef let ref: String let name: String let text: String let html: String? var body: some View { SectionTitle(icon: "info", title: name, mono: true) { EmptyView() } Sections { MarkdownView(source: text, html: html, place: MarkdownPlace(repo: repo, ref: ref)) .padding(Space.s4) .frame(maxWidth: .infinity, alignment: .leading) } } } /// The README of a branch other than the default. private struct BranchReadme: View { let repo: RepoRef let ref: String private struct Answer: Sendable { let name: String; let text: String?; let html: String? } var body: some View { Loading(load: { [repo, ref] in let tree: Tree = try await Client.shared.query("getTree", ["repo": repo.uri, "ref": ref]) guard let name = tree.readme?.name else { return Answer(name: "", text: nil, html: nil) } let file: FileAnswer = try await Client.shared.query("getFile", ["repo": repo.uri, "ref": ref, "path": name]) return Answer(name: name, text: file.text, html: file.html) }) { answer in if let text = answer.text { ReadmeSection(repo: repo, ref: ref, name: answer.name, text: text, html: answer.html) } } } } private struct BranchPicker: View { @Environment(\.dismiss) private var dismiss let repo: RepoRef let chosen: String let choose: (String) -> Void var body: some View { NavigationStack { Loading(load: { [repo] in let list: BranchList = try await Client.shared.query("listBranches", ["repo": repo.uri, "limit": "1000"]) return list.branches }) { branches in List { ForEach(branches) { branch in Button { choose(branch.name) dismiss() } label: { HStack { Text(branch.name).font(.body.monospaced()).lineLimit(1).foregroundStyle(.primary) Spacer() if branch.name == chosen { Image(systemName: "checkmark").foregroundStyle(Color.accent) } } } }.listRowBackground(Color.page) } .listStyle(.plain) } .navigationTitle("Branches") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } } } /// A list a page at a time, for those screens that are only that. /// A list's first page read again, over all that was read of it: the rest stay, /// so a list scrolled far down stays where it was, but for any the new page moved up. func rereading(_ first: [Item], over old: [Item]) -> [Item] { let new = Set(first.map(\.id)) return first + old.dropFirst(first.count).filter { !new.contains($0.id) } } struct PagedList: View { struct Page: Sendable { let items: [Item] let cursor: String? } let empty: String let fetch: @Sendable (String?) async throws -> Page /// Shown over the list, edge to edge. var header: AnyView? @ViewBuilder let row: (Item) -> Row @State private var items: [Item] = [] @State private var cursor: String? @State private var loading = true @State private var failure: String? var body: some View { List { Group { if let header { header.listRowInsets(EdgeInsets()).listRowSeparator(.hidden) } if let failure { Text(failure).foregroundStyle(.secondary) } else if loading { ProgressView().frame(maxWidth: .infinity) } else if items.isEmpty { Text(empty).foregroundStyle(.secondary) } ForEach(items) { item in row(item).onAppear { if item == items.last { Task { await more() } } } } } .listRowBackground(Color.page) } .listStyle(.plain) // Each time it's shown again, as a pull may have changed while it wasn't. .task { await read(keepingPages: true) } .refreshable { await read(keepingPages: false) } } private func read(keepingPages: Bool) async { do { let page = try await fetch(nil) let old = keepingPages ? items : [] cursor = old.count > page.items.count ? cursor : page.cursor items = rereading(page.items, over: old) failure = nil } catch is CancellationError { return } catch let error as URLError where error.code == .cancelled { return } catch { // What's on screen stays, rather than a list gone for a lost connection. if items.isEmpty { failure = error.localizedDescription } } loading = false } private func more() async { guard let cursor, let next = try? await fetch(cursor) else { return } self.cursor = next.cursor items += next.items.filter { !items.contains($0) } } } struct HistoryView: View { let repo: RepoRef let ref: String? var body: some View { PagedList(empty: "No commits yet.", fetch: { [repo, ref] cursor in let list: CommitList = try await Client.shared.query( "listCommits", ["repo": repo.uri, "ref": ref, "limit": "30", "cursor": cursor]) return .init(items: list.commits, cursor: list.cursor) }) { CommitRow(repo: repo, commit: $0) } .navigationTitle("Commits") .navigationBarTitleDisplayMode(.inline) } } struct ReleasesView: View { let repo: RepoRef var body: some View { PagedList(empty: "Nothing published yet.", fetch: { [repo] cursor in let list: ReleaseList = try await Client.shared.query("listReleases", ["repo": repo.uri, "cursor": cursor]) return .init(items: list.releases, cursor: list.cursor) }) { release in AnyView(NavigationLink(value: Route.release(release)) { ReleaseHead(release: release) }) } .navigationTitle("Releases") .navigationBarTitleDisplayMode(.inline) } } private struct ReleaseHead: View { let release: Release var body: some View { VStack(alignment: .leading, spacing: Space.s0_5) { Text("\(release.package) \(release.version)").font(.body.weight(.medium)).lineLimit(2) HStack(spacing: Space.s1) { if let when = release.createdAt { Text(When.short(when)).foregroundStyle(.secondary) } if release.latest == true { if release.createdAt != nil { Text("·").foregroundStyle(.secondary) } Text("Latest").foregroundStyle(Color.success) } } .font(.subheadline) } } } struct ReleaseView: View { let release: Release var body: some View { ScrollView { VStack(alignment: .leading, spacing: Space.s4) { ReleaseHead(release: release) if let notes = release.notes, !notes.isEmpty { MarkdownView(source: notes, html: release.html) } if let files = release.files, !files.isEmpty { VStack(alignment: .leading, spacing: Space.s2) { Text("Files").font(.headline) ForEach(files, id: \.name) { file in HStack(alignment: .firstTextBaseline, spacing: Space.s2) { Text(file.name).font(.footnote.monospaced()) Spacer() if let size = file.size { Text(size.formatted(.byteCount(style: .file))).font(.footnote).foregroundStyle(.secondary) } } } } } } .padding(Space.s4) .frame(maxWidth: .infinity, alignment: .leading) } .navigationTitle(release.version) .navigationBarTitleDisplayMode(.inline) } } struct ItemsView: View { let repo: RepoRef let kind: String @State private var state = "open" /// What's been read of each state, so going back to one shows it at once. @State private var answers: [String: ItemList] = [:] @State private var failure: String? private var shown: ItemList? { answers[state] } /// The counts, which every answer has, while this state's is on its way. private var counts: ItemList? { shown ?? answers.values.first } var body: some View { List { Group { Picker("State", selection: $state) { Text(label("Open", counts?.openCount)).tag("open") Text(label("Closed", counts?.closedCount)).tag("closed") } .pickerStyle(.segmented) .listRowSeparator(.hidden) if let shown { if shown.items.isEmpty { Text(state == "open" ? "Nothing open." : "Nothing closed.").foregroundStyle(.secondary) } ForEach(shown.items) { item in NavigationLink(value: Route.thread(repo, uri: item.uri)) { ItemRow(item: item) } .onAppear { if item == shown.items.last { Task { await more() } } } } } else if let failure { Text(failure).foregroundStyle(.secondary) } else { ProgressView().frame(maxWidth: .infinity) } } .listRowBackground(Color.page) } .listStyle(.plain) .navigationTitle(kind == "pull" ? "Pulls" : "Issues") .navigationBarTitleDisplayMode(.inline) // Each time it's shown again too, as a pull may have changed while it wasn't. .task(id: state) { await read(keepingPages: true) } .refreshable { await read(keepingPages: false) } } private func label(_ name: String, _ count: Int?) -> String { count.map { "\(name) \($0)" } ?? name } private func fetch(_ state: String, _ cursor: String?) async throws -> ItemList { try await Client.shared.query("listItems", ["repo": repo.uri, "kind": kind, "state": state, "limit": "30", "cursor": cursor]) } private func read(keepingPages: Bool) async { let asked = state failure = nil do { var page = try await fetch(asked, nil) if keepingPages, let old = answers[asked], old.items.count > page.items.count { page.cursor = old.cursor page.items = rereading(page.items, over: old.items) } answers[asked] = page } catch is CancellationError { } catch let error as URLError where error.code == .cancelled { } catch { // What's on screen stays, rather than a list gone for a lost connection. failure = error.localizedDescription } } private func more() async { let asked = state guard let cursor = answers[asked]?.cursor, let next = try? await fetch(asked, cursor) else { return } answers[asked]?.cursor = next.cursor let have = answers[asked]?.items ?? [] answers[asked]?.items = have + next.items.filter { !have.contains($0) } } } struct ItemRow: View { let item: Item var body: some View { let style = StateStyle(kind: item.kind, state: item.state, draft: item.draft == true) HStack(alignment: .top, spacing: Space.s3) { StateIcon(style: style, size: Icon.xl).frame(width: 26).padding(.top, Space.s0_5) VStack(alignment: .leading, spacing: Space.s1) { Text(item.title).font(.body.weight(.medium)).lineLimit(3) HStack(spacing: Space.s1_5) { Avatar(url: item.author.avatar, name: item.author.handle, did: item.author.did, size: Size.Avatar.tiny) Text(item.author.handle).lineLimit(1) Text(When.short(item.createdAt)) if item.comments > 0 { SwiftUI.Label { Text("\(item.comments)") } icon: { Oct(name: "comment", size: Icon.xs) }.labelStyle(TightLabel()) } } .font(.footnote) .foregroundStyle(.secondary) if let labels = item.labels, !labels.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Space.s1) { ForEach(labels, id: \.name) { label in HStack(spacing: Space.s1) { Circle().fill(Color.hex(label.color) ?? .neutral).frame(width: 7, height: 7) Text(label.name) } .font(.caption) .padding(.horizontal, Space.s2) .padding(.vertical, Space.s0_5) .overlay(Capsule().stroke(Color.line)) } } } } } } .padding(.vertical, Space.s0_5) } }