diff options
Diffstat (limited to 'Hutch/Views/Repositories')
| -rw-r--r-- | Hutch/Views/Repositories/FileTreeView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/FileTreeViewModel.swift | 24 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/ReadmeView.swift | 141 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryListViewModel.swift | 66 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySettingsViewModel.swift | 74 |
5 files changed, 242 insertions, 65 deletions
diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift index 3750adb..45fe5b6 100644 --- a/Hutch/Views/Repositories/FileTreeView.swift +++ b/Hutch/Views/Repositories/FileTreeView.swift @@ -235,7 +235,7 @@ private struct FileTreeContentView: View { GeometryReader { geometry in ScrollView([.vertical, .horizontal]) { - Text(blob.text) + Text(blob.text ?? "") .font(.system(.body, design: .monospaced)) .multilineTextAlignment(.leading) .fixedSize(horizontal: true, vertical: false) diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift index f270779..a9c86b4 100644 --- a/Hutch/Views/Repositories/FileTreeViewModel.swift +++ b/Hutch/Views/Repositories/FileTreeViewModel.swift @@ -96,6 +96,7 @@ final class FileTreeViewModel { mode object { type + __typename id shortId ... on Tree { @@ -110,12 +111,10 @@ final class FileTreeViewModel { } } ... on TextBlob { - text size } ... on BinaryBlob { size - content } } } @@ -142,6 +141,7 @@ final class FileTreeViewModel { mode object { type + __typename id shortId ... on Tree { @@ -156,12 +156,10 @@ final class FileTreeViewModel { } } ... on TextBlob { - text size } ... on BinaryBlob { size - content } } } @@ -187,6 +185,7 @@ final class FileTreeViewModel { mode object { type + __typename id shortId ... on Tree { @@ -201,12 +200,10 @@ final class FileTreeViewModel { } } ... on TextBlob { - text size } ... on BinaryBlob { size - content } } } @@ -321,16 +318,23 @@ final class FileTreeViewModel { // Otherwise fetch the subtree (handles pagination) await loadSubtree(name: entry.name, treeId: objectSHA) - case .textBlob: - viewingEntry = entry - viewingObject = object + case .textBlob(let blob): + if blob.text != nil { + viewingEntry = entry + viewingObject = object + } else if let blobId = blob.id { + await loadBlob(entry: entry, blobId: blobId) + } case .binaryBlob(let blob): - if blob.content != nil || blob.size != nil { + if blob.content != nil { viewingEntry = entry viewingObject = object } else if let blobId = blob.id { await loadBlob(entry: entry, blobId: blobId) + } else { + viewingEntry = entry + viewingObject = object } case .unknown: diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift index b53885e..576adfc 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -387,14 +387,20 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St let alt = nsText.substring(with: match.range(at: 1)) let source = nsText.substring(with: match.range(at: 2)) let resolvedSource = imageURLResolver?(source) ?? source - return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"# + guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { + return escapeHTML(alt) + } + return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"# } // Links: [text](url) - result = result.replacingOccurrences( - of: #"\[([^\]]+)\]\(([^)]+)\)"#, - with: #"<a href="$2">$1</a>"#, - options: .regularExpression - ) + result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in + let label = nsText.substring(with: match.range(at: 1)) + let rawURL = nsText.substring(with: match.range(at: 2)) + guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else { + return label + } + return #"<a href="\#(sanitizedURL)">\#(label)</a>"# + } // Bold: **text** result = result.replacingOccurrences( of: #"\*\*(.+?)\*\*"#, @@ -676,7 +682,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St ) { return imageHTML } - return #"<a href="\#(url)">\#(label)</a>"# + guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { + return label + } + return #"<a href="\#(sanitizedURL)">\#(label)</a>"# } result = protectMatches( in: result, @@ -691,7 +700,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St ) { return imageHTML } - return #"<a href="\#(url)">\#(url)</a>"# + guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { + return url + } + return #"<a href="\#(sanitizedURL)">\#(url)</a>"# } result = protectMatches( in: result, @@ -742,6 +754,57 @@ nonisolated private func escapeHTMLAttribute(_ text: String) -> String { escapeHTML(text).replacingOccurrences(of: "'", with: "'") } +nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? { + sanitizeReadmeURLString( + rawURL, + allowedSchemes: ["http", "https", "mailto"], + allowsFragmentOnly: true + ) +} + +nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? { + sanitizeReadmeURLString( + rawURL, + allowedSchemes: ["http", "https"], + allowsFragmentOnly: false + ) +} + +nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { + return false + } + if scheme == "about" || scheme == "data" { + return true + } + guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else { + return false + } + return sanitizedURL == escapeHTMLAttribute(url.absoluteString) +} + +nonisolated private func sanitizeReadmeURLString( + _ rawURL: String, + allowedSchemes: Set<String>, + allowsFragmentOnly: Bool +) -> String? { + let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { return nil } + + if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { + return escapeHTMLAttribute(trimmedURL) + } + + guard let components = URLComponents(string: trimmedURL), + let scheme = components.scheme?.lowercased(), + allowedSchemes.contains(scheme), + let sanitizedURL = components.url?.absoluteString else { + return nil + } + + return escapeHTMLAttribute(sanitizedURL) +} + nonisolated private func isOrgTableLine(_ line: String) -> Bool { line.hasPrefix("|") && line.hasSuffix("|") } @@ -899,6 +962,7 @@ struct HTMLWebView: View { let html: String let colorScheme: ColorScheme var style: HTMLWebViewStyle = .readme + @Environment(\.openURL) private var openURL @State private var contentHeight: CGFloat = 1 @State private var loadError: String? @State private var reloadToken = 0 @@ -921,6 +985,7 @@ struct HTMLWebView: View { html: html, colorScheme: colorScheme, style: style, + openURL: openURL, dynamicHeight: $contentHeight, loadError: $loadError, reloadToken: reloadToken @@ -956,6 +1021,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { let html: String let colorScheme: ColorScheme let style: HTMLWebViewStyle + let openURL: OpenURLAction @Binding var dynamicHeight: CGFloat @Binding var loadError: String? let reloadToken: Int @@ -966,12 +1032,13 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() - config.defaultWebpagePreferences.allowsContentJavaScript = true + config.defaultWebpagePreferences.allowsContentJavaScript = false config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore let webView = WKWebView(frame: .zero, configuration: config) webView.isOpaque = false webView.backgroundColor = .clear webView.clipsToBounds = false + webView.allowsLinkPreview = false webView.scrollView.isScrollEnabled = false webView.scrollView.contentInsetAdjustmentBehavior = .never webView.scrollView.clipsToBounds = false @@ -1088,6 +1155,31 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc handleLoadFailure(error) } + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void + ) { + guard let requestURL = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + if navigationAction.navigationType == .linkActivated { + if isAllowedReadmeNavigationURL(requestURL) { + parent.openURL(requestURL) + } + decisionHandler(.cancel) + return + } + + if isAllowedReadmeNavigationURL(requestURL) { + decisionHandler(.allow) + } else { + decisionHandler(.cancel) + } + } + private func handleLoadFailure(_ error: Error) { let nsError = error as NSError guard nsError.code != NSURLErrorCancelled else { return } @@ -1097,28 +1189,15 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc } private func updateHeight(for webView: WKWebView) { - let script = """ - Math.max( - document.body.scrollHeight, - document.body.offsetHeight, - document.documentElement.scrollHeight, - document.documentElement.offsetHeight, - Math.ceil(document.body.getBoundingClientRect().height), - Math.ceil(document.documentElement.getBoundingClientRect().height) - ) - """ - - webView.evaluateJavaScript(script) { [weak self] result, _ in - guard let value = result as? Double, value > 0 else { return } - let height = ceil(value) + 4 - DispatchQueue.main.async { - guard let self else { return } - if let html = self.lastHTML { - Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) - } - if abs(self.parent.dynamicHeight - height) > 0.5 { - self.parent.dynamicHeight = height - } + webView.layoutIfNeeded() + let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4 + guard height > 0 else { return } + DispatchQueue.main.async { + if let html = self.lastHTML { + Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) + } + if abs(self.parent.dynamicHeight - height) > 0.5 { + self.parent.dynamicHeight = height } } } diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift index bba0108..6b63203 100644 --- a/Hutch/Views/Repositories/RepositoryListViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift @@ -38,10 +38,13 @@ final class RepositoryListViewModel { private(set) var hasMore = false private(set) var isSearching = false private(set) var isCreatingRepository = false + private(set) var hasLoadedSearchIndex = false + private var searchIndex: [RepositorySummary] = [] private let client: SRHTClient private static let gitCacheKey = "git.repositories" private static let hgCacheKey = "hg.repositories" + private static let minimumRemoteSearchLength = 3 init(client: SRHTClient) { self.client = client @@ -147,22 +150,23 @@ final class RepositoryListViewModel { do { var filteredResults: [RepositorySummary] - + if isSearch { - // For search queries, fetch all repositories from both services. - filteredResults = try await fetchAllRepositories() - - // Perform client-side filtering - let lowercasedQuery = query.lowercased() - filteredResults = filteredResults.filter { repo in - repo.name.lowercased().contains(lowercasedQuery) || - repo.description?.lowercased().contains(lowercasedQuery) ?? false + if hasLoadedSearchIndex || repositories.isEmpty == false { + filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query) + } else if Self.shouldRefreshSearchIndex(for: query) { + let repositories = try await fetchAllRepositories(useCache: true) + updateSearchIndex(with: repositories) + filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query) + } else { + filteredResults = [] } } else { let repositories = try await fetchAllRepositories(useCache: true) + updateSearchIndex(with: repositories) filteredResults = repositories } - + repositories = filteredResults.sorted(by: repositorySortOrder) } catch { // Only show error if we have no cached data to fall back on @@ -240,6 +244,7 @@ final class RepositoryListViewModel { repository = result.createRepository.repositorySummary(service: .hg) } repositories.insert(repository, at: 0) + insertIntoSearchIndex(repository) return repository } catch { self.error = repositoryCreationErrorMessage(for: error) @@ -371,6 +376,10 @@ final class RepositoryListViewModel { let branch: String } + private var repositoriesForSearchIndex: [RepositorySummary] { + searchIndex + } + private func fetchPage( service: SRHTService, cursor: String?, @@ -502,7 +511,9 @@ final class RepositoryListViewModel { return [] } if !cachedRepositories.isEmpty { - repositories = cachedRepositories.sorted(by: repositorySortOrder) + let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) + repositories = sortedRepositories + updateSearchIndex(with: sortedRepositories) } } @@ -545,4 +556,37 @@ final class RepositoryListViewModel { } return lhs.updated > rhs.updated } + + private func updateSearchIndex(with repositories: [RepositorySummary]) { + searchIndex = repositories.sorted(by: repositorySortOrder) + hasLoadedSearchIndex = !searchIndex.isEmpty + } + + private func insertIntoSearchIndex(_ repository: RepositorySummary) { + let updatedRepositories = (repositoriesForSearchIndex + [repository]) + .uniqued(on: \.id) + .sorted(by: repositorySortOrder) + updateSearchIndex(with: updatedRepositories) + } + + static func shouldRefreshSearchIndex(for query: String) -> Bool { + query.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumRemoteSearchLength + } + + static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] { + let lowercasedQuery = query.lowercased() + return repositories.filter { repo in + repo.name.lowercased().contains(lowercasedQuery) || + repo.description?.lowercased().contains(lowercasedQuery) ?? false + } + } +} + +private extension Array { + func uniqued<ID: Hashable>(on keyPath: KeyPath<Element, ID>) -> [Element] { + var seenIDs: Set<ID> = [] + return filter { element in + seenIDs.insert(element[keyPath: keyPath]).inserted + } + } } diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift index d6f3300..71d6ab6 100644 --- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift +++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift @@ -6,12 +6,20 @@ private struct UpdateRepoResponse: Decodable, Sendable { let updateRepository: UpdatedRepo } +private struct UpdateRepoInfoResponse: Decodable, Sendable { + let updateRepository: UpdatedRepoInfo +} + private struct UpdatedRepo: Decodable, Sendable { - let id: Int - let rid: String + let id: Int? + let rid: String? let name: String let description: String? - let visibility: Visibility + let visibility: Visibility? +} + +private struct UpdatedRepoInfo: Decodable, Sendable { + let id: Int } private struct ACLResponse: Decodable, Sendable { @@ -71,6 +79,7 @@ final class RepositorySettingsViewModel { var editedDescription: String var editedVisibility: Visibility var editedHead: String + private let originalEditedHead: String var isSavingInfo = false // MARK: - Rename fields @@ -116,11 +125,14 @@ final class RepositorySettingsViewModel { self.branches = branches // Extract branch name from HEAD reference + let initialEditedHead: String if let head = repository.head?.name { - self.editedHead = head.replacingOccurrences(of: "refs/heads/", with: "") + initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "") } else { - self.editedHead = "main" + initialEditedHead = "main" } + self.editedHead = initialEditedHead + self.originalEditedHead = initialEditedHead } // MARK: - Update Repository Info @@ -133,22 +145,32 @@ final class RepositorySettingsViewModel { } """ + private static let updateRepoInfoMutation = """ + mutation updateRepository($id: Int!, $input: RepoInput!) { + updateRepository(id: $id, input: $input) { + id + } + } + """ + func saveInfo() async { isSavingInfo = true defer { isSavingInfo = false } error = nil do { - let input: [String: any Sendable] = [ + var input: [String: any Sendable] = [ "description": editedDescription, - "visibility": editedVisibility.rawValue, - "HEAD": editedHead + "visibility": editedVisibility.rawValue ] + if let headReference = selectedHeadReferenceForSave() { + input["HEAD"] = headReference + } _ = try await client.execute( service: service, - query: Self.updateRepoMutation, + query: Self.updateRepoInfoMutation, variables: ["id": repositoryId, "input": input], - responseType: UpdateRepoResponse.self + responseType: UpdateRepoInfoResponse.self ) } catch { self.error = error.localizedDescription @@ -238,8 +260,9 @@ final class RepositorySettingsViewModel { } func addACL() async { - let entity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines) - guard !entity.isEmpty else { return } + let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawEntity.isEmpty else { return } + let entity = Self.gitCanonicalEntity(from: rawEntity) isAddingACL = true defer { isAddingACL = false } error = nil @@ -267,6 +290,33 @@ final class RepositorySettingsViewModel { } } + static func gitCanonicalEntity(from input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + return "~\(username)" + } + + static func gitHeadReference(from input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if trimmed.hasPrefix("refs/") { + return trimmed + } + return "refs/heads/\(trimmed)" + } + + func selectedHeadReferenceForSave() -> String? { + let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedEditedHead != originalEditedHead else { + return nil + } + + return branches.first { + $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead + }?.name + } + func deleteACL(_ entry: ACLEntry) async { isDeletingACL = true defer { isDeletingACL = false } |
