macOS client overlays, menus and notifications
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
import SwiftUI
|
||||
|
||||
typealias Participant = Components.Schemas.Participant
|
||||
|
||||
@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 parentTitle: String?
|
||||
let coAuthors: [Participant]
|
||||
let original: String
|
||||
var title: String
|
||||
var editing = false
|
||||
var toParent: Bool
|
||||
var reviewItems: [MergeWarning] = []
|
||||
@ObservationIgnored var running = false
|
||||
@ObservationIgnored var leave: Leave = { _ in }
|
||||
@ObservationIgnored private var loads = 0
|
||||
|
||||
init(taskId: String, target: String, parentTitle: String?, coAuthors: [Participant], title: String) {
|
||||
self.taskId = taskId
|
||||
self.target = target
|
||||
self.parentTitle = parentTitle
|
||||
self.coAuthors = coAuthors
|
||||
original = title
|
||||
self.title = title
|
||||
toParent = parentTitle != nil
|
||||
}
|
||||
|
||||
var valid: Bool {
|
||||
toParent || !trimmed(title).isEmpty
|
||||
}
|
||||
|
||||
var sentTitle: String {
|
||||
editing ? title : original
|
||||
}
|
||||
|
||||
func load(_ store: Store) async {
|
||||
loads += 1
|
||||
let started = loads
|
||||
var items: [MergeWarning]
|
||||
var isRunning = false
|
||||
do {
|
||||
(items, isRunning) = try await reviewWarnings(taskId, store, leave)
|
||||
} catch {
|
||||
items = [MergeWarning(text: "Review status unavailable", detail: errorText(error))]
|
||||
}
|
||||
if started == loads {
|
||||
reviewItems = items
|
||||
running = isRunning
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
loads += 1
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async {
|
||||
guard let task = store.findTask(taskId), task.status == .active else {
|
||||
return
|
||||
}
|
||||
if store.currentSession(taskId)?.state == .running {
|
||||
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"
|
||||
let parent = task.parentTaskId.flatMap { store.findTask($0) }
|
||||
let coAuthors: [Participant]
|
||||
do {
|
||||
let client = store.api.client
|
||||
async let unmerged = client.getUnmerged(path: .init(taskId: taskId)).ok.body.json
|
||||
async let people = client.getCoAuthors(path: .init(taskId: taskId)).ok.body.json
|
||||
let (u, p) = try await (unmerged, people)
|
||||
if u.commits == 0 && u.files == 0 {
|
||||
overlays.showAlert("Nothing to merge: no commits or uncommitted files differ from \(target)")
|
||||
return
|
||||
}
|
||||
coAuthors = p
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
return
|
||||
}
|
||||
let title = mergeDrafts[taskId] ?? task.mergeTitle
|
||||
mergeDrafts[taskId] = nil
|
||||
let model = MergeModel(taskId: taskId, target: target, parentTitle: parent?.status == .active ? parent?.title : nil, coAuthors: coAuthors, title: title)
|
||||
model.leave = { [weak model] go in
|
||||
if let model {
|
||||
mergeDrafts[taskId] = model.sentTitle
|
||||
}
|
||||
overlays.answerModal("cancel")
|
||||
go()
|
||||
}
|
||||
let off = store.events.add { ev in
|
||||
if ev.taskId == taskId && (ev.kind == "snapshot.created" || isReviewEvent(ev)) {
|
||||
Task {
|
||||
await model.load(store)
|
||||
}
|
||||
}
|
||||
}
|
||||
defer {
|
||||
model.stop()
|
||||
off()
|
||||
}
|
||||
await model.load(store)
|
||||
let content = MergeContent(model: model)
|
||||
let merge = { (afterReview: Bool) in
|
||||
let body: Operations.MergeTask.Input.Body.JsonPayload = model.toParent
|
||||
? .init(target: .parent, afterReview: afterReview ? true : nil)
|
||||
: .init(mergeTitle: model.sentTitle, afterReview: afterReview ? true : nil)
|
||||
do {
|
||||
_ = try await store.api.client.mergeTask(path: .init(taskId: taskId), body: .json(body)).accepted
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
}
|
||||
let anchor = overlays.mergeFrame
|
||||
if !model.running {
|
||||
if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge", value: "ok", primary: true)], anchor: anchor, canSubmit: { model.valid }) == "ok" {
|
||||
await merge(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
let choice = await overlays.showModal(content, [
|
||||
ModalButton(label: "Cancel", value: "cancel"),
|
||||
ModalButton(label: "Merge now", value: "now"),
|
||||
ModalButton(label: "After review", value: "after", primary: true, help: "Merge once the running review finishes with no findings"),
|
||||
], anchor: anchor, canSubmit: { model.valid })
|
||||
if choice == "now" || choice == "after" {
|
||||
await merge(choice == "after")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func reviewWarnings(_ taskId: String, _ store: Store, _ leave: @escaping Leave) async throws -> ([MergeWarning], Bool) {
|
||||
let client = store.api.client
|
||||
async let snapsCall = client.listSnapshots(path: .init(taskId: taskId)).ok.body.json
|
||||
async let reviewsCall = client.listReviews(path: .init(taskId: taskId)).ok.body.json
|
||||
let (snaps, reviews) = try await (snapsCall, reviewsCall)
|
||||
guard let latest = snaps.last else {
|
||||
return ([MergeWarning(text: "These changes haven't been reviewed")], false)
|
||||
}
|
||||
var items: [MergeWarning] = []
|
||||
let open: @MainActor () -> Void = {
|
||||
leave {
|
||||
store.focusTask(taskId, .reviews)
|
||||
store.pickReview(latest.n)
|
||||
}
|
||||
}
|
||||
let r = reviews.first { $0.snapshotN == latest.n }
|
||||
if let r {
|
||||
let found = unignored(r)
|
||||
switch r.state {
|
||||
case .running:
|
||||
items.append(MergeWarning(text: "Review of the latest snapshot is still running", open: open))
|
||||
case .failed:
|
||||
items.append(MergeWarning(text: "Review of the latest snapshot failed", open: open))
|
||||
default:
|
||||
if !found.isEmpty {
|
||||
items.append(MergeWarning(text: "Review of the latest snapshot found \(severityCounts(found))", open: open))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items.append(MergeWarning(text: "The latest snapshot hasn't been reviewed", open: open))
|
||||
}
|
||||
let changes = try await client.getTaskChanges(path: .init(taskId: taskId), query: .init(since: latest.commitSha)).ok.body.json
|
||||
if !changes.files.isEmpty {
|
||||
items.append(MergeWarning(text: "Changes since the last snapshot haven't been reviewed"))
|
||||
}
|
||||
return (items, r?.state == .running)
|
||||
}
|
||||
|
||||
private func unignored(_ r: Review) -> [JSONDict] {
|
||||
r.state == .done ? findings(r).enumerated().filter { r.findingStates.additionalProperties[String($0.offset)] != .ignored }.map(\.element) : []
|
||||
}
|
||||
|
||||
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 questions = store.inbox.filter { $0.taskId == model.taskId && $0.kind == .question }.count
|
||||
var warnings = model.reviewItems
|
||||
if questions > 0 {
|
||||
let taskId = model.taskId
|
||||
let leave = model.leave
|
||||
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.focusTask(taskId, .inbox)
|
||||
}
|
||||
}))
|
||||
}
|
||||
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("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 && !model.coAuthors.isEmpty {
|
||||
DetailsBox(boxed: false) {
|
||||
Text(coAuthorsLabel(model.coAuthors))
|
||||
.foregroundStyle(Palette.muted)
|
||||
} content: {
|
||||
ModalText(text: mergeTrailers(model.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(spacing: 8) {
|
||||
Text(model.title)
|
||||
.font(Fonts.font(13, weight: 700))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
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, anchor: CGRect?) async {
|
||||
if !(await overlays.showConfirm(["Discard \"\(task.title)\"?"], action: "Discard", danger: true, anchor: anchor)) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user