summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-18 20:23:58 -0500
committerChristian Cleberg <[email protected]>2026-03-18 20:23:58 -0500
commitc41cd437acde4e4f4c0781cebd4d5f4bf9517bb9 (patch)
tree28bf13b89e36774720d015a26a8979b567ad0be9 /Hutch/Views/Repositories
parent1745311950fd68ad81957b3ba64f8566cd86ce19 (diff)
parent4dd920558a1c7174c0e9a5b872325df36e363516 (diff)
downloadhutch-c41cd437acde4e4f4c0781cebd4d5f4bf9517bb9.tar.gz
hutch-c41cd437acde4e4f4c0781cebd4d5f4bf9517bb9.tar.bz2
hutch-c41cd437acde4e4f4c0781cebd4d5f4bf9517bb9.zip
v1.2: Merge branch 'dev'
Diffstat (limited to 'Hutch/Views/Repositories')
-rw-r--r--Hutch/Views/Repositories/FileTreeView.swift2
-rw-r--r--Hutch/Views/Repositories/FileTreeViewModel.swift24
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift141
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift66
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsViewModel.swift74
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: "&#39;")
}
+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 }