import Foundation import SwiftUI /// A `#N`, `!N`, `owner/name#N` or `owner/name!N` in a body, as the web's /// autolink package reads them: `#` is an issue, `!` a merge request. /// `range` counts characters. nonisolated struct BodyReference: Equatable, Sendable { let range: Range let destination: NotificationDestination } /// Every reference in `text`, bare ones resolved against `repo`. The /// patterns are the web's: a bare reference needs the start of the text, /// whitespace or an opening bracket before it, and a cross-repository one /// wins where the two overlap. Unlike the web, the target is not checked /// to exist. nonisolated func bodyReferences(in text: String, repo: String) -> [BodyReference] { let cross = /([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)([#!])([0-9]+)/ let bare = /(^|[\s(\[{])([#!])([0-9]+)(?![0-9A-Za-z_])/ func offsets(_ range: Range) -> Range { let lower = text.distance(from: text.startIndex, to: range.lowerBound) return lower..<(lower + text.distance(from: range.lowerBound, to: range.upperBound)) } func destination(_ repo: String, _ kind: Substring, _ number: Substring) -> NotificationDestination? { guard let n = Int64(number) else { return nil } return kind == "#" ? .issue(repo: repo, number: n) : .mr(repo: repo, number: n) } var found: [BodyReference] = [] for match in text.matches(of: cross) { if let d = destination("\(match.1)/\(match.2)", match.3, match.4) { found.append(BodyReference(range: offsets(match.range), destination: d)) } } for match in text.matches(of: bare) { let range = offsets(match.2.startIndex.. URL? { let path = switch destination { case .issue(let repo, let number): "\(repo)/issues/\(number)" case .mr(let repo, let number): "\(repo)/mrs/\(number)" case .build(let repo, let number): "\(repo)/builds/\(number)" } return URL(string: "\(referenceScheme):\(path)") } nonisolated func referenceDestination(_ url: URL) -> NotificationDestination? { guard url.scheme == referenceScheme else { return nil } return notificationDestination(forPath: String(url.absoluteString.dropFirst(referenceScheme.count + 1))) } extension View { /// Opens reference links from a body in-app. Attach outside the /// `List`: a navigation destination inside a lazy container is ignored. func referenceNavigation() -> some View { modifier(ReferenceNavigation()) } } private struct ReferenceNavigation: ViewModifier { @Environment(\.pushRoute) private var pushRoute func body(content: Content) -> some View { content .environment(\.openURL, OpenURLAction { url in guard let destination = referenceDestination(url) else { return .systemAction } switch destination { case .issue(let repo, let number): pushRoute(IssueRoute.issue(repo: repo, number: number)) case .mr(let repo, let number): pushRoute(MRRoute.mr(repo: repo, number: number)) case .build(let repo, let number): pushRoute(BuildRoute.detail(repo: repo, number: number)) } return .handled }) } }