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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
442 lines · 16.6 KB Raw History
import SwiftUI

/// Writes a comment, or with `editing`, changes one's body: `uri` is then the comment's.
struct Composer: View {
    let repo: RepoRef
    let uri: String
    var editing: String? = nil
    let sent: () -> Void

    @Environment(\.dismiss) private var dismiss
    @State private var text = ""
    @State private var sending = false
    @State private var failure: String?
    @FocusState private var focused: Bool

    private var empty: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }

    var body: some View {
        NavigationStack {
            VStack(spacing: Space.s3) {
                TextEditor(text: $text)
                    .focused($focused)
                    .accessibilityLabel("Your comment")
                    .overlay(alignment: .topLeading) {
                        if empty {
                            Text("Write in Markdown")
                                .foregroundStyle(.tertiary)
                                .padding(.top, Space.s2)
                                .padding(.leading, Space.s1_5)
                                .allowsHitTesting(false)
                        }
                    }
                if let failure {
                    Text(failure).font(.footnote).foregroundStyle(Color.danger)
                        .frame(maxWidth: .infinity, alignment: .leading)
                }
            }
            .padding()
            .navigationTitle(editing == nil ? "Comment" : "Edit comment")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button(editing == nil ? "Send" : "Save") { Task { await send() } }
                        .disabled(sending || empty)
                }
            }
            .onAppear {
                if let editing, text.isEmpty { text = editing }
                focused = true
            }
            .interactiveDismissDisabled(editing == nil ? !empty : text != editing)
        }
    }

    private func send() async {
        sending = true
        failure = nil
        defer { sending = false }
        do {
            if editing != nil {
                try await Client.shared.send(Writes.edit(repo: repo, subject: uri, body: text))
            } else {
                try await Client.shared.send(Writes.comment(repo: repo, subject: uri, body: text, verdict: nil))
            }
            sent()
            dismiss()
        } catch {
            failure = error.localizedDescription
        }
    }
}

/// The green button that opens a pull's review.
struct ReviewButton: View {
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            SwiftUI.Label { Text("Review") } icon: { Oct(name: "check", size: Icon.md) }
                .font(.callout.weight(.medium))
                .foregroundStyle(Color.onAction)
        }
        .buttonStyle(.borderedProminent)
        .buttonBorderShape(.capsule)
        .controlSize(.large)
        .tint(Color.action)
    }
}

/// Writes a pull's review. Its author can only comment, as readers take no
/// verdict from them.
struct ReviewSheet: View {
    private enum Choice: CaseIterable, Identifiable {
        case comment, approve, changes
        var id: Self { self }

        var title: String {
            switch self {
            case .comment: "Comment"
            case .approve: "Approve"
            case .changes: "Request changes"
            }
        }

        var verdict: String? {
            switch self {
            case .comment: nil
            case .approve: "approve"
            case .changes: "changes"
            }
        }

        func about(own: Bool) -> String {
            switch self {
            case .comment: "Leave feedback without a verdict."
            case .approve: own ? "You can't approve your own pull." : "Say these changes are good to merge."
            case .changes: own ? "You can't request changes on your own pull." : "Say what must change before this merges."
            }
        }
    }

    let repo: RepoRef
    let uri: String
    /// The DID of the pull's author.
    let author: String
    let sent: () -> Void

    @Environment(Account.self) private var account
    @Environment(\.dismiss) private var dismiss
    @State private var text = ""
    @State private var choice = Choice.comment
    @State private var sending = false
    @State private var failure: String?

    private var own: Bool { account.person?.did == author }
    private var empty: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
    /// A review may say nothing past its verdict. A comment says something.
    private var ready: Bool { !sending && (choice != .comment || !empty) }

    private var action: String {
        switch choice {
        case .comment: "Submit review"
        default: choice.title
        }
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: Space.s3) {
                    TextField("Leave a comment", text: $text, axis: .vertical)
                        .lineLimit(1...8)
                        .accessibilityLabel("Your review")
                        .padding(Space.s4)
                        .background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.card))
                        .padding(.bottom, Space.s2)
                    ForEach(Choice.allCases) { option($0) }
                    if let failure {
                        Text(failure).font(.footnote).foregroundStyle(Color.danger)
                            .frame(maxWidth: .infinity, alignment: .leading)
                    }
                    Button { Task { await send() } } label: {
                        Text(action)
                            .font(.body.weight(.semibold))
                            .foregroundStyle(ready ? AnyShapeStyle(Color.onAction) : AnyShapeStyle(.secondary))
                            .frame(maxWidth: .infinity)
                            .padding(.vertical, Space.s4)
                            .background(Color.action.opacity(ready ? 1 : 0.25), in: RoundedRectangle(cornerRadius: Radius.card))
                    }
                    .buttonStyle(.plain)
                    .disabled(!ready)
                    .padding(.top, Space.s2)
                }
                .padding(Space.s4)
            }
            .navigationTitle("Review")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button { dismiss() } label: { Oct(name: "x", size: Icon.xl) }
                        .tint(.primary)
                        .accessibilityLabel("Close")
                }
            }
            .interactiveDismissDisabled(!empty)
        }
    }

    private func option(_ option: Choice) -> some View {
        let open = option == .comment || !own
        return Button { choice = option } label: {
            HStack(spacing: Space.s3) {
                VStack(alignment: .leading, spacing: Space.s1) {
                    Text(option.title).font(.body.weight(.medium)).foregroundStyle(open ? .primary : .secondary)
                    Text(option.about(own: own)).font(.subheadline).foregroundStyle(.secondary)
                }
                Spacer(minLength: 0)
                if choice == option {
                    Oct(name: "check-circle-fill", size: Icon.xl).foregroundStyle(Color.accent)
                }
            }
            .padding(Space.s4)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.card))
            .contentShape(RoundedRectangle(cornerRadius: Radius.card))
        }
        .buttonStyle(.plain)
        .disabled(!open)
        .accessibilityAddTraits(choice == option ? .isSelected : [])
    }

    private func send() async {
        sending = true
        failure = nil
        defer { sending = false }
        do {
            try await Client.shared.send(Writes.comment(repo: repo, subject: uri, body: text, verdict: choice.verdict))
            sent()
            dismiss()
        } catch {
            failure = error.localizedDescription
        }
    }
}

/// How people reacted, and adding or taking back one's own.
struct ReactionBar: View {
    private static let all = [
        ("+1", "👍"), ("-1", "👎"), ("laugh", "😄"), ("hooray", "🎉"),
        ("confused", "😕"), ("heart", "❤️"), ("rocket", "🚀"), ("eyes", "👀"),
    ]

    @Environment(Account.self) private var account
    let repo: RepoRef
    let subject: String
    let reactions: [ReactionCount]
    let changed: () -> Void
    @State private var failure: String?
    /// What a touch made of them, until the server's answer replaces it.
    @State private var touched: [ReactionCount]?

    private var shown: [ReactionCount] { touched ?? reactions }

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: Space.s1_5) {
                if account.signedIn {
                    Menu {
                        ForEach(Self.all, id: \.0) { content, emoji in
                            Button(emoji) { toggle(content, mine: mine(content)) }
                        }
                    } label: {
                        Oct(name: "smiley", size: Icon.lg)
                            .padding(.horizontal, Space.s2)
                            .padding(.vertical, Space.s1_5)
                            .background(Color.fill, in: Capsule())
                    }
                    .accessibilityLabel("Add a reaction")
                }
                ForEach(shown.filter { $0.count > 0 }, id: \.content) { reaction in
                    Button {
                        toggle(reaction.content, mine: reaction.reaction != nil)
                    } label: {
                        Text("\(emoji(reaction.content)) \(reaction.count)")
                            .font(.subheadline)
                            .foregroundStyle(.primary)
                            .padding(.horizontal, Space.s2)
                            .padding(.vertical, Space.s1)
                            .background(reaction.reaction != nil ? Color.accentSoft : Color.fill, in: Capsule())
                    }
                    .buttonStyle(.borderless)
                    .disabled(!account.signedIn)
                }
            }
        }
        .onChange(of: reactions) { touched = nil }
        .alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) {
            Button("OK", role: .cancel) {}
        } message: {
            Text(failure ?? "")
        }
    }

    private func emoji(_ content: String) -> String { Self.all.first { $0.0 == content }?.1 ?? content }
    private func mine(_ content: String) -> Bool { shown.first { $0.content == content }?.reaction != nil }

    private func toggle(_ content: String, mine: Bool) {
        var next = shown
        if let at = next.firstIndex(where: { $0.content == content }) {
            next[at].count += mine ? -1 : 1
            next[at].reaction = mine ? nil : "mine"
        } else {
            next.append(ReactionCount(content: content, count: 1, reaction: "mine"))
        }
        touched = next
        Task {
            do {
                try await Client.shared.send(Writes.react(repo: repo, subject: subject, content: content, undo: mine))
                changed()
            } catch {
                touched = nil
                failure = error.localizedDescription
            }
        }
    }
}

/// What can happen to an open pull now, asked of the server rather than worked
/// out here, with the buttons for what the asker may do.
struct MergeSection: View {
    let repo: RepoRef
    let pull: String
    let base: String
    let queue: QueueInfo?
    /// Moved when the thread is read again, as whether it can merge may be different.
    let refresh: Int
    let changed: () -> Void

    @State private var status: MergeStatus?
    @State private var loadFailed = false
    @State private var confirming = false
    /// The write under way, until the thread read after it shows the change.
    @State private var doing: String?
    @State private var failure: String?

    private var busy: Bool { doing != nil }
    private var queued: Bool { queue?.state == "queued" }

    var body: some View {
        VStack(alignment: .leading, spacing: Space.s3) {
            if let queue, queued {
                said(Standing(title: queue.said, icon: "clock", tone: .waiting))
                BigButton(title: doing == "leaving" ? "Leaving…" : "Leave the queue", role: .destructive, disabled: busy) { act("leaving", Writes.leaveQueue(repo: repo, pull: pull)) }
            } else if let status {
                // One the queue turned away can be queued again, once fixed.
                if let queue {
                    said(.ejected(reason: queue.reason))
                }
                said(.of(status, base: base))
                let merges = Standing.canMerge(status)
                if merges {
                    BigButton(title: doing == "merging" ? "Merging…" : "Merge pull", main: true, disabled: busy) { confirming = true }
                        .confirmationDialog("Merge this pull?", isPresented: $confirming, titleVisibility: .visible) {
                            Button("Merge pull") { act("merging", Writes.merge(repo: repo, pull: pull)) }
                        }
                }
                if Standing.canQueue(status) {
                    BigButton(title: doing == "adding" ? "Adding…" : "Add to merge queue", main: !merges, disabled: busy) { act("adding", Writes.queue(repo: repo, pull: pull)) }
                }
            } else if loadFailed {
                Text("Can't say whether this can merge.").foregroundStyle(.secondary)
            } else {
                ProgressView().frame(maxWidth: .infinity)
            }
            if let failure {
                Text(failure).font(.footnote).foregroundStyle(Color.danger).padding(.leading, Space.s9)
            }
        }
        .padding(Space.s4)
        .frame(maxWidth: .infinity, alignment: .leading)
        .onChange(of: queue) { doing = nil }
        .task(id: "\(queued) \(refresh)") {
            if queued { return }
            do {
                status = try await Client.shared.query("getMergeStatus", ["repo": repo.uri, "pull": pull])
            } catch is CancellationError {
            } catch {
                loadFailed = true
            }
        }
    }

    private func said(_ it: Standing) -> some View {
        HStack(alignment: .top, spacing: Space.s3) {
            Oct(name: it.icon, size: Icon.xl).foregroundStyle(color(it.tone)).frame(width: 24)
            VStack(alignment: .leading, spacing: Space.s1) {
                Text(it.title)
                ForEach(it.lines, id: \.self) { line in
                    Text(line).font(.subheadline).foregroundStyle(.secondary)
                }
            }
        }
    }

    private func color(_ tone: Standing.Tone) -> Color {
        switch tone {
        case .good: .success
        case .waiting: .attention
        case .bad: .danger
        case .neutral: .neutral
        }
    }

    private func act(_ what: String, _ write: Write) {
        doing = what
        failure = nil
        Task {
            do {
                try await Client.shared.send(write)
                changed()
                // The change usually shows sooner, and ends this: see onChange.
                try? await Task.sleep(for: .seconds(3))
            } catch {
                failure = error.localizedDescription
            }
            doing = nil
        }
    }
}

/// A row's button, as wide as its words, indented under the icon beside them.
/// The one marked `main` is what lands the pull next.
private struct BigButton: View {
    let title: String
    var role: ButtonRole?
    var main = false
    let disabled: Bool
    let action: () -> Void

    var body: some View {
        Group {
            if main {
                button.buttonStyle(.borderedProminent).tint(Color.action).foregroundStyle(Color.onAction)
                    .buttonBorderShape(.roundedRectangle(radius: 14))
            } else {
                button.quietButton(large: true, radius: 14)
            }
        }
        .controlSize(.large)
        .disabled(disabled)
        .padding(.leading, Space.s9)
    }

    private var button: some View {
        Button(role: role, action: action) {
            Text(title).font(.body.weight(.medium)).frame(maxWidth: .infinity)
        }
    }
}