johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
import SwiftUI
struct ThreadView: View {
/// How much of a long description shows before Read more.
private static let foldedHeight: CGFloat = 360
/// How much taller than that it must be to fold at all.
private static let foldSlack: CGFloat = 80
/// As much as is drawn folded, far more than `foldedHeight` holds.
private static let foldedLength = 2000
let repo: RepoRef
let uri: String
@Environment(Account.self) private var account
@State private var screen: CGFloat = 0
@State private var expanded = false
@State private var bodyHeight: CGFloat = 0
/// Moved by a write, so the thread is read again as it now stands.
@State private var edits = 0
@State private var composing = false
@State private var reviewing = false
@State private var isPull = false
@State private var failure: String?
@State private var canSetState: Bool?
@State private var state: String?
@State private var title: String?
@State private var author: String?
@State private var reporting: Reporting?
/// What's reported of it: nothing of one's own, or by no one.
private var reportable: Bool {
account.signedIn && author != nil && account.person?.did != author
}
private var canClose: Bool { canSetState == true && state != "merged" }
var body: some View {
Loading(load: { [repo, uri] in
let thread: PullThread = try await Client.shared.query(
"getThread", ["repo": repo.uri, "uri": uri, "filesOnly": "true"])
return thread
}, refresh: edits) { thread in
content(thread)
.onChange(of: thread.item.state, initial: true) {
canSetState = thread.viewer?.setState
state = thread.item.state
title = thread.item.title
isPull = thread.item.kind == "pull"
author = thread.item.author.did
}
.sheet(isPresented: $composing) {
Composer(repo: repo, uri: uri) { edits += 1 }
}
.sheet(isPresented: $reviewing) {
ReviewSheet(repo: repo, uri: uri, author: thread.item.author.did) { edits += 1 }
}
}
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
if account.signedIn {
HStack {
Spacer()
Button {
composing = true
} label: {
SwiftUI.Label { Text("Comment") } icon: { Oct(name: "comment", size: Icon.md) }
.font(.callout.weight(.medium))
}
.floating()
if isPull, state == "open" {
ReviewButton { reviewing = true }
}
}
.padding(.horizontal)
.padding(.bottom, Space.s2)
}
}
.onGeometryChange(for: CGFloat.self, of: \.size.width) { screen = $0 }
.alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) {
Button("OK", role: .cancel) {}
} message: {
Text(failure ?? "")
}
.toolbar {
if let title {
ToolbarItem(placement: .principal) {
VStack(alignment: .leading, spacing: 0) {
Text(title).font(.subheadline.weight(.semibold)).lineLimit(1)
Text(repo.label).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
// A bar doesn't keep it off the buttons beside it when it's long.
.frame(width: titleWidth, alignment: .leading)
}
}
if let url = pageURL {
ToolbarItem(placement: .topBarTrailing) { ShareLink(item: url) }
}
if canClose || reportable {
ToolbarItem(placement: .topBarTrailing) {
Menu {
if canClose {
if state == "open" {
Button("Close", role: .destructive) { setState("closed") }
} else {
Button("Reopen") { setState("open") }
}
}
if reportable {
let what = isPull ? "this pull" : "this issue"
Button("Report \(what)") { reporting = Reporting(subject: uri, what: what) }
}
} label: {
SwiftUI.Label("More", systemImage: "ellipsis")
}
}
}
}
.sheet(item: $reporting) { ReportSheet(reporting: $0) }
}
private func setState(_ next: String) {
Task {
do {
try await Client.shared.send(Writes.setState(repo: repo, subject: uri, state: next))
edits += 1
} catch {
failure = error.localizedDescription
}
}
}
private var pageURL: URL? {
// Its page is named by who wrote it and its key, the two either side
// of the collection, in a private repository's longer URI too.
guard let kind = kind(ofURI: uri) else { return nil }
let parts = uri.split(separator: "/").map(String.init)
guard let at = parts.firstIndex(of: "dev.knotless.\(kind)"), at > 0, at + 1 < parts.count else { return nil }
return URL(string: "https://knotless.dev/\(repo.handle)/\(repo.name)/\(kind)/\(parts[at - 1])/\(parts[at + 1])")
}
/// The screen, less the back button on one side and the bar's buttons on the other.
private var titleWidth: CGFloat {
let buttons: CGFloat = (pageURL == nil ? 0 : 1) + (canClose || reportable ? 1 : 0)
return max(0, screen - 76 - 16 - buttons * 52)
}
private func content(_ thread: PullThread) -> some View {
ScrollView {
VStack(spacing: 0) {
header(thread)
if let changes = thread.changes { changesSection(changes) }
statusSection(thread)
let comments = thread.comments ?? []
if !comments.isEmpty {
ThreadSection("Conversation") {
ForEach(Array(Comment.threaded(comments).enumerated()), id: \.element.id) { at, comment in
if at > 0 { Divider().padding(.leading, Space.s4) }
CommentRow(repo: repo, comment: comment) { edits += 1 }
}
}
}
}
}
.background(Color.groupedPage)
}
/// The links here are only their own words, as a page's are: nothing marks
/// them as rows, and the text around them doesn't respond to a touch.
private func header(_ thread: PullThread) -> some View {
let item = thread.item
return VStack(alignment: .leading, spacing: Space.s3) {
NavigationLink(value: Route.repo(repo)) {
HStack(spacing: Space.s1_5) {
OwnerAvatar(repo: repo, known: item.author.did == repo.did ? item.author.avatar : nil)
Text(repo.handle).lineLimit(1)
Text("/").foregroundStyle(.secondary)
Text(repo.name).lineLimit(1)
}
.font(.subheadline.weight(.medium))
}
.buttonStyle(.plain)
Text(item.title).font(.title2.bold())
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Space.s2) {
StatePill(style: StateStyle(kind: item.kind, state: item.state, draft: item.draft == true))
if let base = thread.base, let head = thread.head?.branch { branches(base, head) }
}
.padding(.horizontal, Space.s4)
}
.padding(.horizontal, -16)
NavigationLink(value: Route.profile(item.author.handle)) {
HStack(spacing: Space.s3) {
Avatar(url: item.author.avatar, name: item.author.handle, did: item.author.did, size: Size.Avatar.large)
Text(item.author.handle).font(.body.weight(.medium)).lineLimit(1)
Text("· \(When.short(item.createdAt))").foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.accessibilityIdentifier("thread-author")
.padding(.top, Space.s1)
body(thread.body, html: thread.html, author: thread.item.author.did)
ReactionBar(repo: repo, subject: uri, reactions: thread.reactions ?? []) { edits += 1 }
}
.padding(Space.s4)
.frame(maxWidth: .infinity, alignment: .leading)
// Runs on up behind the navigation bar, and past a pull down.
.background(Color.card.padding(.top, -1000))
}
private func changesSection(_ changes: Changes) -> some View {
let files = changes.files ?? []
let commits = changes.commits ?? []
return ThreadSection("Changes") {
NavigationLink(value: Route.files(repo, uri: uri)) {
ThreadRow {
RowIcon(name: "oct-file-diff")
} content: {
Text(files.count == 1 ? "1 file changed" : "\(files.count) files changed")
Spacer()
Text("+\(files.reduce(0) { $0 + $1.added })").foregroundStyle(Color.success)
Text("-\(files.reduce(0) { $0 + $1.removed })").foregroundStyle(Color.danger)
RowChevron(name: "chevron-right")
}
}
.buttonStyle(RowPress())
RowDivider()
NavigationLink(value: Route.commits(repo, uri: uri)) {
ThreadRow {
RowIcon(name: "oct-git-commit")
} content: {
Text(commits.count == 1 ? "1 commit" : "\(commits.count) commits")
Spacer()
if let date = commits.last?.date { Text(When.short(date)).foregroundStyle(.secondary) }
RowChevron(name: "chevron-right")
}
}
.buttonStyle(RowPress())
}
}
@ViewBuilder private func statusSection(_ thread: PullThread) -> some View {
let item = thread.item
let reviews = thread.reviews ?? []
let checks = thread.checks ?? []
let merges = item.kind == "pull" && item.state == "open" && account.signedIn
if item.kind == "pull", merges || !reviews.isEmpty || !checks.isEmpty {
ThreadSection("Status") {
reviewsRow(reviews)
if !checks.isEmpty {
RowDivider()
checksRow(checks)
}
if merges {
RowDivider()
MergeSection(repo: repo, pull: uri, base: thread.base ?? "its base", queue: thread.queue, refresh: edits) { edits += 1 }
}
}
}
}
@ViewBuilder private func reviewsRow(_ reviews: [Review]) -> some View {
let standing = reviews.filter { $0.outdated != true }
let asked = standing.filter { $0.verdict == "changes" }.count
let approved = standing.filter { $0.verdict == "approve" }.count
let said = reviews.isEmpty ? "None" : asked > 0 ? "Changes asked" : approved > 0 ? "\(approved) approved" : "Waiting"
let label = ThreadRow {
RowIcon(name: "oct-person")
} content: {
Text("Reviews")
Text("· \(said)").foregroundStyle(.secondary)
Spacer()
}
if reviews.isEmpty {
label
} else {
FoldRow(label: label) {
ForEach(reviews, id: \.reviewer.did) { review in
ThreadRow {
Avatar(url: review.reviewer.avatar, name: review.reviewer.handle, did: review.reviewer.did, size: Size.Avatar.small)
} content: {
Text(review.reviewer.handle).lineLimit(1)
Spacer()
verdict(review.verdict, outdated: review.outdated == true)
}
}
}
}
}
private func checksRow(_ checks: [Check]) -> some View {
let summary = CheckSummary(checks)
return FoldRow(label: ThreadRow {
Oct(name: summary.symbol, size: Icon.xl).foregroundStyle(summary.color)
} content: {
Text(summary.text)
Spacer()
}) {
ForEach(checks) { check in
NavigationLink(value: Route.run(repo, uri: check.uri)) {
ThreadRow {
StatusMark(check: check)
} content: {
Text(check.workflow).lineLimit(1)
Spacer()
Text(RunWords.took(check).map { check.skipped == nil ? $0 : "Skipped" } ?? RunWords.status(check))
.font(.subheadline)
.foregroundStyle(.secondary)
RowChevron(name: "chevron-right")
}
}
.buttonStyle(RowPress())
}
}
}
private func branches(_ base: String, _ head: String) -> some View {
HStack(spacing: Space.s1_5) {
BranchChip(name: base)
Image(systemName: "arrow.left").font(.caption).foregroundStyle(.secondary)
.accessibilityLabel("from")
BranchChip(name: head)
}
}
@ViewBuilder private func body(_ text: String?, html: String?, author: String) -> some View {
if let text, !text.isEmpty {
let folded = !expanded && folds(text)
TickableMarkdown(source: text, html: html, place: MarkdownPlace(repo: repo, subject: uri), repo: repo, subject: uri, author: author, limit: expanded ? nil : Self.foldedLength) { edits += 1 }
// Its whole height and then cut, or its text is squeezed to
// fit and truncates, rather than reading as it does opened.
.fixedSize(horizontal: false, vertical: true)
.onGeometryChange(for: CGFloat.self, of: \.size.height) { bodyHeight = $0 }
.frame(maxHeight: folded ? Self.foldedHeight : nil, alignment: .top)
.clipped()
.mask(LinearGradient(stops: [
.init(color: .black, location: folded ? 0.7 : 1),
.init(color: folded ? .clear : .black, location: 1),
], startPoint: .top, endPoint: .bottom))
.overlay(alignment: .bottom) {
if folded {
Button { expanded = true } label: {
SwiftUI.Label { Text("Read more") } icon: { Oct(name: "arrow-down", size: Icon.md) }.font(.callout.weight(.medium))
}
.floating()
}
}
} else {
Text("No description.").foregroundStyle(.secondary)
}
}
/// Whether folding hides enough to be worth a button. Until it's measured,
/// a guess from its length, so what's going to fold doesn't first draw whole.
private func folds(_ text: String) -> Bool {
if text.count > Self.foldedLength { return true }
return bodyHeight == 0 ? text.count > 800 : bodyHeight > Self.foldedHeight + Self.foldSlack
}
@ViewBuilder private func verdict(_ verdict: String?, outdated: Bool) -> some View {
switch verdict {
case "approve":
SwiftUI.Label { Text(outdated ? "Approved earlier" : "Approved") } icon: { Oct(name: "check-circle-fill") }
.foregroundStyle(outdated ? Color.neutral : Color.success).font(.subheadline)
case "changes":
SwiftUI.Label { Text("Changes asked") } icon: { Oct(name: "alert-fill") }
.foregroundStyle(outdated ? Color.neutral : Color.danger).font(.subheadline)
default:
Text("Waiting").foregroundStyle(.secondary).font(.subheadline)
}
}
}
/// A titled part of a thread, its rows white from edge to edge on the grey behind.
struct ThreadSection<Content: View>: View {
let title: String
@ViewBuilder let content: Content
init(_ title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.title3.bold())
.padding(.horizontal, Space.s4)
.padding(.top, Space.s7)
.padding(.bottom, Space.s3)
VStack(spacing: 0) { content }
.background(Color.card)
.overlay(alignment: .top) { Divider() }
.overlay(alignment: .bottom) { Divider() }
}
}
}
/// A row with its icon in a column, so the words of a section's rows line up.
struct ThreadRow<Icon: View, Content: View>: View {
@ViewBuilder let icon: Icon
@ViewBuilder let content: Content
var body: some View {
HStack(spacing: Space.s3) {
icon.frame(width: 24)
HStack(spacing: Space.s1_5) { content }
}
.padding(.horizontal, Space.s4)
.padding(.vertical, Space.s3_5)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}
struct RowDivider: View {
var body: some View { Divider().padding(.leading, Size.smallRowInset) }
}
struct RowIcon: View {
let name: String
var body: some View {
Image(name).renderingMode(.template).resizable().scaledToFit()
.frame(width: 20, height: 20)
.foregroundStyle(.secondary)
}
}
struct RowChevron: View {
let name: String
var body: some View {
Oct(name: name, size: Icon.sm).foregroundStyle(.tertiary)
}
}
/// A row that opens to show more under it.
struct FoldRow<Label: View, Detail: View>: View {
let label: Label
@ViewBuilder let detail: Detail
@State private var open = false
var body: some View {
VStack(spacing: 0) {
Button {
withAnimation(.snappy(duration: 0.2)) { open.toggle() }
} label: {
HStack(spacing: 0) {
label
RowChevron(name: open ? "chevron-up" : "chevron-down").padding(.trailing, Space.s4)
}
.contentShape(Rectangle())
}
.buttonStyle(RowPress())
if open { detail }
}
}
}
/// A whole row as a link or button, shaded only while it's held.
struct RowPress: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.primary)
.background(configuration.isPressed ? Color.fill : .clear)
}
}
extension View {
/// A button that floats over the page: glass where the system has it.
@ViewBuilder func floating() -> some View {
if #available(iOS 26, *) {
buttonStyle(.glass).buttonBorderShape(.capsule).controlSize(.large).tint(.primary)
} else {
buttonStyle(.bordered).buttonBorderShape(.capsule).controlSize(.large).tint(.primary)
}
}
}
struct CommentRow: View {
let repo: RepoRef
let comment: Comment
let changed: () -> Void
@Environment(Account.self) private var account
@State private var reporting: Reporting?
@State private var editing = false
@State private var deleting = false
@State private var failure: String?
private var mine: Bool { account.signedIn && account.person?.did == comment.author.did }
var body: some View {
VStack(alignment: .leading, spacing: Space.s2) {
HStack(spacing: Space.s2) {
NavigationLink(value: Route.profile(comment.author.handle)) {
HStack(spacing: Space.s2) {
Avatar(url: comment.author.avatar, name: comment.author.handle, did: comment.author.did, size: Size.Avatar.small)
Text(comment.author.handle).font(.subheadline.weight(.medium)).lineLimit(1)
}
}
.buttonStyle(.plain)
Text(When.short(comment.createdAt)).font(.subheadline).foregroundStyle(.secondary)
if comment.verdict == "approve" {
Oct(name: "check-circle-fill").foregroundStyle(Color.success).accessibilityLabel("Approved")
} else if comment.verdict == "changes" {
Oct(name: "alert-fill").foregroundStyle(Color.danger).accessibilityLabel("Asked for changes")
}
if account.signedIn, let uri = comment.uri {
Spacer(minLength: 0)
Menu {
if mine {
Button("Edit") { editing = true }
Button("Delete", role: .destructive) { deleting = true }
} else {
Button("Report this comment") { reporting = Reporting(subject: uri, what: "this comment") }
}
} label: {
Oct(name: "kebab-horizontal")
.foregroundStyle(.secondary)
.frame(width: Size.Avatar.small, height: Size.Avatar.small)
.contentShape(Rectangle())
}
.accessibilityLabel("More about this comment")
}
}
// An answer sits under the lines its thread starts with.
if let lines = comment.line, comment.reply == nil {
LineQuote(at: lines, resolved: comment.resolved == true)
}
if let body = comment.body, !body.isEmpty {
if let uri = comment.uri {
TickableMarkdown(source: body, html: comment.html, place: MarkdownPlace(repo: repo, subject: uri), repo: repo, subject: uri, author: comment.author.did, changed: changed)
} else {
MarkdownView(source: body, html: comment.html, place: MarkdownPlace(repo: repo, subject: comment.uri))
}
}
if let uri = comment.uri {
ReactionBar(repo: repo, subject: uri, reactions: comment.reactions ?? [], changed: changed)
}
}
.padding(.vertical, Space.s4)
.padding(.trailing, Space.s4)
.padding(.leading, comment.reply == nil ? 16 : 40)
.frame(maxWidth: .infinity, alignment: .leading)
.sheet(item: $reporting) { ReportSheet(reporting: $0) }
.sheet(isPresented: $editing) {
if let uri = comment.uri {
Composer(repo: repo, uri: uri, editing: comment.body ?? "", sent: changed)
}
}
.confirmationDialog("Delete this comment for good?", isPresented: $deleting, titleVisibility: .visible) {
Button("Delete", role: .destructive) { Task { await delete() } }
}
.alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) {
Button("OK", role: .cancel) {}
} message: {
Text(failure ?? "")
}
}
private func delete() async {
guard let uri = comment.uri else { return }
do {
try await Client.shared.send(Writes.deleteComment(repo: repo, subject: uri))
changed()
} catch {
failure = error.localizedDescription
}
}
}
/// A repository's owner's picture. A thread's answer doesn't name it, so unless
/// its author is the owner it's asked for, once while the app runs. A plain
/// ground until the answer, as the identicon would flash first.
private struct OwnerAvatar: View {
let repo: RepoRef
let known: String?
@State private var answered = false
@State private var asked: String?
private var url: String? { known ?? asked ?? Faces.address(of: repo.did) }
var body: some View {
Group {
if url != nil || answered {
Avatar(url: url, name: repo.handle, did: repo.did, size: Size.Avatar.small)
} else {
Circle().fill(Color.fill).frame(width: 24, height: 24)
}
}
.task(id: repo.did) {
guard url == nil else { return }
let profile: Profile? = try? await Client.shared.query("getProfile", ["actor": repo.did])
guard !Task.isCancelled else { return }
if let avatar = profile?.avatar { Faces.keep(address: avatar, of: repo.did) }
asked = profile?.avatar
answered = true
}
}
}
/// The lines of a pull's diff a comment is on, as they were when it was
/// written: those it names marked, and for one alone a few before it.
private struct LineQuote: View {
let at: Comment.Lines
let resolved: Bool
private var first: Int { at.startLine ?? at.line }
private var place: String {
at.startLine.map { "\(at.path):\($0)-\(at.line)" } ?? "\(at.path):\(at.line)"
}
private var mark: Color { at.side == "old" ? .danger : .success }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: Space.s2) {
Text(place).font(.footnote.monospaced()).lineLimit(1).truncationMode(.head)
Spacer(minLength: 4)
if resolved { Text("Resolved").font(.caption).foregroundStyle(.secondary).fixedSize() }
if at.outdated { Text("Outdated").font(.caption).foregroundStyle(.secondary).fixedSize() }
}
.padding(.horizontal, Space.s2_5)
.padding(.vertical, Space.s2)
if let lines = at.lines, !lines.isEmpty {
Divider()
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(lines, id: \.number) { line in
HStack(spacing: Space.s2_5) {
Text(String(line.number)).foregroundStyle(.secondary)
.frame(minWidth: 28, alignment: .trailing)
Text(line.text).fixedSize()
Spacer(minLength: 0)
}
.font(.caption.monospaced())
.padding(.vertical, Space.s0_5)
.padding(.trailing, Space.s2_5)
.background(line.number >= first ? mark.opacity(0.18) : .clear)
}
}
.frame(minWidth: 0)
}
} else {
Divider()
Text("These lines can no longer be read.").font(.footnote).foregroundStyle(.secondary)
.padding(.horizontal, Space.s2_5).padding(.vertical, Space.s2)
}
}
.background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.medium))
.clipShape(RoundedRectangle(cornerRadius: Radius.medium))
}
}
struct CommitsView: View {
let repo: RepoRef
let uri: String
private struct Answer: Sendable { let home: RepoRef; let commits: [Commit] }
var body: some View {
Loading(load: { [repo, uri] in
let thread: PullThread = try await Client.shared.query(
"getThread", ["repo": repo.uri, "uri": uri, "filesOnly": "true"])
return Answer(home: thread.changes?.repo?.ref ?? repo, commits: thread.changes?.commits ?? [])
}) { answer in
List { ForEach(answer.commits) { CommitRow(repo: answer.home, commit: $0) }.listRowBackground(Color.page) }
.listStyle(.plain)
}
.navigationTitle("Commits")
.navigationBarTitleDisplayMode(.inline)
}
}