gitbay/Views/MRs/DiffCommentSheet.swift
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}