~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
671 lines · 27.9 KB Raw History
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)
    }
}