johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
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)
}
}
}