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 = false) async { guard let task = store.findTask(taskId), task.status == .active else { return } 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)) } }