johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
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<Content: View>: 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<Trailing: View>: 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<Trailing: View>: 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<Item: Identifiable>(_ 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<Item: Identifiable & Equatable & Sendable, Row: View>: 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<Commit, CommitRow>(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<Release, AnyView>(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)
}
}