import SwiftUI /// A new review comment on a line of the diff, or a range ending on it. /// On the new side the comment can carry a ```suggestion block; the /// server reads the block out of the body, so the sheet only writes text. struct DiffCommentSheet: View { let draft: DiffCommentDraft let model: MRDetailViewModel let suggesting: Bool @State private var startLine: Int @State private var text: String @State private var sent = false @Environment(\.dismiss) private var dismiss init(draft: DiffCommentDraft, model: MRDetailViewModel, suggesting: Bool) { self.draft = draft self.model = model self.suggesting = suggesting _startLine = State(initialValue: draft.line) _text = State(initialValue: suggesting ? draft.suggestion(from: draft.line) : "") } var body: some View { NavigationStack { Form { Section { Text(anchor) .font(.gbMono(.caption)) .accessibilityIdentifier("diff-comment-anchor") if draft.firstLine < draft.line { Stepper(value: $startLine, in: draft.firstLine...draft.line) { Text("From line \(startLine)") .font(.gbSans(.subheadline)) } .onChange(of: startLine) { old, new in text = draft.rebuilt(text, from: old, to: new) } .accessibilityIdentifier("diff-comment-start") } } Section { TextEditor(text: $text) .font(.gbMono(.subheadline)) .frame(minHeight: 160) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("diff-comment-body") if draft.canSuggest { Button("Insert suggestion") { if !text.isEmpty && !text.hasSuffix("\n") { text += "\n" } text += draft.suggestion(from: startLine) } .font(.gbSans(.subheadline)) .accessibilityIdentifier("diff-comment-insert-suggestion") } MarkupPreview(text: text, format: "md", identifier: "diff-comment-preview") } footer: { if draft.canSuggest { Text("A suggestion replaces the lines in its range with what the block holds. An empty block deletes them.") } } // Only this sheet's own send: an error left from an earlier // action on the screen is not this comment's. if sent, let error = model.actionError { Section { GBNotice(error) } } } .scrollDismissesKeyboard(.interactively) .navigationTitle(suggesting ? "Suggest a Change" : "Comment") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Send") { sent = true Task { if await model.diffComment(draft, startLine: startLine, body: text) { dismiss() } } } .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .accessibilityIdentifier("diff-comment-send") } } } .interactiveDismissDisabled(model.working) } } private var anchor: String { let side = draft.old ? " (old)" : "" return startLine < draft.line ? "\(draft.path):\(startLine)–\(draft.line)\(side)" : "\(draft.path):\(draft.line)\(side)" } } /// What a thread's suggestion replaces and proposes, as removed and added /// lines, with the action the server allows. struct SuggestionView: View { let suggestion: Suggestion let thread: ReviewThread let model: MRDetailViewModel @ScaledMetric(relativeTo: .caption2) private var markerWidth: CGFloat = 14 var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Text("Suggested change") .font(.gbSans(.caption).weight(.semibold)) if suggestion.outdated { GBChip("outdated", .gbWarn) } } VStack(alignment: .leading, spacing: 0) { ForEach(Array(Suggestion.lines(suggestion.original).enumerated()), id: \.offset) { _, line in row("−", line, .gbBad, .gbDiffDel) } ForEach(Array(Suggestion.lines(suggestion.replacement).enumerated()), id: \.offset) { _, line in row("+", line, .gbOK, .gbDiffAdd) } } .font(.gbMono(.caption2)) if suggestion.outdated { Text(suggestion.reason ?? "The lines it replaces have changed.") .font(.gbSans(.caption)) .foregroundStyle(.secondary) } else if !thread.isResolved { if suggestion.appliesOnServer { Button("Apply suggestion") { Task { await model.applySuggestion(thread) } } .font(.gbSans(.caption)) .buttonStyle(.bordered) .disabled(model.working) .accessibilityIdentifier("apply-suggestion-\(thread.id)") } else { // The repository requires signed commits, and only the // user's own key can sign; the CLI applies it from a clone. Text("This repository requires signed commits. Apply it from a clone:") .font(.gbSans(.caption)) .foregroundStyle(.secondary) Text("gitbay mr apply-suggestion \(model.number) \(thread.id)") .font(.gbMono(.caption)) .textSelection(.enabled) } } } } private func row(_ marker: String, _ text: String, _ markerColor: Color, _ background: Color) -> some View { HStack(spacing: 0) { Text(marker) .frame(width: markerWidth) .foregroundStyle(markerColor) Text(text.isEmpty ? " " : text) Spacer(minLength: 0) } .background(background) .accessibilityElement(children: .ignore) .accessibilityLabel("\(marker == "+" ? "Proposed" : "Replaced"): \(text)") } }