gitbay/Views/WebURLRoute.swift

v1.11.0
gitbay-ios/gitbay/Views/WebURLRoute.swift history · blame · raw

107 lines · 4831 bytes

  1import Foundation
  2
  3/// The screen a web page's URL opens, for links to the instance that
  4/// arrive as universal links. The shapes follow the forge's route table;
  5/// a page the app has no screen for is nil, and goes to Safari.
  6nonisolated enum WebURLRoute: Hashable, Sendable {
  7    case repo(RepoRoute)
  8    case issue(IssueRoute)
  9    case mr(MRRoute)
 10    case build(BuildRoute)
 11    case release(ReleaseRoute)
 12    case org(OrgRoute)
 13    case snippet(SnippetRoute)
 14
 15    init?(url: URL) {
 16        guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
 17              let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
 18        // Split before decoding, so an escaped slash stays in its segment.
 19        let s = components.percentEncodedPath.split(separator: "/").compactMap {
 20            String($0).removingPercentEncoding
 21        }
 22        guard let owner = s.first else { return nil }
 23        if s.count == 1 {
 24            switch owner {
 25            case "bookmarks": self = .repo(.bookmarks)
 26            case "notifications": self = .repo(.notifications)
 27            default: self = .repo(.profile(owner))
 28            }
 29            return
 30        }
 31        if s[1] == "-" {
 32            guard let route = Self.ownerPage(owner, Array(s.dropFirst(2))) else { return nil }
 33            self = route
 34            return
 35        }
 36        let repo = "\(owner)/\(s[1])"
 37        guard s.count > 2 else {
 38            self = .repo(.repo(repo))
 39            return
 40        }
 41        guard let route = Self.repoPage(repo, s[2], Array(s.dropFirst(3))) else { return nil }
 42        self = route
 43    }
 44
 45    /// `/{owner}/-/...`
 46    private static func ownerPage(_ owner: String, _ rest: [String]) -> WebURLRoute? {
 47        switch (rest.first, rest.count) {
 48        case ("repositories", 1), ("bookmarks", 1), ("people", 1): .repo(.profile(owner))
 49        case ("labels", 1): .org(.labels(owner))
 50        case ("milestones", 1): .org(.milestones(owner))
 51        case ("snippets", 1): .snippet(.list(owner: owner))
 52        case ("snippets", 2) where rest[1] != "new": .snippet(.snippet(id: rest[1]))
 53        default: nil
 54        }
 55    }
 56
 57    /// `/{owner}/{repo}/{section}/...`
 58    private static func repoPage(_ repo: String, _ section: String, _ rest: [String]) -> WebURLRoute? {
 59        let number = rest.count == 1 ? Int64(rest[0]).flatMap { $0 > 0 ? $0 : nil } : nil
 60        let ref = rest.first
 61        let path = rest.dropFirst().joined(separator: "/")
 62        switch section {
 63        case "issues":
 64            if rest.isEmpty { return .issue(.list(repo: repo)) }
 65            return number.map { .issue(.issue(repo: repo, number: $0)) }
 66        case "mrs":
 67            if rest.isEmpty { return .mr(.list(repo: repo)) }
 68            return number.map { .mr(.mr(repo: repo, number: $0)) }
 69        case "builds":
 70            if rest.isEmpty { return .build(.list(repo: repo)) }
 71            return number.map { .build(.detail(repo: repo, number: $0)) }
 72        case "tree":
 73            return ref.map { .repo(.tree(repo: repo, directory: path, ref: $0)) }
 74        case "blob" where !path.isEmpty:
 75            return .repo(.file(repo: repo, path: path, ref: ref))
 76        case "blame" where !path.isEmpty:
 77            return .repo(.blame(repo: repo, path: path, ref: ref))
 78        case "commit" where rest.count == 1:
 79            return .repo(.commit(repo: repo, sha: rest[0]))
 80        case "log" where rest.count <= 1:
 81            return .repo(.log(repo: repo, ref: ref, path: nil))
 82        case "wiki" where ref != "_raw":
 83            if rest.isEmpty { return .repo(.wiki(repo: repo)) }
 84            return .repo(.wikiPage(repo: repo, page: rest.joined(separator: "/")))
 85        case _ where !rest.isEmpty:
 86            return nil
 87        case "refs": return .repo(.refs(repo: repo))
 88        case "compare": return .repo(.compare(repo: repo, defaultBranch: nil))
 89        case "releases": return .release(.list(repo: repo))
 90        case "milestones": return .repo(.milestones(repo: repo))
 91        case "labels": return .repo(.labels(repo: repo))
 92        default: return nil
 93        }
 94    }
 95
 96    /// The signed-in account a link opens under: the current one when it
 97    /// is on the link's instance, otherwise the first that is. Both sides
 98    /// go through `GitbayInstance`'s normalisation, as `PushTarget` does.
 99    static func account(for url: URL, current: Account?, accounts: [Account]) -> Account? {
100        guard let instance = try? GitbayInstance(url: url.absoluteString) else { return nil }
101        let matches = { (account: Account) in
102            (try? GitbayInstance(url: account.instance.baseURL.absoluteString))?.baseURL == instance.baseURL
103        }
104        if let current, matches(current) { return current }
105        return accounts.first(where: matches)
106    }
107}