Commit 3e71822629

3e718226294cbc34afc3a47656da00baf5c0ae08

parent: 1dadf31dc2

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 05:18 UTC

Suggestions: the block follows the range; renamed files; one copy

Moving the start of the range rebuilds an untouched suggestion to cover
it, where it used to keep the tapped line alone and replace the whole
range with it. A removed line in a renamed file sends the new name, the
one the server's diff lists. The thread's comment leaves out the block
it draws above, the marker column scales with the text, and the sheet
shows only its own send's error.

Ref #23

Layout: unified · split

gitbay/MRs/DiffCommentDraft.swift +11 −1
@@ -32,7 +32,9 @@ nonisolated struct DiffCommentDraft: Sendable, Hashable, Identifiable {
3232 init?(file: UnifiedDiff.File, hunk: UnifiedDiff.Hunk, line target: UnifiedDiff.Line) {
3333 let old = target.kind == .deletion
3434 guard let number = old ? target.oldNumber : target.newNumber else { return nil }
35 self.path = old ? file.oldPath : file.newPath
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
3638 self.old = old
3739 self.line = number
3840 var lines: [(Int, String)] = []
@@ -60,4 +62,12 @@ nonisolated struct DiffCommentDraft: Sendable, Hashable, Identifiable {
6062 let text = candidates.filter { $0.number >= startLine }.map(\.text)
6163 return "```suggestion\n" + text.map { $0 + "\n" }.joined() + "```\n"
6264 }
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 }
6373}
gitbay/MRs/Suggestion.swift +10
@@ -33,4 +33,14 @@ nonisolated struct Suggestion: Decodable, Sendable, Hashable {
3333 if lines.last == "" { lines.removeLast() }
3434 return lines
3535 }
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 }
3646}
gitbay/Views/MRs/DiffCommentSheet.swift +10 −2
@@ -11,6 +11,7 @@ struct DiffCommentSheet: View {
1111
1212 @State private var startLine: Int
1313 @State private var text: String
14 @State private var sent = false
1415 @Environment(\.dismiss) private var dismiss
1516
1617 init(draft: DiffCommentDraft, model: MRDetailViewModel, suggesting: Bool) {
@@ -33,6 +34,9 @@ struct DiffCommentSheet: View {
3334 Text("From line \(startLine)")
3435 .font(.gbSans(.subheadline))
3536 }
37 .onChange(of: startLine) { old, new in
38 text = draft.rebuilt(text, from: old, to: new)
39 }
3640 .accessibilityIdentifier("diff-comment-start")
3741 }
3842 }
@@ -57,7 +61,9 @@ struct DiffCommentSheet: View {
5761 Text("A suggestion replaces the lines in its range with what the block holds. An empty block deletes them.")
5862 }
5963 }
60 if let error = model.actionError {
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 {
6167 Section { GBNotice(error) }
6268 }
6369 }
@@ -73,6 +79,7 @@ struct DiffCommentSheet: View {
7379 ProgressView()
7480 } else {
7581 Button("Send") {
82 sent = true
7683 Task {
7784 if await model.diffComment(draft, startLine: startLine, body: text) {
7885 dismiss()
@@ -103,6 +110,7 @@ struct SuggestionView: View {
103110 let suggestion: Suggestion
104111 let thread: ReviewThread
105112 let model: MRDetailViewModel
113 @ScaledMetric(relativeTo: .caption2) private var markerWidth: CGFloat = 14
106114
107115 var body: some View {
108116 VStack(alignment: .leading, spacing: 6) {
@@ -152,7 +160,7 @@ struct SuggestionView: View {
152160 private func row(_ marker: String, _ text: String, _ markerColor: Color, _ background: Color) -> some View {
153161 HStack(spacing: 0) {
154162 Text(marker)
155 .frame(width: 14)
163 .frame(width: markerWidth)
156164 .foregroundStyle(markerColor)
157165 Text(text.isEmpty ? " " : text)
158166 Spacer(minLength: 0)
gitbay/Views/MRs/MRView.swift +4 −1
@@ -706,7 +706,10 @@ struct ReviewThreadView: View {
706706 .foregroundStyle(.tertiary)
707707 }
708708 // Review comments carry no format; the web renders them as Markdown.
709 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)
710713 .font(.gbSans(.subheadline))
711714 }
712715 }
gitbayTests/SuggestionTests.swift +41
@@ -92,6 +92,47 @@ struct DiffCommentDraftTests {
9292 }
9393}
9494
95struct 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
95136@MainActor
96137struct SuggestionViewModelTests {
97138