Review comments on diff lines, and suggested changes !156
8 files changed, +556 −4
Layout: unified · split
gitbay/MRs/DiffCommentDraft.swift added +73
| @@ -0,0 +1,73 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// A new review comment on lines of a merge request's diff: where it is | ||
| 4 | /// anchored, and the ```suggestion block that proposes replacement lines. | ||
| 5 | /// `mr diff-comment` decides whether the anchor is in the diff; this only | ||
| 6 | /// builds what the command is asked. | ||
| 7 | nonisolated struct DiffCommentDraft: Sendable, Hashable, Identifiable { | ||
| 8 | let path: String | ||
| 9 | /// The old side is a removed line; everything else anchors on the new file. | ||
| 10 | let old: Bool | ||
| 11 | /// The last line of the range, the one the comment was started from. | ||
| 12 | let line: Int | ||
| 13 | /// The lines on the same side of the hunk, in order, that a range may | ||
| 14 | /// start from: the tapped line and those above it, back to the first | ||
| 15 | /// line of the hunk on that side. | ||
| 16 | let candidates: [(number: Int, text: String)] | ||
| 17 | |||
| 18 | var id: String { "\(path):\(old ? "old" : "new"):\(line)" } | ||
| 19 | |||
| 20 | static func == (a: Self, b: Self) -> Bool { a.id == b.id } | ||
| 21 | func hash(into hasher: inout Hasher) { hasher.combine(id) } | ||
| 22 | |||
| 23 | /// A suggestion replaces new-file lines; a removed line has nothing | ||
| 24 | /// to replace. | ||
| 25 | var canSuggest: Bool { !old } | ||
| 26 | |||
| 27 | /// The earliest line a range may start on. | ||
| 28 | var firstLine: Int { candidates.first?.number ?? line } | ||
| 29 | |||
| 30 | /// The draft for a line of the diff, or nil for a line no side can | ||
| 31 | /// anchor (none in a well-formed diff). | ||
| 32 | init?(file: UnifiedDiff.File, hunk: UnifiedDiff.Hunk, line target: UnifiedDiff.Line) { | ||
| 33 | let old = target.kind == .deletion | ||
| 34 | guard let number = old ? target.oldNumber : target.newNumber else { return nil } | ||
| 35 | // The server checks --path against the diff's file names, which | ||
| 36 | // list a rename under its new name only, on either side. | ||
| 37 | self.path = file.displayPath | ||
| 38 | self.old = old | ||
| 39 | self.line = number | ||
| 40 | var lines: [(Int, String)] = [] | ||
| 41 | for l in hunk.lines { | ||
| 42 | let n = old ? (l.kind == .addition ? nil : l.oldNumber) : (l.kind == .deletion ? nil : l.newNumber) | ||
| 43 | guard let n else { continue } | ||
| 44 | if n > number { break } | ||
| 45 | lines.append((n, l.text)) | ||
| 46 | } | ||
| 47 | self.candidates = lines | ||
| 48 | } | ||
| 49 | |||
| 50 | /// `mr diff-comment <owner/name> <n> --path <p> --line <l> [--start-line <s>] [--old] --file -`. | ||
| 51 | func argv(repo: String, number: Int64, startLine: Int) -> [String] { | ||
| 52 | var argv = ["mr", "diff-comment", repo, String(number), "--path", path, "--line", String(line)] | ||
| 53 | if startLine < line { argv += ["--start-line", String(startLine)] } | ||
| 54 | if old { argv.append("--old") } | ||
| 55 | argv += ["--file", "-"] | ||
| 56 | return argv | ||
| 57 | } | ||
| 58 | |||
| 59 | /// The ```suggestion block for lines `startLine` through `line`, with | ||
| 60 | /// the current text in it to edit. | ||
| 61 | func suggestion(from startLine: Int) -> String { | ||
| 62 | let text = candidates.filter { $0.number >= startLine }.map(\.text) | ||
| 63 | return "```suggestion\n" + text.map { $0 + "\n" }.joined() + "```\n" | ||
| 64 | } | ||
| 65 | |||
| 66 | /// The body after the range's start moves from `old` to `new`: an | ||
| 67 | /// untouched suggestion is rebuilt to cover the new range, since | ||
| 68 | /// sending the old one would replace the whole range with fewer | ||
| 69 | /// lines. Text the user changed is theirs and stays. | ||
| 70 | func rebuilt(_ text: String, from old: Int, to new: Int) -> String { | ||
| 71 | text == suggestion(from: old) ? suggestion(from: new) : text | ||
| 72 | } | ||
| 73 | } | ||
gitbay/MRs/MRDetailViewModel.swift +13
| @@ -169,6 +169,19 @@ final class MRDetailViewModel { | |||
| 169 | ) | 169 | ) |
| 170 | } | 170 | } |
| 171 | 171 | ||
| 172 | /// Start a review thread on lines of the diff. The body goes over | ||
| 173 | /// stdin; a ```suggestion block in it is the server's to read. Returns | ||
| 174 | /// whether it was accepted, so the sheet can close. | ||
| 175 | func diffComment(_ draft: DiffCommentDraft, startLine: Int, body: String) async -> Bool { | ||
| 176 | await perform(draft.argv(repo: repoPath, number: number, startLine: startLine), stdin: body) | ||
| 177 | return actionError == nil | ||
| 178 | } | ||
| 179 | |||
| 180 | /// Commit a thread's suggestion to the source branch on the server. | ||
| 181 | func applySuggestion(_ thread: ReviewThread) async { | ||
| 182 | await perform(["mr", "apply-suggestion"] + ref + [String(thread.id)]) | ||
| 183 | } | ||
| 184 | |||
| 172 | func setDraft(_ draft: Bool) async { | 185 | func setDraft(_ draft: Bool) async { |
| 173 | await perform(["mr", draft ? "draft" : "ready"] + ref) | 186 | await perform(["mr", draft ? "draft" : "ready"] + ref) |
| 174 | } | 187 | } |
gitbay/MRs/MRModels.swift +12 −1
| @@ -302,16 +302,27 @@ nonisolated struct ReviewThread: Decodable, Sendable, Hashable, Identifiable { | |||
| 302 | let path: String | 302 | let path: String |
| 303 | let side: String // new | old | 303 | let side: String // new | old |
| 304 | let line: Int64 | 304 | let line: Int64 |
| 305 | /// The first line of a range; absent for a one-line thread. | ||
| 306 | let startLine: Int64? | ||
| 305 | /// Anchored to an older head; the diff has moved under it. | 307 | /// Anchored to an older head; the diff has moved under it. |
| 306 | let stale: Bool | 308 | let stale: Bool |
| 307 | let resolvedBy: String? | 309 | let resolvedBy: String? |
| 310 | /// What the thread's ```suggestion block proposes (v1.39.0). | ||
| 311 | let suggestion: Suggestion? | ||
| 308 | let comments: [Comment] | 312 | let comments: [Comment] |
| 309 | 313 | ||
| 310 | enum CodingKeys: String, CodingKey { | 314 | enum CodingKeys: String, CodingKey { |
| 311 | case id, path, side, line, stale, comments | 315 | case id, path, side, line, stale, suggestion, comments |
| 316 | case startLine = "start_line" | ||
| 312 | case resolvedBy = "resolved_by" | 317 | case resolvedBy = "resolved_by" |
| 313 | } | 318 | } |
| 314 | 319 | ||
| 320 | /// `path:line`, or `path:start–line` for a range. | ||
| 321 | var anchor: String { | ||
| 322 | if let startLine, startLine < line { return "\(path):\(startLine)–\(line)" } | ||
| 323 | return "\(path):\(line)" | ||
| 324 | } | ||
| 325 | |||
| 315 | var isResolved: Bool { resolvedBy != nil } | 326 | var isResolved: Bool { resolvedBy != nil } |
| 316 | 327 | ||
| 317 | nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable { | 328 | nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable { |
gitbay/MRs/Suggestion.swift added +46
| @@ -0,0 +1,46 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// The change a review thread's ```suggestion block proposes, as | ||
| 4 | /// `mr threads` carries it: lines `startLine` through `endLine` of `path`, | ||
| 5 | /// `original` replaced by `replacement`. Both texts end every line with | ||
| 6 | /// its terminator, so "" is no lines. | ||
| 7 | nonisolated struct Suggestion: Decodable, Sendable, Hashable { | ||
| 8 | let path: String | ||
| 9 | let startLine: Int64 | ||
| 10 | let endLine: Int64 | ||
| 11 | let original: String | ||
| 12 | let replacement: String | ||
| 13 | /// The lines it replaces have changed at the head, or the file moved; | ||
| 14 | /// it cannot be applied then, and `reason` says which. | ||
| 15 | let outdated: Bool | ||
| 16 | let reason: String? | ||
| 17 | /// "server" where `mr apply-suggestion` commits it; "local" where the | ||
| 18 | /// repository requires signed commits, which only a clone can make. | ||
| 19 | let apply: String | ||
| 20 | |||
| 21 | enum CodingKeys: String, CodingKey { | ||
| 22 | case path, original, replacement, outdated, reason, apply | ||
| 23 | case startLine = "start_line" | ||
| 24 | case endLine = "end_line" | ||
| 25 | } | ||
| 26 | |||
| 27 | var appliesOnServer: Bool { apply == "server" } | ||
| 28 | |||
| 29 | /// The text as lines, without the terminator the last one carries. | ||
| 30 | static func lines(_ text: String) -> [String] { | ||
| 31 | guard !text.isEmpty else { return [] } | ||
| 32 | var lines = text.components(separatedBy: "\n") | ||
| 33 | if lines.last == "" { lines.removeLast() } | ||
| 34 | return lines | ||
| 35 | } | ||
| 36 | |||
| 37 | /// A comment's text without its ```suggestion block, which the thread | ||
| 38 | /// draws as replaced and proposed lines instead. | ||
| 39 | static func withoutBlock(_ body: String) -> String { | ||
| 40 | guard let start = body.range(of: "```suggestion") else { return body } | ||
| 41 | let afterOpen = body[start.upperBound...] | ||
| 42 | guard let close = afterOpen.range(of: "```") else { return body } | ||
| 43 | let rest = body[..<start.lowerBound] + body[close.upperBound...] | ||
| 44 | return rest.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 45 | } | ||
| 46 | } | ||
gitbay/Views/MRs/DiffCommentSheet.swift added +172
| @@ -0,0 +1,172 @@ | |||
| 1 | import 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. | ||
| 6 | struct 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. | ||
| 108 | struct 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 | } | ||
gitbay/Views/MRs/DiffView.swift +45 −1
| @@ -100,6 +100,16 @@ private struct HunkView: View { | |||
| 100 | let file: UnifiedDiff.File | 100 | let file: UnifiedDiff.File |
| 101 | let hunk: UnifiedDiff.Hunk | 101 | let hunk: UnifiedDiff.Hunk |
| 102 | let model: MRDetailViewModel? | 102 | let model: MRDetailViewModel? |
| 103 | @State private var composing: Compose? | ||
| 104 | /// The line tapped, whose actions are on offer. | ||
| 105 | @State private var picked: DiffCommentDraft? | ||
| 106 | |||
| 107 | /// A comment being written on a line, and whether it opened as a suggestion. | ||
| 108 | private struct Compose: Identifiable { | ||
| 109 | let draft: DiffCommentDraft | ||
| 110 | let suggesting: Bool | ||
| 111 | var id: String { draft.id + (suggesting ? ":s" : "") } | ||
| 112 | } | ||
| 103 | 113 | ||
| 104 | var body: some View { | 114 | var body: some View { |
| 105 | ScrollView(.horizontal) { | 115 | ScrollView(.horizontal) { |
| @@ -119,7 +129,19 @@ private struct HunkView: View { | |||
| 119 | Rectangle().fill(Color.gbLine).frame(height: 1) | 129 | Rectangle().fill(Color.gbLine).frame(height: 1) |
| 120 | } | 130 | } |
| 121 | ForEach(hunk.lines) { line in | 131 | ForEach(hunk.lines) { line in |
| 122 | LineView(line: line) | 132 | if model != nil { |
| 133 | // A tap rather than a context menu: in a list row SwiftUI | ||
| 134 | // hoists the first line's menu to the whole hunk, so every | ||
| 135 | // line would offer line one's anchor. | ||
| 136 | LineView(line: line) | ||
| 137 | .contentShape(Rectangle()) | ||
| 138 | .onTapGesture { pick(line) } | ||
| 139 | .accessibilityAddTraits(.isButton) | ||
| 140 | .accessibilityHint("Comment on this line") | ||
| 141 | .accessibilityAction { pick(line) } | ||
| 142 | } else { | ||
| 143 | LineView(line: line) | ||
| 144 | } | ||
| 123 | if let model { | 145 | if let model { |
| 124 | ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in | 146 | ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in |
| 125 | ReviewThreadView(thread: thread, model: model) | 147 | ReviewThreadView(thread: thread, model: model) |
| @@ -132,6 +154,27 @@ private struct HunkView: View { | |||
| 132 | } | 154 | } |
| 133 | } | 155 | } |
| 134 | .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8)) | 156 | .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8)) |
| 157 | .confirmationDialog( | ||
| 158 | picked.map { "\($0.path):\($0.line)\($0.old ? " (old)" : "")" } ?? "", | ||
| 159 | isPresented: Binding(get: { picked != nil }, set: { if !$0 { picked = nil } }), | ||
| 160 | titleVisibility: .visible, | ||
| 161 | presenting: picked | ||
| 162 | ) { draft in | ||
| 163 | Button("Comment") { composing = Compose(draft: draft, suggesting: false) } | ||
| 164 | if draft.canSuggest { | ||
| 165 | Button("Suggest a change") { composing = Compose(draft: draft, suggesting: true) } | ||
| 166 | } | ||
| 167 | Button("Cancel", role: .cancel) {} | ||
| 168 | } | ||
| 169 | .sheet(item: $composing) { compose in | ||
| 170 | if let model { | ||
| 171 | DiffCommentSheet(draft: compose.draft, model: model, suggesting: compose.suggesting) | ||
| 172 | } | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 176 | private func pick(_ line: UnifiedDiff.Line) { | ||
| 177 | picked = DiffCommentDraft(file: file, hunk: hunk, line: line) | ||
| 135 | } | 178 | } |
| 136 | } | 179 | } |
| 137 | 180 | ||
| @@ -159,6 +202,7 @@ private struct LineView: View { | |||
| 159 | .background(background) | 202 | .background(background) |
| 160 | .accessibilityElement(children: .ignore) | 203 | .accessibilityElement(children: .ignore) |
| 161 | .accessibilityLabel(line.accessibilityLabel) | 204 | .accessibilityLabel(line.accessibilityLabel) |
| 205 | .accessibilityIdentifier("diff-line-\(line.oldNumber ?? 0)-\(line.newNumber ?? 0)") | ||
| 162 | } | 206 | } |
| 163 | 207 | ||
| 164 | private var marker: String { | 208 | private var marker: String { |
gitbay/Views/MRs/MRView.swift +8 −2
| @@ -679,7 +679,7 @@ struct ReviewThreadView: View { | |||
| 679 | ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right") | 679 | ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right") |
| 680 | .font(.gbSans(.caption)) | 680 | .font(.gbSans(.caption)) |
| 681 | .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn) | 681 | .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn) |
| 682 | Text("\(thread.path):\(thread.line)") | 682 | Text(thread.anchor) |
| 683 | .font(.gbMono(.caption)) | 683 | .font(.gbMono(.caption)) |
| 684 | .lineLimit(1) | 684 | .lineLimit(1) |
| 685 | if thread.stale { | 685 | if thread.stale { |
| @@ -693,6 +693,9 @@ struct ReviewThreadView: View { | |||
| 693 | .buttonStyle(.bordered) | 693 | .buttonStyle(.bordered) |
| 694 | .disabled(model.working) | 694 | .disabled(model.working) |
| 695 | } | 695 | } |
| 696 | if let suggestion = thread.suggestion { | ||
| 697 | SuggestionView(suggestion: suggestion, thread: thread, model: model) | ||
| 698 | } | ||
| 696 | ForEach(thread.comments) { comment in | 699 | ForEach(thread.comments) { comment in |
| 697 | VStack(alignment: .leading, spacing: 2) { | 700 | VStack(alignment: .leading, spacing: 2) { |
| 698 | HStack { | 701 | HStack { |
| @@ -703,7 +706,10 @@ struct ReviewThreadView: View { | |||
| 703 | .foregroundStyle(.tertiary) | 706 | .foregroundStyle(.tertiary) |
| 704 | } | 707 | } |
| 705 | // Review comments carry no format; the web renders them as Markdown. | 708 | // Review comments carry no format; the web renders them as Markdown. |
| 706 | FormattedBodyView(text: comment.body, repo: model.repoPath) | 709 | // The thread's first comment holds its suggestion, drawn above. |
| 710 | FormattedBodyView(text: thread.suggestion != nil && comment.id == thread.comments.first?.id | ||
| 711 | ? Suggestion.withoutBlock(comment.body) : comment.body, | ||
| 712 | repo: model.repoPath) | ||
| 707 | .font(.gbSans(.subheadline)) | 713 | .font(.gbSans(.subheadline)) |
| 708 | } | 714 | } |
| 709 | } | 715 | } |
gitbayTests/SuggestionTests.swift added +187
| @@ -0,0 +1,187 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 6 | let box = StubProtocol.box() | ||
| 7 | let client = GitbayClient( | ||
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 9 | token: "test-token", | ||
| 10 | session: box.session() | ||
| 11 | ) | ||
| 12 | return (client, box) | ||
| 13 | } | ||
| 14 | |||
| 15 | private func posted(_ stub: StubProtocol.Box) throws -> ([String], String?) { | ||
| 16 | let seen = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 17 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 18 | return (try #require(body["argv"] as? [String]), body["stdin"] as? String) | ||
| 19 | } | ||
| 20 | |||
| 21 | private let diffText = """ | ||
| 22 | diff --git a/main.go b/main.go | ||
| 23 | --- a/main.go | ||
| 24 | +++ b/main.go | ||
| 25 | @@ -10,4 +10,4 @@ func main() { | ||
| 26 | \tname := os.Args[1] | ||
| 27 | -\tlog.Print("starting") | ||
| 28 | +\tlog.Printf("starting %s", name) | ||
| 29 | \trun(name) | ||
| 30 | } | ||
| 31 | """ | ||
| 32 | |||
| 33 | private let mrShowJSON = """ | ||
| 34 | {"protocol_version":1,"data":{"number":4,"title":"t","state":"open","author":"krz",\ | ||
| 35 | "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z"},\ | ||
| 36 | "exit_code":0} | ||
| 37 | """ | ||
| 38 | |||
| 39 | private let threadsJSON = """ | ||
| 40 | {"protocol_version":1,"data":[{"id":9,"path":"main.go","side":"new","start_line":11,"line":12,\ | ||
| 41 | "stale":false,"suggestion":{"path":"main.go","start_line":11,"end_line":12,"commit":"abc",\ | ||
| 42 | "original":"\\tlog.Printf(\\"starting %s\\", name)\\n\\trun(name)\\n",\ | ||
| 43 | "replacement":"\\tlog.Printf(\\"starting %q\\", name)\\n\\trun(name)\\n",\ | ||
| 44 | "outdated":false,"apply":"server"},\ | ||
| 45 | "comments":[{"id":9,"author":"cmc","body":"quote it","created_at":"2026-08-22T09:00:00.000Z"}]}],\ | ||
| 46 | "exit_code":0} | ||
| 47 | """ | ||
| 48 | |||
| 49 | private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"# | ||
| 50 | |||
| 51 | private func hunk() throws -> (UnifiedDiff.File, UnifiedDiff.Hunk) { | ||
| 52 | let file = try #require(UnifiedDiff.parse(diffText).files.first) | ||
| 53 | return (file, try #require(file.hunks.first)) | ||
| 54 | } | ||
| 55 | |||
| 56 | struct DiffCommentDraftTests { | ||
| 57 | |||
| 58 | @Test func aNewSideLineAnchorsOnTheNewFileAndCanSuggest() throws { | ||
| 59 | let (file, hunk) = try hunk() | ||
| 60 | let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context }) | ||
| 61 | let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target)) | ||
| 62 | |||
| 63 | #expect(draft.canSuggest) | ||
| 64 | #expect(draft.line == 12) | ||
| 65 | // The removed line has no new-side number and is not a candidate. | ||
| 66 | #expect(draft.candidates.map(\.number) == [10, 11, 12]) | ||
| 67 | #expect(draft.firstLine == 10) | ||
| 68 | #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 12) | ||
| 69 | == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12", "--file", "-"]) | ||
| 70 | #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 11) | ||
| 71 | == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12", | ||
| 72 | "--start-line", "11", "--file", "-"]) | ||
| 73 | } | ||
| 74 | |||
| 75 | @Test func theSuggestionBlockCarriesTheRangesCurrentText() throws { | ||
| 76 | let (file, hunk) = try hunk() | ||
| 77 | let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context }) | ||
| 78 | let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target)) | ||
| 79 | |||
| 80 | #expect(draft.suggestion(from: 11) == "```suggestion\n\tlog.Printf(\"starting %s\", name)\n\trun(name)\n```\n") | ||
| 81 | } | ||
| 82 | |||
| 83 | @Test func aRemovedLineAnchorsOnTheOldSideAndCannotSuggest() throws { | ||
| 84 | let (file, hunk) = try hunk() | ||
| 85 | let target = try #require(hunk.lines.first { $0.kind == .deletion }) | ||
| 86 | let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target)) | ||
| 87 | |||
| 88 | #expect(!draft.canSuggest) | ||
| 89 | #expect(draft.candidates.map(\.number) == [10, 11]) | ||
| 90 | #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 11) | ||
| 91 | == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "11", "--old", "--file", "-"]) | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | struct SuggestionReviewFixTests { | ||
| 96 | |||
| 97 | /// The server checks --path against the diff's names, which list a | ||
| 98 | /// renamed file under its new name only, so both sides send it. | ||
| 99 | @Test func aRemovedLineInARenamedFileSendsTheNewPath() throws { | ||
| 100 | let diff = UnifiedDiff.parse(""" | ||
| 101 | diff --git a/old.go b/new.go | ||
| 102 | --- a/old.go | ||
| 103 | +++ b/new.go | ||
| 104 | @@ -1,2 +1,2 @@ | ||
| 105 | -a | ||
| 106 | +b | ||
| 107 | c | ||
| 108 | """) | ||
| 109 | let file = try #require(diff.files.first) | ||
| 110 | let hunk = try #require(file.hunks.first) | ||
| 111 | let removed = try #require(hunk.lines.first { $0.kind == .deletion }) | ||
| 112 | #expect(DiffCommentDraft(file: file, hunk: hunk, line: removed)?.path == "new.go") | ||
| 113 | } | ||
| 114 | |||
| 115 | /// Moving the start of the range rebuilds an untouched suggestion so it | ||
| 116 | /// covers the range, and leaves an edited one alone. | ||
| 117 | @Test func movingTheRangeStartRebuildsAnUntouchedSuggestion() throws { | ||
| 118 | let (file, hunk) = try hunk() | ||
| 119 | let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context }) | ||
| 120 | let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target)) | ||
| 121 | let untouched = draft.suggestion(from: 12) | ||
| 122 | |||
| 123 | #expect(draft.rebuilt(untouched, from: 12, to: 11) == draft.suggestion(from: 11)) | ||
| 124 | #expect(draft.rebuilt("my own words", from: 12, to: 11) == "my own words") | ||
| 125 | } | ||
| 126 | |||
| 127 | /// The thread draws the suggestion as replaced and proposed lines, so | ||
| 128 | /// the comment shows the rest of its text without the block. | ||
| 129 | @Test func theBlockIsLeftOutOfTheCommentShownBesideTheSuggestion() { | ||
| 130 | #expect(Suggestion.withoutBlock("Say who.\n```suggestion\n return x\n```\n") == "Say who.") | ||
| 131 | #expect(Suggestion.withoutBlock("```suggestion\n```\n") == "") | ||
| 132 | #expect(Suggestion.withoutBlock("no block here") == "no block here") | ||
| 133 | } | ||
| 134 | } | ||
| 135 | |||
| 136 | @MainActor | ||
| 137 | struct SuggestionViewModelTests { | ||
| 138 | |||
| 139 | private func loaded() async throws -> (MRDetailViewModel, StubProtocol.Box) { | ||
| 140 | let (client, stub) = try makeClient() | ||
| 141 | stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) | ||
| 142 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff")) | ||
| 143 | stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) | ||
| 144 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=revisions")) | ||
| 145 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 4) | ||
| 146 | await model.load() | ||
| 147 | return (model, stub) | ||
| 148 | } | ||
| 149 | |||
| 150 | @Test func threadsDecodeTheirRangeAndSuggestion() async throws { | ||
| 151 | let (model, _) = try await loaded() | ||
| 152 | |||
| 153 | let thread = try #require(model.threads.first) | ||
| 154 | #expect(thread.startLine == 11) | ||
| 155 | let suggestion = try #require(thread.suggestion) | ||
| 156 | #expect(suggestion.appliesOnServer) | ||
| 157 | #expect(!suggestion.outdated) | ||
| 158 | #expect(Suggestion.lines(suggestion.replacement) == ["\tlog.Printf(\"starting %q\", name)", "\trun(name)"]) | ||
| 159 | #expect(Suggestion.lines("") == []) | ||
| 160 | } | ||
| 161 | |||
| 162 | @Test func applyingASuggestionNamesItsThread() async throws { | ||
| 163 | let (model, stub) = try await loaded() | ||
| 164 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | ||
| 165 | |||
| 166 | await model.applySuggestion(try #require(model.threads.first)) | ||
| 167 | |||
| 168 | let (argv, _) = try posted(stub) | ||
| 169 | #expect(argv == ["mr", "apply-suggestion", "krz/gitbay", "4", "9"]) | ||
| 170 | } | ||
| 171 | |||
| 172 | @Test func aNewCommentSendsItsAnchorAndBodyOnStdin() async throws { | ||
| 173 | let (model, stub) = try await loaded() | ||
| 174 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | ||
| 175 | let (file, hunk) = try hunk() | ||
| 176 | let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context }) | ||
| 177 | let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target)) | ||
| 178 | |||
| 179 | let sent = await model.diffComment(draft, startLine: 11, body: "quote it") | ||
| 180 | |||
| 181 | #expect(sent) | ||
| 182 | let (argv, stdin) = try posted(stub) | ||
| 183 | #expect(argv == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12", | ||
| 184 | "--start-line", "11", "--file", "-"]) | ||
| 185 | #expect(stdin == "quote it") | ||
| 186 | } | ||
| 187 | } | ||