diff options
Diffstat (limited to 'Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift')
| -rw-r--r-- | Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift | 545 |
1 files changed, 545 insertions, 0 deletions
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift new file mode 100644 index 0000000..30ee3bb --- /dev/null +++ b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift @@ -0,0 +1,545 @@ +import Foundation + +private struct HgRepositorySummaryResponse: Decodable, Sendable { + let repository: HgRepositorySummaryPayload? +} + +private struct HgRepositorySummaryPayload: Decodable, Sendable { + let id: Int + let rid: String + let name: String + let description: String? + let visibility: Visibility + let readme: String? + let nonPublishing: Bool? + let tip: HgSummaryTip? + let branches: HgNamedRevisionPage? + let tags: HgNamedRevisionPage? + let bookmarks: HgNamedRevisionPage? +} + +private struct HgSummaryTip: Decodable, Sendable { + let id: String? + let author: String? + let description: String? + let branch: String? + let tags: [String]? + + var resolvedRevision: HgRevision? { + guard + let id, + let author, + let description + else { + return nil + } + + return HgRevision( + id: id, + author: author, + description: description, + branch: branch, + tags: tags + ) + } +} + +private struct HgRevisionLogResponse: Decodable, Sendable { + let repository: HgRevisionLogRepository? +} + +private struct HgRevisionLogRepository: Decodable, Sendable { + let log: HgRevisionPage? +} + +private struct HgReadmeFileResponse: Decodable, Sendable { + let repository: HgReadmeFileRepository? +} + +private struct HgReadmeFileRepository: Decodable, Sendable { + let readme: String? +} + +private struct HgFilesResponse: Decodable, Sendable { + let repository: HgFilesRepository? +} + +private struct HgFilesRepository: Decodable, Sendable { + let files: HgFilePage? +} + +private struct HgFilePage: Decodable, Sendable { + let results: [HgFile] + let cursor: String? +} + +private struct HgNamedRevisionPage: Decodable, Sendable { + let results: [HgNamedRevision] + let cursor: String? + + private enum CodingKeys: String, CodingKey { + case results + case cursor + } + + init(results: [HgNamedRevision], cursor: String?) { + self.results = results + self.cursor = cursor + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.results = try container.decodeIfPresent([HgNamedRevision?].self, forKey: .results)?.compactMap { $0 } ?? [] + self.cursor = try container.decodeIfPresent(String.self, forKey: .cursor) + } +} + +private struct HgCatResponse: Decodable, Sendable { + let repository: HgCatRepository? +} + +private struct HgCatRepository: Decodable, Sendable { + let cat: String? +} + +struct HgRevisionPage: Decodable, Sendable { + let results: [HgRevision] + let cursor: String? +} + +struct HgRevision: Decodable, Sendable, Identifiable, Hashable { + let id: String + let author: String + let description: String + let branch: String? + let tags: [String]? + + var displayShortId: String { + String(id.prefix(12)) + } + + var title: String { + description.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: String? { + let body = description + .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + .dropFirst() + .first + .map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return body?.isEmpty == false ? body : nil + } + + var primaryName: String { + if let tag = tags?.first, !tag.isEmpty { + return tag + } + if let branch, !branch.isEmpty { + return branch + } + return displayShortId + } +} + +struct HgFile: Decodable, Sendable, Hashable, Identifiable { + let name: String + + var id: String { name } + + var isDirectory: Bool { + name.hasSuffix("/") + } +} + +struct HgNamedRevision: Decodable, Sendable, Identifiable, Hashable { + let name: String + let id: String + + var displayShortId: String { + String(id.prefix(12)) + } +} + +@Observable +@MainActor +final class HgRepositoryDetailViewModel { + enum Tab: String, CaseIterable { + case summary = "Summary" + case browse = "Browse" + case log = "Log" + case tags = "Tags" + case branches = "Branches" + case bookmarks = "Bookmarks" + } + + enum ReadmeContent { + case html(String) + case markdown(String) + case org(String) + case plainText(String) + } + + let repository: RepositorySummary + private let client: SRHTClient + + private(set) var summaryLoaded = false + private(set) var isLoadingSummary = false + private(set) var readmeContent: ReadmeContent? + private(set) var readmePath: String? + private(set) var nonPublishing = false + private(set) var tip: HgRevision? + private(set) var branches: [HgNamedRevision] = [] + private(set) var tags: [HgNamedRevision] = [] + private(set) var bookmarks: [HgNamedRevision] = [] + + private(set) var log: [HgRevision] = [] + private(set) var isLoadingLog = false + private(set) var isLoadingMoreLog = false + private var logCursor: String? + private var hasMoreLog = true + + private(set) var currentBrowsePath = "" + private(set) var pathStack: [String] = [] + private(set) var files: [HgFile] = [] + private(set) var fileContent: String? + private(set) var selectedFilePath: String? + private(set) var isLoadingBrowse = false + private(set) var browseRevspec = "tip" + + var error: String? + + init(repository: RepositorySummary, client: SRHTClient) { + self.repository = repository + self.client = client + } + + private static let summaryQuery = """ + query hgRepositorySummary($rid: ID!) { + repository(rid: $rid) { + id + rid + name + description + visibility + readme + nonPublishing + tip { + id + author + description + branch + tags + } + branches { + results { + name + id + } + cursor + } + tags { + results { + name + id + } + cursor + } + bookmarks { + results { + name + id + } + cursor + } + } + } + """ + + private static let logQuery = """ + query hgRepositoryLog($rid: ID!, $cursor: Cursor) { + repository(rid: $rid) { + log(cursor: $cursor) { + results { + id + author + description + branch + tags + } + cursor + } + } + } + """ + + private static func readmeFileQuery(filename: String) -> String { + """ + query hgReadmeFile($rid: ID!) { + repository(rid: $rid) { + readme: cat(path: "\(filename)", revspec: "tip") + } + } + """ + } + + private static let readmeFilenames = [ + "README.md", "README.org", "README.txt", "README", + "readme.md", "readme.org" + ] + + private static let filesQuery = """ + query hgFiles($rid: ID!, $path: String!, $revspec: String!) { + repository(rid: $rid) { + files(path: $path, revspec: $revspec) { + results { + name + } + cursor + } + } + } + """ + + private static let catQuery = """ + query hgCat($rid: ID!, $path: String!, $revspec: String!) { + repository(rid: $rid) { + cat(path: $path, revspec: $revspec) + } + } + """ + + func loadSummary() async { + guard !isLoadingSummary, !summaryLoaded else { return } + isLoadingSummary = true + defer { isLoadingSummary = false } + error = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.summaryQuery, + variables: ["rid": repository.rid], + responseType: HgRepositorySummaryResponse.self + ) + + guard let repository = result.repository else { + summaryLoaded = true + return + } + + tip = repository.tip?.resolvedRevision + branches = repository.branches?.results ?? [] + tags = repository.tags?.results ?? [] + bookmarks = repository.bookmarks?.results ?? [] + nonPublishing = repository.nonPublishing ?? false + + if let html = repository.readme, !html.isEmpty { + readmePath = nil + readmeContent = .html(html) + } else { + await loadReadmeFile() + } + + summaryLoaded = true + } catch { + self.error = error.localizedDescription + } + } + + func loadLog() async { + guard !isLoadingLog else { return } + isLoadingLog = true + defer { isLoadingLog = false } + error = nil + logCursor = nil + hasMoreLog = true + + do { + let page = try await fetchLogPage(cursor: nil) + log = page.results + logCursor = page.cursor + hasMoreLog = page.cursor != nil + } catch { + if isEmptyRepositoryError(error) { + log = [] + logCursor = nil + hasMoreLog = false + } else { + self.error = error.localizedDescription + } + } + } + + func loadMoreLogIfNeeded(currentItem: HgRevision) async { + guard let last = log.last, + last.id == currentItem.id, + hasMoreLog, + !isLoadingMoreLog else { + return + } + + isLoadingMoreLog = true + defer { isLoadingMoreLog = false } + + do { + let page = try await fetchLogPage(cursor: logCursor) + log.append(contentsOf: page.results) + logCursor = page.cursor + hasMoreLog = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + } + + private func fetchLogPage(cursor: String?) async throws -> HgRevisionPage { + var variables: [String: any Sendable] = ["rid": repository.rid] + if let cursor { + variables["cursor"] = cursor + } + + let result = try await client.execute( + service: .hg, + query: Self.logQuery, + variables: variables, + responseType: HgRevisionLogResponse.self + ) + return result.repository?.log ?? HgRevisionPage(results: [], cursor: nil) + } + + private func loadReadmeFile() async { + for filename in Self.readmeFilenames { + do { + let result = try await client.execute( + service: .hg, + query: Self.readmeFileQuery(filename: filename), + variables: ["rid": repository.rid], + responseType: HgReadmeFileResponse.self + ) + + if let text = result.repository?.readme, !text.isEmpty { + readmePath = filename + if filename.hasSuffix(".md") { + readmeContent = .markdown(text) + } else if filename.hasSuffix(".org") { + readmeContent = .org(text) + } else { + readmeContent = .plainText(text) + } + return + } + } catch { + if isEmptyRepositoryError(error) { + readmeContent = nil + readmePath = nil + return + } + continue + } + } + } + + func loadBrowseRoot() async { + await loadFiles(at: "") + } + + func openFile(_ file: HgFile) async { + let path = joinedPath(for: file.name) + if file.isDirectory { + await loadFiles(at: path) + return + } + + isLoadingBrowse = true + defer { isLoadingBrowse = false } + error = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.catQuery, + variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec], + responseType: HgCatResponse.self + ) + + if let text = result.repository?.cat { + selectedFilePath = path + fileContent = text + } else { + await loadFiles(at: path) + } + } catch { + self.error = error.localizedDescription + } + } + + func navigateToPath(index: Int) async { + guard index >= 0, index <= pathStack.count else { return } + let targetPath = Array(pathStack.prefix(index)).joined(separator: "/") + await loadFiles(at: targetPath) + } + + func dismissFileView() { + selectedFilePath = nil + fileContent = nil + } + + func changeBrowseRevspec(_ newRevspec: String) async { + guard browseRevspec != newRevspec else { return } + browseRevspec = newRevspec + await loadBrowseRoot() + } + + private func loadFiles(at path: String) async { + isLoadingBrowse = true + defer { isLoadingBrowse = false } + error = nil + selectedFilePath = nil + fileContent = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.filesQuery, + variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec], + responseType: HgFilesResponse.self + ) + + currentBrowsePath = path + pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init) + files = result.repository?.files?.results ?? [] + } catch { + if isEmptyRepositoryError(error) { + currentBrowsePath = path + pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init) + files = [] + } else { + self.error = error.localizedDescription + } + } + } + + private func joinedPath(for name: String) -> String { + let cleanedName = name.hasSuffix("/") ? String(name.dropLast()) : name + return currentBrowsePath.isEmpty ? cleanedName : "\(currentBrowsePath)/\(cleanedName)" + } + + private func isEmptyRepositoryError(_ error: Error) -> Bool { + if let srhtError = error as? SRHTError, + case .graphQLErrors(let errors) = srhtError { + return errors.contains { + let message = $0.message.localizedLowercase + return message.contains("missing") + || message.contains("not found") + || message.contains("unknown revision") + || message.contains("unknown revision or path not in the working tree") + } + } + + let message = error.localizedDescription.localizedLowercase + return message.contains("missing") + || message.contains("not found") + || message.contains("unknown revision") + } +} |
