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 {
3131
3232 if let body = issue.body, !body.isEmpty {
3333 Section {
34 FormattedBodyView(text: body, format: issue.bodyFormat)
34 FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath)
3535 .padding(.vertical, 4)
3636 }
3737 }
@@ -41,6 +41,7 @@ struct IssueView: View {
4141 }
4242 }
4343 .overlay { LoadStateOverlay(state: model.state) }
44 .referenceNavigation()
4445 .scrollDismissesKeyboard(.interactively)
4546 .navigationTitle("#\(model.number)")
4647 .navigationBarTitleDisplayMode(.inline)
@@ -166,7 +167,7 @@ struct IssueView: View {
166167 .font(.gbSans(.caption))
167168 .foregroundStyle(.tertiary)
168169 }
169 FormattedBodyView(text: comment.body, format: comment.bodyFormat)
170 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
170171 .font(.gbSans(.subheadline))
171172 }
172173 .padding(.vertical, 2)
gitbay/Views/MRs/MRView.swift +3 −2
@@ -37,7 +37,7 @@ struct MRView: View {
3737
3838 if let body = mr.body, !body.isEmpty {
3939 Section {
40 FormattedBodyView(text: body, format: mr.bodyFormat)
40 FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath)
4141 .padding(.vertical, 4)
4242 }
4343 }
@@ -72,6 +72,7 @@ struct MRView: View {
7272 }
7373 }
7474 .overlay { LoadStateOverlay(state: model.state) }
75 .referenceNavigation()
7576 .scrollDismissesKeyboard(.interactively)
7677 .navigationTitle("!\(model.number)")
7778 .navigationBarTitleDisplayMode(.inline)
@@ -532,7 +533,7 @@ struct MRView: View {
532533 .font(.gbSans(.caption))
533534 .foregroundStyle(.tertiary)
534535 }
535 FormattedBodyView(text: comment.body, format: comment.bodyFormat)
536 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
536537 .font(.gbSans(.subheadline))
537538 }
538539 .padding(.vertical, 2)
gitbay/Views/Repos/FormattedBodyView.swift +4 −1
@@ -8,6 +8,9 @@ import SwiftUI
88struct FormattedBodyView: View {
99 let text: String
1010 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?
1114
1215 @Environment(\.colorScheme) private var colorScheme
1316
@@ -22,7 +25,7 @@ struct FormattedBodyView: View {
2225 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)
2326 )
2427 } else {
25 MarkdownView(markdown: text)
28 MarkdownView(markdown: text, referenceRepo: repo)
2629 }
2730 }
2831}
gitbay/Views/Repos/MarkdownView.swift +36 −10
@@ -10,6 +10,8 @@ struct MarkdownView: View {
1010 /// Repository context for resolving relative link targets. Nil leaves them as authored,
1111 /// which is right for a body with no file to be relative to.
1212 var linkOptions: OrgRenderOptions?
13 /// The repository `#N` and `!N` refer to. Nil links no references.
14 var referenceRepo: String?
1315
1416 var body: some View {
1517 VStack(alignment: .leading, spacing: 12) {
@@ -223,7 +225,8 @@ struct MarkdownView: View {
223225 private func blockView(_ block: Block) -> some View {
224226 switch block {
225227 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)
227230 .font(headingFont(level))
228231 .padding(.top, level <= 2 ? 8 : 4)
229232 case .code(let code):
@@ -238,7 +241,7 @@ struct MarkdownView: View {
238241 RoundedRectangle(cornerRadius: 2)
239242 .fill(.tertiary)
240243 .frame(width: 3)
241 Self.inline(text, linkOptions: linkOptions).foregroundStyle(.secondary)
244 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo).foregroundStyle(.secondary)
242245 }
243246 .fixedSize(horizontal: false, vertical: true)
244247 case .bullet(let items):
@@ -246,7 +249,7 @@ struct MarkdownView: View {
246249 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
247250 HStack(alignment: .firstTextBaseline, spacing: 8) {
248251 Text("•")
249 Self.inline(item, linkOptions: linkOptions)
252 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
250253 .fixedSize(horizontal: false, vertical: true)
251254 }
252255 }
@@ -256,17 +259,17 @@ struct MarkdownView: View {
256259 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
257260 HStack(alignment: .firstTextBaseline, spacing: 8) {
258261 Text("\(index + 1).").monospacedDigit()
259 Self.inline(item, linkOptions: linkOptions)
262 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
260263 .fixedSize(horizontal: false, vertical: true)
261264 }
262265 }
263266 }
264267 case .table(let table):
265 MarkdownTableView(table: table, linkOptions: linkOptions)
268 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo)
266269 case .rule:
267270 Divider()
268271 case .paragraph(let text):
269 Self.inline(text, linkOptions: linkOptions)
272 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
270273 }
271274 }
272275
@@ -277,15 +280,18 @@ struct MarkdownView: View {
277280 static func inline(
278281 _ text: String,
279282 codeStyle: Font.TextStyle = .subheadline,
280 linkOptions: OrgRenderOptions? = nil
283 linkOptions: OrgRenderOptions? = nil,
284 referenceRepo: String? = nil
281285 ) -> Text {
282 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions))
286 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions,
287 referenceRepo: referenceRepo))
283288 }
284289
285290 static func attributed(
286291 _ text: String,
287292 codeStyle: Font.TextStyle = .subheadline,
288 linkOptions: OrgRenderOptions? = nil
293 linkOptions: OrgRenderOptions? = nil,
294 referenceRepo: String? = nil
289295 ) -> AttributedString {
290296 guard var attributed = try? AttributedString(
291297 markdown: text,
@@ -306,9 +312,28 @@ struct MarkdownView: View {
306312 attributed[range].link = url
307313 }
308314 }
315 if let referenceRepo {
316 linkReferences(in: &attributed, repo: referenceRepo)
317 }
309318 return attributed
310319 }
311320
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
312337 private func headingFont(_ level: Int) -> Font {
313338 switch level {
314339 case 1: .title2.bold()
@@ -338,6 +363,7 @@ struct MarkdownView: View {
338363private struct MarkdownTableView: View {
339364 let table: MarkdownView.Table
340365 var linkOptions: OrgRenderOptions?
366 var referenceRepo: String?
341367
342368 private static let spacing: CGFloat = 14
343369
@@ -372,7 +398,7 @@ private struct MarkdownTableView: View {
372398
373399 private func cell(_ text: String, _ column: Int) -> some View {
374400 let alignment = table.alignments[column]
375 return MarkdownView.inline(text, linkOptions: linkOptions)
401 return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
376402 .multilineTextAlignment(Self.textAlignment(alignment))
377403 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
378404 .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}