Files
byre/mac/Sources/Byre/MergeDialog.swift
T

302 lines
12 KiB
Swift

import ByreAPI
import SwiftUI
typealias Participant = Components.Schemas.Participant
typealias MergeFacts = Components.Schemas.MergeFacts
@MainActor
private var mergeDrafts: [String: String] = [:]
private let maxTitle = 72
typealias Leave = @MainActor (@escaping @MainActor () -> Void) -> Void
struct MergeWarning {
let text: String
var open: (@MainActor () -> Void)?
var detail = ""
}
@MainActor
@Observable
final class MergeModel {
let taskId: String
let target: String
let keepOpen: Bool
let parentTitle: String?
let original: String
var title: String
var editing = false
var toParent: Bool
@ObservationIgnored var leave: Leave = { _ in }
init(taskId: String, target: String, keepOpen: Bool, parentTitle: String?, title: String) {
self.taskId = taskId
self.target = target
self.keepOpen = keepOpen
self.parentTitle = parentTitle
original = title
self.title = title
toParent = parentTitle != nil
}
var valid: Bool {
toParent || !trimmed(title).isEmpty
}
var sentTitle: String {
editing ? title : original
}
}
@MainActor
func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays, keepOpen: Bool? = nil) async {
guard let task = store.findTask(taskId), task.status == .active else {
return
}
let keepOpen = keepOpen ?? (task.readyMerge == ._continue)
if store.taskBusy(taskId) {
overlays.showAlert("Agent is mid-turn; merge when it's idle")
return
}
if task.mergeQueuedAt != nil {
overlays.showAlert("Merge already held behind another merge in this project")
return
}
if task.mergingIntoParent {
overlays.showAlert("Already waiting for the parent task's agent to merge this subtask")
return
}
let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch"
if task.mergeFacts.unmergedCommits == 0 && task.mergeFacts.unmergedFiles == 0 {
overlays.showAlert("Nothing to merge: no commits or uncommitted files differ from \(target)")
return
}
let parent = task.workflowStage == nil ? task.parentTaskId.flatMap { store.findTask($0) } : nil
let title = mergeDrafts[taskId] ?? task.mergeTitle
mergeDrafts[taskId] = nil
let model = MergeModel(taskId: taskId, target: target, keepOpen: keepOpen, parentTitle: !keepOpen && parent?.status == .active ? parent?.name : nil, title: title)
model.leave = { [weak model] go in
if let model {
mergeDrafts[taskId] = model.sentTitle
}
overlays.answerModal("cancel")
go()
}
let content = MergeContent(model: model)
let choice = await overlays.showModal(content, keepOpen
? [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & continue", value: "now", primary: true)]
: store.findTask(taskId)?.mergeFacts.reviewRunning == true
? [
ModalButton(label: "Cancel", value: "cancel"),
ModalButton(label: "Merge & complete now", value: "now"),
ModalButton(label: "Merge & complete after review", value: "after", primary: true, help: "Merge once the running review finishes with no findings"),
]
: [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & complete", value: "now", primary: true)],
inPane: true, canSubmit: { model.valid })
if choice != "now" && choice != "after" {
return
}
let afterReview = choice == "after"
let body: Operations.MergeTask.Input.Body.JsonPayload = model.toParent
? .init(target: .parent, afterReview: afterReview ? true : nil)
: .init(mergeTitle: model.sentTitle, afterReview: afterReview ? true : nil, keepOpen: keepOpen ? true : nil)
do {
_ = try await store.api.client.mergeTask(path: .init(taskId: taskId), body: .json(body)).accepted
} catch {
overlays.showAlert(errorText(error))
}
}
@MainActor
private func reviewWarnings(_ taskId: String, _ f: MergeFacts, _ store: Store, _ leave: @escaping Leave) -> [MergeWarning] {
if f.reviewSnapshot == 0 {
return [MergeWarning(text: "These changes haven't been reviewed")]
}
var items: [MergeWarning] = []
let open: @MainActor () -> Void = {
leave {
store.jumpTo(taskId, .review(f.reviewSnapshot))
}
}
if f.reviewCount == 0 {
items.append(MergeWarning(text: "The latest snapshot hasn't been reviewed", open: open))
}
if f.reviewRunning {
items.append(MergeWarning(text: "The review of the latest snapshot is still running", open: open))
}
if f.reviewFailed {
items.append(MergeWarning(text: "The review of the latest snapshot failed", open: open))
}
if f.reviewFindings > 0 {
items.append(MergeWarning(text: "The review of the latest snapshot has \(f.reviewFindings) open finding\(f.reviewFindings == 1 ? "" : "s")", open: open))
}
if f.unreviewedChanges {
items.append(MergeWarning(text: "Changes since the last snapshot haven't been reviewed"))
}
return items
}
private func coAuthorsLabel(_ coAuthors: [Participant]) -> String {
let missing = coAuthors.filter { $0.gitName.isEmpty || $0.gitEmail.isEmpty }.count
return "\(coAuthors.count) co-author\(coAuthors.count == 1 ? "" : "s")\(missing > 0 ? " (\(missing) without git identity)" : "")"
}
private func mergeTrailers(_ coAuthors: [Participant]) -> String {
var lines: [String] = []
let credited = coAuthors.filter { !$0.gitName.isEmpty && !$0.gitEmail.isEmpty }
let missing = coAuthors.filter { $0.gitName.isEmpty || $0.gitEmail.isEmpty }
if !credited.isEmpty {
lines.append("Co-authored-by:")
lines += credited.map { " \($0.gitName) <\($0.gitEmail)>" }
}
if !missing.isEmpty {
if !lines.isEmpty {
lines.append("")
}
lines.append("No git identity, so no trailer:")
lines += missing.map { " \($0.displayName) (\($0.login))" }
}
return lines.joined(separator: "\n")
}
private struct MergeContent: View {
@Bindable var model: MergeModel
@Environment(Store.self) private var store
@FocusState private var focused: Bool
var body: some View {
let taskId = model.taskId
let leave = model.leave
let facts = store.findTask(taskId)?.mergeFacts
let coAuthors = facts?.participants.filter { $0.login != store.me?.login } ?? []
let questions = store.inbox.filter { $0.taskId == taskId }.count
var warnings = facts.map { reviewWarnings(taskId, $0, store, leave) } ?? []
if questions > 0 {
warnings.append(MergeWarning(text: questions == 1 ? "The agent's open question will be dismissed" : "The agent's \(questions) open questions will be dismissed", open: {
leave {
store.showQuestion(taskId)
}
}))
}
return VStack(alignment: .leading, spacing: 0) {
if let parentTitle = model.parentTitle {
VStack(alignment: .leading, spacing: 8) {
option(true, "Into \(parentTitle)", "Its agent merges this subtask's branch into the parent's branch; the subtask closes once it has.")
option(false, "Into \(model.target)", "Squashed into one commit on the default branch and pushed, like any task.")
}
.padding(.bottom, 16)
}
if !model.toParent {
Text(model.keepOpen ? "Merge the work so far into \(model.target), keeping the task open, as:" : "Merge into \(model.target) as:")
.foregroundStyle(Palette.muted)
.padding(.bottom, 4)
titleRow
.padding(.bottom, 16)
}
if !warnings.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(warnings.enumerated()), id: \.offset) { _, w in
warningRow(w)
}
}
.foregroundStyle(Palette.warn)
.padding(.bottom, 16)
}
if !model.toParent && !coAuthors.isEmpty {
DetailsBox(boxed: false) {
Text(coAuthorsLabel(coAuthors))
.foregroundStyle(Palette.muted)
} content: {
ModalText(text: mergeTrailers(coAuthors))
.padding(.top, 8)
}
.padding(.bottom, 16)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var titleRow: some View {
if model.editing {
TextField("", text: Binding(get: { model.title }, set: { model.title = String($0.prefix(maxTitle)) }))
.modifier(FieldStyle(focused: focused))
.focused($focused)
.accessibilityLabel("Commit title")
.onAppear {
focused = true
}
} else {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(model.title)
.font(Fonts.font(13, weight: 700))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
Button("✎") {
model.editing = true
}
.buttonStyle(.plain)
.foregroundStyle(Palette.muted)
.padding(.horizontal, 6)
.help("Edit the commit title")
.accessibilityLabel("Edit the commit title")
}
}
}
private func option(_ value: Bool, _ label: String, _ hint: String) -> some View {
Button {
model.toParent = value
} label: {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: model.toParent == value ? "largecircle.fill.circle" : "circle")
.foregroundStyle(model.toParent == value ? Palette.accent : Palette.muted)
VStack(alignment: .leading, spacing: 0) {
Text(label)
Text(hint)
.font(Fonts.font(12))
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private func warningRow(_ w: MergeWarning) -> some View {
if let open = w.open {
HStack(alignment: .firstTextBaseline, spacing: 0) {
Text("⚠ ")
Button(action: open) {
Text(w.text)
.underline()
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
.buttonStyle(.plain)
}
} else {
Text("⚠ \(w.text)")
.fixedSize(horizontal: false, vertical: true)
.help(w.detail)
}
}
}
@MainActor
func confirmDiscard(_ task: ByreTask, _ store: Store, _ overlays: Overlays) async {
if !(await overlays.showConfirm(["Discard \"\(task.name)\"?"], action: "Discard", danger: true, inPane: true)) {
return
}
do {
_ = try await store.api.client.discardTask(path: .init(taskId: task.id)).accepted
try await store.setFocus(taskId: .some(nil))
} catch {
overlays.showAlert(errorText(error))
}
}