import Foundation /// The screen a web page's URL opens, for links to the instance that /// arrive as universal links. The shapes follow the forge's route table; /// a page the app has no screen for is nil, and goes to Safari. nonisolated enum WebURLRoute: Hashable, Sendable { case repo(RepoRoute) case issue(IssueRoute) case mr(MRRoute) case build(BuildRoute) case release(ReleaseRoute) case org(OrgRoute) case snippet(SnippetRoute) init?(url: URL) { guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http", let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } // Split before decoding, so an escaped slash stays in its segment. let s = components.percentEncodedPath.split(separator: "/").compactMap { String($0).removingPercentEncoding } guard let owner = s.first else { return nil } if s.count == 1 { switch owner { case "bookmarks": self = .repo(.bookmarks) case "notifications": self = .repo(.notifications) default: self = .repo(.profile(owner)) } return } if s[1] == "-" { guard let route = Self.ownerPage(owner, Array(s.dropFirst(2))) else { return nil } self = route return } let repo = "\(owner)/\(s[1])" guard s.count > 2 else { self = .repo(.repo(repo)) return } guard let route = Self.repoPage(repo, s[2], Array(s.dropFirst(3))) else { return nil } self = route } /// `/{owner}/-/...` private static func ownerPage(_ owner: String, _ rest: [String]) -> WebURLRoute? { switch (rest.first, rest.count) { case ("repositories", 1), ("bookmarks", 1), ("people", 1): .repo(.profile(owner)) case ("labels", 1): .org(.labels(owner)) case ("milestones", 1): .org(.milestones(owner)) case ("snippets", 1): .snippet(.list(owner: owner)) case ("snippets", 2) where rest[1] != "new": .snippet(.snippet(id: rest[1])) default: nil } } /// `/{owner}/{repo}/{section}/...` private static func repoPage(_ repo: String, _ section: String, _ rest: [String]) -> WebURLRoute? { let number = rest.count == 1 ? Int64(rest[0]).flatMap { $0 > 0 ? $0 : nil } : nil let ref = rest.first let path = rest.dropFirst().joined(separator: "/") switch section { case "issues": if rest.isEmpty { return .issue(.list(repo: repo)) } return number.map { .issue(.issue(repo: repo, number: $0)) } case "mrs": if rest.isEmpty { return .mr(.list(repo: repo)) } return number.map { .mr(.mr(repo: repo, number: $0)) } case "builds": if rest.isEmpty { return .build(.list(repo: repo)) } return number.map { .build(.detail(repo: repo, number: $0)) } case "tree": return ref.map { .repo(.tree(repo: repo, directory: path, ref: $0)) } case "blob" where !path.isEmpty: return .repo(.file(repo: repo, path: path, ref: ref)) case "blame" where !path.isEmpty: return .repo(.blame(repo: repo, path: path, ref: ref)) case "commit" where rest.count == 1: return .repo(.commit(repo: repo, sha: rest[0])) case "log" where rest.count <= 1: return .repo(.log(repo: repo, ref: ref, path: nil)) case "wiki" where ref != "_raw": if rest.isEmpty { return .repo(.wiki(repo: repo)) } return .repo(.wikiPage(repo: repo, page: rest.joined(separator: "/"))) case _ where !rest.isEmpty: return nil case "refs": return .repo(.refs(repo: repo)) case "compare": return .repo(.compare(repo: repo, defaultBranch: nil)) case "releases": return .release(.list(repo: repo)) case "milestones": return .repo(.milestones(repo: repo)) case "labels": return .repo(.labels(repo: repo)) default: return nil } } /// The signed-in account a link opens under: the current one when it /// is on the link's instance, otherwise the first that is. Both sides /// go through `GitbayInstance`'s normalisation, as `PushTarget` does. static func account(for url: URL, current: Account?, accounts: [Account]) -> Account? { guard let instance = try? GitbayInstance(url: url.absoluteString) else { return nil } let matches = { (account: Account) in (try? GitbayInstance(url: account.instance.baseURL.absoluteString))?.baseURL == instance.baseURL } if let current, matches(current) { return current } return accounts.first(where: matches) } }