Commit d3a69034f3

d3a69034f33047d9abb16b15de426cb60f0028d8

parent: 76cfe39723

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-30 02:20 UTC

Link #N, !N and owner/name#N in bodies

Issue and MR bodies and comments link references the way the web's
autolink does: # is an issue, ! a merge request, nothing inside code
spans, fenced code or existing links. Taps open the issue or MR in-app.

Layout: unified · split

gitbay/Views/Issues/IssueView.swift +3 −2
@@ -31,7 +31,7 @@ struct IssueView: View {
31 31
32 if let body = issue.body, !body.isEmpty { 32 if let body = issue.body, !body.isEmpty {
33 Section { 33 Section {
34 FormattedBodyView(text: body, format: issue.bodyFormat) 34 FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath)
35 .padding(.vertical, 4) 35 .padding(.vertical, 4)
36 } 36 }
37 } 37 }
@@ -41,6 +41,7 @@ struct IssueView: View {
41 } 41 }
42 } 42 }
43 .overlay { LoadStateOverlay(state: model.state) } 43 .overlay { LoadStateOverlay(state: model.state) }
44 .referenceNavigation()
44 .scrollDismissesKeyboard(.interactively) 45 .scrollDismissesKeyboard(.interactively)
45 .navigationTitle("#\(model.number)") 46 .navigationTitle("#\(model.number)")
46 .navigationBarTitleDisplayMode(.inline) 47 .navigationBarTitleDisplayMode(.inline)
@@ -166,7 +167,7 @@ struct IssueView: View {
166 .font(.gbSans(.caption)) 167 .font(.gbSans(.caption))
167 .foregroundStyle(.tertiary) 168 .foregroundStyle(.tertiary)
168 } 169 }
169 FormattedBodyView(text: comment.body, format: comment.bodyFormat) 170 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
170 .font(.gbSans(.subheadline)) 171 .font(.gbSans(.subheadline))
171 } 172 }
172 .padding(.vertical, 2) 173 .padding(.vertical, 2)
gitbay/Views/MRs/MRView.swift +3 −2
@@ -37,7 +37,7 @@ struct MRView: View {
37 37
38 if let body = mr.body, !body.isEmpty { 38 if let body = mr.body, !body.isEmpty {
39 Section { 39 Section {
40 FormattedBodyView(text: body, format: mr.bodyFormat) 40 FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath)
41 .padding(.vertical, 4) 41 .padding(.vertical, 4)
42 } 42 }
43 } 43 }
@@ -72,6 +72,7 @@ struct MRView: View {
72 } 72 }
73 } 73 }
74 .overlay { LoadStateOverlay(state: model.state) } 74 .overlay { LoadStateOverlay(state: model.state) }
75 .referenceNavigation()
75 .scrollDismissesKeyboard(.interactively) 76 .scrollDismissesKeyboard(.interactively)
76 .navigationTitle("!\(model.number)") 77 .navigationTitle("!\(model.number)")
77 .navigationBarTitleDisplayMode(.inline) 78 .navigationBarTitleDisplayMode(.inline)
@@ -532,7 +533,7 @@ struct MRView: View {
532 .font(.gbSans(.caption)) 533 .font(.gbSans(.caption))
533 .foregroundStyle(.tertiary) 534 .foregroundStyle(.tertiary)
534 } 535 }
535 FormattedBodyView(text: comment.body, format: comment.bodyFormat) 536 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
536 .font(.gbSans(.subheadline)) 537 .font(.gbSans(.subheadline))
537 } 538 }
538 .padding(.vertical, 2) 539 .padding(.vertical, 2)
gitbay/Views/Repos/FormattedBodyView.swift +4 −1
@@ -8,6 +8,9 @@ import SwiftUI
8struct FormattedBodyView: View { 8struct FormattedBodyView: View {
9 let text: String 9 let text: String
10 var format: String? 10 var format: String?
11 /// The repository `#N` and `!N` refer to; nil links no references.
12 /// The links open through `referenceNavigation()`.
13 var repo: String?
11 14
12 @Environment(\.colorScheme) private var colorScheme 15 @Environment(\.colorScheme) private var colorScheme
13 16
@@ -22,7 +25,7 @@ struct FormattedBodyView: View {
22 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone) 25 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)
23 ) 26 )
24 } else { 27 } else {
25 MarkdownView(markdown: text) 28 MarkdownView(markdown: text, referenceRepo: repo)
26 } 29 }
27 } 30 }
28} 31}
gitbay/Views/Repos/MarkdownView.swift +36 −10
@@ -10,6 +10,8 @@ struct MarkdownView: View {
10 /// Repository context for resolving relative link targets. Nil leaves them as authored, 10 /// Repository context for resolving relative link targets. Nil leaves them as authored,
11 /// which is right for a body with no file to be relative to. 11 /// which is right for a body with no file to be relative to.
12 var linkOptions: OrgRenderOptions? 12 var linkOptions: OrgRenderOptions?
13 /// The repository `#N` and `!N` refer to. Nil links no references.
14 var referenceRepo: String?
13 15
14 var body: some View { 16 var body: some View {
15 VStack(alignment: .leading, spacing: 12) { 17 VStack(alignment: .leading, spacing: 12) {
@@ -223,7 +225,8 @@ struct MarkdownView: View {
223 private func blockView(_ block: Block) -> some View { 225 private func blockView(_ block: Block) -> some View {
224 switch block { 226 switch block {
225 case .heading(let level, let text): 227 case .heading(let level, let text):
226 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions) 228 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions,
229 referenceRepo: referenceRepo)
227 .font(headingFont(level)) 230 .font(headingFont(level))
228 .padding(.top, level <= 2 ? 8 : 4) 231 .padding(.top, level <= 2 ? 8 : 4)
229 case .code(let code): 232 case .code(let code):
@@ -238,7 +241,7 @@ struct MarkdownView: View {
238 RoundedRectangle(cornerRadius: 2) 241 RoundedRectangle(cornerRadius: 2)
239 .fill(.tertiary) 242 .fill(.tertiary)
240 .frame(width: 3) 243 .frame(width: 3)
241 Self.inline(text, linkOptions: linkOptions).foregroundStyle(.secondary) 244 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo).foregroundStyle(.secondary)
242 } 245 }
243 .fixedSize(horizontal: false, vertical: true) 246 .fixedSize(horizontal: false, vertical: true)
244 case .bullet(let items): 247 case .bullet(let items):
@@ -246,7 +249,7 @@ struct MarkdownView: View {
246 ForEach(Array(items.enumerated()), id: \.offset) { _, item in 249 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
247 HStack(alignment: .firstTextBaseline, spacing: 8) { 250 HStack(alignment: .firstTextBaseline, spacing: 8) {
248 Text("•") 251 Text("•")
249 Self.inline(item, linkOptions: linkOptions) 252 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
250 .fixedSize(horizontal: false, vertical: true) 253 .fixedSize(horizontal: false, vertical: true)
251 } 254 }
252 } 255 }
@@ -256,17 +259,17 @@ struct MarkdownView: View {
256 ForEach(Array(items.enumerated()), id: \.offset) { index, item in 259 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
257 HStack(alignment: .firstTextBaseline, spacing: 8) { 260 HStack(alignment: .firstTextBaseline, spacing: 8) {
258 Text("\(index + 1).").monospacedDigit() 261 Text("\(index + 1).").monospacedDigit()
259 Self.inline(item, linkOptions: linkOptions) 262 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
260 .fixedSize(horizontal: false, vertical: true) 263 .fixedSize(horizontal: false, vertical: true)
261 } 264 }
262 } 265 }
263 } 266 }
264 case .table(let table): 267 case .table(let table):
265 MarkdownTableView(table: table, linkOptions: linkOptions) 268 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo)
266 case .rule: 269 case .rule:
267 Divider() 270 Divider()
268 case .paragraph(let text): 271 case .paragraph(let text):
269 Self.inline(text, linkOptions: linkOptions) 272 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
270 } 273 }
271 } 274 }
272 275
@@ -277,15 +280,18 @@ struct MarkdownView: View {
277 static func inline( 280 static func inline(
278 _ text: String, 281 _ text: String,
279 codeStyle: Font.TextStyle = .subheadline, 282 codeStyle: Font.TextStyle = .subheadline,
280 linkOptions: OrgRenderOptions? = nil 283 linkOptions: OrgRenderOptions? = nil,
284 referenceRepo: String? = nil
281 ) -> Text { 285 ) -> Text {
282 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions)) 286 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions,
287 referenceRepo: referenceRepo))
283 } 288 }
284 289
285 static func attributed( 290 static func attributed(
286 _ text: String, 291 _ text: String,
287 codeStyle: Font.TextStyle = .subheadline, 292 codeStyle: Font.TextStyle = .subheadline,
288 linkOptions: OrgRenderOptions? = nil 293 linkOptions: OrgRenderOptions? = nil,
294 referenceRepo: String? = nil
289 ) -> AttributedString { 295 ) -> AttributedString {
290 guard var attributed = try? AttributedString( 296 guard var attributed = try? AttributedString(
291 markdown: text, 297 markdown: text,
@@ -306,9 +312,28 @@ struct MarkdownView: View {
306 attributed[range].link = url 312 attributed[range].link = url
307 } 313 }
308 } 314 }
315 if let referenceRepo {
316 linkReferences(in: &attributed, repo: referenceRepo)
317 }
309 return attributed 318 return attributed
310 } 319 }
311 320
321 /// Links `#N`, `!N` and `owner/name#N` outside code spans and
322 /// existing links. Fenced code never reaches here.
323 private static func linkReferences(in attributed: inout AttributedString, repo: String) {
324 for reference in bodyReferences(in: String(attributed.characters), repo: repo) {
325 let lower = attributed.characters.index(attributed.startIndex,
326 offsetBy: reference.range.lowerBound)
327 let upper = attributed.characters.index(lower, offsetBy: reference.range.count)
328 let skip = attributed[lower..<upper].runs.contains {
329 $0.link != nil || $0.inlinePresentationIntent?.contains(.code) == true
330 }
331 if !skip, let url = referenceURL(reference.destination) {
332 attributed[lower..<upper].link = url
333 }
334 }
335 }
336
312 private func headingFont(_ level: Int) -> Font { 337 private func headingFont(_ level: Int) -> Font {
313 switch level { 338 switch level {
314 case 1: .title2.bold() 339 case 1: .title2.bold()
@@ -338,6 +363,7 @@ struct MarkdownView: View {
338private struct MarkdownTableView: View { 363private struct MarkdownTableView: View {
339 let table: MarkdownView.Table 364 let table: MarkdownView.Table
340 var linkOptions: OrgRenderOptions? 365 var linkOptions: OrgRenderOptions?
366 var referenceRepo: String?
341 367
342 private static let spacing: CGFloat = 14 368 private static let spacing: CGFloat = 14
343 369
@@ -372,7 +398,7 @@ private struct MarkdownTableView: View {
372 398
373 private func cell(_ text: String, _ column: Int) -> some View { 399 private func cell(_ text: String, _ column: Int) -> some View {
374 let alignment = table.alignments[column] 400 let alignment = table.alignments[column]
375 return MarkdownView.inline(text, linkOptions: linkOptions) 401 return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
376 .multilineTextAlignment(Self.textAlignment(alignment)) 402 .multilineTextAlignment(Self.textAlignment(alignment))
377 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment)) 403 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
378 .fixedSize(horizontal: cellWidth == nil, vertical: true) 404 .fixedSize(horizontal: cellWidth == nil, vertical: true)
gitbay/Views/Shared/BodyReferences.swift added +96
@@ -0,0 +1,96 @@
1import Foundation
2import SwiftUI
3
4/// A `#N`, `!N`, `owner/name#N` or `owner/name!N` in a body, as the web's
5/// autolink package reads them: `#` is an issue, `!` a merge request.
6/// `range` counts characters.
7nonisolated struct BodyReference: Equatable, Sendable {
8 let range: Range<Int>
9 let destination: NotificationDestination
10}
11
12/// Every reference in `text`, bare ones resolved against `repo`. The
13/// patterns are the web's: a bare reference needs the start of the text,
14/// whitespace or an opening bracket before it, and a cross-repository one
15/// wins where the two overlap. Unlike the web, the target is not checked
16/// to exist.
17nonisolated func bodyReferences(in text: String, repo: String) -> [BodyReference] {
18 let cross = /([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)([#!])([0-9]+)/
19 let bare = /(^|[\s(\[{])([#!])([0-9]+)(?![0-9A-Za-z_])/
20
21 func offsets(_ range: Range<String.Index>) -> Range<Int> {
22 let lower = text.distance(from: text.startIndex, to: range.lowerBound)
23 return lower..<(lower + text.distance(from: range.lowerBound, to: range.upperBound))
24 }
25 func destination(_ repo: String, _ kind: Substring, _ number: Substring) -> NotificationDestination? {
26 guard let n = Int64(number) else { return nil }
27 return kind == "#" ? .issue(repo: repo, number: n) : .mr(repo: repo, number: n)
28 }
29
30 var found: [BodyReference] = []
31 for match in text.matches(of: cross) {
32 if let d = destination("\(match.1)/\(match.2)", match.3, match.4) {
33 found.append(BodyReference(range: offsets(match.range), destination: d))
34 }
35 }
36 for match in text.matches(of: bare) {
37 let range = offsets(match.2.startIndex..<match.3.endIndex)
38 guard !found.contains(where: { $0.range.overlaps(range) }),
39 let d = destination(repo, match.2, match.3) else { continue }
40 found.append(BodyReference(range: range, destination: d))
41 }
42 return found.sorted { $0.range.lowerBound < $1.range.lowerBound }
43}
44
45nonisolated private let referenceScheme = "gitbay-ref"
46
47/// The link a reference carries: the web path under an app-only scheme,
48/// so only `referenceNavigation()` acts on it.
49nonisolated func referenceURL(_ destination: NotificationDestination) -> URL? {
50 let path = switch destination {
51 case .issue(let repo, let number): "\(repo)/issues/\(number)"
52 case .mr(let repo, let number): "\(repo)/mrs/\(number)"
53 case .build(let repo, let number): "\(repo)/builds/\(number)"
54 }
55 return URL(string: "\(referenceScheme):\(path)")
56}
57
58nonisolated func referenceDestination(_ url: URL) -> NotificationDestination? {
59 guard url.scheme == referenceScheme else { return nil }
60 return notificationDestination(forPath: String(url.absoluteString.dropFirst(referenceScheme.count + 1)))
61}
62
63extension View {
64 /// Opens reference links from a body in-app. Attach outside the
65 /// `List`: a navigation destination inside a lazy container is ignored.
66 func referenceNavigation() -> some View {
67 modifier(ReferenceNavigation())
68 }
69}
70
71private struct ReferenceNavigation: ViewModifier {
72
73 @Environment(SessionStore.self) private var session
74 @State private var target: NotificationDestination?
75
76 func body(content: Content) -> some View {
77 content
78 .environment(\.openURL, OpenURLAction { url in
79 guard let destination = referenceDestination(url) else { return .systemAction }
80 target = destination
81 return .handled
82 })
83 .navigationDestination(item: $target) { destination in
84 if let client = session.client {
85 switch destination {
86 case .issue(let repo, let number):
87 IssueView(client: client, repo: repo, number: number)
88 case .mr(let repo, let number):
89 MRView(client: client, repo: repo, number: number)
90 case .build(let repo, let number):
91 BuildDetailView(client: client, repo: repo, number: number)
92 }
93 }
94 }
95 }
96}
gitbayTests/BodyReferenceTests.swift added +91
@@ -0,0 +1,91 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5/// `#N`, `!N`, `owner/name#N` and `owner/name!N`, found the way the web's
6/// autolink package finds them: `#` is an issue, `!` a merge request.
7struct BodyReferenceTests {
8
9 private func refs(_ text: String, repo: String = "krz/gitbay") -> [BodyReference] {
10 bodyReferences(in: text, repo: repo)
11 }
12
13 @Test func bareHashIsAnIssueInThisRepo() {
14 #expect(refs("see #12 please") == [
15 BodyReference(range: 4..<7, destination: .issue(repo: "krz/gitbay", number: 12)),
16 ])
17 }
18
19 @Test func bangIsAMergeRequest() {
20 #expect(refs("!3") == [
21 BodyReference(range: 0..<2, destination: .mr(repo: "krz/gitbay", number: 3)),
22 ])
23 }
24
25 @Test func crossRepoReferenceNamesItsRepo() {
26 #expect(refs("fixed in krz/solar#7 and krz/solar!8.") == [
27 BodyReference(range: 9..<20, destination: .issue(repo: "krz/solar", number: 7)),
28 BodyReference(range: 25..<36, destination: .mr(repo: "krz/solar", number: 8)),
29 ])
30 }
31
32 @Test func aBareReferenceNeedsABoundaryBefore() {
33 #expect(refs("a1b2#3").isEmpty)
34 #expect(refs("(#4)").map(\.range) == [1..<3])
35 #expect(refs("[#5]").map(\.range) == [1..<3])
36 #expect(refs("line\n#6").map(\.range) == [5..<7])
37 }
38
39 @Test func digitsMustEndAtAWordBoundary() {
40 #expect(refs("#12abc").isEmpty)
41 #expect(refs("#12, #13").count == 2)
42 }
43
44 @Test func aCrossReferenceIsNotAlsoReadAsABareOne() {
45 #expect(refs("krz/solar#7").count == 1)
46 }
47
48 @Test func offsetsCountCharactersNotBytes() {
49 #expect(refs("é #1").map(\.range) == [2..<4])
50 }
51
52 @Test func theURLRoundTrips() throws {
53 for destination in [NotificationDestination.issue(repo: "krz/gitbay", number: 12),
54 .mr(repo: "krz/solar", number: 3)] {
55 let url = try #require(referenceURL(destination))
56 #expect(referenceDestination(url) == destination)
57 }
58 #expect(referenceDestination(URL(string: "https://gitbay.org/krz/gitbay/issues/1")!) == nil)
59 }
60}
61
62@MainActor
63struct BodyReferenceMarkdownTests {
64
65 private func links(_ markdown: String) -> [String: URL] {
66 let attributed = MarkdownView.attributed(markdown, referenceRepo: "krz/gitbay")
67 var found: [String: URL] = [:]
68 for run in attributed.runs {
69 if let link = run.link {
70 found[String(attributed[run.range].characters)] = link
71 }
72 }
73 return found
74 }
75
76 @Test func referencesBecomeLinks() {
77 let found = links("closes #12, see krz/solar!3")
78 #expect(found["#12"] == referenceURL(.issue(repo: "krz/gitbay", number: 12)))
79 #expect(found["krz/solar!3"] == referenceURL(.mr(repo: "krz/solar", number: 3)))
80 }
81
82 @Test func codeSpansAndExistingLinksAreLeftAlone() {
83 let found = links("`#12` and [#13](https://example.com)")
84 #expect(found.count == 1)
85 #expect(found["#13"] == URL(string: "https://example.com"))
86 }
87
88 @Test func noRepoMeansNoReferenceLinks() {
89 #expect(MarkdownView.attributed("#12").runs.allSatisfy { $0.link == nil })
90 }
91}