gitbay/Views/MRs/DiffCommentSheet.swift

main
gitbay-ios/gitbay/Views/MRs/DiffCommentSheet.swift history · blame · raw

172 lines · 7356 bytes

3 symbols in this file
  1import SwiftUI
  2
  3/// A new review comment on a line of the diff, or a range ending on it.
  4/// On the new side the comment can carry a ```suggestion block; the
  5/// server reads the block out of the body, so the sheet only writes text.
  6struct DiffCommentSheet: View {
  7
  8    let draft: DiffCommentDraft
  9    let model: MRDetailViewModel
 10    let suggesting: Bool
 11
 12    @State private var startLine: Int
 13    @State private var text: String
 14    @State private var sent = false
 15    @Environment(\.dismiss) private var dismiss
 16
 17    init(draft: DiffCommentDraft, model: MRDetailViewModel, suggesting: Bool) {
 18        self.draft = draft
 19        self.model = model
 20        self.suggesting = suggesting
 21        _startLine = State(initialValue: draft.line)
 22        _text = State(initialValue: suggesting ? draft.suggestion(from: draft.line) : "")
 23    }
 24
 25    var body: some View {
 26        NavigationStack {
 27            Form {
 28                Section {
 29                    Text(anchor)
 30                        .font(.gbMono(.caption))
 31                        .accessibilityIdentifier("diff-comment-anchor")
 32                    if draft.firstLine < draft.line {
 33                        Stepper(value: $startLine, in: draft.firstLine...draft.line) {
 34                            Text("From line \(startLine)")
 35                                .font(.gbSans(.subheadline))
 36                        }
 37                        .onChange(of: startLine) { old, new in
 38                            text = draft.rebuilt(text, from: old, to: new)
 39                        }
 40                        .accessibilityIdentifier("diff-comment-start")
 41                    }
 42                }
 43                Section {
 44                    TextEditor(text: $text)
 45                        .font(.gbMono(.subheadline))
 46                        .frame(minHeight: 160)
 47                        .autocorrectionDisabled()
 48                        .textInputAutocapitalization(.never)
 49                        .accessibilityIdentifier("diff-comment-body")
 50                    if draft.canSuggest {
 51                        Button("Insert suggestion") {
 52                            if !text.isEmpty && !text.hasSuffix("\n") { text += "\n" }
 53                            text += draft.suggestion(from: startLine)
 54                        }
 55                        .font(.gbSans(.subheadline))
 56                        .accessibilityIdentifier("diff-comment-insert-suggestion")
 57                    }
 58                    MarkupPreview(text: text, format: "md", identifier: "diff-comment-preview")
 59                } footer: {
 60                    if draft.canSuggest {
 61                        Text("A suggestion replaces the lines in its range with what the block holds. An empty block deletes them.")
 62                    }
 63                }
 64                // Only this sheet's own send: an error left from an earlier
 65                // action on the screen is not this comment's.
 66                if sent, let error = model.actionError {
 67                    Section { GBNotice(error) }
 68                }
 69            }
 70            .scrollDismissesKeyboard(.interactively)
 71            .navigationTitle(suggesting ? "Suggest a Change" : "Comment")
 72            .navigationBarTitleDisplayMode(.inline)
 73            .toolbar {
 74                ToolbarItem(placement: .cancellationAction) {
 75                    Button("Cancel") { dismiss() }
 76                }
 77                ToolbarItem(placement: .confirmationAction) {
 78                    if model.working {
 79                        ProgressView()
 80                    } else {
 81                        Button("Send") {
 82                            sent = true
 83                            Task {
 84                                if await model.diffComment(draft, startLine: startLine, body: text) {
 85                                    dismiss()
 86                                }
 87                            }
 88                        }
 89                        .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
 90                        .accessibilityIdentifier("diff-comment-send")
 91                    }
 92                }
 93            }
 94            .interactiveDismissDisabled(model.working)
 95        }
 96    }
 97
 98    private var anchor: String {
 99        let side = draft.old ? " (old)" : ""
100        return startLine < draft.line
101            ? "\(draft.path):\(startLine)–\(draft.line)\(side)"
102            : "\(draft.path):\(draft.line)\(side)"
103    }
104}
105
106/// What a thread's suggestion replaces and proposes, as removed and added
107/// lines, with the action the server allows.
108struct SuggestionView: View {
109
110    let suggestion: Suggestion
111    let thread: ReviewThread
112    let model: MRDetailViewModel
113    @ScaledMetric(relativeTo: .caption2) private var markerWidth: CGFloat = 14
114
115    var body: some View {
116        VStack(alignment: .leading, spacing: 6) {
117            HStack(spacing: 6) {
118                Text("Suggested change")
119                    .font(.gbSans(.caption).weight(.semibold))
120                if suggestion.outdated {
121                    GBChip("outdated", .gbWarn)
122                }
123            }
124            VStack(alignment: .leading, spacing: 0) {
125                ForEach(Array(Suggestion.lines(suggestion.original).enumerated()), id: \.offset) { _, line in
126                    row("−", line, .gbBad, .gbDiffDel)
127                }
128                ForEach(Array(Suggestion.lines(suggestion.replacement).enumerated()), id: \.offset) { _, line in
129                    row("+", line, .gbOK, .gbDiffAdd)
130                }
131            }
132            .font(.gbMono(.caption2))
133            if suggestion.outdated {
134                Text(suggestion.reason ?? "The lines it replaces have changed.")
135                    .font(.gbSans(.caption))
136                    .foregroundStyle(.secondary)
137            } else if !thread.isResolved {
138                if suggestion.appliesOnServer {
139                    Button("Apply suggestion") {
140                        Task { await model.applySuggestion(thread) }
141                    }
142                    .font(.gbSans(.caption))
143                    .buttonStyle(.bordered)
144                    .disabled(model.working)
145                    .accessibilityIdentifier("apply-suggestion-\(thread.id)")
146                } else {
147                    // The repository requires signed commits, and only the
148                    // user's own key can sign; the CLI applies it from a clone.
149                    Text("This repository requires signed commits. Apply it from a clone:")
150                        .font(.gbSans(.caption))
151                        .foregroundStyle(.secondary)
152                    Text("gitbay mr apply-suggestion \(model.number) \(thread.id)")
153                        .font(.gbMono(.caption))
154                        .textSelection(.enabled)
155                }
156            }
157        }
158    }
159
160    private func row(_ marker: String, _ text: String, _ markerColor: Color, _ background: Color) -> some View {
161        HStack(spacing: 0) {
162            Text(marker)
163                .frame(width: markerWidth)
164                .foregroundStyle(markerColor)
165            Text(text.isEmpty ? " " : text)
166            Spacer(minLength: 0)
167        }
168        .background(background)
169        .accessibilityElement(children: .ignore)
170        .accessibilityLabel("\(marker == "+" ? "Proposed" : "Replaced"): \(text)")
171    }
172}