Commit d3a69034f3
Verified · cmc
Layout: unified · split
gitbay/Views/Issues/IssueView.swift +3 −2
| @@ -31,7 +31,7 @@ struct IssueView: View { | ||
| 31 | 31 | |
| 32 | 32 | if let body = issue.body, !body.isEmpty { |
| 33 | 33 | Section { |
| 34 | FormattedBodyView(text: body, format: issue.bodyFormat) | |
| 34 | FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath) | |
| 35 | 35 | .padding(.vertical, 4) |
| 36 | 36 | } |
| 37 | 37 | } |
| @@ -41,6 +41,7 @@ struct IssueView: View { | ||
| 41 | 41 | } |
| 42 | 42 | } |
| 43 | 43 | .overlay { LoadStateOverlay(state: model.state) } |
| 44 | .referenceNavigation() | |
| 44 | 45 | .scrollDismissesKeyboard(.interactively) |
| 45 | 46 | .navigationTitle("#\(model.number)") |
| 46 | 47 | .navigationBarTitleDisplayMode(.inline) |
| @@ -166,7 +167,7 @@ struct IssueView: View { | ||
| 166 | 167 | .font(.gbSans(.caption)) |
| 167 | 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 | 171 | .font(.gbSans(.subheadline)) |
| 171 | 172 | } |
| 172 | 173 | .padding(.vertical, 2) |
gitbay/Views/MRs/MRView.swift +3 −2
| @@ -37,7 +37,7 @@ struct MRView: View { | ||
| 37 | 37 | |
| 38 | 38 | if let body = mr.body, !body.isEmpty { |
| 39 | 39 | Section { |
| 40 | FormattedBodyView(text: body, format: mr.bodyFormat) | |
| 40 | FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath) | |
| 41 | 41 | .padding(.vertical, 4) |
| 42 | 42 | } |
| 43 | 43 | } |
| @@ -72,6 +72,7 @@ struct MRView: View { | ||
| 72 | 72 | } |
| 73 | 73 | } |
| 74 | 74 | .overlay { LoadStateOverlay(state: model.state) } |
| 75 | .referenceNavigation() | |
| 75 | 76 | .scrollDismissesKeyboard(.interactively) |
| 76 | 77 | .navigationTitle("!\(model.number)") |
| 77 | 78 | .navigationBarTitleDisplayMode(.inline) |
| @@ -532,7 +533,7 @@ struct MRView: View { | ||
| 532 | 533 | .font(.gbSans(.caption)) |
| 533 | 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 | 537 | .font(.gbSans(.subheadline)) |
| 537 | 538 | } |
| 538 | 539 | .padding(.vertical, 2) |
gitbay/Views/Repos/FormattedBodyView.swift +4 −1
| @@ -8,6 +8,9 @@ import SwiftUI | ||
| 8 | 8 | struct FormattedBodyView: View { |
| 9 | 9 | let text: String |
| 10 | 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 | 15 | @Environment(\.colorScheme) private var colorScheme |
| 13 | 16 | |
| @@ -22,7 +25,7 @@ struct FormattedBodyView: View { | ||
| 22 | 25 | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone) |
| 23 | 26 | ) |
| 24 | 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 | 10 | /// Repository context for resolving relative link targets. Nil leaves them as authored, |
| 11 | 11 | /// which is right for a body with no file to be relative to. |
| 12 | 12 | var linkOptions: OrgRenderOptions? |
| 13 | /// The repository `#N` and `!N` refer to. Nil links no references. | |
| 14 | var referenceRepo: String? | |
| 13 | 15 | |
| 14 | 16 | var body: some View { |
| 15 | 17 | VStack(alignment: .leading, spacing: 12) { |
| @@ -223,7 +225,8 @@ struct MarkdownView: View { | ||
| 223 | 225 | private func blockView(_ block: Block) -> some View { |
| 224 | 226 | switch block { |
| 225 | 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 | 230 | .font(headingFont(level)) |
| 228 | 231 | .padding(.top, level <= 2 ? 8 : 4) |
| 229 | 232 | case .code(let code): |
| @@ -238,7 +241,7 @@ struct MarkdownView: View { | ||
| 238 | 241 | RoundedRectangle(cornerRadius: 2) |
| 239 | 242 | .fill(.tertiary) |
| 240 | 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 | 246 | .fixedSize(horizontal: false, vertical: true) |
| 244 | 247 | case .bullet(let items): |
| @@ -246,7 +249,7 @@ struct MarkdownView: View { | ||
| 246 | 249 | ForEach(Array(items.enumerated()), id: \.offset) { _, item in |
| 247 | 250 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 248 | 251 | Text("•") |
| 249 | Self.inline(item, linkOptions: linkOptions) | |
| 252 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 250 | 253 | .fixedSize(horizontal: false, vertical: true) |
| 251 | 254 | } |
| 252 | 255 | } |
| @@ -256,17 +259,17 @@ struct MarkdownView: View { | ||
| 256 | 259 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in |
| 257 | 260 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 258 | 261 | Text("\(index + 1).").monospacedDigit() |
| 259 | Self.inline(item, linkOptions: linkOptions) | |
| 262 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 260 | 263 | .fixedSize(horizontal: false, vertical: true) |
| 261 | 264 | } |
| 262 | 265 | } |
| 263 | 266 | } |
| 264 | 267 | case .table(let table): |
| 265 | MarkdownTableView(table: table, linkOptions: linkOptions) | |
| 268 | MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 266 | 269 | case .rule: |
| 267 | 270 | Divider() |
| 268 | 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 | 280 | static func inline( |
| 278 | 281 | _ text: String, |
| 279 | 282 | codeStyle: Font.TextStyle = .subheadline, |
| 280 | linkOptions: OrgRenderOptions? = nil | |
| 283 | linkOptions: OrgRenderOptions? = nil, | |
| 284 | referenceRepo: String? = nil | |
| 281 | 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 | 290 | static func attributed( |
| 286 | 291 | _ text: String, |
| 287 | 292 | codeStyle: Font.TextStyle = .subheadline, |
| 288 | linkOptions: OrgRenderOptions? = nil | |
| 293 | linkOptions: OrgRenderOptions? = nil, | |
| 294 | referenceRepo: String? = nil | |
| 289 | 295 | ) -> AttributedString { |
| 290 | 296 | guard var attributed = try? AttributedString( |
| 291 | 297 | markdown: text, |
| @@ -306,9 +312,28 @@ struct MarkdownView: View { | ||
| 306 | 312 | attributed[range].link = url |
| 307 | 313 | } |
| 308 | 314 | } |
| 315 | if let referenceRepo { | |
| 316 | linkReferences(in: &attributed, repo: referenceRepo) | |
| 317 | } | |
| 309 | 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 | 337 | private func headingFont(_ level: Int) -> Font { |
| 313 | 338 | switch level { |
| 314 | 339 | case 1: .title2.bold() |
| @@ -338,6 +363,7 @@ struct MarkdownView: View { | ||
| 338 | 363 | private struct MarkdownTableView: View { |
| 339 | 364 | let table: MarkdownView.Table |
| 340 | 365 | var linkOptions: OrgRenderOptions? |
| 366 | var referenceRepo: String? | |
| 341 | 367 | |
| 342 | 368 | private static let spacing: CGFloat = 14 |
| 343 | 369 | |
| @@ -372,7 +398,7 @@ private struct MarkdownTableView: View { | ||
| 372 | 398 | |
| 373 | 399 | private func cell(_ text: String, _ column: Int) -> some View { |
| 374 | 400 | let alignment = table.alignments[column] |
| 375 | return MarkdownView.inline(text, linkOptions: linkOptions) | |
| 401 | return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 376 | 402 | .multilineTextAlignment(Self.textAlignment(alignment)) |
| 377 | 403 | .frame(width: cellWidth, alignment: Self.frameAlignment(alignment)) |
| 378 | 404 | .fixedSize(horizontal: cellWidth == nil, vertical: true) |
gitbay/Views/Shared/BodyReferences.swift added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 7 | nonisolated 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. | |
| 17 | nonisolated 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 | ||
| 45 | nonisolated 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. | |
| 49 | nonisolated 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 | ||
| 58 | nonisolated 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 | ||
| 63 | extension 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 | ||
| 71 | private 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 7 | struct 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 | |
| 63 | struct 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 | } | |