From 32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Tue, 17 Mar 2026 23:19:43 -0500 Subject: v1.0 --- Hutch/API_REFERENCE.md | 147 +++ Hutch/App/AppState.swift | 222 ++++ Hutch/App/DeepLink.swift | 54 + Hutch/App/HutchApp.swift | 20 + Hutch/App/RootView.swift | 197 ++++ .../AccentColor.colorset/Contents.json | 11 + Hutch/Assets.xcassets/AppIcon.appiconset/1.png | Bin 0 -> 47870 bytes Hutch/Assets.xcassets/AppIcon.appiconset/2.png | Bin 0 -> 42089 bytes .../AppIcon.appiconset/Contents.json | 37 + Hutch/Assets.xcassets/Contents.json | 6 + Hutch/Extensions/Date+Relative.swift | 10 + Hutch/Extensions/DateFormatter+SRHT.swift | 39 + Hutch/Extensions/ErrorViews.swift | 159 +++ Hutch/Extensions/Int+ByteFormatting.swift | 8 + Hutch/Extensions/KeychainHelper.swift | 103 ++ Hutch/Extensions/SRHTWebURL.swift | 50 +- Hutch/Info.plist | 23 + Hutch/Models/ArtifactInfo.swift | 22 + Hutch/Models/Builds.swift | 111 ++ Hutch/Models/CommitDetail.swift | 65 ++ Hutch/Models/CommitSummary.swift | 22 + Hutch/Models/Git.swift | 241 +++++ Hutch/Models/Meta.swift | 65 ++ Hutch/Models/RepositorySummary.swift | 57 + Hutch/Models/Todo.swift | 234 ++++ Hutch/Models/User.swift | 10 + Hutch/Networking/GraphQLRequest.swift | 45 + Hutch/Networking/NetworkMonitor.swift | 48 + Hutch/Networking/Pagination.swift | 176 +++ Hutch/Networking/ResponseCache.swift | 33 + Hutch/Networking/SRHTClient.swift | 521 +++++++++ Hutch/Networking/SRHTError.swift | 63 ++ Hutch/Networking/SRHTService.swift | 34 + Hutch/Views/Auth/AuthView.swift | 95 ++ Hutch/Views/Builds/BuildDetailView.swift | 383 +++++++ Hutch/Views/Builds/BuildDetailViewModel.swift | 235 ++++ Hutch/Views/Builds/BuildListView.swift | 217 ++++ Hutch/Views/Builds/BuildListViewModel.swift | 220 ++++ Hutch/Views/Builds/BuildRowView.swift | 106 ++ Hutch/Views/Repositories/ArtifactsView.swift | 73 ++ Hutch/Views/Repositories/CommitDetailView.swift | 332 ++++++ .../Views/Repositories/CommitDetailViewModel.swift | 102 ++ Hutch/Views/Repositories/CommitLogView.swift | 59 + Hutch/Views/Repositories/CommitRowView.swift | 30 + Hutch/Views/Repositories/DiffView.swift | 79 ++ Hutch/Views/Repositories/FileTreeView.swift | 446 ++++++++ Hutch/Views/Repositories/FileTreeViewModel.swift | 524 +++++++++ .../Repositories/HgRepositoryDetailView.swift | 506 ++++++--- .../Repositories/HgRepositoryDetailViewModel.swift | 545 ++++++++++ .../Repositories/HgRepositorySettingsView.swift | 239 +++++ .../HgRepositorySettingsViewModel.swift | 287 +++++ Hutch/Views/Repositories/ReadmeView.swift | 1125 ++++++++++++++++++++ Hutch/Views/Repositories/ReferencesListView.swift | 90 ++ .../Views/Repositories/RepositoryDetailView.swift | 107 ++ .../Repositories/RepositoryDetailViewModel.swift | 397 +++++++ Hutch/Views/Repositories/RepositoryListView.swift | 224 ++++ .../Repositories/RepositoryListViewModel.swift | 548 ++++++++++ Hutch/Views/Repositories/RepositoryRowView.swift | 85 ++ .../Repositories/RepositorySettingsView.swift | 58 +- .../Repositories/RepositorySettingsViewModel.swift | 24 +- .../Repositories/RepositorySummarySupport.swift | 51 +- Hutch/Views/Settings/SettingsView.swift | 724 +++++++++++++ Hutch/Views/Settings/SettingsViewModel.swift | 394 +++++++ Hutch/Views/Tickets/TicketDetailView.swift | 828 ++++++++++++++ Hutch/Views/Tickets/TicketDetailViewModel.swift | 551 ++++++++++ Hutch/Views/Tickets/TicketListView.swift | 389 +++++++ Hutch/Views/Tickets/TicketListViewModel.swift | 215 ++++ Hutch/Views/Tickets/TrackerListView.swift | 214 ++++ Hutch/Views/Tickets/TrackerListViewModel.swift | 166 +++ 69 files changed, 13298 insertions(+), 203 deletions(-) create mode 100644 Hutch/API_REFERENCE.md create mode 100644 Hutch/App/AppState.swift create mode 100644 Hutch/App/DeepLink.swift create mode 100644 Hutch/App/HutchApp.swift create mode 100644 Hutch/App/RootView.swift create mode 100644 Hutch/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Hutch/Assets.xcassets/AppIcon.appiconset/1.png create mode 100644 Hutch/Assets.xcassets/AppIcon.appiconset/2.png create mode 100644 Hutch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Hutch/Assets.xcassets/Contents.json create mode 100644 Hutch/Extensions/Date+Relative.swift create mode 100644 Hutch/Extensions/DateFormatter+SRHT.swift create mode 100644 Hutch/Extensions/ErrorViews.swift create mode 100644 Hutch/Extensions/Int+ByteFormatting.swift create mode 100644 Hutch/Extensions/KeychainHelper.swift create mode 100644 Hutch/Info.plist create mode 100644 Hutch/Models/ArtifactInfo.swift create mode 100644 Hutch/Models/Builds.swift create mode 100644 Hutch/Models/CommitDetail.swift create mode 100644 Hutch/Models/CommitSummary.swift create mode 100644 Hutch/Models/Git.swift create mode 100644 Hutch/Models/Meta.swift create mode 100644 Hutch/Models/RepositorySummary.swift create mode 100644 Hutch/Models/Todo.swift create mode 100644 Hutch/Models/User.swift create mode 100644 Hutch/Networking/GraphQLRequest.swift create mode 100644 Hutch/Networking/NetworkMonitor.swift create mode 100644 Hutch/Networking/Pagination.swift create mode 100644 Hutch/Networking/ResponseCache.swift create mode 100644 Hutch/Networking/SRHTClient.swift create mode 100644 Hutch/Networking/SRHTError.swift create mode 100644 Hutch/Networking/SRHTService.swift create mode 100644 Hutch/Views/Auth/AuthView.swift create mode 100644 Hutch/Views/Builds/BuildDetailView.swift create mode 100644 Hutch/Views/Builds/BuildDetailViewModel.swift create mode 100644 Hutch/Views/Builds/BuildListView.swift create mode 100644 Hutch/Views/Builds/BuildListViewModel.swift create mode 100644 Hutch/Views/Builds/BuildRowView.swift create mode 100644 Hutch/Views/Repositories/ArtifactsView.swift create mode 100644 Hutch/Views/Repositories/CommitDetailView.swift create mode 100644 Hutch/Views/Repositories/CommitDetailViewModel.swift create mode 100644 Hutch/Views/Repositories/CommitLogView.swift create mode 100644 Hutch/Views/Repositories/CommitRowView.swift create mode 100644 Hutch/Views/Repositories/DiffView.swift create mode 100644 Hutch/Views/Repositories/FileTreeView.swift create mode 100644 Hutch/Views/Repositories/FileTreeViewModel.swift create mode 100644 Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift create mode 100644 Hutch/Views/Repositories/HgRepositorySettingsView.swift create mode 100644 Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift create mode 100644 Hutch/Views/Repositories/ReadmeView.swift create mode 100644 Hutch/Views/Repositories/ReferencesListView.swift create mode 100644 Hutch/Views/Repositories/RepositoryDetailView.swift create mode 100644 Hutch/Views/Repositories/RepositoryDetailViewModel.swift create mode 100644 Hutch/Views/Repositories/RepositoryListView.swift create mode 100644 Hutch/Views/Repositories/RepositoryListViewModel.swift create mode 100644 Hutch/Views/Repositories/RepositoryRowView.swift create mode 100644 Hutch/Views/Settings/SettingsView.swift create mode 100644 Hutch/Views/Settings/SettingsViewModel.swift create mode 100644 Hutch/Views/Tickets/TicketDetailView.swift create mode 100644 Hutch/Views/Tickets/TicketDetailViewModel.swift create mode 100644 Hutch/Views/Tickets/TicketListView.swift create mode 100644 Hutch/Views/Tickets/TicketListViewModel.swift create mode 100644 Hutch/Views/Tickets/TrackerListView.swift create mode 100644 Hutch/Views/Tickets/TrackerListViewModel.swift (limited to 'Hutch') diff --git a/Hutch/API_REFERENCE.md b/Hutch/API_REFERENCE.md new file mode 100644 index 0000000..909316c --- /dev/null +++ b/Hutch/API_REFERENCE.md @@ -0,0 +1,147 @@ +# Sourcehut API Reference + +This document provides context and references for working with Sourcehut's APIs. + +## Official Documentation + +The official Sourcehut API documentation can be found at: +- **https://man.sr.ht** - Contains links to API reference pages for all *.sr.ht services + +## Services and Their APIs + +### git.sr.ht (Git Repositories) +- **GraphQL API**: https://git.sr.ht/graphql +- **REST API**: https://git.sr.ht/api +- **Scope required**: `REPOSITORIES:RO` (read-only), `REPOSITORIES:RW` (read-write) + +### todo.sr.ht (Ticket Tracking) +- **GraphQL API**: https://todo.sr.ht/graphql +- **REST API**: https://todo.sr.ht/api +- **Scope required**: `TICKETS:RO` (read-only), `TICKETS:RW` (read-write) + +### hg.sr.ht (Mercurial Repositories) +- **GraphQL API**: https://hg.sr.ht/graphql +- **REST API**: https://hg.sr.ht/api +- **Scope required**: `REPOSITORIES:RO` (read-only), `REPOSITORIES:RW` (read-write) + +### lists.sr.ht (Mailing Lists) +- **GraphQL API**: https://lists.sr.ht/graphql +- **REST API**: https://lists.sr.ht/api +- **Scope required**: `LISTS:RO` (read-only), `LISTS:RW` (read-write) + +### builds.sr.ht (CI/CD) +- **GraphQL API**: https://builds.sr.ht/graphql +- **REST API**: https://builds.sr.ht/api +- **Scope required**: `BUILDS:RO` (read-only), `BUILDS:RW` (read-write) + +### meta.sr.ht (User Accounts) +- **GraphQL API**: https://meta.sr.ht/graphql +- **REST API**: https://meta.sr.ht/api +- **Scope required**: `ACCOUNT:RO` (read-only), `ACCOUNT:RW` (read-write) + +## Authentication + +All API requests require a **Personal Access Token** with the appropriate scopes. + +- **Token creation**: https://meta.sr.ht/oauth2/personal-token +- **Token format**: `Bearer ` in the Authorization header + +## GraphQL Conventions + +### Common Types + +```graphql +# Cursor-based pagination +type Query { + items(cursor: Cursor, filter: Filter): ItemsPage +} + +type ItemsPage { + results: [Item!]! + cursor: Cursor +} + +# Filter structure (varies by service) +input Filter { + search: String + # ... other service-specific filters +} +``` + +### Error Handling + +Sourcehut APIs return GraphQL errors in the following format: + +```json +{ + "data": null, + "errors": [ + { + "message": "Error message", + "path": ["query", "field"], + "extensions": { + "code": "ERROR_CODE" + } + } + ] +} +``` + +## Rate Limiting + +- **Rate limits**: Vary by service and authentication status +- **Headers**: Check `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` +- **Unauthenticated**: Lower limits (typically 60 requests/hour) +- **Authenticated**: Higher limits (typically 5000 requests/hour) + +## Development Notes + +### API Exploration + +Use GraphiQL interfaces available at each service's `/graphql` endpoint to: +- Explore the schema +- Test queries +- Understand available fields and types + +### Common Issues + +1. **Filtering**: Some services may not support all filter operations. Check the schema. +2. **Pagination**: Always handle `cursor` properly for pagination. +3. **Caching**: Sourcehut APIs may have aggressive caching. Use cache headers appropriately. + +### Example Query Structure + +```graphql +query { + repositories(cursor: $cursor, filter: $filter) { + results { + id + name + description + # ... other fields + } + cursor + } +} + +# Variables +{ + "filter": { + "search": "query string" + } +} +``` + +## Troubleshooting + +1. **401 Unauthorized**: Check token scopes and expiration +2. **403 Forbidden**: Verify you have access to the requested resource +3. **429 Too Many Requests**: Implement proper rate limiting in your client +4. **500 Internal Server Error**: Check if the API is temporarily down + +## Additional Resources + +- Sourcehut API Status: https://status.sr.ht +- Sourcehut Meta (announcements): https://meta.sr.ht +- Sourcehut GitHub Mirror: https://github.com/sourcehut +- IRC: #sourcehut on Libera.Chat \ No newline at end of file diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift new file mode 100644 index 0000000..624c78d --- /dev/null +++ b/Hutch/App/AppState.swift @@ -0,0 +1,222 @@ +import Foundation +import SwiftUI +import WebKit + +/// Central application state shared across the view hierarchy. +@Observable +@MainActor +final class AppState { + + enum AuthPhase { + /// App just launched, checking for an existing token. + case launching + /// No valid token — show the token entry screen. + case unauthenticated + /// Token validated, user is signed in. + case authenticated + } + + // MARK: - Authentication + + private(set) var authPhase: AuthPhase = .launching + + /// Convenience for views that need a simple bool. + var isAuthenticated: Bool { + authPhase == .authenticated && currentUser != nil + } + + // MARK: - Current user (populated after successful validation) + + private(set) var currentUser: User? + + // MARK: - Networking + + let client: SRHTClient + + // MARK: - Deep link pending navigation + + /// Set by the deep link handler; consumed by RootView to drive navigation. + var pendingDeepLink: DeepLink? + + // MARK: - Init + + init() { + let token = KeychainHelper.loadToken() + self.client = SRHTClient(token: token) + } + + // MARK: - Launch validation + + /// Called once at app launch. If a token exists in Keychain, validates it + /// silently. On failure, clears the token and falls through to unauthenticated. + func validateOnLaunch() async { + guard client.hasToken else { + authPhase = .unauthenticated + return + } + + do { + let user = try await fetchMe() + currentUser = user + authPhase = .authenticated + } catch { + try? KeychainHelper.deleteToken() + client.setToken(nil) + currentUser = nil + authPhase = .unauthenticated + } + } + + // MARK: - Token management + + /// Validate a new token by querying meta.sr.ht, then persist it. + /// Throws on network/GraphQL errors so the caller can display the message. + func connect(with token: String) async throws { + // Temporarily set the token so the client can use it for the request. + client.setToken(token) + + do { + let user = try await fetchMe() + try KeychainHelper.saveToken(token) + currentUser = user + authPhase = .authenticated + } catch { + // Roll back — don't leave an invalid token in the client. + client.setToken(nil) + throw error + } + } + + func signOut() async { + clearSessionState() + URLCache.shared.removeAllCachedResponses() + HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } + await clearWebData() + clearWebContentRenderCaches() + authPhase = .unauthenticated + } + + func resetAppData() async { + clearSessionState() + + if let bundleIdentifier = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier) + } + URLCache.shared.removeAllCachedResponses() + HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } + await clearWebData() + clearWebContentRenderCaches() + + authPhase = .unauthenticated + } + + // MARK: - Deep link resolution + + /// Resolve a repository by owner and name for deep linking. + func resolveRepository(owner: String, name: String) async throws -> RepositorySummary { + let result = try await client.execute( + service: .git, + query: Self.repoLookupQuery, + variables: ["owner": owner, "name": name], + responseType: RepoLookupResponse.self + ) + return result.user.repository + } + + /// Resolve a tracker by owner and name for deep linking. + func resolveTracker(owner: String, name: String) async throws -> TrackerSummary { + let result = try await client.execute( + service: .todo, + query: Self.trackerLookupQuery, + variables: ["owner": owner, "name": name], + responseType: TrackerLookupResponse.self + ) + return result.user.tracker + } + + // MARK: - Private + + private static let meQuery = """ + { + me { + id + username + canonicalName + email + avatar + } + } + """ + + private struct MeResponse: Decodable { + let me: User + } + + private func fetchMe() async throws -> User { + let result = try await client.execute( + service: .meta, + query: Self.meQuery, + responseType: MeResponse.self + ) + return result.me + } + + // MARK: - Deep link queries + + private static let repoLookupQuery = """ + query repoLookup($owner: String!, $name: String!) { + user(username: $owner) { + repository(name: $name) { + id rid name description visibility updated + owner { canonicalName } + HEAD { name target } + } + } + } + """ + + private struct RepoLookupResponse: Decodable, Sendable { + let user: RepoLookupUser + } + + private struct RepoLookupUser: Decodable, Sendable { + let repository: RepositorySummary + } + + private static let trackerLookupQuery = """ + query trackerLookup($owner: String!, $name: String!) { + user(username: $owner) { + tracker(name: $name) { + id rid name description visibility updated + owner { canonicalName } + } + } + } + """ + + private struct TrackerLookupResponse: Decodable, Sendable { + let user: TrackerLookupUser + } + + private struct TrackerLookupUser: Decodable, Sendable { + let tracker: TrackerSummary + } + + private func clearSessionState() { + try? KeychainHelper.deleteAll() + client.setToken(nil) + client.responseCache.clear() + currentUser = nil + pendingDeepLink = nil + } + + private func clearWebData() async { + await withCheckedContinuation { continuation in + let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() + let since = Date(timeIntervalSince1970: 0) + WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) { + continuation.resume() + } + } + } +} diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift new file mode 100644 index 0000000..a4b8269 --- /dev/null +++ b/Hutch/App/DeepLink.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Represents a parsed `hutch://` deep link. +enum DeepLink: Equatable { + /// hutch://git// + case repository(owner: String, repo: String) + /// hutch://todo/// + case ticket(owner: String, tracker: String, ticketId: Int) + /// hutch://builds/ + case build(jobId: Int) + + /// Attempt to parse a URL into a DeepLink. + /// Expected format: hutch:// + init?(url: URL) { + guard url.scheme == "hutch" else { return nil } + + // url.host gives the first path component for opaque URLs; + // use standardized path components from the full string. + let components = url.pathComponents(fromScheme: "hutch") + + switch components.first { + case "git" where components.count >= 3: + let owner = components[1] + let repo = components[2] + self = .repository(owner: owner, repo: repo) + + case "todo" where components.count >= 4: + let owner = components[1] + let tracker = components[2] + guard let ticketId = Int(components[3]) else { return nil } + self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId) + + case "builds" where components.count >= 2: + guard let jobId = Int(components[1]) else { return nil } + self = .build(jobId: jobId) + + default: + return nil + } + } +} + +private extension URL { + /// Parse path components from a custom-scheme URL. + /// For `hutch://git/~user/repo`, returns `["git", "~user", "repo"]`. + func pathComponents(fromScheme scheme: String) -> [String] { + // Remove scheme prefix and split by "/" + var str = absoluteString + if str.hasPrefix("\(scheme)://") { + str = String(str.dropFirst("\(scheme)://".count)) + } + return str.split(separator: "/").map(String.init) + } +} diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift new file mode 100644 index 0000000..fda0f06 --- /dev/null +++ b/Hutch/App/HutchApp.swift @@ -0,0 +1,20 @@ +import SwiftUI + +@main +struct HutchApp: App { + @State private var appState = AppState() + @State private var networkMonitor = NetworkMonitor() + + var body: some Scene { + WindowGroup { + RootView() + .environment(appState) + .environment(networkMonitor) + .onOpenURL { url in + if let link = DeepLink(url: url) { + appState.pendingDeepLink = link + } + } + } + } +} diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift new file mode 100644 index 0000000..7af162d --- /dev/null +++ b/Hutch/App/RootView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +/// The root view of the app. Shows a TabView when authenticated, or a +/// full-screen sheet for token entry on first launch. +struct RootView: View { + @Environment(AppState.self) private var appState + + enum Tab: Hashable { + case repositories + case builds + case tickets + case settings + } + + @State private var selectedTab: Tab = .repositories + @State private var repoPath = NavigationPath() + @State private var buildsPath = NavigationPath() + @State private var ticketsPath = NavigationPath() + @State private var isResolvingDeepLink = false + + var body: some View { + Group { + switch appState.authPhase { + case .launching: + ProgressView("Connecting…") + .task { + await appState.validateOnLaunch() + } + + case .unauthenticated: + // Full-screen token entry that cannot be dismissed. + TokenEntryView() + + case .authenticated: + tabContent + } + } + .onChange(of: appState.pendingDeepLink) { _, newValue in + consumePendingDeepLinkIfPossible(newValue) + } + .onChange(of: appState.authPhase) { _, newPhase in + handleAuthPhaseChange(newPhase) + } + } + + // MARK: - Tab View + + private var tabContent: some View { + TabView(selection: $selectedTab) { + NavigationStack(path: $repoPath) { + RepositoryListView() + } + .tag(Tab.repositories) + .tabItem { + Label("Repositories", systemImage: "book.closed") + } + + NavigationStack(path: $buildsPath) { + BuildListView() + // Int destination used by deep links (hutch://builds/). + // JobSummary destination is registered inside BuildListView. + .navigationDestination(for: Int.self) { jobId in + BuildDetailView(jobId: jobId) + } + } + .tag(Tab.builds) + .tabItem { + Label("Builds", systemImage: "hammer") + } + + NavigationStack(path: $ticketsPath) { + TrackerListView() + // Deep link destination for jumping straight to a ticket. + .navigationDestination(for: TicketDeepLinkTarget.self) { target in + TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId) + } + } + .tag(Tab.tickets) + .tabItem { + Label("Tickets", systemImage: "ticket") + } + + SettingsView() + .tag(Tab.settings) + .tabItem { + Label("Settings", systemImage: "gear") + } + } + .overlay { + if isResolvingDeepLink { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView("Opening link…") + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } + } + } + + // MARK: - Deep Link Handling + + private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) { + switch newPhase { + case .launching: + break + case .unauthenticated: + repoPath = NavigationPath() + buildsPath = NavigationPath() + ticketsPath = NavigationPath() + selectedTab = .repositories + isResolvingDeepLink = false + case .authenticated: + consumePendingDeepLinkIfPossible(appState.pendingDeepLink) + } + } + + private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) { + guard appState.isAuthenticated, let link else { return } + handleDeepLink(link) + appState.pendingDeepLink = nil + } + + private func handleDeepLink(_ link: DeepLink) { + guard appState.isAuthenticated else { return } + + switch link { + case .repository(let owner, let repo): + resolveRepositoryLink(owner: owner, repo: repo) + + case .build(let jobId): + // Reset the builds navigation and push the detail + buildsPath = NavigationPath() + selectedTab = .builds + // Defer the push slightly so the tab switch takes effect + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + buildsPath.append(jobId) + } + + case .ticket(let owner, let tracker, let ticketId): + resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) + } + } + + private func resolveRepositoryLink(owner: String, repo: String) { + isResolvingDeepLink = true + Task { + defer { isResolvingDeepLink = false } + do { + let summary = try await appState.resolveRepository(owner: owner, name: repo) + repoPath = NavigationPath() + selectedTab = .repositories + try? await Task.sleep(for: .milliseconds(100)) + repoPath.append(summary) + } catch { + // Silently fail — the repo may not exist or be inaccessible + } + } + } + + private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) { + isResolvingDeepLink = true + Task { + defer { isResolvingDeepLink = false } + do { + let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) + ticketsPath = NavigationPath() + selectedTab = .tickets + try? await Task.sleep(for: .milliseconds(100)) + ticketsPath.append(trackerSummary) + try? await Task.sleep(for: .milliseconds(100)) + ticketsPath.append(TicketDeepLinkTarget( + ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()), + trackerName: trackerSummary.name, + trackerId: trackerSummary.id, + trackerRid: trackerSummary.rid, + ticketId: ticketId + )) + } catch { + // Silently fail + } + } + } +} + +// MARK: - Ticket Deep Link Navigation Target + +/// Hashable wrapper to push a ticket detail view from a deep link. +struct TicketDeepLinkTarget: Hashable { + let ownerUsername: String + let trackerName: String + let trackerId: Int + let trackerRid: String + let ticketId: Int +} diff --git a/Hutch/Assets.xcassets/AccentColor.colorset/Contents.json b/Hutch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Hutch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hutch/Assets.xcassets/AppIcon.appiconset/1.png b/Hutch/Assets.xcassets/AppIcon.appiconset/1.png new file mode 100644 index 0000000..cc1ce81 Binary files /dev/null and b/Hutch/Assets.xcassets/AppIcon.appiconset/1.png differ diff --git a/Hutch/Assets.xcassets/AppIcon.appiconset/2.png b/Hutch/Assets.xcassets/AppIcon.appiconset/2.png new file mode 100644 index 0000000..89e0d2b Binary files /dev/null and b/Hutch/Assets.xcassets/AppIcon.appiconset/2.png differ diff --git a/Hutch/Assets.xcassets/AppIcon.appiconset/Contents.json b/Hutch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..5e229c5 --- /dev/null +++ b/Hutch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,37 @@ +{ + "images" : [ + { + "filename" : "1.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "2.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hutch/Assets.xcassets/Contents.json b/Hutch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Hutch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hutch/Extensions/Date+Relative.swift b/Hutch/Extensions/Date+Relative.swift new file mode 100644 index 0000000..f06ba59 --- /dev/null +++ b/Hutch/Extensions/Date+Relative.swift @@ -0,0 +1,10 @@ +import Foundation + +extension Date { + /// A short relative description like "2h ago", "3d ago", or "Jan 5, 2025". + var relativeDescription: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: self, relativeTo: .now) + } +} diff --git a/Hutch/Extensions/DateFormatter+SRHT.swift b/Hutch/Extensions/DateFormatter+SRHT.swift new file mode 100644 index 0000000..c017b11 --- /dev/null +++ b/Hutch/Extensions/DateFormatter+SRHT.swift @@ -0,0 +1,39 @@ +import Foundation + +extension DateFormatter { + /// Formatter for the sr.ht `Time` scalar: `%Y-%m-%dT%H:%M:%SZ` (UTC). + static let srht: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter + }() + + /// Fallback formatter that also accepts fractional seconds. + static let srhtFractional: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter + }() +} + +extension JSONDecoder.DateDecodingStrategy { + /// Tries the primary sr.ht format first, then falls back to fractional seconds. + static let srhtFlexible: JSONDecoder.DateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + if let date = DateFormatter.srht.date(from: string) { + return date + } + if let date = DateFormatter.srhtFractional.date(from: string) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot decode date string: \(string)" + ) + } +} diff --git a/Hutch/Extensions/ErrorViews.swift b/Hutch/Extensions/ErrorViews.swift new file mode 100644 index 0000000..086c0de --- /dev/null +++ b/Hutch/Extensions/ErrorViews.swift @@ -0,0 +1,159 @@ +import SwiftUI + +// MARK: - SRHTErrorBanner ViewModifier + +/// Displays a dismissible error banner at the top of the screen. +/// Attach to any view with `.srhtErrorBanner(error:)`. +struct SRHTErrorBanner: ViewModifier { + @Binding var error: String? + + func body(content: Content) -> some View { + content + .overlay(alignment: .top) { + if let message = error { + banner(message) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.3), value: error) + } + + @ViewBuilder + private func banner(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.white) + + Text(message) + .font(.subheadline) + .foregroundStyle(.white) + .lineLimit(3) + + Spacer() + + Button { + error = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.white.opacity(0.8)) + } + } + .padding(12) + .background(Color.red.gradient, in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 12) + .padding(.top, 4) + } +} + +extension View { + /// Attach an error banner that shows at the top when `error` is non-nil. + func srhtErrorBanner(error: Binding) -> some View { + modifier(SRHTErrorBanner(error: error)) + } +} + +// MARK: - Shared Screen States + +struct SRHTLoadingStateView: View { + let message: String + + var body: some View { + VStack(spacing: 12) { + ProgressView() + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct SRHTErrorStateView: View { + let title: String + let message: String + let retryAction: (() async -> Void)? + + var body: some View { + ContentUnavailableView { + SwiftUI.Label(title, systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + if let retryAction { + Button("Retry") { + Task { await retryAction() } + } + .buttonStyle(.borderedProminent) + } + } + } +} + +// MARK: - NoConnectionView + +/// Empty state view shown when the device is offline. Includes a retry button. +struct NoConnectionView: View { + var retryAction: () async -> Void + + var body: some View { + ContentUnavailableView { + SwiftUI.Label("No Connection", systemImage: "wifi.slash") + } description: { + Text("Check your internet connection and try again.") + } actions: { + Button { + Task { await retryAction() } + } label: { + Text("Retry") + } + .buttonStyle(.borderedProminent) + } + } +} + +// MARK: - Connectivity Overlay + +/// ViewModifier that shows NoConnectionView when the device is offline and +/// there is no content to display. When content exists, shows a subtle +/// offline indicator instead. +struct ConnectivityOverlay: ViewModifier { + @Environment(NetworkMonitor.self) private var networkMonitor + let hasContent: Bool + var retryAction: () async -> Void + + func body(content: Content) -> some View { + content + .overlay { + if !networkMonitor.isConnected, !hasContent { + NoConnectionView(retryAction: retryAction) + } + } + .safeAreaInset(edge: .bottom) { + if !networkMonitor.isConnected, hasContent { + offlineBadge + } + } + } + + private var offlineBadge: some View { + HStack(spacing: 6) { + Image(systemName: "wifi.slash") + .font(.caption2) + Text("Offline — showing cached data") + .font(.caption2) + } + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.orange.gradient, in: Capsule()) + .padding(.bottom, 4) + } +} + +extension View { + /// Overlay a no-connection view when offline with no content, or a + /// subtle offline badge when showing cached data. + func connectivityOverlay(hasContent: Bool, retryAction: @escaping () async -> Void) -> some View { + modifier(ConnectivityOverlay(hasContent: hasContent, retryAction: retryAction)) + } +} diff --git a/Hutch/Extensions/Int+ByteFormatting.swift b/Hutch/Extensions/Int+ByteFormatting.swift new file mode 100644 index 0000000..a17ece4 --- /dev/null +++ b/Hutch/Extensions/Int+ByteFormatting.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Int { + /// Human-readable byte size string (e.g. "1.2 MB", "340 KB"). + var formattedByteCount: String { + ByteCountFormatter.string(fromByteCount: Int64(self), countStyle: .file) + } +} diff --git a/Hutch/Extensions/KeychainHelper.swift b/Hutch/Extensions/KeychainHelper.swift new file mode 100644 index 0000000..01f8e5a --- /dev/null +++ b/Hutch/Extensions/KeychainHelper.swift @@ -0,0 +1,103 @@ +import Foundation +@preconcurrency import Security + +enum KeychainHelper: Sendable { + + private static let service = "net.cleberg.Hutch" + private static let tokenAccount = "srht-access-token" + + // MARK: - Save + + static func saveToken(_ token: String) throws { + guard let data = token.data(using: .utf8) else { + throw KeychainError.encodingFailed + } + + // Delete any existing item first + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: tokenAccount + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: tokenAccount, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.saveFailed(status) + } + } + + // MARK: - Load + + static func loadToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: tokenAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let token = String(data: data, encoding: .utf8) else { + return nil + } + return token + } + + // MARK: - Delete + + static func deleteToken() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: tokenAccount + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.deleteFailed(status) + } + } + + static func deleteAll() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.deleteFailed(status) + } + } +} + +enum KeychainError: LocalizedError { + case encodingFailed + case saveFailed(OSStatus) + case deleteFailed(OSStatus) + + var errorDescription: String? { + switch self { + case .encodingFailed: + "Failed to encode token data." + case .saveFailed(let status): + "Keychain save failed with status \(status)." + case .deleteFailed(let status): + "Keychain delete failed with status \(status)." + } + } +} diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift index 53556de..f63352b 100644 --- a/Hutch/Extensions/SRHTWebURL.swift +++ b/Hutch/Extensions/SRHTWebURL.swift @@ -9,6 +9,41 @@ enum SRHTWebURL { ) } + static func commit(repository: RepositorySummary, commitId: String) -> URL? { + userScopedURL( + host: "\(repository.service.rawValue).sr.ht", + ownerCanonicalName: repository.owner.canonicalName, + pathComponents: [repository.name, commitPathComponent(for: repository.service), commitId] + ) + } + + static func file(repository: RepositorySummary, revspec: String, path: String) -> URL? { + switch repository.service { + case .git: + return userScopedURL( + host: "git.sr.ht", + ownerCanonicalName: repository.owner.canonicalName, + pathComponents: [repository.name, "tree", revspec, "item"] + pathComponents(from: path) + ) + case .hg: + var components = URLComponents() + components.scheme = "https" + components.host = "hg.sr.ht" + + let ownerUsername = username(from: repository.owner.canonicalName) + let encodedRepository = repository.name.addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? repository.name + let encodedPath = path.split(separator: "/").map { + String($0).addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? String($0) + }.joined(separator: "/") + + components.percentEncodedPath = "/~\(ownerUsername)/\(encodedRepository)/browse/\(encodedPath)" + components.queryItems = [URLQueryItem(name: "rev", value: revspec)] + return components.url + default: + return nil + } + } + static func build(jobId: Int, ownerCanonicalName: String) -> URL? { userScopedURL( host: "builds.sr.ht", @@ -35,7 +70,7 @@ enum SRHTWebURL { static func profile(canonicalName: String) -> URL? { userScopedURL( - host: "meta.sr.ht", + host: "sr.ht", ownerCanonicalName: canonicalName, pathComponents: [] ) @@ -76,9 +111,22 @@ enum SRHTWebURL { return canonicalName } + private static func commitPathComponent(for service: SRHTService) -> String { + switch service { + case .hg: + return "rev" + default: + return "commit" + } + } + private static let pathComponentCharacterSet: CharacterSet = { var characterSet = CharacterSet.urlPathAllowed characterSet.remove(charactersIn: "/") return characterSet }() + + private static func pathComponents(from path: String) -> [String] { + path.split(separator: "/").map(String.init) + } } diff --git a/Hutch/Info.plist b/Hutch/Info.plist new file mode 100644 index 0000000..8360406 --- /dev/null +++ b/Hutch/Info.plist @@ -0,0 +1,23 @@ + + + + + CFBundleDisplayName + Hutch + CFBundleName + Hutch + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + .cleberg.Hutch + CFBundleURLSchemes + + hutch + + + + + diff --git a/Hutch/Models/ArtifactInfo.swift b/Hutch/Models/ArtifactInfo.swift new file mode 100644 index 0000000..dbe9d96 --- /dev/null +++ b/Hutch/Models/ArtifactInfo.swift @@ -0,0 +1,22 @@ +import Foundation + +/// An artifact with its parent reference name, used in the artifacts tab. +struct ArtifactInfo: Codable, Sendable, Identifiable { + let id: Int + let filename: String + let checksum: String + let size: Int + let url: URL +} + +struct ArtifactPage: Codable, Sendable { + let results: [ArtifactInfo] + let cursor: String? +} + +/// A reference (tag) that has associated artifacts. +struct ReferenceWithArtifacts: Codable, Sendable, Identifiable { + var id: String { name } + let name: String + let artifacts: [ArtifactInfo] +} diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift new file mode 100644 index 0000000..6694c35 --- /dev/null +++ b/Hutch/Models/Builds.swift @@ -0,0 +1,111 @@ +import Foundation + +// MARK: - Enums + +/// Status of a build job. +enum JobStatus: String, Codable, Sendable { + case pending = "PENDING" + case queued = "QUEUED" + case running = "RUNNING" + case success = "SUCCESS" + case failed = "FAILED" + case cancelled = "CANCELLED" + case timeout = "TIMEOUT" + + /// Whether the job can be cancelled. + var isCancellable: Bool { + switch self { + case .pending, .queued, .running: true + default: false + } + } +} + +/// Status of a single build task within a job. +enum TaskStatus: String, Codable, Sendable { + case pending = "PENDING" + case running = "RUNNING" + case success = "SUCCESS" + case failed = "FAILED" + case skipped = "SKIPPED" +} + +// MARK: - Build Task + +/// A single task within a build job. +struct BuildTask: Codable, Sendable, Identifiable { + var id: String { name } + let name: String + let status: TaskStatus + let log: BuildLog? +} + +// MARK: - Job Summary (for list view) + +/// Lightweight job model matching the fields returned by the jobs list query. +struct JobSummary: Codable, Sendable, Identifiable, Hashable { + let id: Int + let created: Date + let updated: Date + let status: JobStatus + let note: String? + let tags: [String] + let visibility: Visibility? + let image: String? + let tasks: [JobTaskSummary] + + /// Number of completed (success) tasks. + var completedTaskCount: Int { + tasks.filter { $0.status == .success }.count + } + + /// Display label: note if available, otherwise tags joined. + var displayLabel: String { + if let note, !note.isEmpty { + return note + } + if !tags.isEmpty { + return tags.joined(separator: ", ") + } + return "Job #\(id)" + } +} + +/// Minimal task info for the list query. +struct JobTaskSummary: Codable, Sendable, Hashable { + let name: String + let status: TaskStatus +} + +// MARK: - Job Detail (for detail view) + +/// Full job model with all fields for the detail view. +struct JobDetail: Codable, Sendable { + let id: Int + let created: Date + let updated: Date + let status: JobStatus + let note: String? + let tags: [String] + let visibility: Visibility? + let image: String? + let manifest: String? + let tasks: [BuildTask] + let log: BuildLog? + let owner: Entity +} + +/// The log associated with a build job. +struct BuildLog: Codable, Sendable { + let fullURL: String +} + +// MARK: - Job Group + +/// A group of related build jobs. +struct JobGroup: Codable, Sendable, Identifiable { + let id: Int + let created: Date + let note: String? + let jobs: [JobSummary] +} diff --git a/Hutch/Models/CommitDetail.swift b/Hutch/Models/CommitDetail.swift new file mode 100644 index 0000000..c95199d --- /dev/null +++ b/Hutch/Models/CommitDetail.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Full commit detail returned by the revparse_single query. +struct CommitDetail: Codable, Sendable, Identifiable { + let id: String + let shortId: String + let author: CommitAuthor + let committer: CommitAuthor + let message: String + let diff: String? + let trailers: [CommitTrailer] + let parents: [ParentCommit] + let tree: CommitTree? + + /// First line of the commit message. + var title: String { + message.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespaces) + } + + /// The commit message body (everything after the first line), if any. + var body: String? { + guard let newlineIndex = message.firstIndex(of: "\n") else { return nil } + let body = message[message.index(after: newlineIndex)...] + .trimmingCharacters(in: .whitespacesAndNewlines) + return body.isEmpty ? nil : body + } +} + +struct CommitTrailer: Codable, Sendable, Identifiable { + var id: String { "\(name):\(value)" } + let name: String + let value: String +} + +struct ParentCommit: Codable, Sendable, Identifiable, Hashable { + let id: String + let shortId: String + let author: ParentAuthor +} + +struct ParentAuthor: Codable, Sendable, Hashable { + let name: String +} + +struct CommitTree: Codable, Sendable { + let entries: CommitTreeEntries +} + +struct CommitTreeEntries: Codable, Sendable { + let results: [CommitTreeEntry] + let cursor: String? +} + +struct CommitTreeEntry: Codable, Sendable, Identifiable { + let id: String + let name: String + let mode: Int + let object: CommitTreeObject? +} + +struct CommitTreeObject: Codable, Sendable { + let type: String? + let id: String? + let shortId: String? +} diff --git a/Hutch/Models/CommitSummary.swift b/Hutch/Models/CommitSummary.swift new file mode 100644 index 0000000..88c3e7d --- /dev/null +++ b/Hutch/Models/CommitSummary.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Lightweight commit model for list views. Matches the subset of fields +/// returned by the repository log query. +struct CommitSummary: Codable, Sendable, Identifiable, Hashable { + let id: String + let shortId: String + let author: CommitAuthor + let message: String + + /// First line of the commit message. + var title: String { + message.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespaces) + } +} + +/// A compact author representation used in commit list responses. +struct CommitAuthor: Codable, Sendable, Hashable { + let name: String + let email: String? + let time: Date +} diff --git a/Hutch/Models/Git.swift b/Hutch/Models/Git.swift new file mode 100644 index 0000000..19ec31b --- /dev/null +++ b/Hutch/Models/Git.swift @@ -0,0 +1,241 @@ +import Foundation + +// MARK: - Enums + +/// Repository visibility level. +enum Visibility: String, Codable, Sendable { + case `public` = "PUBLIC" + case unlisted = "UNLISTED" + case `private` = "PRIVATE" +} + +/// Repository access mode. +enum AccessMode: String, Codable, Sendable { + case ro = "RO" + case rw = "RW" +} + +// MARK: - Entity + +/// The `Entity` GraphQL interface from git.sr.ht. Represents the owner of a +/// resource (typically a user). +struct Entity: Codable, Sendable, Hashable { + let canonicalName: String +} + +// MARK: - Repository + +/// A git repository from git.sr.ht. +struct Repository: Codable, Sendable, Identifiable { + let id: Int + let created: Date + let updated: Date + let name: String + let description: String? + let visibility: Visibility + let readme: String? + let accessMode: AccessMode + let owner: Entity + + enum CodingKeys: String, CodingKey { + case id, created, updated, name, description, visibility, readme + case accessMode = "access" + case owner + } +} + +// MARK: - Signature + +/// A Git commit/tag signature (author or committer). +struct Signature: Codable, Sendable { + let name: String + let email: String + let time: Date +} + +// MARK: - Trailer + +/// A Git commit trailer (e.g. "Signed-off-by", "Co-authored-by"). +struct Trailer: Codable, Sendable { + let name: String + let value: String +} + +// MARK: - Commit + +/// A Git commit from git.sr.ht. +struct Commit: Codable, Sendable, Identifiable { + let id: String + let shortId: String + let author: Signature + let committer: Signature + let message: String + let diff: String? + let trailers: [Trailer] +} + +// MARK: - Reference + +/// A Git reference (branch or tag name). +struct Reference: Codable, Sendable, Hashable { + let name: String + let target: String? +} + +// MARK: - TreeEntry + +/// An entry in a Git tree (file or directory). +struct TreeEntry: Codable, Sendable, Identifiable { + let id: String + let name: String + let mode: Int? + let object: GitObject? +} + +/// A Git object returned by the git.sr.ht GraphQL API. +/// The API returns `type` as "TREE", "BLOB", "COMMIT", or "TAG". +/// Both TextBlob and BinaryBlob have type == "BLOB"; they are +/// distinguished by the presence of the "text" key (TextBlob) vs +/// the "content" key (BinaryBlob). +enum GitObject: Sendable { + case tree(GitTree) + case textBlob(GitTextBlob) + case binaryBlob(GitBinaryBlob) + case unknown +} + +struct GitTree: Codable, Sendable { + let id: String? + let shortId: String? + let entries: GitTreeEntryPage? +} + +struct GitTextBlob: Codable, Sendable { + let id: String? + let shortId: String? + let text: String + let size: Int? +} + +struct GitBinaryBlob: Codable, Sendable { + let id: String? + let shortId: String? + let size: Int? + let content: String? +} + +struct GitTreeEntryPage: Codable, Sendable { + let results: [TreeEntry] + let cursor: String? +} + +// MARK: - GitObject Codable + +extension GitObject: Codable { + private enum CodingKeys: String, CodingKey { + case type, id, shortId, entries, text, size, content + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decodeIfPresent(String.self, forKey: .type) + + switch type { + case "TREE": + let tree = GitTree( + id: try container.decodeIfPresent(String.self, forKey: .id), + shortId: try container.decodeIfPresent(String.self, forKey: .shortId), + entries: try container.decodeIfPresent(GitTreeEntryPage.self, forKey: .entries) + ) + self = .tree(tree) + + case "BLOB": + // TextBlob has a "text" key; BinaryBlob does not + if container.contains(.text) { + let blob = GitTextBlob( + id: try container.decodeIfPresent(String.self, forKey: .id), + shortId: try container.decodeIfPresent(String.self, forKey: .shortId), + text: try container.decode(String.self, forKey: .text), + size: try container.decodeIfPresent(Int.self, forKey: .size) + ) + self = .textBlob(blob) + } else { + let blob = GitBinaryBlob( + id: try container.decodeIfPresent(String.self, forKey: .id), + shortId: try container.decodeIfPresent(String.self, forKey: .shortId), + size: try container.decodeIfPresent(Int.self, forKey: .size), + content: try container.decodeIfPresent(String.self, forKey: .content) + ) + self = .binaryBlob(blob) + } + + default: + self = .unknown + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .tree(let tree): + try container.encode("TREE", forKey: .type) + try container.encodeIfPresent(tree.id, forKey: .id) + try container.encodeIfPresent(tree.shortId, forKey: .shortId) + try container.encodeIfPresent(tree.entries, forKey: .entries) + case .textBlob(let blob): + try container.encode("BLOB", forKey: .type) + try container.encodeIfPresent(blob.id, forKey: .id) + try container.encodeIfPresent(blob.shortId, forKey: .shortId) + try container.encode(blob.text, forKey: .text) + try container.encodeIfPresent(blob.size, forKey: .size) + case .binaryBlob(let blob): + try container.encode("BLOB", forKey: .type) + try container.encodeIfPresent(blob.id, forKey: .id) + try container.encodeIfPresent(blob.shortId, forKey: .shortId) + try container.encodeIfPresent(blob.size, forKey: .size) + try container.encodeIfPresent(blob.content, forKey: .content) + case .unknown: + break + } + } +} + +/// Convenience helpers for checking object type. +extension GitObject { + var isTree: Bool { + if case .tree = self { return true } + return false + } + + var treeId: String? { + switch self { + case .tree(let t): t.id + case .textBlob(let b): b.id + case .binaryBlob(let b): b.id + case .unknown: nil + } + } +} + +// MARK: - Tag + +/// An annotated Git tag from git.sr.ht. +struct Tag: Codable, Sendable, Identifiable { + let id: String + let shortId: String + let name: String + let message: String? + let tagger: Signature? +} + +// MARK: - Artifact + +/// A release artifact attached to a Git tag. +struct Artifact: Codable, Sendable, Identifiable { + let id: Int + let created: Date + let filename: String + let checksum: String + let size: Int + let url: URL +} diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift new file mode 100644 index 0000000..c0434bc --- /dev/null +++ b/Hutch/Models/Meta.swift @@ -0,0 +1,65 @@ +import Foundation + +// MARK: - User Profile (full) + +/// Extended user profile from meta.sr.ht with all fields from the `me` query. +struct UserProfile: Codable, Sendable { + let username: String + let canonicalName: String + let email: String + let url: String? + let location: String? + let bio: String? + let avatar: String? + let userType: String? + let sshKeys: SSHKeyPage + let pgpKeys: PGPKeyPage + let paymentStatus: String? + let subscription: Subscription? +} + +// MARK: - SSH Key + +struct SSHKey: Codable, Sendable, Identifiable { + let id: Int + let fingerprint: String + let comment: String? + let created: Date + let lastUsed: Date? +} + +struct SSHKeyPage: Codable, Sendable { + let results: [SSHKey] + let cursor: String? +} + +// MARK: - PGP Key + +struct PGPKey: Codable, Sendable, Identifiable { + let id: Int + let fingerprint: String + let created: Date +} + +struct PGPKeyPage: Codable, Sendable { + let results: [PGPKey] + let cursor: String? +} + +// MARK: - Subscription + +struct Subscription: Codable, Sendable { + let status: String? + let autorenew: Bool? + let interval: String? +} + +// MARK: - Personal Access Token + +struct PersonalAccessToken: Codable, Sendable, Identifiable { + let id: Int + let issued: Date + let expires: Date? + let comment: String? + let grants: String? +} diff --git a/Hutch/Models/RepositorySummary.swift b/Hutch/Models/RepositorySummary.swift new file mode 100644 index 0000000..f4ecc6e --- /dev/null +++ b/Hutch/Models/RepositorySummary.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Lightweight repository model matching the fields returned by the +/// repositories list query. Avoids optionalizing all fields on the full +/// `Repository` model. +struct RepositorySummary: Codable, Sendable, Identifiable, Hashable { + let id: Int + /// GraphQL resource identifier used by `repository(rid:)` queries. + let rid: String + let service: SRHTService + let name: String + let description: String? + let visibility: Visibility + let updated: Date + let owner: Entity + let head: Reference? + + enum CodingKeys: String, CodingKey { + case id, rid, service, name, description, visibility, updated, owner + case head = "HEAD" + } + + init( + id: Int, + rid: String, + service: SRHTService, + name: String, + description: String?, + visibility: Visibility, + updated: Date, + owner: Entity, + head: Reference? + ) { + self.id = id + self.rid = rid + self.service = service + self.name = name + self.description = description + self.visibility = visibility + self.updated = updated + self.owner = owner + self.head = head + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(Int.self, forKey: .id) + self.rid = try container.decode(String.self, forKey: .rid) + self.service = try container.decodeIfPresent(SRHTService.self, forKey: .service) ?? .git + self.name = try container.decode(String.self, forKey: .name) + self.description = try container.decodeIfPresent(String.self, forKey: .description) + self.visibility = try container.decode(Visibility.self, forKey: .visibility) + self.updated = try container.decode(Date.self, forKey: .updated) + self.owner = try container.decode(Entity.self, forKey: .owner) + self.head = try container.decodeIfPresent(Reference.self, forKey: .head) + } +} diff --git a/Hutch/Models/Todo.swift b/Hutch/Models/Todo.swift new file mode 100644 index 0000000..8a2b06c --- /dev/null +++ b/Hutch/Models/Todo.swift @@ -0,0 +1,234 @@ +import Foundation + +// MARK: - Enums + +/// Status of a ticket. +enum TicketStatus: String, Codable, Sendable, CaseIterable { + case reported = "REPORTED" + case confirmed = "CONFIRMED" + case inProgress = "IN_PROGRESS" + case pending = "PENDING" + case resolved = "RESOLVED" + + /// Whether this status represents an open (unresolved) ticket. + var isOpen: Bool { + switch self { + case .reported, .confirmed, .inProgress, .pending: true + case .resolved: false + } + } + + var displayName: String { + switch self { + case .reported: "Reported" + case .confirmed: "Confirmed" + case .inProgress: "In Progress" + case .pending: "Pending" + case .resolved: "Resolved" + } + } +} + +/// Resolution of a ticket. +enum TicketResolution: String, Codable, Sendable { + case unresolved = "UNRESOLVED" + case fixed = "FIXED" + case implemented = "IMPLEMENTED" + case wontFix = "WONT_FIX" + case byDesign = "BY_DESIGN" + case invalid = "INVALID" + case duplicate = "DUPLICATE" + case notOurBug = "NOT_OUR_BUG" + case closed = "CLOSED" + case notApplicable = "NOT_APPLICABLE" + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer().decode(String.self) + self = TicketResolution(rawValue: value) ?? .unresolved + } + + var displayName: String { + switch self { + case .unresolved: "Unresolved" + case .fixed: "Fixed" + case .implemented: "Implemented" + case .wontFix: "Won't Fix" + case .byDesign: "By Design" + case .invalid: "Invalid" + case .duplicate: "Duplicate" + case .notOurBug: "Not Our Bug" + case .closed: "Closed" + case .notApplicable: "Not Applicable" + } + } +} + +/// Authenticity of a ticket or comment. +enum Authenticity: String, Codable, Sendable { + case authentic = "AUTHENTIC" + case tampered = "TAMPERED" + case unauthenticated = "UNAUTHENTICATED" +} + +// MARK: - Label + +/// A label that can be applied to tickets. +/// Named `TicketLabel` to avoid collision with `SwiftUI.Label`. +struct TicketLabel: Codable, Sendable, Identifiable, Hashable { + let id: Int + let name: String + let backgroundColor: String + let foregroundColor: String +} + +// MARK: - Tracker + +/// A bug tracker from todo.sr.ht. +struct Tracker: Codable, Sendable, Identifiable, Hashable { + let id: Int + let created: Date + let updated: Date + let name: String + let description: String? + let visibility: Visibility + let owner: Entity + + static func == (lhs: Tracker, rhs: Tracker) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +// MARK: - Tracker Summary (for list view) + +/// Lightweight tracker model matching the fields returned by the trackers list query. +struct TrackerSummary: Codable, Sendable, Identifiable, Hashable { + let id: Int + /// GraphQL resource identifier used by `tracker(id:)` queries. + let rid: String + let name: String + let description: String? + let visibility: Visibility + let updated: Date + let owner: Entity +} + +// MARK: - Ticket Summary (for list view) + +/// Lightweight ticket model for the list query. +struct TicketSummary: Codable, Sendable, Identifiable, Hashable { + let id: Int + let title: String + let status: TicketStatus + let resolution: TicketResolution? + let created: Date + let submitter: Entity + let labels: [TicketLabel] + let assignees: [Entity] + + static func == (lhs: TicketSummary, rhs: TicketSummary) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +// MARK: - Ticket Detail + +/// Full ticket model for the detail view. +struct TicketDetail: Codable, Sendable { + let id: Int + let created: Date + let updated: Date + let title: String + let description: String? + let status: TicketStatus + let resolution: TicketResolution? + let authenticity: Authenticity + let submitter: Entity + let assignees: [Entity] + let labels: [TicketLabel] +} + +// MARK: - Event + +/// A timeline event on a ticket. Each event contains one or more changes. +struct TicketEvent: Codable, Sendable, Identifiable { + let id: Int + let created: Date + var changes: [EventChange] +} + +/// A single change within an event, decoded from the polymorphic EventDetail +/// interface using inline fragments. +struct EventChange: Codable, Sendable, Identifiable { + let id: UUID + + let eventType: String + + // Comment fields + let author: Entity? + var text: String? + let authenticity: Authenticity? + + // StatusChange fields + let oldStatus: TicketStatus? + let newStatus: TicketStatus? + + // LabelUpdate fields + let labeler: Entity? + let label: EventLabel? + + // Assignment fields + let assigner: Entity? + let assignee: Entity? + + // Mention fields + // TicketMention: mentioned is a Ticket with { id } + // UserMention: mentioned is an Entity with { canonicalName } + let mentioned: MentionTarget? + + // Created fields (author is shared with Comment) + + private enum CodingKeys: String, CodingKey { + case eventType + case author, text, authenticity + case oldStatus, newStatus + case labeler, label + case assigner, assignee + case mentioned + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = UUID() + self.eventType = try container.decode(String.self, forKey: .eventType) + self.author = try container.decodeIfPresent(Entity.self, forKey: .author) + self.text = try container.decodeIfPresent(String.self, forKey: .text) + self.authenticity = try container.decodeIfPresent(Authenticity.self, forKey: .authenticity) + self.oldStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .oldStatus) + self.newStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .newStatus) + self.labeler = try container.decodeIfPresent(Entity.self, forKey: .labeler) + self.label = try container.decodeIfPresent(EventLabel.self, forKey: .label) + self.assigner = try container.decodeIfPresent(Entity.self, forKey: .assigner) + self.assignee = try container.decodeIfPresent(Entity.self, forKey: .assignee) + self.mentioned = try container.decodeIfPresent(MentionTarget.self, forKey: .mentioned) + } +} + +/// Decoded from the `mentioned` field which may be a Ticket (with `id`) or +/// an Entity (with `canonicalName`) depending on the event type. +struct MentionTarget: Codable, Sendable { + let id: Int? + let canonicalName: String? +} + +/// Label info as returned within a LabelUpdate event change. +struct EventLabel: Codable, Sendable { + let name: String +} diff --git a/Hutch/Models/User.swift b/Hutch/Models/User.swift new file mode 100644 index 0000000..a9350a3 --- /dev/null +++ b/Hutch/Models/User.swift @@ -0,0 +1,10 @@ +import Foundation + +/// A Sourcehut user from meta.sr.ht. +struct User: Decodable, Sendable { + let id: Int + let username: String + let canonicalName: String + let email: String + let avatar: String? +} diff --git a/Hutch/Networking/GraphQLRequest.swift b/Hutch/Networking/GraphQLRequest.swift new file mode 100644 index 0000000..bce7b40 --- /dev/null +++ b/Hutch/Networking/GraphQLRequest.swift @@ -0,0 +1,45 @@ +import Foundation + +/// The JSON body sent with every GraphQL request. +struct GraphQLRequestBody: Encodable, Sendable { + let query: String + let variables: [String: AnyCodable]? +} + +/// The top-level shape of every GraphQL response. +struct GraphQLResponse: Decodable { + let data: T? + let errors: [GraphQLError]? +} + +// MARK: - AnyCodable + +/// A type-erased `Codable` wrapper so callers can pass `[String: Any]` variables +/// without losing type information at the encoding boundary. +struct AnyCodable: Sendable, Encodable { + let value: any Sendable + + init(_ value: any Sendable) { + self.value = value + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case let v as String: + try container.encode(v) + case let v as Int: + try container.encode(v) + case let v as Double: + try container.encode(v) + case let v as Bool: + try container.encode(v) + case let v as [any Sendable]: + try container.encode(v.map { AnyCodable($0) }) + case let v as [String: any Sendable]: + try container.encode(v.mapValues { AnyCodable($0) }) + default: + try container.encodeNil() + } + } +} diff --git a/Hutch/Networking/NetworkMonitor.swift b/Hutch/Networking/NetworkMonitor.swift new file mode 100644 index 0000000..4d5d364 --- /dev/null +++ b/Hutch/Networking/NetworkMonitor.swift @@ -0,0 +1,48 @@ +import Foundation +import Network + +/// Observes network connectivity using `NWPathMonitor`. +/// Shared singleton injected into the environment. +@Observable +@MainActor +final class NetworkMonitor { + + private(set) var isConnected = true + private(set) var connectionType: ConnectionType = .unknown + + enum ConnectionType: Sendable { + case wifi + case cellular + case wiredEthernet + case unknown + } + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "net.cleberg.Hutch.NetworkMonitor") + + init() { + startMonitoring() + } + + private func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor [weak self] in + guard let self else { return } + self.isConnected = path.status == .satisfied + self.connectionType = self.resolveConnectionType(path) + } + } + monitor.start(queue: queue) + } + + private nonisolated func resolveConnectionType(_ path: NWPath) -> ConnectionType { + if path.usesInterfaceType(.wifi) { return .wifi } + if path.usesInterfaceType(.cellular) { return .cellular } + if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet } + return .unknown + } + + deinit { + monitor.cancel() + } +} diff --git a/Hutch/Networking/Pagination.swift b/Hutch/Networking/Pagination.swift new file mode 100644 index 0000000..8adeaee --- /dev/null +++ b/Hutch/Networking/Pagination.swift @@ -0,0 +1,176 @@ +import Foundation + +/// The standard paginated response shape used by all sr.ht GraphQL APIs. +/// { results: [T], cursor: String? } +/// A null cursor means the list is exhausted. +struct CursorPage: Decodable, Sendable { + let results: [Element] + let cursor: String? +} + +/// An `AsyncSequence` that lazily fetches pages from a paginated sr.ht GraphQL +/// query. Each element yielded is a single `Element` from the `results` array. +/// +/// The sequence re-issues the query with an updated `$cursor` variable on each +/// page until the server returns a null cursor. +/// +/// Usage: +/// ```swift +/// let sequence = SRHTPaginatedSequence( +/// client: client, +/// service: .git, +/// query: "query($cursor: String) { me { repositories(cursor: $cursor) { results { id name } cursor } } }", +/// variables: nil, +/// resultKeyPath: "me.repositories" +/// ) +/// for try await repo in sequence { +/// print(repo.name) +/// } +/// ``` +struct SRHTPaginatedSequence: AsyncSequence, Sendable { + let client: SRHTClient + let service: SRHTService + let query: String + let variables: [String: any Sendable]? + let resultKeyPath: String + + func makeAsyncIterator() -> Iterator { + Iterator( + client: client, + service: service, + query: query, + variables: variables, + resultKeyPath: resultKeyPath + ) + } + + struct Iterator: AsyncIteratorProtocol { + private let client: SRHTClient + private let service: SRHTService + private let query: String + private let baseVariables: [String: any Sendable]? + private let resultKeyPath: String + + /// Buffer of elements from the current page. + private var buffer: [Element] = [] + /// Index into the current buffer. + private var bufferIndex = 0 + /// The cursor for the next page. Nil means we haven't started or are done. + private var nextCursor: String? = nil + /// Whether we've exhausted all pages. + private var isFinished = false + + init( + client: SRHTClient, + service: SRHTService, + query: String, + variables: [String: any Sendable]?, + resultKeyPath: String + ) { + self.client = client + self.service = service + self.query = query + self.baseVariables = variables + self.resultKeyPath = resultKeyPath + } + + mutating func next() async throws -> Element? { + // Yield buffered elements first. + if bufferIndex < buffer.count { + let element = buffer[bufferIndex] + bufferIndex += 1 + return element + } + + // If we already know there are no more pages, stop. + if isFinished { + return nil + } + + // Fetch the next page. + var vars = baseVariables ?? [:] + if let cursor = nextCursor { + vars["cursor"] = cursor + } + + let page = try await fetchPage(variables: vars) + + if let cursor = page.cursor { + nextCursor = cursor + } else { + isFinished = true + } + + buffer = page.results + bufferIndex = 0 + + guard bufferIndex < buffer.count else { + return nil + } + + let element = buffer[bufferIndex] + bufferIndex += 1 + return element + } + + private func fetchPage(variables: [String: any Sendable]) async throws -> CursorPage { + // We decode the raw JSON and navigate the key path manually, + // since the paginated object can be nested arbitrarily + // (e.g. "me.repositories" or just "repositories"). + let raw = try await client.execute( + service: service, + query: query, + variables: variables.isEmpty ? nil : variables, + responseType: RawJSON.self + ) + + // Walk the key path to find the paginated object. + let pathComponents = resultKeyPath.split(separator: ".").map(String.init) + var current = raw.value + for component in pathComponents { + guard let dict = current as? [String: Any], + let next = dict[component] else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing key path: \(resultKeyPath)")) + ) + } + current = next + } + + // Re-serialize the nested object and decode as CursorPage. + let pageData = try JSONSerialization.data(withJSONObject: current) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(.srht) + return try decoder.decode(CursorPage.self, from: pageData) + } + } +} + +// MARK: - RawJSON + +/// A Decodable wrapper that preserves the raw JSON structure as Foundation objects +/// so we can navigate dynamic key paths at runtime. +struct RawJSON: Decodable, Sendable { + let value: Any + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let dict = try? container.decode([String: RawJSON].self) { + value = dict.mapValues(\.value) + } else if let array = try? container.decode([RawJSON].self) { + value = array.map(\.value) + } else if let string = try? container.decode(String.self) { + value = string + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if container.decodeNil() { + value = NSNull() + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } +} diff --git a/Hutch/Networking/ResponseCache.swift b/Hutch/Networking/ResponseCache.swift new file mode 100644 index 0000000..a6cc269 --- /dev/null +++ b/Hutch/Networking/ResponseCache.swift @@ -0,0 +1,33 @@ +import Foundation +import os + +/// Thread-safe in-memory cache for raw GraphQL response data. +/// Keyed by a caller-provided string (typically service name + query hash). +final class ResponseCache: Sendable { + + private let storage: OSAllocatedUnfairLock<[String: Data]> + + init() { + self.storage = OSAllocatedUnfairLock(initialState: [:]) + } + + /// Store raw response data under a cache key. + func set(_ data: Data, forKey key: String) { + storage.withLock { $0[key] = data } + } + + /// Retrieve cached response data. Returns nil on cache miss. + func get(forKey key: String) -> Data? { + storage.withLock { $0[key] } + } + + /// Remove a specific entry. + func remove(forKey key: String) { + storage.withLock { _ = $0.removeValue(forKey: key) } + } + + /// Clear all cached data (e.g. on sign-out). + func clear() { + storage.withLock { $0.removeAll() } + } +} diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift new file mode 100644 index 0000000..a24fe24 --- /dev/null +++ b/Hutch/Networking/SRHTClient.swift @@ -0,0 +1,521 @@ +import Foundation +import os + +private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient") + +/// Placeholder type for decoding GraphQL error responses when the data shape is unknown. +private struct EmptyData: Decodable {} + +/// A lightweight GraphQL client for Sourcehut services. +/// All requests require a personal access token set via ``token``. +final class SRHTClient: Sendable { + + private let session: URLSession + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + /// The personal access token used for `Authorization: Bearer` headers. + /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``. + private let _token: OSAllocatedUnfairLock + + /// In-memory response cache for stale-while-revalidate pattern. + let responseCache = ResponseCache() + + var hasToken: Bool { + _token.withLock { $0 != nil } + } + + init(session: URLSession = .shared, token: String? = nil) { + self.session = session + self.decoder = JSONDecoder() + self.decoder.dateDecodingStrategy = .srhtFlexible + self.encoder = JSONEncoder() + self._token = OSAllocatedUnfairLock(initialState: token) + } + + /// Update the stored token (e.g. after the user saves a new one in Keychain). + func setToken(_ token: String?) { + _token.withLock { $0 = token } + } + + /// Execute a GraphQL query or mutation against a Sourcehut service. + /// + /// - Parameters: + /// - service: The target Sourcehut service (determines the endpoint URL). + /// - query: The GraphQL query or mutation string. + /// - variables: Optional dictionary of GraphQL variables. + /// - responseType: The expected `Decodable` type nested under `data`. + /// - Returns: The decoded `data` payload. + func execute( + service: SRHTService, + query: String, + variables: [String: any Sendable]? = nil, + responseType: T.Type + ) async throws -> T { + guard let token = _token.withLock({ $0 }), !token.isEmpty else { + throw SRHTError.unauthorized + } + + // Build request + var request = URLRequest(url: service.url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = GraphQLRequestBody( + query: query, + variables: variables?.mapValues { AnyCodable($0) } + ) + request.httpBody = try encoder.encode(body) + + // Execute + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw SRHTError.networkError(error) + } + + // Check HTTP status + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { + throw SRHTError.unauthorized + } + if !(200...299).contains(http.statusCode) { + // Try to extract GraphQL errors from the response body even on non-2xx + if let gqlResponse = try? decoder.decode(GraphQLResponse.self, from: data), + let errors = gqlResponse.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + throw SRHTError.httpError(http.statusCode) + } + } + + // Decode GraphQL response envelope + let graphQLResponse: GraphQLResponse + do { + graphQLResponse = try decoder.decode(GraphQLResponse.self, from: data) + } catch { + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "" + let variablesDescription = String(describing: variables) + if let decodingError = error as? DecodingError { + logger.error( + """ + Decoding failed for \(String(describing: T.self), privacy: .public) + service: \(service.rawValue, privacy: .public) + query: + \(query, privacy: .public) + variables: + \(variablesDescription, privacy: .public) + decodingError: + \(String(describing: decodingError), privacy: .public) + response: + \(responseBody, privacy: .public) + """ + ) + } else { + logger.error( + """ + Decoding failed for \(String(describing: T.self), privacy: .public) + service: \(service.rawValue, privacy: .public) + query: + \(query, privacy: .public) + variables: + \(variablesDescription, privacy: .public) + error: + \(String(describing: error), privacy: .public) + response: + \(responseBody, privacy: .public) + """ + ) + } + #else + logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)") + #endif + throw SRHTError.decodingError(error) + } + + // Surface GraphQL-level errors + if let errors = graphQLResponse.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + + guard let result = graphQLResponse.data else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) + ) + } + + return result + } + + // MARK: - Multipart Upload + + /// Execute a GraphQL mutation with a file upload using the + /// graphql-multipart-request-spec (multipart/form-data). + /// + /// - Parameters: + /// - service: The target Sourcehut service. + /// - query: The GraphQL mutation string. + /// - variables: Variables dict; the file variable should be set to `nil`. + /// - fileVariablePath: The dot-separated path to the file variable (e.g. "input.avatar"). + /// - fileData: The raw file data (e.g. JPEG). + /// - fileName: The file name to send (e.g. "avatar.jpg"). + /// - mimeType: The MIME type (e.g. "image/jpeg"). + /// - responseType: The expected `Decodable` type nested under `data`. + func executeMultipart( + service: SRHTService, + query: String, + variables: [String: any Sendable], + fileVariablePath: String, + fileData: Data, + fileName: String, + mimeType: String, + responseType: T.Type + ) async throws -> T { + guard let token = _token.withLock({ $0 }), !token.isEmpty else { + throw SRHTError.unauthorized + } + + let boundary = "Boundary-\(UUID().uuidString)" + + var request = URLRequest(url: service.url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + // Build the operations JSON (file variable mapped to null) + let operationsBody = GraphQLRequestBody( + query: query, + variables: variables.mapValues { AnyCodable($0) } + ) + let operationsData = try encoder.encode(operationsBody) + + // Build the map JSON: { "0": ["variables."] } + let mapDict = ["0": ["variables.\(fileVariablePath)"]] + let mapData = try encoder.encode(mapDict) + + // Assemble multipart body + var body = Data() + + // Part: operations + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"operations\"\r\n") + body.append("Content-Type: application/json\r\n\r\n") + body.append(operationsData) + body.append("\r\n") + + // Part: map + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"map\"\r\n") + body.append("Content-Type: application/json\r\n\r\n") + body.append(mapData) + body.append("\r\n") + + // Part: file + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"0\"; filename=\"\(fileName)\"\r\n") + body.append("Content-Type: \(mimeType)\r\n\r\n") + body.append(fileData) + body.append("\r\n") + + // Closing boundary + body.append("--\(boundary)--\r\n") + + request.httpBody = body + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw SRHTError.networkError(error) + } + + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { + throw SRHTError.unauthorized + } + if !(200...299).contains(http.statusCode) { + throw SRHTError.httpError(http.statusCode) + } + } + + let graphQLResponse: GraphQLResponse + do { + graphQLResponse = try decoder.decode(GraphQLResponse.self, from: data) + } catch { + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "" + let variablesDescription = String(describing: variables) + if let decodingError = error as? DecodingError { + logger.error( + """ + Decoding failed for \(String(describing: T.self), privacy: .public) + service: \(service.rawValue, privacy: .public) + query: + \(query, privacy: .public) + variables: + \(variablesDescription, privacy: .public) + decodingError: + \(String(describing: decodingError), privacy: .public) + response: + \(responseBody, privacy: .public) + """ + ) + } else { + logger.error( + """ + Decoding failed for \(String(describing: T.self), privacy: .public) + service: \(service.rawValue, privacy: .public) + query: + \(query, privacy: .public) + variables: + \(variablesDescription, privacy: .public) + error: + \(String(describing: error), privacy: .public) + response: + \(responseBody, privacy: .public) + """ + ) + } + #else + logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)") + #endif + throw SRHTError.decodingError(error) + } + + if let errors = graphQLResponse.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + + guard let result = graphQLResponse.data else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) + ) + } + + return result + } + + // MARK: - Cached Execute + + /// Execute a query and cache the raw response data. Returns cached data + /// immediately on cache hit, then refreshes in the background via the + /// `onRefresh` callback. + func executeCached( + service: SRHTService, + query: String, + variables: [String: any Sendable]? = nil, + responseType: T.Type, + cacheKey: String + ) async throws -> T { + // Try cache first + if let cachedData = responseCache.get(forKey: cacheKey) { + if let cached = try? decoder.decode(GraphQLResponse.self, from: cachedData), + let data = cached.data { + return data + } + } + + // No cache hit — fetch normally + return try await executeAndCache( + service: service, + query: query, + variables: variables, + responseType: responseType, + cacheKey: cacheKey + ) + } + + /// Execute a query, cache the raw data, and return the decoded result. + func executeAndCache( + service: SRHTService, + query: String, + variables: [String: any Sendable]? = nil, + responseType: T.Type, + cacheKey: String + ) async throws -> T { + guard let token = _token.withLock({ $0 }), !token.isEmpty else { + throw SRHTError.unauthorized + } + + var request = URLRequest(url: service.url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = GraphQLRequestBody( + query: query, + variables: variables?.mapValues { AnyCodable($0) } + ) + request.httpBody = try encoder.encode(body) + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw SRHTError.networkError(error) + } + + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { + throw SRHTError.unauthorized + } + if !(200...299).contains(http.statusCode) { + throw SRHTError.httpError(http.statusCode) + } + } + + // Cache the raw response data before decoding + responseCache.set(data, forKey: cacheKey) + + let graphQLResponse: GraphQLResponse + do { + graphQLResponse = try decoder.decode(GraphQLResponse.self, from: data) + } catch { + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "" + let variablesDescription = String(describing: variables) + if let decodingError = error as? DecodingError { + logger.error( + """ + Decoding failed for \(String(describing: T.self), privacy: .public) + service: \(service.rawValue, privacy: .public) + query: + \(query, privacy: .public) + variables: + \(variablesDescription, privacy: .public) + decodingError: + \(String(describing: decodingError), privacy: .public) + response: + \(responseBody, privacy: .public) + """ + ) + } else { + logger.error( + """ + Decoding failed for \(String(describing: T.self), privacy: .public) + service: \(service.rawValue, privacy: .public) + query: + \(query, privacy: .public) + variables: + \(variablesDescription, privacy: .public) + error: + \(String(describing: error), privacy: .public) + response: + \(responseBody, privacy: .public) + """ + ) + } + #else + logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)") + #endif + throw SRHTError.decodingError(error) + } + + if let errors = graphQLResponse.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + + guard let result = graphQLResponse.data else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) + ) + } + + return result + } + + // MARK: - Plain-text fetch + + /// Fetch the contents of a URL as plain text, using the same authorization header. + /// Used for build logs and other non-GraphQL resources. + func fetchText(url: URL) async throws -> String { + guard let token = _token.withLock({ $0 }), !token.isEmpty else { + throw SRHTError.unauthorized + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw SRHTError.networkError(error) + } + + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { + throw SRHTError.unauthorized + } + if !(200...299).contains(http.statusCode) { + throw SRHTError.httpError(http.statusCode) + } + } + + guard let text = String(data: data, encoding: .utf8) else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text")) + ) + } + + return text + } + + // MARK: - Pagination + + /// Returns an `AsyncSequence` that lazily iterates through all pages of a + /// paginated sr.ht GraphQL query. + /// + /// The query must accept a `$cursor: String` variable and return the standard + /// `{ results: [T], cursor: String? }` shape at the given key path. + func paginated( + service: SRHTService, + query: String, + variables: [String: any Sendable]? = nil, + resultKeyPath: String, + type: T.Type + ) -> SRHTPaginatedSequence { + SRHTPaginatedSequence( + client: self, + service: service, + query: query, + variables: variables, + resultKeyPath: resultKeyPath + ) + } + + /// Fetches all pages of a paginated sr.ht GraphQL query and returns the + /// collected results. + func fetchAll( + service: SRHTService, + query: String, + variables: [String: any Sendable]? = nil, + resultKeyPath: String, + type: T.Type + ) async throws -> [T] { + var all: [T] = [] + for try await element in paginated( + service: service, + query: query, + variables: variables, + resultKeyPath: resultKeyPath, + type: type + ) { + all.append(element) + } + return all + } +} + +// MARK: - Data Helper + +private extension Data { + mutating func append(_ string: String) { + if let data = string.data(using: .utf8) { + append(data) + } + } +} diff --git a/Hutch/Networking/SRHTError.swift b/Hutch/Networking/SRHTError.swift new file mode 100644 index 0000000..f148c00 --- /dev/null +++ b/Hutch/Networking/SRHTError.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Errors produced by the Sourcehut GraphQL client. +enum SRHTError: LocalizedError, Sendable { + /// The server returned one or more GraphQL-level errors. + case graphQLErrors([GraphQLError]) + /// The HTTP response had a non-2xx status code. + case httpError(Int) + /// The response data could not be decoded. + case decodingError(any Error) + /// A networking error from URLSession (timeout, DNS, connectivity, etc.). + case networkError(any Error) + /// 401 or no authentication token configured. + case unauthorized + + var errorDescription: String? { + switch self { + case .graphQLErrors(let errors): + let messages = errors.map(\.message).joined(separator: "\n") + return "GraphQL error: \(messages)" + case .httpError(let code): + return "Server returned HTTP \(code)." + case .decodingError(let error): + return "Failed to decode response: \(error.localizedDescription)" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .unauthorized: + return "Authentication required. Please sign in again." + } + } + + /// Whether this error represents a connectivity issue (no internet, timeout, DNS). + var isConnectivityError: Bool { + switch self { + case .networkError(let error): + let nsError = error as NSError + let connectivityCodes: Set = [ + NSURLErrorNotConnectedToInternet, + NSURLErrorNetworkConnectionLost, + NSURLErrorTimedOut, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorDNSLookupFailed, + NSURLErrorInternationalRoamingOff, + NSURLErrorDataNotAllowed + ] + return connectivityCodes.contains(nsError.code) + default: + return false + } + } +} + +/// A single error entry from the GraphQL `errors` array. +struct GraphQLError: Decodable, Sendable { + let message: String + let locations: [GraphQLErrorLocation]? +} + +struct GraphQLErrorLocation: Decodable, Sendable { + let line: Int + let column: Int +} diff --git a/Hutch/Networking/SRHTService.swift b/Hutch/Networking/SRHTService.swift new file mode 100644 index 0000000..5db06dd --- /dev/null +++ b/Hutch/Networking/SRHTService.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Each Sourcehut service exposes its own GraphQL endpoint. +enum SRHTService: String, Codable, Sendable, CaseIterable { + case meta + case git + case hg + case builds + case lists + case todo + case paste + case pages + case man + + /// The GraphQL endpoint URL for this service. + var url: URL { + // Force-unwrap is safe here — these are compile-time constant strings. + URL(string: "https://\(rawValue).sr.ht/query")! + } + + var displayName: String { + switch self { + case .meta: "Meta" + case .git: "Git" + case .hg: "Mercurial" + case .builds: "Builds" + case .lists: "Lists" + case .todo: "Todo" + case .paste: "Paste" + case .pages: "Pages" + case .man: "Man" + } + } +} diff --git a/Hutch/Views/Auth/AuthView.swift b/Hutch/Views/Auth/AuthView.swift new file mode 100644 index 0000000..cbf8669 --- /dev/null +++ b/Hutch/Views/Auth/AuthView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +/// Token entry screen shown when the user is not authenticated. +struct TokenEntryView: View { + private let createAccountURL = URL(string: "https://meta.sr.ht/register")! + private let personalAccessTokensURL = URL(string: "https://meta.sr.ht/oauth/personal-access-tokens")! + + @Environment(AppState.self) private var appState + @State private var token = "" + @State private var isConnecting = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + Form { + Section { + Text("Enter your SourceHut personal access token to connect.") + } header: { + Text("Welcome to Hutch") + } footer: { + Text("Hutch stores your SourceHut personal access token securely in the iOS keychain.") + } + + Section { + SecureField("Personal Access Token", text: $token) + .textContentType(.password) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .disabled(isConnecting) + } header: { + Text("Token") + } + + if let errorMessage { + Section { + Label { + Text(errorMessage) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + .foregroundStyle(.red) + } + } + + Section { + Button { + connect() + } label: { + HStack { + Text("Connect") + if isConnecting { + Spacer() + ProgressView() + } + } + } + .disabled(tokenTrimmed.isEmpty || isConnecting) + } + + Section { + Link(destination: createAccountURL) { + Label("Create SourceHut account", systemImage: "person.badge.plus") + } + + Link(destination: personalAccessTokensURL) { + Label("Create Personal Access Token", systemImage: "key") + } + } header: { + Text("Need an account?") + } footer: { + Text("These links open SourceHut in your browser. After signing up, create a Personal Access Token there and paste it here.") + } + } + .navigationTitle("Hutch") + } + } + + private var tokenTrimmed: String { + token.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func connect() { + errorMessage = nil + isConnecting = true + Task { + do { + try await appState.connect(with: tokenTrimmed) + } catch { + errorMessage = error.localizedDescription + } + isConnecting = false + } + } +} diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift new file mode 100644 index 0000000..977cd87 --- /dev/null +++ b/Hutch/Views/Builds/BuildDetailView.swift @@ -0,0 +1,383 @@ +import SwiftUI + +struct BuildDetailView: View { + let jobId: Int + + @Environment(AppState.self) private var appState + @State private var viewModel: BuildDetailViewModel? + @State private var rebuiltJobId: Int? + @State private var showEditResubmitSheet = false + @State private var showCancelConfirmation = false + + var body: some View { + Group { + if let viewModel { + detailContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading build…") + } + } + .navigationTitle("Job #\(jobId)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + SRHTShareButton( + url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) }, + target: .build + ) { + Image(systemName: "square.and.arrow.up") + } + } + } + .navigationDestination(isPresented: Binding( + get: { rebuiltJobId != nil }, + set: { isPresented in + if !isPresented { + rebuiltJobId = nil + } + } + )) { + if let rebuiltJobId { + BuildDetailView(jobId: rebuiltJobId) + } + } + .sheet(isPresented: $showEditResubmitSheet) { + if let viewModel, let job = viewModel.job { + EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in + showEditResubmitSheet = false + rebuiltJobId = jobId + } + } + } + .alert("Cancel Build?", isPresented: $showCancelConfirmation) { + Button("Keep Running", role: .cancel) {} + Button("Cancel Build", role: .destructive) { + Task { await viewModel?.cancelJob() } + } + } message: { + Text("The build will stop as soon as possible.") + } + .task { + if viewModel == nil { + let vm = BuildDetailViewModel(jobId: jobId, client: appState.client) + viewModel = vm + await vm.loadJob() + } + } + } + + @ViewBuilder + private func detailContent(_ viewModel: BuildDetailViewModel) -> some View { + if viewModel.isLoading, viewModel.job == nil { + SRHTLoadingStateView(message: "Loading build…") + } else if let error = viewModel.error, viewModel.job == nil { + SRHTErrorStateView( + title: "Couldn't Load Build", + message: error, + retryAction: { await viewModel.loadJob() } + ) + } else if let job = viewModel.job { + List { + // Status & metadata + Section("Details") { + HStack { + Text("Status") + Spacer() + HStack(spacing: 6) { + JobStatusIcon(status: job.status) + Text(job.status.rawValue) + .font(.subheadline.weight(.medium)) + } + } + + if let note = job.note, !note.isEmpty { + LabeledContent("Note", value: note) + } + + if let image = job.image { + LabeledContent("Image", value: image) + } + + if !job.tags.isEmpty { + LabeledContent("Tags", value: job.tags.joined(separator: ", ")) + } + + if let visibility = job.visibility { + LabeledContent("Visibility", value: visibility.rawValue.capitalized) + } + + LabeledContent("Owner", value: job.owner.canonicalName) + LabeledContent("Created", value: job.created.relativeDescription) + LabeledContent("Updated", value: job.updated.relativeDescription) + } + + // Per-task logs + if !job.tasks.isEmpty { + ForEach(job.tasks) { task in + Section { + TaskLogSection(task: task, viewModel: viewModel) + } header: { + HStack(spacing: 6) { + TaskStatusIcon(status: task.status) + Text(task.name) + } + } + } + } + + // Cancel button + if job.status.isCancellable { + Section { + Button(role: .destructive) { + showCancelConfirmation = true + } label: { + HStack { + Text("Cancel Build") + if viewModel.isCancelling { + Spacer() + ProgressView() + } + } + } + .disabled(viewModel.isCancelling) + } + } + + if let manifest = job.manifest, + !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Section { + Button { + Task { + rebuiltJobId = await viewModel.rebuildJob() + } + } label: { + HStack { + Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild") + if viewModel.isRebuilding { + Spacer() + ProgressView() + } + } + } + .disabled(viewModel.isRebuilding) + + Button { + showEditResubmitSheet = true + } label: { + Text("Edit & Resubmit") + } + .disabled(viewModel.isSubmittingEditedBuild) + } footer: { + Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.") + } + } + } + .refreshable { + await viewModel.loadJob() + } + .srhtErrorBanner(error: Binding( + get: { viewModel.error }, + set: { viewModel.error = $0 } + )) + } + } +} + +private struct EditResubmitBuildSheet: View { + let viewModel: BuildDetailViewModel + let job: JobDetail + let onSubmitted: (Int) -> Void + + @Environment(\.dismiss) private var dismiss + @Bindable var viewModelBindable: BuildDetailViewModel + @State private var manifest: String + @State private var tagsText: String + @State private var note: String + @State private var secrets = false + @State private var execute = true + @State private var visibility: Visibility + + init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) { + self.viewModel = viewModel + self._viewModelBindable = Bindable(viewModel) + self.job = job + self.onSubmitted = onSubmitted + _manifest = State(initialValue: job.manifest ?? "") + _tagsText = State(initialValue: job.tags.joined(separator: ", ")) + _note = State(initialValue: job.note ?? "") + _visibility = State(initialValue: job.visibility ?? .public) + } + + var body: some View { + NavigationStack { + Form { + Section("Build Manifest") { + TextField("Build manifest", text: $manifest, axis: .vertical) + .font(.system(.body, design: .monospaced)) + .lineLimit(12...24) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section("Build Options") { + TextField("Note (optional)", text: $note) + TextField("Tags (comma-separated, optional)", text: $tagsText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) + } + Toggle("Start build now", isOn: $execute) + Toggle("Allow build secrets", isOn: $secrets) + } + + Section { + Text("This submits a new build. “Start build now” and “Allow build secrets” use local defaults because the current job does not include those original values.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let error = viewModel.error { + Section { + Label { + Text(error) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + .foregroundStyle(.red) + } + } + } + .navigationTitle("Edit & Resubmit") + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + viewModelBindable.error = nil + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + viewModelBindable.error = nil + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + let tags = tagsText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if let jobId = await viewModel.submitBuild( + manifest: manifest, + tags: tags, + note: note, + secrets: secrets, + execute: execute, + visibility: visibility + ) { + onSubmitted(jobId) + } + } + } label: { + if viewModel.isSubmittingEditedBuild { + ProgressView() + .controlSize(.small) + } else { + Text("Submit Build") + } + } + .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild) + } + } + } + } +} + +// MARK: - Task Log Section + +private struct TaskLogSection: View { + let task: BuildTask + let viewModel: BuildDetailViewModel + + @State private var isExpanded: Bool + + init(task: BuildTask, viewModel: BuildDetailViewModel) { + self.task = task + self.viewModel = viewModel + self._isExpanded = State(initialValue: task.status == .failed) + } + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + if viewModel.loadingTaskLogs.contains(task.name) { + HStack { + Spacer() + ProgressView("Loading log…") + Spacer() + } + } else if let logText = viewModel.taskLogs[task.name] { + ScrollView(.horizontal, showsIndicators: false) { + Text(logText) + .font(.caption2.monospaced()) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else if task.log == nil { + Text("No log available.") + .foregroundStyle(.secondary) + } + } label: { + HStack { + Text(task.status.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .task { + if isExpanded { + await viewModel.loadTaskLog(task: task) + } + } + .onChange(of: isExpanded) { _, expanded in + if expanded { + Task { await viewModel.loadTaskLog(task: task) } + } + } + } +} + +// MARK: - Task Status Icon + +private struct TaskStatusIcon: View { + let status: TaskStatus + + var body: some View { + Image(systemName: iconName) + .foregroundStyle(color) + .frame(width: 20) + } + + private var iconName: String { + switch status { + case .success: "checkmark.circle.fill" + case .failed: "xmark.circle.fill" + case .running: "arrow.trianglehead.2.clockwise.rotate.90" + case .pending: "circle.dashed" + case .skipped: "forward.circle.fill" + } + } + + private var color: Color { + switch status { + case .success: .green + case .failed: .red + case .running: .yellow + case .pending: .gray + case .skipped: .secondary + } + } +} diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift new file mode 100644 index 0000000..c14e47b --- /dev/null +++ b/Hutch/Views/Builds/BuildDetailViewModel.swift @@ -0,0 +1,235 @@ +import Foundation + +// MARK: - Response types + +private struct JobDetailResponse: Decodable, Sendable { + let job: JobDetail +} + +private struct CancelResponse: Decodable, Sendable { + let cancel: CancelResult +} + +private struct CancelResult: Decodable, Sendable { + let id: Int +} + +private struct SubmitJobResponse: Decodable, Sendable { + let submit: SubmittedJob +} + +private struct SubmittedJob: Decodable, Sendable { + let id: Int +} + +// MARK: - View Model + +@Observable +@MainActor +final class BuildDetailViewModel { + + let jobId: Int + private let client: SRHTClient + + private(set) var job: JobDetail? + private(set) var isLoading = false + private(set) var taskLogs: [String: String] = [:] + private(set) var loadingTaskLogs: Set = [] + private(set) var isCancelling = false + private(set) var isRebuilding = false + private(set) var isSubmittingEditedBuild = false + var error: String? + + init(jobId: Int, client: SRHTClient) { + self.jobId = jobId + self.client = client + } + + // MARK: - Queries + + private static let detailQuery = """ + query job($id: Int!) { + job(id: $id) { + id + created + updated + status + note + tags + visibility + image + manifest + tasks { name status log { fullURL } } + log { fullURL } + owner { canonicalName } + } + } + """ + + private static let cancelMutation = """ + mutation cancel($id: Int!) { + cancel(jobId: $id) { + id + } + } + """ + + private static let submitMutation = """ + mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) { + submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) { + id + } + } + """ + + private static let editableSubmitMutation = """ + mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) { + submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) { + id + } + } + """ + + // MARK: - Public API + + func loadJob() async { + guard !isLoading else { return } + isLoading = true + error = nil + + do { + let result = try await client.execute( + service: .builds, + query: Self.detailQuery, + variables: ["id": jobId], + responseType: JobDetailResponse.self + ) + job = result.job + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + func loadTaskLog(task: BuildTask) async { + guard let log = task.log, + let logURL = URL(string: log.fullURL), + !loadingTaskLogs.contains(task.name), + taskLogs[task.name] == nil else { return } + loadingTaskLogs.insert(task.name) + + do { + taskLogs[task.name] = try await client.fetchText(url: logURL) + } catch { + self.error = error.localizedDescription + } + + loadingTaskLogs.remove(task.name) + } + + func cancelJob() async { + guard let job, job.status.isCancellable, !isCancelling else { return } + isCancelling = true + error = nil + + do { + _ = try await client.execute( + service: .builds, + query: Self.cancelMutation, + variables: ["id": jobId], + responseType: CancelResponse.self + ) + // Reload job to get updated status. + await loadJob() + } catch { + self.error = error.localizedDescription + } + + isCancelling = false + } + + func rebuildJob() async -> Int? { + guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else { + return nil + } + + isRebuilding = true + error = nil + defer { isRebuilding = false } + + var variables: [String: any Sendable] = [ + "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines) + ] + if !job.tags.isEmpty { + variables["tags"] = job.tags + } + if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines) + } + if let visibility = job.visibility { + variables["visibility"] = visibility.rawValue + } + + do { + let result = try await client.execute( + service: .builds, + query: Self.submitMutation, + variables: variables, + responseType: SubmitJobResponse.self + ) + return result.submit.id + } catch { + self.error = error.localizedDescription + return nil + } + } + + func submitBuild( + manifest: String, + tags: [String], + note: String, + secrets: Bool, + execute: Bool, + visibility: Visibility + ) async -> Int? { + guard !isSubmittingEditedBuild else { return nil } + + let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedManifest.isEmpty else { + error = "Paste a build manifest." + return nil + } + + isSubmittingEditedBuild = true + error = nil + defer { isSubmittingEditedBuild = false } + + var variables: [String: any Sendable] = [ + "manifest": trimmedManifest, + "secrets": secrets, + "execute": execute, + "visibility": visibility.rawValue + ] + if !tags.isEmpty { + variables["tags"] = tags + } + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedNote.isEmpty { + variables["note"] = trimmedNote + } + + do { + let result = try await client.execute( + service: .builds, + query: Self.editableSubmitMutation, + variables: variables, + responseType: SubmitJobResponse.self + ) + return result.submit.id + } catch { + self.error = "Couldn’t submit the build. \(error.localizedDescription)" + return nil + } + } +} diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift new file mode 100644 index 0000000..875a836 --- /dev/null +++ b/Hutch/Views/Builds/BuildListView.swift @@ -0,0 +1,217 @@ +import SwiftUI + +struct BuildListView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: BuildListViewModel? + @State private var showSubmitSheet = false + @State private var submittedJobId: Int? + + var body: some View { + Group { + if let viewModel { + listContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading builds…") + } + } + .navigationTitle("Builds") + .toolbar { + if viewModel != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + showSubmitSheet = true + } label: { + Image(systemName: "plus") + } + } + } + } + .sheet(isPresented: $showSubmitSheet) { + if let viewModel { + SubmitBuildSheet(viewModel: viewModel) { jobId in + showSubmitSheet = false + submittedJobId = jobId + } + } + } + .navigationDestination(for: JobSummary.self) { job in + BuildDetailView(jobId: job.id) + } + .navigationDestination(isPresented: Binding( + get: { submittedJobId != nil }, + set: { isPresented in + if !isPresented { + submittedJobId = nil + } + } + )) { + if let submittedJobId { + BuildDetailView(jobId: submittedJobId) + } + } + .task { + if viewModel == nil { + let vm = BuildListViewModel(client: appState.client) + viewModel = vm + await vm.loadJobs() + } + } + } + + @ViewBuilder + private func listContent(_ viewModel: BuildListViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.jobs) { job in + NavigationLink(value: job) { + BuildRowView(job: job) + } + .task { + await viewModel.loadMoreIfNeeded(currentItem: job) + } + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoading, viewModel.jobs.isEmpty { + SRHTLoadingStateView(message: "Loading builds…") + } else if let error = viewModel.error, viewModel.jobs.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Builds", + message: error, + retryAction: { await viewModel.loadJobs() } + ) + } else if viewModel.jobs.isEmpty, viewModel.error == nil { + ContentUnavailableView( + "No Builds", + systemImage: "hammer", + description: Text("Your build jobs will appear here.") + ) + } + } + .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) { + await viewModel.loadJobs() + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadJobs() + } + } +} + +private struct SubmitBuildSheet: View { + let viewModel: BuildListViewModel + let onSubmitted: (Int) -> Void + + @Environment(\.dismiss) private var dismiss + @Bindable var viewModelBindable: BuildListViewModel + @State private var manifest = "" + @State private var tagsText = "" + @State private var note = "" + @State private var secrets = false + @State private var execute = true + @State private var visibility: Visibility = .public + + init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) { + self.viewModel = viewModel + self._viewModelBindable = Bindable(viewModel) + self.onSubmitted = onSubmitted + } + + var body: some View { + NavigationStack { + Form { + Section("Build Manifest") { + TextField("Paste a build manifest", text: $manifest, axis: .vertical) + .font(.system(.body, design: .monospaced)) + .lineLimit(12...24) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section("Build Options") { + TextField("Note (optional)", text: $note) + TextField("Tags (comma-separated, optional)", text: $tagsText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) + } + Toggle("Start build now", isOn: $execute) + Toggle("Allow build secrets", isOn: $secrets) + } + + Section { + Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let error = viewModel.error { + Section { + Label { + Text(error) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + .foregroundStyle(.red) + } + } + } + .navigationTitle("Submit Build") + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + viewModelBindable.error = nil + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + viewModelBindable.error = nil + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + let tags = tagsText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if let jobId = await viewModel.submitBuild( + manifest: manifest, + tags: tags, + note: note, + secrets: secrets, + execute: execute, + visibility: visibility + ) { + onSubmitted(jobId) + } + } + } label: { + if viewModel.isSubmitting { + ProgressView() + .controlSize(.small) + } else { + Text("Submit Build") + } + } + .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting) + } + } + } + } +} diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift new file mode 100644 index 0000000..8d0f961 --- /dev/null +++ b/Hutch/Views/Builds/BuildListViewModel.swift @@ -0,0 +1,220 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct JobsResponse: Decodable, Sendable { + let jobs: JobsPage +} + +private struct JobsPage: Decodable, Sendable { + let results: [JobSummary] + let cursor: String? +} + +private struct SubmitJobResponse: Decodable, Sendable { + let submit: SubmittedJob +} + +private struct SubmittedJob: Decodable, Sendable { + let id: Int +} + +// MARK: - View Model + +@Observable +@MainActor +final class BuildListViewModel { + + private(set) var jobs: [JobSummary] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var isRefreshing = false + private(set) var isSubmitting = false + var error: String? + + private var cursor: String? + private var hasMore = true + private let client: SRHTClient + + private static let cacheKey = "builds.jobs" + + init(client: SRHTClient) { + self.client = client + } + + // MARK: - Query + + private static let query = """ + query jobs($cursor: Cursor) { + jobs(cursor: $cursor) { + results { + id + created + updated + status + note + tags + visibility + image + tasks { name status } + } + cursor + } + } + """ + + private static let submitMutation = """ + mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) { + submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) { + id + } + } + """ + + // MARK: - Public API + + /// Fetch the first page of jobs. Shows cached data instantly if available, + /// then refreshes from the network in the background. + func loadJobs() async { + // Show cached data immediately on first load + if jobs.isEmpty { + loadFromCache() + } + + if jobs.isEmpty { + isLoading = true + } else { + isRefreshing = true + } + error = nil + cursor = nil + hasMore = true + + do { + let page = try await fetchPage(cursor: nil, useCache: true) + jobs = page.results + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + if jobs.isEmpty { + self.error = error.localizedDescription + } + } + + isLoading = false + isRefreshing = false + } + + func loadMoreIfNeeded(currentItem: JobSummary) async { + guard let last = jobs.last, + last.id == currentItem.id, + hasMore, + !isLoadingMore else { + return + } + + isLoadingMore = true + + do { + let page = try await fetchPage(cursor: cursor, useCache: false) + jobs.append(contentsOf: page.results) + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + + isLoadingMore = false + } + + func submitBuild( + manifest: String, + tags: [String], + note: String, + secrets: Bool, + execute: Bool, + visibility: Visibility + ) async -> Int? { + guard !isSubmitting else { return nil } + + let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedManifest.isEmpty else { + error = "Paste a build manifest." + return nil + } + + isSubmitting = true + error = nil + defer { isSubmitting = false } + + var variables: [String: any Sendable] = [ + "manifest": trimmedManifest, + "secrets": secrets, + "execute": execute, + "visibility": visibility.rawValue + ] + if !tags.isEmpty { + variables["tags"] = tags + } + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedNote.isEmpty { + variables["note"] = trimmedNote + } + + do { + let result = try await client.execute( + service: .builds, + query: Self.submitMutation, + variables: variables, + responseType: SubmitJobResponse.self + ) + await loadJobs() + return result.submit.id + } catch { + self.error = "Couldn’t submit the build. \(error.localizedDescription)" + return nil + } + } + + // MARK: - Private + + private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + if useCache && cursor == nil { + let result = try await client.executeAndCache( + service: .builds, + query: Self.query, + variables: variables.isEmpty ? nil : variables, + responseType: JobsResponse.self, + cacheKey: Self.cacheKey + ) + return result.jobs + } else { + let result = try await client.execute( + service: .builds, + query: Self.query, + variables: variables.isEmpty ? nil : variables, + responseType: JobsResponse.self + ) + return result.jobs + } + } + + private func loadFromCache() { + guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .srhtFlexible + if let response = try? decoder.decode( + GraphQLResponse.self, + from: data + ), let page = response.data?.jobs { + jobs = page.results + cursor = page.cursor + hasMore = page.cursor != nil + } + } +} diff --git a/Hutch/Views/Builds/BuildRowView.swift b/Hutch/Views/Builds/BuildRowView.swift new file mode 100644 index 0000000..e2afd27 --- /dev/null +++ b/Hutch/Views/Builds/BuildRowView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct BuildRowView: View { + let job: JobSummary + + var body: some View { + HStack(spacing: 12) { + JobStatusIcon(status: job.status) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 4) { + Text(job.displayLabel) + .font(.subheadline) + .lineLimit(1) + + HStack(spacing: 8) { + if let image = job.image { + Text(image) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(job.created.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + } + + if !job.tasks.isEmpty { + TaskProgressView(tasks: job.tasks) + } + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Job Status Icon + +struct JobStatusIcon: View { + let status: JobStatus + + var body: some View { + Image(systemName: iconName) + .foregroundStyle(color) + .symbolEffect(.pulse, isActive: status == .running) + } + + private var iconName: String { + switch status { + case .success: "checkmark.circle.fill" + case .failed, .timeout: "xmark.circle.fill" + case .running: "arrow.trianglehead.2.clockwise.rotate.90" + case .queued: "clock.fill" + case .pending: "circle.dashed" + case .cancelled: "minus.circle.fill" + } + } + + private var color: Color { + switch status { + case .success: .green + case .failed, .timeout: .red + case .running: .yellow + case .queued: .orange + case .pending, .cancelled: .gray + } + } +} + +// MARK: - Task Progress + +struct TaskProgressView: View { + let tasks: [JobTaskSummary] + + var body: some View { + HStack(spacing: 6) { + ProgressView(value: progress, total: 1.0) + .tint(progressColor) + .frame(maxWidth: 80) + + Text("\(completedCount)/\(tasks.count) tasks") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private var completedCount: Int { + tasks.filter { $0.status == .success }.count + } + + private var progress: Double { + tasks.isEmpty ? 0 : Double(completedCount) / Double(tasks.count) + } + + private var progressColor: Color { + if tasks.contains(where: { $0.status == .failed }) { + return .red + } + if completedCount == tasks.count { + return .green + } + return .blue + } +} diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift new file mode 100644 index 0000000..ef3b972 --- /dev/null +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct ArtifactsView: View { + let viewModel: RepositoryDetailViewModel + @Environment(\.openURL) private var openURL + + var body: some View { + List { + ForEach(viewModel.referenceArtifacts) { refArtifacts in + Section(refArtifacts.name) { + ForEach(refArtifacts.artifacts) { artifact in + ArtifactRow(artifact: artifact) { + openURL(artifact.url) + } + } + } + } + } + .listStyle(.insetGrouped) + .overlay { + if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty { + SRHTLoadingStateView(message: "Loading artifacts…") + } else if let error = viewModel.error, viewModel.referenceArtifacts.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Artifacts", + message: error, + retryAction: { await viewModel.loadArtifacts() } + ) + } else if viewModel.referenceArtifacts.isEmpty { + ContentUnavailableView( + "No Artifacts", + systemImage: "archivebox", + description: Text("This repository has no release artifacts.") + ) + } + } + .task { + if viewModel.referenceArtifacts.isEmpty { + await viewModel.loadArtifacts() + } + } + .refreshable { + await viewModel.loadArtifacts() + } + } +} + +private struct ArtifactRow: View { + let artifact: ArtifactInfo + let onDownload: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(artifact.filename) + .font(.subheadline) + + Text(artifact.size.formattedByteCount) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + onDownload() + } label: { + Image(systemName: "arrow.down.circle") + .imageScale(.large) + } + } + } +} diff --git a/Hutch/Views/Repositories/CommitDetailView.swift b/Hutch/Views/Repositories/CommitDetailView.swift new file mode 100644 index 0000000..d303542 --- /dev/null +++ b/Hutch/Views/Repositories/CommitDetailView.swift @@ -0,0 +1,332 @@ +import SwiftUI + +struct CommitDetailView: View { + let commitSummary: CommitSummary + let repository: RepositorySummary + + @Environment(AppState.self) private var appState + @State private var viewModel: CommitDetailViewModel? + + var body: some View { + Group { + if let viewModel { + commitContent(viewModel) + } else { + ProgressView() + } + } + .navigationTitle(commitSummary.shortId) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) { + Image(systemName: "square.and.arrow.up") + } + } + } + .task { + if viewModel == nil { + let vm = CommitDetailViewModel( + repositoryRid: repository.rid, + service: repository.service, + commitId: commitSummary.id, + client: appState.client + ) + viewModel = vm + await vm.loadCommit() + } + } + } + + @ViewBuilder + private func commitContent(_ viewModel: CommitDetailViewModel) -> some View { + if viewModel.isLoading { + ProgressView() + } else if let error = viewModel.error { + ContentUnavailableView { + Label("Error", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { + Task { await viewModel.loadCommit() } + } + } + } else if let commit = viewModel.commit { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + // Header + commitHeader(commit) + + sectionDivider + + // Message + commitMessage(commit) + + // Trailers + if !commit.trailers.isEmpty { + sectionDivider + trailersSection(commit.trailers) + } + + // Parents + if !commit.parents.isEmpty { + sectionDivider + parentsSection(commit.parents) + } + + // Diff + if let diff = commit.diff, !diff.isEmpty { + sectionDivider + diffSection(diff) + } + + // Tree + if let tree = commit.tree, !tree.entries.results.isEmpty { + sectionDivider + treeSection(tree.entries.results) + } + } + } + .navigationDestination(for: ParentCommit.self) { parent in + CommitDetailView( + commitSummary: CommitSummary( + id: parent.id, + shortId: parent.shortId, + author: CommitAuthor(name: parent.author.name, email: nil, time: .now), + message: "" + ), + repository: repository + ) + } + } + } + + // MARK: - Header + + @ViewBuilder + private func commitHeader(_ commit: CommitDetail) -> some View { + VStack(alignment: .leading, spacing: 8) { + // Full hash — tappable to copy + Button { + UIPasteboard.general.string = commit.id + } label: { + HStack(spacing: 4) { + Text(commit.id) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "doc.on.doc") + .font(.caption2) + } + .foregroundStyle(.secondary) + } + + // Author + HStack { + Label(commit.author.name, systemImage: "person") + Spacer() + Text(commit.author.time.relativeDescription) + .foregroundStyle(.secondary) + } + .font(.subheadline) + + // Committer (if different from author) + if commit.committer.name != commit.author.name + || commit.committer.email != commit.author.email { + HStack { + Label(commit.committer.name, systemImage: "person.badge.shield.checkmark") + Spacer() + Text(commit.committer.time.relativeDescription) + .foregroundStyle(.secondary) + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding() + } + + // MARK: - Message + + @ViewBuilder + private func commitMessage(_ commit: CommitDetail) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Message") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + Text(commit.title) + .font(.headline) + + if let body = commit.body { + Text(body) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Trailers + + @ViewBuilder + private func trailersSection(_ trailers: [CommitTrailer]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Trailers") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + ForEach(trailers) { trailer in + HStack(alignment: .top, spacing: 4) { + Text("\(trailer.name):") + .font(.subheadline.monospaced().weight(.medium)) + Text(trailer.value) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Parents + + @ViewBuilder + private func parentsSection(_ parents: [ParentCommit]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Parents") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + ForEach(parents) { parent in + NavigationLink(value: parent) { + HStack { + Text(parent.shortId) + .font(.subheadline.monospaced()) + Text(parent.author.name) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .buttonStyle(.plain) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Diff + + @ViewBuilder + private func diffSection(_ diff: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Diff") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.horizontal) + .padding(.top) + + DiffView(diff: invertDiff(diff)) + .padding(.bottom) + } + } + + /// The sr.ht API returns diffs comparing current→parent (inverted). + /// This swaps +/- prefixes so the diff reads as parent→current. + private func invertDiff(_ diff: String) -> String { + diff.split(separator: "\n", omittingEmptySubsequences: false) + .map { line in + let s = String(line) + if s.hasPrefix("@@") || s.hasPrefix("diff ") || s.hasPrefix("index ") { + return s + } + if s.hasPrefix("---") { + return "+++" + s.dropFirst(3) + } + if s.hasPrefix("+++") { + return "---" + s.dropFirst(3) + } + if s.hasPrefix("+") { + return "-" + s.dropFirst(1) + } + if s.hasPrefix("-") { + return "+" + s.dropFirst(1) + } + return s + } + .joined(separator: "\n") + } + + // MARK: - Tree + + @ViewBuilder + private func treeSection(_ entries: [CommitTreeEntry]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Tree") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + ForEach(entries) { entry in + HStack(spacing: 8) { + Image(systemName: treeEntryIcon(for: entry)) + .foregroundStyle(treeEntryColor(for: entry)) + .frame(width: 20) + Text(entry.name) + .font(.subheadline.monospaced()) + Spacer() + if let obj = entry.object, let shortId = obj.shortId { + Text(shortId) + .font(.caption.monospaced()) + .foregroundStyle(.tertiary) + } + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Helpers + + private var sectionDivider: some View { + Divider().padding(.horizontal) + } + + private func treeEntryIcon(for entry: CommitTreeEntry) -> String { + guard let type = entry.object?.type else { + return "doc" + } + switch type { + case "tree": return "folder" + case "blob": return "doc.text" + case "tag": return "tag" + case "commit": return "arrow.triangle.branch" + default: return "doc" + } + } + + private func treeEntryColor(for entry: CommitTreeEntry) -> Color { + guard let type = entry.object?.type else { + return .secondary + } + switch type { + case "tree": return .blue + case "blob": return .secondary + case "tag": return .orange + case "commit": return .purple + default: return .secondary + } + } +} diff --git a/Hutch/Views/Repositories/CommitDetailViewModel.swift b/Hutch/Views/Repositories/CommitDetailViewModel.swift new file mode 100644 index 0000000..5a8a23d --- /dev/null +++ b/Hutch/Views/Repositories/CommitDetailViewModel.swift @@ -0,0 +1,102 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct CommitResponse: Decodable, Sendable { + let repository: CommitRepository? +} + +private struct CommitRepository: Decodable, Sendable { + // swiftlint:disable:next identifier_name + let revparse_single: CommitDetail +} + +// MARK: - View Model + +@Observable +@MainActor +final class CommitDetailViewModel { + + let repositoryRid: String + let service: SRHTService + private let client: SRHTClient + + private(set) var commit: CommitDetail? + private(set) var isLoading = false + var error: String? + + init(repositoryRid: String, service: SRHTService, commitId: String, client: SRHTClient) { + self.repositoryRid = repositoryRid + self.service = service + self.commitId = commitId + self.client = client + } + + private let commitId: String + + // MARK: - Query + + private static let query = """ + query commit($rid: ID!, $id: String!) { + repository(rid: $rid) { + revparse_single(revspec: $id) { + id + shortId + author { name email time } + committer { name email time } + message + diff + trailers { name value } + parents { id shortId author { name } } + tree { + entries { + results { id name mode object { type id shortId } } + cursor + } + } + } + } + } + """ + + func loadCommit() async { + guard !isLoading else { return } + isLoading = true + error = nil + + do { + let result = try await executeWithRetry() + commit = result.repository?.revparse_single + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + /// Execute the commit query, retrying once after a 1-second delay on 502/503. + private func executeWithRetry() async throws -> CommitResponse { + do { + return try await client.execute( + service: service, + query: Self.query, + variables: [ + "rid": repositoryRid, + "id": commitId + ], + responseType: CommitResponse.self + ) + } catch let SRHTError.httpError(code) where code == 502 || code == 503 { + try await Task.sleep(for: .seconds(1)) + return try await client.execute( + service: service, + query: Self.query, + variables: [ + "rid": repositoryRid, + "id": commitId + ], + responseType: CommitResponse.self + ) + } + } +} diff --git a/Hutch/Views/Repositories/CommitLogView.swift b/Hutch/Views/Repositories/CommitLogView.swift new file mode 100644 index 0000000..d63c5eb --- /dev/null +++ b/Hutch/Views/Repositories/CommitLogView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct CommitLogView: View { + let viewModel: RepositoryDetailViewModel + + var body: some View { + List { + ForEach(viewModel.commits) { commit in + NavigationLink(value: commit) { + CommitRowView(commit: commit) + } + .task { + await viewModel.loadMoreCommitsIfNeeded(currentItem: commit) + } + } + + if viewModel.isLoadingMoreCommits { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoadingCommits, viewModel.commits.isEmpty { + SRHTLoadingStateView(message: "Loading commits…") + } else if let error = viewModel.error, viewModel.commits.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Commits", + message: error, + retryAction: { await viewModel.loadCommits() } + ) + } else if viewModel.commits.isEmpty { + ContentUnavailableView( + "No Commits", + systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", + description: Text("This repository has no commit history.") + ) + } + } + .task { + if viewModel.commits.isEmpty { + await viewModel.loadCommits() + } + } + .refreshable { + await viewModel.loadCommits() + } + .navigationDestination(for: CommitSummary.self) { commit in + CommitDetailView( + commitSummary: commit, + repository: viewModel.repository + ) + } + } +} diff --git a/Hutch/Views/Repositories/CommitRowView.swift b/Hutch/Views/Repositories/CommitRowView.swift new file mode 100644 index 0000000..b5eb31d --- /dev/null +++ b/Hutch/Views/Repositories/CommitRowView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct CommitRowView: View { + let commit: CommitSummary + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(commit.title) + .font(.subheadline) + .lineLimit(1) + + HStack(spacing: 8) { + Text(commit.shortId) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + + Text(commit.author.name) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Text(commit.author.time.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + } +} diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift new file mode 100644 index 0000000..b1db464 --- /dev/null +++ b/Hutch/Views/Repositories/DiffView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// Renders a unified diff string with syntax highlighting: +/// - Green background for added lines (+) +/// - Red background for removed lines (-) +/// - Gray for hunk headers (@@) +/// - File headers (--- / +++ / diff) in bold +struct DiffView: View { + let diff: String + + var body: some View { + let lines = diff.components(separatedBy: "\n") + + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + DiffLineView(line: line) + } + } + .font(.caption.monospaced()) + } +} + +private struct DiffLineView: View { + let line: String + + var body: some View { + Text(line.isEmpty ? " " : line) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background(backgroundColor) + .foregroundStyle(foregroundColor) + .fontWeight(isHeader ? .semibold : .regular) + } + + private var kind: DiffLineKind { + if line.hasPrefix("@@") { return .hunk } + if line.hasPrefix("+++") || line.hasPrefix("---") { return .fileHeader } + if line.hasPrefix("diff ") { return .fileHeader } + if line.hasPrefix("index ") { return .meta } + if line.hasPrefix("+") { return .added } + if line.hasPrefix("-") { return .removed } + return .context + } + + private var backgroundColor: Color { + switch kind { + case .added: .green.opacity(0.15) + case .removed: .red.opacity(0.15) + case .hunk: .gray.opacity(0.12) + case .fileHeader: .gray.opacity(0.08) + case .meta: .gray.opacity(0.05) + case .context: .clear + } + } + + private var foregroundColor: Color { + switch kind { + case .added: .green + case .removed: .red + case .hunk: .secondary + case .meta: .secondary + default: .primary + } + } + + private var isHeader: Bool { + kind == .fileHeader + } +} + +private enum DiffLineKind { + case added + case removed + case hunk + case fileHeader + case meta + case context +} diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift new file mode 100644 index 0000000..3750adb --- /dev/null +++ b/Hutch/Views/Repositories/FileTreeView.swift @@ -0,0 +1,446 @@ +import SwiftUI + +struct FileTreeView: View { + let repository: RepositorySummary + let client: SRHTClient + + @State private var viewModel: FileTreeViewModel? + + var body: some View { + Group { + if let viewModel { + FileTreeContentView(repository: repository, viewModel: viewModel) + } else { + SRHTLoadingStateView(message: "Loading files…") + } + } + .task { + if viewModel == nil { + let vm = FileTreeViewModel( + repositoryRid: repository.rid, + service: repository.service, + client: client + ) + viewModel = vm + async let loadTree: () = vm.loadRootTree() + async let loadRefs: () = vm.loadReferences() + _ = await (loadTree, loadRefs) + } + } + } +} + +// MARK: - Content View + +private struct FileTreeContentView: View { + let repository: RepositorySummary + let viewModel: FileTreeViewModel + + @State private var showRefPicker = false + + var body: some View { + VStack(spacing: 0) { + breadcrumbBar + Divider() + contentArea + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showRefPicker = true + } label: { + Label( + revspecLabel, + systemImage: "arrow.triangle.branch" + ) + .font(.subheadline) + } + } + } + .sheet(isPresented: $showRefPicker) { + RefPickerSheet(viewModel: viewModel, isPresented: $showRefPicker) + } + .srhtErrorBanner(error: Binding( + get: { viewModel.error }, + set: { viewModel.error = $0 } + )) + .refreshable { + await viewModel.loadRootTree() + } + } + + private var shareURL: URL? { + guard let viewingEntry = viewModel.viewingEntry else { return nil } + return SRHTWebURL.file( + repository: repository, + revspec: viewModel.revspec, + path: currentFilePath(for: viewingEntry) + ) + } + + private var revspecLabel: String { + let revspec = viewModel.revspec + if revspec == "HEAD" { + return "HEAD" + } + if revspec.hasPrefix("refs/heads/") { + return String(revspec.dropFirst("refs/heads/".count)) + } else if revspec.hasPrefix("refs/tags/") { + return String(revspec.dropFirst("refs/tags/".count)) + } + return revspec + } + + // MARK: - Breadcrumb Bar + + private var breadcrumbBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + HStack(spacing: 4) { + ForEach(Array(viewModel.navStack.enumerated()), id: \.offset) { index, navEntry in + if index > 0 { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Button { + Task { + await viewModel.navigateToBreadcrumb(at: index) + } + } label: { + Text(navEntry.name) + .font(.subheadline.monospaced()) + .foregroundStyle( + index == viewModel.navStack.count - 1 && viewModel.viewingEntry == nil + ? .primary : .secondary + ) + } + .buttonStyle(.plain) + } + + if let viewing = viewModel.viewingEntry { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + + Text(viewing.name) + .font(.subheadline.monospaced()) + .foregroundStyle(.primary) + } + } + + Spacer(minLength: 0) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + .background(.bar) + } + + private func currentFilePath(for entry: TreeEntry) -> String { + let directoryComponents = viewModel.navStack + .dropFirst() + .map(\.name) + return (directoryComponents + [entry.name]).joined(separator: "/") + } + + // MARK: - Content Area + + @ViewBuilder + private var contentArea: some View { + if viewModel.isLoading, viewModel.entries.isEmpty, viewModel.viewingEntry == nil { + SRHTLoadingStateView(message: "Loading files…") + } else if let entry = viewModel.viewingEntry, let object = viewModel.viewingObject { + // Viewing a file + fileContentView(entry: entry, object: object) + } else if let error = viewModel.error, viewModel.entries.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Files", + message: error, + retryAction: { await viewModel.loadRootTree() } + ) + } else if !viewModel.entries.isEmpty { + // Viewing a directory listing + treeListView + } else if viewModel.navStack.isEmpty { + ContentUnavailableView( + "No Files", + systemImage: "folder", + description: Text("This repository could not be loaded.") + ) + } else { + ContentUnavailableView( + "Empty Directory", + systemImage: "folder", + description: Text("This directory has no files.") + ) + } + } + + // MARK: - File Content View + + @ViewBuilder + private func fileContentView(entry: TreeEntry, object: GitObject) -> some View { + switch object { + case .textBlob(let blob): + textBlobView(entry: entry, blob: blob) + case .binaryBlob(let blob): + binaryBlobView(entry: entry, blob: blob) + default: + ContentUnavailableView( + "Unknown Object", + systemImage: "questionmark.folder", + description: Text("Cannot display this object type.") + ) + } + } + + // MARK: - Tree List + + private var treeListView: some View { + let sorted = viewModel.entries.sorted { a, b in + let aIsTree = a.object?.isTree == true + let bIsTree = b.object?.isTree == true + if aIsTree != bIsTree { return aIsTree } + return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending + } + + return List(sorted) { entry in + TreeEntryRow(entry: entry) + .contentShape(Rectangle()) + .onTapGesture { + Task { + await viewModel.navigateInto(entry: entry) + } + } + } + .listStyle(.plain) + } + + // MARK: - Text Blob + + @ViewBuilder + private func textBlobView(entry: TreeEntry, blob: GitTextBlob) -> some View { + VStack(spacing: 0) { + HStack { + Spacer() + SRHTShareButton(url: shareURL, target: .file) { + Label("Share File", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + } + .padding(.horizontal) + .padding(.top, 12) + + GeometryReader { geometry in + ScrollView([.vertical, .horizontal]) { + Text(blob.text) + .font(.system(.body, design: .monospaced)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: geometry.size.width, + minHeight: geometry.size.height, + alignment: .topLeading) + .padding() + } + .frame(width: geometry.size.width, height: geometry.size.height) + } + } + } + + // MARK: - Binary Blob + + @ViewBuilder + private func binaryBlobView(entry: TreeEntry, blob: GitBinaryBlob) -> some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "doc.zipper") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text(entry.name) + .font(.headline) + + if let size = blob.size { + Text(formatBytes(size)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Text("Binary file — cannot be displayed inline.") + .font(.subheadline) + .foregroundStyle(.tertiary) + + SRHTShareButton(url: shareURL, target: .file) { + Label("Share File", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + + if let content = blob.content, let url = URL(string: content) { + Link(destination: url) { + Label("Open in Safari", systemImage: "safari") + } + .buttonStyle(.borderedProminent) + } + + Button { + viewModel.dismissFileView() + } label: { + Text("Back to directory") + } + + Spacer() + } + .frame(maxWidth: .infinity) + } + + // MARK: - Helpers + + private func formatBytes(_ bytes: Int) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } +} + +// MARK: - Tree Entry Row + +private struct TreeEntryRow: View { + let entry: TreeEntry + + var body: some View { + Label { + Text(entry.name) + .font(.body.monospaced()) + .lineLimit(1) + } icon: { + Image(systemName: iconName) + .foregroundStyle(iconColor) + } + } + + private var iconName: String { + switch entry.object { + case .tree: "folder.fill" + case .unknown: "questionmark.circle" + default: "doc" + } + } + + private var iconColor: Color { + switch entry.object { + case .tree: .blue + case .unknown: .orange + default: .secondary + } + } +} + +// MARK: - Ref Picker Sheet + +private struct RefPickerSheet: View { + let viewModel: FileTreeViewModel + @Binding var isPresented: Bool + + var body: some View { + NavigationStack { + List { + Section { + Button { + Task { + await viewModel.changeRevspec("HEAD") + isPresented = false + } + } label: { + refRow( + title: "HEAD", + systemImage: "arrow.triangle.branch", + color: .blue, + isSelected: viewModel.revspec == "HEAD" + ) + } + .buttonStyle(.plain) + } + + if !viewModel.branches.isEmpty { + Section("Branches") { + ForEach(viewModel.branches, id: \.name) { ref in + Button { + Task { + await viewModel.changeRevspec(ref.name) + isPresented = false + } + } label: { + refRow( + title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""), + systemImage: "arrow.triangle.branch", + color: .blue, + isSelected: viewModel.revspec == ref.name + ) + } + .buttonStyle(.plain) + } + } + } + + if !viewModel.tags.isEmpty { + Section("Tags") { + ForEach(viewModel.tags, id: \.name) { ref in + Button { + Task { + await viewModel.changeRevspec(ref.name) + isPresented = false + } + } label: { + refRow( + title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""), + systemImage: "tag", + color: .orange, + isSelected: viewModel.revspec == ref.name + ) + } + .buttonStyle(.plain) + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Select Ref") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + isPresented = false + } + } + } + .overlay { + if viewModel.isLoadingRefs { + SRHTLoadingStateView(message: "Loading references…") + } + } + } + } + + private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View { + HStack(spacing: 12) { + Image(systemName: systemImage) + .foregroundStyle(color) + + Text(title) + .font(.body.monospaced()) + .foregroundStyle(.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) + } +} diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift new file mode 100644 index 0000000..f270779 --- /dev/null +++ b/Hutch/Views/Repositories/FileTreeViewModel.swift @@ -0,0 +1,524 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct RevparseResponse: Decodable, Sendable { + let repository: RevparseRepository? +} + +private struct RevparseRepository: Decodable, Sendable { + let revparse_single: RevparseCommit? +} + +private struct RevparseCommit: Decodable, Sendable { + let tree: GitTree? +} + +private struct SubtreeResponse: Decodable, Sendable { + let repository: SubtreeRepository? +} + +private struct SubtreeRepository: Decodable, Sendable { + let object: SubtreeObject? +} + +/// Dedicated decoding struct for the subtree query response. +/// Does not use GitObject enum — decodes entries directly from the Tree inline fragment. +private struct SubtreeObject: Decodable, Sendable { + let entries: GitTreeEntryPage? +} + +private struct BlobResponse: Decodable, Sendable { + let repository: BlobRepository? +} + +private struct BlobRepository: Decodable, Sendable { + let object: GitObject? +} + +// MARK: - Navigation Stack Entry + +struct FileNavEntry: Hashable { + let name: String + let treeId: String +} + +// MARK: - View Model + +@Observable +@MainActor +final class FileTreeViewModel { + + let repositoryRid: String + let service: SRHTService + private let client: SRHTClient + + /// The current revspec. "HEAD" by default, or a full ref name. + var revspec: String = "HEAD" + + /// Navigation stack: each entry is a (name, treeId) pair. + /// The first entry is always the root. + private(set) var navStack: [FileNavEntry] = [] + + /// The tree entries at the current directory level. + private(set) var entries: [TreeEntry] = [] + + /// When viewing a file (text blob or binary blob), this holds the object. + private(set) var viewingEntry: TreeEntry? + private(set) var viewingObject: GitObject? + + private(set) var isLoading = false + var error: String? + + // Available references for the branch/tag picker + private(set) var branches: [Reference] = [] + private(set) var tags: [Reference] = [] + private(set) var isLoadingRefs = false + + init(repositoryRid: String, service: SRHTService, client: SRHTClient) { + self.repositoryRid = repositoryRid + self.service = service + self.client = client + } + + // MARK: - Queries + + private static let rootTreeQuery = """ + query files($rid: ID!, $revspec: String!) { + repository(rid: $rid) { + revparse_single(revspec: $revspec) { + tree { + id + entries { + results { + id + name + mode + object { + type + id + shortId + ... on Tree { + entries { + results { + id + name + mode + object { type id shortId } + } + cursor + } + } + ... on TextBlob { + text + size + } + ... on BinaryBlob { + size + content + } + } + } + cursor + } + } + } + } + } + """ + + /// Query to fetch additional pages of tree entries by tree ID + cursor. + private static let treeEntriesPageQuery = """ + query treeEntriesPage($rid: ID!, $treeId: String!, $cursor: Cursor) { + repository(rid: $rid) { + object(id: $treeId) { + type + id + ... on Tree { + entries(cursor: $cursor) { + results { + id + name + mode + object { + type + id + shortId + ... on Tree { + entries { + results { + id + name + mode + object { type id shortId } + } + cursor + } + } + ... on TextBlob { + text + size + } + ... on BinaryBlob { + size + content + } + } + } + cursor + } + } + } + } + } + """ + + private static let subtreeQuery = """ + query subtree($rid: ID!, $treeId: String!, $cursor: Cursor) { + repository(rid: $rid) { + object(id: $treeId) { + type + id + ... on Tree { + entries(cursor: $cursor) { + results { + id + name + mode + object { + type + id + shortId + ... on Tree { + entries { + results { + id + name + mode + object { type id shortId } + } + cursor + } + } + ... on TextBlob { + text + size + } + ... on BinaryBlob { + size + content + } + } + } + cursor + } + } + } + } + } + """ + + private static let blobQuery = """ + query blob($rid: ID!, $blobId: String!) { + repository(rid: $rid) { + object(id: $blobId) { + type + id + ... on TextBlob { + text + size + } + ... on BinaryBlob { + size + content + } + } + } + } + """ + + private static let refsQuery = """ + query refs($rid: ID!) { + repository(rid: $rid) { + references { + results { name target } + cursor + } + } + } + """ + + // MARK: - Load Root Tree + + func loadRootTree() async { + guard !isLoading else { return } + isLoading = true + error = nil + viewingEntry = nil + viewingObject = nil + defer { isLoading = false } + + let variables: [String: any Sendable] = [ + "rid": repositoryRid, + "revspec": revspec + ] + + do { + let result: RevparseResponse + do { + result = try await client.execute( + service: service, + query: Self.rootTreeQuery, + variables: variables, + responseType: RevparseResponse.self + ) + } catch { + if isMissingGitReferenceError(error) { + navStack = [FileNavEntry(name: "root", treeId: "")] + entries = [] + return + } + throw error + } + if let tree = result.repository?.revparse_single?.tree, + let rootId = tree.id { + navStack = [FileNavEntry(name: "root", treeId: rootId)] + var allEntries = tree.entries?.results ?? [] + var cursor = tree.entries?.cursor + // Follow cursor pagination for remaining pages + while let nextCursor = cursor { + let pageEntries = try await fetchTreeEntriesPage(treeId: rootId, cursor: nextCursor) + allEntries.append(contentsOf: pageEntries.results) + cursor = pageEntries.cursor + } + entries = allEntries + } else { + navStack = [] + entries = [] + } + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Navigate Into Folder + + func navigateInto(entry: TreeEntry) async { + guard let object = entry.object else { return } + + switch object { + case .tree(let tree): + // Use the git object SHA from entry.object, NOT entry.id + guard let objectSHA = tree.id else { return } + // If we already have the entries inline and no further pages, use them directly + if let inlineEntries = tree.entries?.results, !inlineEntries.isEmpty, tree.entries?.cursor == nil { + navStack.append(FileNavEntry(name: entry.name, treeId: objectSHA)) + entries = inlineEntries + viewingEntry = nil + viewingObject = nil + return + } + // Otherwise fetch the subtree (handles pagination) + await loadSubtree(name: entry.name, treeId: objectSHA) + + case .textBlob: + viewingEntry = entry + viewingObject = object + + case .binaryBlob(let blob): + if blob.content != nil || blob.size != nil { + viewingEntry = entry + viewingObject = object + } else if let blobId = blob.id { + await loadBlob(entry: entry, blobId: blobId) + } + + case .unknown: + break + } + } + + private func loadSubtree(name: String, treeId: String) async { + guard !isLoading else { return } + isLoading = true + error = nil + viewingEntry = nil + viewingObject = nil + defer { isLoading = false } + + let variables: [String: any Sendable] = [ + "rid": repositoryRid, + "treeId": treeId + ] + + do { + let result = try await client.execute( + service: service, + query: Self.subtreeQuery, + variables: variables, + responseType: SubtreeResponse.self + ) + navStack.append(FileNavEntry(name: name, treeId: treeId)) + var allEntries = result.repository?.object?.entries?.results ?? [] + var cursor = result.repository?.object?.entries?.cursor + while let nextCursor = cursor { + let pageEntries = try await fetchTreeEntriesPage(treeId: treeId, cursor: nextCursor) + allEntries.append(contentsOf: pageEntries.results) + cursor = pageEntries.cursor + } + entries = allEntries + } catch { + self.error = error.localizedDescription + } + } + + private func loadBlob(entry: TreeEntry, blobId: String) async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + let variables: [String: any Sendable] = [ + "rid": repositoryRid, + "blobId": blobId + ] + + do { + let result = try await client.execute( + service: service, + query: Self.blobQuery, + variables: variables, + responseType: BlobResponse.self + ) + viewingEntry = entry + viewingObject = result.repository?.object ?? .unknown + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Navigate to Breadcrumb + + func navigateToBreadcrumb(at index: Int) async { + guard index >= 0, index < navStack.count else { return } + + // If tapping current level, do nothing + if index == navStack.count - 1, viewingEntry == nil { + return + } + + // Clear file view + viewingEntry = nil + viewingObject = nil + + // Trim the stack + let targetEntry = navStack[index] + navStack = Array(navStack.prefix(index + 1)) + + if index == 0 { + // Go back to root — reload from revparse_single + await loadRootTree() + } else { + // Load the subtree at this level + isLoading = true + error = nil + defer { isLoading = false } + + let variables: [String: any Sendable] = [ + "rid": repositoryRid, + "treeId": targetEntry.treeId + ] + + do { + let result = try await client.execute( + service: service, + query: Self.subtreeQuery, + variables: variables, + responseType: SubtreeResponse.self + ) + var allEntries = result.repository?.object?.entries?.results ?? [] + var cursor = result.repository?.object?.entries?.cursor + while let nextCursor = cursor { + let pageEntries = try await fetchTreeEntriesPage(treeId: targetEntry.treeId, cursor: nextCursor) + allEntries.append(contentsOf: pageEntries.results) + cursor = pageEntries.cursor + } + entries = allEntries + } catch { + self.error = error.localizedDescription + } + } + } + + /// Fetch a single page of tree entries by tree ID and cursor. + private func fetchTreeEntriesPage(treeId: String, cursor: String) async throws -> GitTreeEntryPage { + let variables: [String: any Sendable] = [ + "rid": repositoryRid, + "treeId": treeId, + "cursor": cursor + ] + let result = try await client.execute( + service: service, + query: Self.treeEntriesPageQuery, + variables: variables, + responseType: SubtreeResponse.self + ) + return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil) + } + + /// Dismiss the file view and go back to the directory listing. + func dismissFileView() { + viewingEntry = nil + viewingObject = nil + } + + // MARK: - Change Revspec + + func changeRevspec(_ newRevspec: String) async { + revspec = newRevspec + await loadRootTree() + } + + // MARK: - Load References + + func loadReferences() async { + guard !isLoadingRefs else { return } + isLoadingRefs = true + + do { + let result = try await client.execute( + service: service, + query: Self.refsQuery, + variables: ["rid": repositoryRid], + responseType: RefsResponseLocal.self + ) + let allRefs = result.repository?.references.results ?? [] + branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") } + tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") } + } catch { + // Silently fail for refs — non-critical + } + + isLoadingRefs = false + } + + private func isMissingGitReferenceError(_ error: Error) -> Bool { + guard let srhtError = error as? SRHTError else { return false } + guard case .graphQLErrors(let errors) = srhtError else { return false } + return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") } + } +} + +// File-private refs response to avoid collision with RepositoryDetailViewModel's private type +private struct RefsResponseLocal: Decodable, Sendable { + let repository: RefsRepoLocal? +} + +private struct RefsRepoLocal: Decodable, Sendable { + let references: RefsPageLocal +} + +private struct RefsPageLocal: Decodable, Sendable { + let results: [Reference] + let cursor: String? +} diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift index 8e47d33..6779165 100644 --- a/Hutch/Views/Repositories/HgRepositoryDetailView.swift +++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift @@ -11,19 +11,46 @@ struct HgRepositoryDetailView: View { @State private var viewModel: HgRepositoryDetailViewModel? @State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary @State private var showSettings = false + @State private var isShowingRepositoryDetails = false + @State private var showBrowseRefPicker = false + + private var shareURL: URL? { + guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } + return SRHTWebURL.file( + repository: repository, + revspec: viewModel.browseRevspec, + path: selectedFilePath + ) + } var body: some View { Group { if let viewModel { content(viewModel) } else { - ProgressView() + SRHTLoadingStateView(message: "Loading repository…") } } .navigationTitle(repository.name) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItemGroup(placement: .topBarTrailing) { + if selectedTab == .browse, let viewModel { + Button { + showBrowseRefPicker = true + } label: { + Label( + browseRevspecLabel(viewModel.browseRevspec), + systemImage: "arrow.triangle.branch" + ) + .font(.subheadline) + } + } + + SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) { + Image(systemName: "square.and.arrow.up") + } + Button { showSettings = true } label: { @@ -41,6 +68,11 @@ struct HgRepositoryDetailView: View { } ) } + .sheet(isPresented: $showBrowseRefPicker) { + if let viewModel { + HgBrowseRefPickerSheet(viewModel: viewModel, isPresented: $showBrowseRefPicker) + } + } .task { if viewModel == nil { let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client) @@ -82,82 +114,134 @@ struct HgRepositoryDetailView: View { revisionsList(viewModel.bookmarks, emptyTitle: "No Bookmarks", emptyDescription: "This repository does not have any bookmarks.") } } - .alert("Error", isPresented: .constant(viewModel.error != nil)) { - Button("OK") { viewModel.error = nil } - } message: { - if let error = viewModel.error { - Text(error) - } - } + .srhtErrorBanner(error: Binding( + get: { viewModel.error }, + set: { viewModel.error = $0 } + )) } @ViewBuilder private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View { - if viewModel.isLoadingSummary && !viewModel.summaryLoaded { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - summaryCards(viewModel) - - if let readmeView = readmeContentView(viewModel) { - readmeView - } else { - ContentUnavailableView( - "No README", - systemImage: "doc.text", - description: Text("This repository does not have a README file.") - ) - } - } - .padding() + ScrollView { + VStack(alignment: .leading, spacing: 16) { + headerSection + metadataSection(viewModel) + repositoryDetailsSection(viewModel) + latestChangeSection(viewModel) + readmeSection(viewModel) } - .refreshable { - await viewModel.loadSummary() + .padding() + } + .overlay { + if viewModel.isLoadingSummary, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil { + SRHTLoadingStateView(message: "Loading repository…") + } else if let error = viewModel.error, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil { + SRHTErrorStateView( + title: "Couldn't Load Repository", + message: error, + retryAction: { await viewModel.loadSummary() } + ) } } + .refreshable { + await viewModel.loadSummary() + } } - private func summaryCards(_ viewModel: HgRepositoryDetailViewModel) -> some View { - VStack(alignment: .leading, spacing: 12) { - LabeledContent("Visibility", value: visibilityLabel(repository.visibility)) - LabeledContent("Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing") - + private var headerSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text(repository.owner.canonicalName) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(repository.name) + .font(.largeTitle.weight(.semibold)) if let description = repository.description, !description.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("Description") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - Text(description) - } + Text(description) + .font(.body) } + } + } - if let tip = viewModel.tip { - VStack(alignment: .leading, spacing: 6) { - Text("Tip") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - Text(tip.title) - .font(.headline) - HStack { - Text(tip.displayShortId) - .font(.caption.monospaced()) - Spacer() - Text(tip.author.time.relativeDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - Text(tip.author.name) - .font(.subheadline) - .foregroundStyle(.secondary) - } + @ViewBuilder + private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { + VStack(alignment: .leading, spacing: 10) { + SummaryMetadataRow( + icon: "arrow.triangle.branch", + title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility) + ) + + if let readmePath = viewModel.readmePath { + SummaryMetadataRow( + icon: "doc.text", + title: readmePath + ) } } - .padding() - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { + DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { + VStack(alignment: .leading, spacing: 12) { + SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility)) + SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing") + SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true) + SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: repository).readWrite, monospace: true) + SummaryDetailRow(label: "RID", value: repository.rid, monospace: true) + } + .padding(.top, 8) + } label: { + Text("Repository Details") + .font(.subheadline.weight(.medium)) + } + } + + @ViewBuilder + private func latestChangeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { + VStack(alignment: .leading, spacing: 8) { + if viewModel.isLoadingSummary && viewModel.tip == nil { + SRHTLoadingStateView(message: "Loading latest change…") + .frame(maxWidth: .infinity) + } else if let tip = viewModel.tip { + SummaryMetadataRow( + icon: "arrow.trianglehead.clockwise", + title: tip.title, + subtitle: "\(tip.displayShortId) — \(tip.author)" + ) + } else if let error = viewModel.error, !viewModel.summaryLoaded { + SRHTErrorStateView( + title: "Couldn't Load Latest Change", + message: error, + retryAction: { await viewModel.loadSummary() } + ) + } else { + ContentUnavailableView( + "No Recent Revisions", + systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", + description: Text("This repository does not have any revision history yet.") + ) + } + } + } + + @ViewBuilder + private func readmeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { + if viewModel.isLoadingSummary && !viewModel.summaryLoaded { + SRHTLoadingStateView(message: "Loading README…") + } else if let readmeView = readmeContentView(viewModel) { + readmeView + } else if let error = viewModel.error, !viewModel.summaryLoaded { + SRHTErrorStateView( + title: "Couldn't Load README", + message: error, + retryAction: { await viewModel.loadSummary() } + ) + } else { + ContentUnavailableView( + "No README", + systemImage: "doc.text", + description: Text("This repository does not have a README file.") + ) + } } @ViewBuilder @@ -166,21 +250,31 @@ struct HgRepositoryDetailView: View { browseBreadcrumbs(viewModel) Divider() - if viewModel.isLoadingBrowse { - Spacer() - ProgressView() - Spacer() + if viewModel.isLoadingBrowse, viewModel.files.isEmpty, viewModel.selectedFilePath == nil { + SRHTLoadingStateView(message: "Loading files…") } else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent { - GeometryReader { geometry in - ScrollView([.vertical, .horizontal]) { - Text(fileContent) - .font(.system(.body, design: .monospaced)) - .frame( - minWidth: geometry.size.width, - minHeight: geometry.size.height, - alignment: .topLeading - ) - .padding() + VStack(spacing: 0) { + HStack { + Spacer() + SRHTShareButton(url: shareURL, target: .file) { + Label("Share File", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + } + .padding(.horizontal) + .padding(.top, 12) + + GeometryReader { geometry in + ScrollView([.vertical, .horizontal]) { + Text(fileContent) + .font(.system(.body, design: .monospaced)) + .frame( + minWidth: geometry.size.width, + minHeight: geometry.size.height, + alignment: .topLeading + ) + .padding() + } } } .safeAreaInset(edge: .bottom) { @@ -193,6 +287,12 @@ struct HgRepositoryDetailView: View { .background(.bar) } .navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name) + } else if let error = viewModel.error, viewModel.files.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Files", + message: error, + retryAction: { await viewModel.loadBrowseRoot() } + ) } else if viewModel.files.isEmpty { ContentUnavailableView( "No Files", @@ -201,13 +301,7 @@ struct HgRepositoryDetailView: View { ) } else { List(viewModel.files) { file in - Label { - Text(displayFileName(file.name)) - .font(.body.monospaced()) - } icon: { - Image(systemName: file.isDirectory ? "folder.fill" : "doc") - .foregroundStyle(file.isDirectory ? .blue : .secondary) - } + HgFileRow(file: file) .contentShape(Rectangle()) .onTapGesture { Task { await viewModel.openFile(file) } @@ -282,8 +376,14 @@ struct HgRepositoryDetailView: View { } .listStyle(.plain) .overlay { - if viewModel.isLoadingLog { - ProgressView() + if viewModel.isLoadingLog, viewModel.log.isEmpty { + SRHTLoadingStateView(message: "Loading revisions…") + } else if let error = viewModel.error, viewModel.log.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Revisions", + message: error, + retryAction: { await viewModel.loadLog() } + ) } else if viewModel.log.isEmpty { ContentUnavailableView( "No Revisions", @@ -298,7 +398,7 @@ struct HgRepositoryDetailView: View { } @ViewBuilder - private func revisionsList(_ revisions: [HgRevision], emptyTitle: String, emptyDescription: String) -> some View { + private func revisionsList(_ revisions: [HgNamedRevision], emptyTitle: String, emptyDescription: String) -> some View { if revisions.isEmpty { ContentUnavailableView( emptyTitle, @@ -307,7 +407,7 @@ struct HgRepositoryDetailView: View { ) } else { List(revisions) { revision in - revisionRow(revision) + namedRevisionRow(revision) } .listStyle(.plain) } @@ -335,9 +435,7 @@ struct HgRepositoryDetailView: View { } HStack { - Text(revision.author.name) - Spacer() - Text(revision.author.time.relativeDescription) + Text(revision.author) } .font(.caption) .foregroundStyle(.secondary) @@ -345,75 +443,197 @@ struct HgRepositoryDetailView: View { .padding(.vertical, 4) } - @ViewBuilder - private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? { - let imageURLResolver = makeImageURLResolver(viewModel) + private func namedRevisionRow(_ revision: HgNamedRevision) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(revision.name) + .font(.headline) + Spacer() + Text(revision.displayShortId) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + .padding(.vertical, 6) + } + private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? { guard let content = viewModel.readmeContent else { return nil } + return AnyView( + RenderedMarkupContentView( + content: sharedReadmeContent(from: content), + readmePath: viewModel.readmePath, + colorScheme: colorScheme, + ownerCanonicalName: repository.owner.canonicalName, + repositoryName: repository.name, + repositoryHost: "hg.sr.ht" + ) + ) + } + + private func browseRevspecLabel(_ revspec: String) -> String { + if revspec == "tip" { + return "tip" + } + return revspec + } + + private func sharedReadmeContent(from content: HgRepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent { switch content { case .html(let html): - return AnyView( - HTMLWebView(html: html, colorScheme: colorScheme) - .frame(minHeight: 400) - ) + .html(html) case .markdown(let text): - return AnyView( - HTMLWebView( - html: markdownToHTML(text, imageURLResolver: imageURLResolver), - colorScheme: colorScheme - ) - .frame(minHeight: 400) - ) + .markdown(text) case .org(let text): - return AnyView( - HTMLWebView( - html: orgToHTML(text, imageURLResolver: imageURLResolver), - colorScheme: colorScheme - ) - .frame(minHeight: 400) - ) + .org(text) case .plainText(let text): - return AnyView( - Text(text) - .font(.system(.body, design: .monospaced)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - ) + .plainText(text) } } - private func makeImageURLResolver(_ viewModel: HgRepositoryDetailViewModel) -> (String) -> String? { - let owner = repository.owner.canonicalName - let repositoryName = repository.name - let readmePath = viewModel.readmePath - - return { source in - resolveRepositoryAssetURL( - source, - owner: owner, - repositoryName: repositoryName, - readmePath: readmePath - )? - .replacingOccurrences(of: "git.sr.ht", with: "hg.sr.ht") + private func displayFileName(_ name: String) -> String { + name.hasSuffix("/") ? String(name.dropLast()) : name + } + +} + +private struct HgBrowseRefPickerSheet: View { + let viewModel: HgRepositoryDetailViewModel + @Binding var isPresented: Bool + + var body: some View { + NavigationStack { + List { + Section { + Button { + Task { + await viewModel.changeBrowseRevspec("tip") + isPresented = false + } + } label: { + refRow( + title: "tip", + systemImage: "arrow.triangle.branch", + color: .blue, + isSelected: viewModel.browseRevspec == "tip" + ) + } + .buttonStyle(.plain) + } + + if !viewModel.branches.isEmpty { + Section("Branches") { + ForEach(viewModel.branches) { revision in + Button { + Task { + await viewModel.changeBrowseRevspec(revision.name) + isPresented = false + } + } label: { + refRow( + title: revision.name, + systemImage: "arrow.triangle.branch", + color: .blue, + isSelected: viewModel.browseRevspec == revision.name + ) + } + .buttonStyle(.plain) + } + } + } + + if !viewModel.tags.isEmpty { + Section("Tags") { + ForEach(viewModel.tags) { revision in + Button { + Task { + await viewModel.changeBrowseRevspec(revision.name) + isPresented = false + } + } label: { + refRow( + title: revision.name, + systemImage: "tag", + color: .orange, + isSelected: viewModel.browseRevspec == revision.name + ) + } + .buttonStyle(.plain) + } + } + } + + if !viewModel.bookmarks.isEmpty { + Section("Bookmarks") { + ForEach(viewModel.bookmarks) { revision in + Button { + Task { + await viewModel.changeBrowseRevspec(revision.name) + isPresented = false + } + } label: { + refRow( + title: revision.name, + systemImage: "bookmark", + color: .purple, + isSelected: viewModel.browseRevspec == revision.name + ) + } + .buttonStyle(.plain) + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Select Ref") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + isPresented = false + } + } + } } } - private func displayFileName(_ name: String) -> String { - name.hasSuffix("/") ? String(name.dropLast()) : name + private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View { + HStack(spacing: 12) { + Image(systemName: systemImage) + .foregroundStyle(color) + + Text(title) + .font(.body.monospaced()) + .foregroundStyle(.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) } +} - private func visibilityLabel(_ visibility: Visibility) -> String { - switch visibility { - case .public: - return "Public" - case .unlisted: - return "Unlisted" - case .private: - return "Private" +private struct HgFileRow: View { + let file: HgFile + + var body: some View { + Label { + Text(displayName) + .font(.body.monospaced()) + .lineLimit(1) + } icon: { + Image(systemName: file.isDirectory ? "folder.fill" : "doc") + .foregroundStyle(file.isDirectory ? .blue : .secondary) } } + + private var displayName: String { + file.name.hasSuffix("/") ? String(file.name.dropLast()) : file.name + } } 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") + } +} diff --git a/Hutch/Views/Repositories/HgRepositorySettingsView.swift b/Hutch/Views/Repositories/HgRepositorySettingsView.swift new file mode 100644 index 0000000..3894b9d --- /dev/null +++ b/Hutch/Views/Repositories/HgRepositorySettingsView.swift @@ -0,0 +1,239 @@ +import SwiftUI + +struct HgRepositorySettingsView: View { + let repository: RepositorySummary + let client: SRHTClient + let onDeleted: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: HgRepositorySettingsViewModel? + @State private var showDeleteConfirmation = false + @State private var pendingACLDeletion: HgACLEntry? + + var body: some View { + NavigationStack { + Group { + if let viewModel { + settingsForm(viewModel) + } else { + SRHTLoadingStateView(message: "Loading settings…") + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + .task { + if viewModel == nil { + let vm = HgRepositorySettingsViewModel(repository: repository, client: client) + viewModel = vm + async let info: () = vm.loadRepositoryInfo() + async let acls: () = vm.loadACLs() + _ = await (info, acls) + } + } + } + + @ViewBuilder + private func settingsForm(_ viewModel: HgRepositorySettingsViewModel) -> some View { + @Bindable var vm = viewModel + + Form { + infoSection(viewModel) + accessSection(viewModel) + featuresSection(viewModel) + histeditSection(viewModel) + deleteSection(viewModel) + } + .srhtErrorBanner(error: $vm.error) + .alert( + "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?", + isPresented: $showDeleteConfirmation + ) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + Task { + await viewModel.deleteRepository() + if viewModel.didDelete { + dismiss() + onDeleted() + } + } + } + } message: { + Text("This cannot be undone.") + } + .alert("Remove Access?", isPresented: Binding( + get: { pendingACLDeletion != nil }, + set: { isPresented in + if !isPresented { + pendingACLDeletion = nil + } + } + )) { + Button("Cancel", role: .cancel) {} + Button("Remove Access", role: .destructive) { + guard let entry = pendingACLDeletion else { return } + Task { + await viewModel.deleteACL(entry) + pendingACLDeletion = nil + } + } + } message: { + if let entry = pendingACLDeletion { + Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.") + } + } + } + + @ViewBuilder + private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { + Section("Info") { + LabeledContent("Name") { + Text(repository.name) + .font(.body.monospaced()) + } + + TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) + .lineLimit(3...6) + + Picker("Visibility", selection: Bindable(viewModel).editedVisibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) + } + + Button { + Task { await viewModel.saveInfo() } + } label: { + if viewModel.isSavingInfo { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Save Changes") + .frame(maxWidth: .infinity) + } + } + .disabled(viewModel.isSavingInfo) + } + } + + @ViewBuilder + private func accessSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { + Section("Access") { + if viewModel.isLoadingACLs { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if viewModel.acls.isEmpty { + Text("No access entries yet.") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.acls) { entry in + HStack { + Text(entry.entity.canonicalName) + Spacer() + Text(entry.mode) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + pendingACLDeletion = entry + } label: { + Label("Remove Access", systemImage: "trash") + } + } + } + } + + HStack { + TextField("Username or ~username", text: Bindable(viewModel).newACLEntity) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + Picker("", selection: Bindable(viewModel).newACLMode) { + Text("RO").tag("RO") + Text("RW").tag("RW") + } + .pickerStyle(.segmented) + .frame(width: 100) + + Button { + Task { await viewModel.addACL() } + } label: { + if viewModel.isAddingACL { + ProgressView() + } else { + Text("Add") + } + } + .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + Text("Add a SourceHut user and choose read-only or read/write access.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { + Section("Features") { + Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing) + + Button { + Task { await viewModel.saveInfo() } + } label: { + if viewModel.isSavingInfo { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Save Changes") + .frame(maxWidth: .infinity) + } + } + .disabled(viewModel.isSavingInfo) + } + } + + @ViewBuilder + private func histeditSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { + Section("Histedit") { + TextField("Revision hash", text: Bindable(viewModel).histeditRevision) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .disabled(true) + + Text("Removing revisions is not available through the public hg.sr.ht API, so Hutch can’t do this yet.") + .font(.caption) + .foregroundStyle(.secondary) + + Button("Remove Revision", role: .destructive) {} + .disabled(true) + } + } + + @ViewBuilder + private func deleteSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { + Section { + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + if viewModel.isDeleting { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Delete Repository") + .frame(maxWidth: .infinity) + } + } + .disabled(viewModel.isDeleting) + } + } +} diff --git a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift new file mode 100644 index 0000000..3e431f8 --- /dev/null +++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift @@ -0,0 +1,287 @@ +import Foundation + +private struct HgUpdateRepositoryResponse: Decodable, Sendable { + let updateRepository: HgUpdatedRepository +} + +private struct HgUpdatedRepository: Decodable, Sendable { + let id: Int +} + +private struct HgRepositoryInfoResponse: Decodable, Sendable { + let repository: HgRepositoryInfo? +} + +private struct HgRepositoryInfo: Decodable, Sendable { + let description: String? + let visibility: Visibility + let nonPublishing: Bool? +} + +private struct HgACLResponse: Decodable, Sendable { + let repository: HgACLRepository? +} + +private struct HgACLRepository: Decodable, Sendable { + let accessControlList: HgACLPage +} + +private struct HgACLPage: Decodable, Sendable { + let results: [HgACLEntry] + let cursor: String? +} + +private struct HgUpdateACLResponse: Decodable, Sendable { + let updateACL: HgACLEntry +} + +private struct HgDeleteACLResponse: Decodable, Sendable { + let deleteACL: HgDeletedACL +} + +private struct HgDeletedACL: Decodable, Sendable { + let id: Int +} + +private struct HgDeleteRepositoryResponse: Decodable, Sendable { + let deleteRepository: HgDeletedRepository +} + +private struct HgDeletedRepository: Decodable, Sendable { + let id: Int +} + +struct HgACLEntry: Decodable, Sendable, Identifiable { + let id: Int + let mode: String + let entity: Entity +} + +@Observable +@MainActor +final class HgRepositorySettingsViewModel { + let repositoryId: Int + let repositoryRid: String + let repositoryName: String + private let client: SRHTClient + + var editedDescription: String + var editedVisibility: Visibility + var editedNonPublishing: Bool + var isSavingInfo = false + + private(set) var acls: [HgACLEntry] = [] + private(set) var isLoadingACLs = false + var newACLEntity = "" + var newACLMode = "RO" + var isAddingACL = false + var isDeletingACL = false + + var histeditRevision = "" + var isDeleting = false + var didDelete = false + var error: String? + + init(repository: RepositorySummary, client: SRHTClient) { + self.repositoryId = repository.id + self.repositoryRid = repository.rid + self.repositoryName = repository.name + self.client = client + self.editedDescription = repository.description ?? "" + self.editedVisibility = repository.visibility + self.editedNonPublishing = false + } + + private static let updateRepositoryMutation = """ + mutation updateRepository($id: Int!, $input: RepoInput!) { + updateRepository(id: $id, input: $input) { + id + } + } + """ + + private static let accessControlListQuery = """ + query hgAccessControlList($rid: ID!) { + repository(rid: $rid) { + accessControlList { + results { + id + mode + entity { canonicalName } + } + cursor + } + } + } + """ + + private static let updateACLMutation = """ + mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) { + updateACL(repoId: $repoId, mode: $mode, entity: $entity) { + id + mode + entity { canonicalName } + } + } + """ + + private static let deleteACLMutation = """ + mutation deleteACL($id: Int!) { + deleteACL(id: $id) { id } + } + """ + + private static let deleteRepositoryMutation = """ + mutation deleteRepository($id: Int!) { + deleteRepository(id: $id) { id } + } + """ + + private static let repositoryInfoQuery = """ + query hgRepositoryInfo($rid: ID!) { + repository(rid: $rid) { + description + visibility + nonPublishing + } + } + """ + + func loadRepositoryInfo() async { + error = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.repositoryInfoQuery, + variables: ["rid": repositoryRid], + responseType: HgRepositoryInfoResponse.self + ) + + if let repository = result.repository { + editedDescription = repository.description ?? "" + editedVisibility = repository.visibility + editedNonPublishing = repository.nonPublishing ?? false + } + } catch { + self.error = error.localizedDescription + } + } + + func saveInfo() async { + isSavingInfo = true + defer { isSavingInfo = false } + error = nil + + do { + let input: [String: any Sendable] = [ + "description": editedDescription, + "visibility": editedVisibility.rawValue, + "nonPublishing": editedNonPublishing + ] + _ = try await client.execute( + service: .hg, + query: Self.updateRepositoryMutation, + variables: ["id": repositoryId, "input": input], + responseType: HgUpdateRepositoryResponse.self + ) + } catch { + self.error = error.localizedDescription + } + } + + func loadACLs() async { + guard !isLoadingACLs else { return } + isLoadingACLs = true + defer { isLoadingACLs = false } + error = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.accessControlListQuery, + variables: ["rid": repositoryRid], + responseType: HgACLResponse.self + ) + acls = result.repository?.accessControlList.results ?? [] + } catch { + self.error = error.localizedDescription + } + } + + func addACL() async { + let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawEntity.isEmpty else { return } + let entity = hgCanonicalEntity(from: rawEntity) + isAddingACL = true + defer { isAddingACL = false } + error = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.updateACLMutation, + variables: [ + "repoId": repositoryId, + "mode": newACLMode, + "entity": entity + ], + responseType: HgUpdateACLResponse.self + ) + if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) { + acls[index] = result.updateACL + } else { + acls.append(result.updateACL) + } + newACLEntity = "" + } catch { + let message = error.localizedDescription + if message.localizedCaseInsensitiveContains("No such repository or user found") { + self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first." + } else { + self.error = message + } + } + } + + private func hgCanonicalEntity(from input: String) -> String { + let username = input.hasPrefix("~") ? String(input.dropFirst()) : input + return "~\(username)" + } + + func deleteACL(_ entry: HgACLEntry) async { + isDeletingACL = true + defer { isDeletingACL = false } + error = nil + + do { + _ = try await client.execute( + service: .hg, + query: Self.deleteACLMutation, + variables: ["id": entry.id], + responseType: HgDeleteACLResponse.self + ) + acls.removeAll { $0.id == entry.id } + } catch { + self.error = error.localizedDescription + } + } + + func deleteRepository() async { + isDeleting = true + defer { isDeleting = false } + error = nil + + do { + _ = try await client.execute( + service: .hg, + query: Self.deleteRepositoryMutation, + variables: ["id": repositoryId], + responseType: HgDeleteRepositoryResponse.self + ) + didDelete = true + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift new file mode 100644 index 0000000..b53885e --- /dev/null +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -0,0 +1,1125 @@ +import SwiftUI +import WebKit + +struct ReadmeView: View { + let viewModel: RepositoryDetailViewModel + + @Environment(\.colorScheme) private var colorScheme + @State private var isShowingRepositoryDetails = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + headerSection + metadataSection + repositoryDetailsSection + latestChangeSection + readmeSection + } + .padding() + } + .task { + async let readme: () = viewModel.loadReadme() + async let commits: () = viewModel.loadCommits() + async let refs: () = viewModel.loadReferences() + _ = await (readme, commits, refs) + } + .navigationDestination(for: CommitSummary.self) { commit in + CommitDetailView( + commitSummary: commit, + repository: viewModel.repository + ) + } + } + + @ViewBuilder + private var headerSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text(viewModel.repository.owner.canonicalName) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(viewModel.repository.name) + .font(.largeTitle.weight(.semibold)) + if let description = viewModel.repository.description, !description.isEmpty { + Text(description) + .font(.body) + } + } + } + + @ViewBuilder + private var metadataSection: some View { + VStack(alignment: .leading, spacing: 10) { + SummaryMetadataRow( + icon: "arrow.triangle.branch", + title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility) + ) + + if let readmePath = viewModel.readmePath { + SummaryMetadataRow( + icon: "doc.text", + title: readmePath + ) + } + } + } + + private var repositoryDetailsSection: some View { + DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { + VStack(alignment: .leading, spacing: 12) { + SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility)) + SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true) + SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true) + SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true) + } + .padding(.top, 8) + } label: { + Text("Repository Details") + .font(.subheadline.weight(.medium)) + } + } + + @ViewBuilder + private var latestChangeSection: some View { + VStack(alignment: .leading, spacing: 8) { + if viewModel.isLoadingCommits && viewModel.commits.isEmpty { + SRHTLoadingStateView(message: "Loading latest change…") + .frame(maxWidth: .infinity) + } else if let commit = viewModel.commits.first { + NavigationLink(value: commit) { + SummaryMetadataRow( + icon: "arrow.trianglehead.clockwise", + title: commit.title, + subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)" + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } else if let error = viewModel.error, viewModel.commits.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Latest Change", + message: error, + retryAction: { await viewModel.loadCommits() } + ) + } else { + ContentUnavailableView( + "No Recent Commits", + systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", + description: Text("This repository does not have any commit history yet.") + ) + } + } + } + + @ViewBuilder + private var readmeSection: some View { + if viewModel.isLoadingReadme { + SRHTLoadingStateView(message: "Loading README…") + } else if let content = viewModel.readmeContent { + RenderedMarkupContentView( + content: sharedReadmeContent(from: content), + readmePath: viewModel.readmePath, + colorScheme: colorScheme, + ownerCanonicalName: viewModel.repository.owner.canonicalName, + repositoryName: viewModel.repository.name + ) + } else if let error = viewModel.error, !viewModel.readmeLoaded { + SRHTErrorStateView( + title: "Couldn't Load README", + message: error, + retryAction: { await viewModel.loadReadme() } + ) + } else { + ContentUnavailableView( + "No README", + systemImage: "doc.text", + description: Text("This repository does not have a README file.") + ) + } + } + + private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent { + switch content { + case .html(let html): + .html(html) + case .markdown(let text): + .markdown(text) + case .org(let text): + .org(text) + case .plainText(let text): + .plainText(text) + } + } +} + +enum RenderedMarkupContent: Sendable { + case html(String) + case markdown(String) + case org(String) + case plainText(String) +} + +struct RenderedMarkupContentView: View { + let content: RenderedMarkupContent + let readmePath: String? + let colorScheme: ColorScheme + let ownerCanonicalName: String + let repositoryName: String + var repositoryHost = "git.sr.ht" + + @State private var renderedHTML: String? + + private var cacheKey: String { + switch content { + case .html(let html): + "html:\(readmePath ?? "custom"):\(html)" + case .markdown(let text): + "markdown:\(readmePath ?? ""):\(text)" + case .org(let text): + "org:\(readmePath ?? ""):\(text)" + case .plainText(let text): + "plain:\(readmePath ?? ""):\(text)" + } + } + + var body: some View { + Group { + switch content { + case .html(let html): + HTMLWebView(html: html, colorScheme: colorScheme) + case .markdown, .org: + if let renderedHTML { + HTMLWebView(html: renderedHTML, colorScheme: colorScheme) + } else { + SRHTLoadingStateView(message: "Preparing README…") + } + case .plainText(let text): + Text(text) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .task(id: cacheKey) { + await prepareHTMLIfNeeded() + } + } + + private func prepareHTMLIfNeeded() async { + switch content { + case .html, .plainText: + renderedHTML = nil + case .markdown(let text): + if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { + renderedHTML = cached + return + } + let html = await Task.detached(priority: .userInitiated) { + markdownToHTML(text) { source in + resolveRepositoryAssetURL( + source, + owner: ownerCanonicalName, + repositoryName: repositoryName, + readmePath: readmePath + )? + .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) + } + }.value + RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) + guard !Task.isCancelled else { return } + renderedHTML = html + case .org(let text): + if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { + renderedHTML = cached + return + } + let html = await Task.detached(priority: .userInitiated) { + orgToHTML(text) { source in + resolveRepositoryAssetURL( + source, + owner: ownerCanonicalName, + repositoryName: repositoryName, + readmePath: readmePath + )? + .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) + } + }.value + RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) + guard !Task.isCancelled else { return } + renderedHTML = html + } + } +} + +private final class RenderedReadmeHTMLCache: @unchecked Sendable { + static let shared = RenderedReadmeHTMLCache() + + private let storage = NSCache() + + func html(forKey key: String) -> String? { + storage.object(forKey: key as NSString) as String? + } + + func setHTML(_ html: String, forKey key: String) { + storage.setObject(html as NSString, forKey: key as NSString) + } + + func removeAll() { + storage.removeAllObjects() + } +} + +@MainActor +func clearWebContentRenderCaches() { + RenderedReadmeHTMLCache.shared.removeAll() + HTMLWebViewCoordinator.heightCache.removeAllObjects() +} + +// MARK: - Markdown to HTML + +nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { + let normalizedText = text + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var html = "" + var inCodeBlock = false + var inList = false + var paragraph: [String] = [] + + func flushParagraph() { + if !paragraph.isEmpty { + let normalizedParagraph = paragraph + .map { $0.trimmingCharacters(in: .whitespaces) } + .joined(separator: " ") + html += "

" + normalizedParagraph + "

\n" + paragraph = [] + } + } + + func closeList() { + if inList { + html += "\n" + inList = false + } + } + + for line in lines { + // Fenced code blocks + if line.hasPrefix("```") { + if inCodeBlock { + html += "\n" + inCodeBlock = false + } else { + flushParagraph() + closeList() + html += "
"
+                inCodeBlock = true
+            }
+            continue
+        }
+
+        if inCodeBlock {
+            html += escapeHTML(line) + "\n"
+            continue
+        }
+
+        // Headings
+        if line.hasPrefix("### ") {
+            flushParagraph()
+            closeList()
+            html += "

" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "

\n" + continue + } + if line.hasPrefix("## ") { + flushParagraph() + closeList() + html += "

" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "

\n" + continue + } + if line.hasPrefix("# ") { + flushParagraph() + closeList() + html += "

" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "

\n" + continue + } + + // List items + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") { + flushParagraph() + if !inList { + html += "
    \n" + inList = true + } + html += "
  • " + renderTaskListItem( + String(trimmed.dropFirst(2)), + inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) } + ) + "
  • \n" + continue + } + + // Blank line + if trimmed.isEmpty { + flushParagraph() + closeList() + continue + } + + // Regular text — accumulate into paragraph + paragraph.append(processInline(line, imageURLResolver: imageURLResolver)) + } + + // Flush remaining state + if inCodeBlock { + html += "
\n" + } + flushParagraph() + closeList() + + return html +} + +nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { + var result = escapeHTML(text) + + // Images: ![alt](url) + result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in + let alt = nsText.substring(with: match.range(at: 1)) + let source = nsText.substring(with: match.range(at: 2)) + let resolvedSource = imageURLResolver?(source) ?? source + return #"\#(escapeHTMLAttribute(alt))"# + } + // Links: [text](url) + result = result.replacingOccurrences( + of: #"\[([^\]]+)\]\(([^)]+)\)"#, + with: #"$1"#, + options: .regularExpression + ) + // Bold: **text** + result = result.replacingOccurrences( + of: #"\*\*(.+?)\*\*"#, + with: "$1", + options: .regularExpression + ) + // Italic: *text* + result = result.replacingOccurrences( + of: #"\*(.+?)\*"#, + with: "$1", + options: .regularExpression + ) + // Inline code: `text` + result = result.replacingOccurrences( + of: #"`([^`]+)`"#, + with: "$1", + options: .regularExpression + ) + + return result +} + +// MARK: - Org-mode to HTML + +nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { + let normalizedText = text + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var html = "" + var listType: OrgListType? + var inQuoteBlock = false + var inPropertyDrawer = false + var srcLanguage: String? + var paragraph: [String] = [] + var tableRows: [[String]] = [] + var propertyRows: [(String, String)] = [] + + func flushParagraph() { + if !paragraph.isEmpty { + let normalizedParagraph = paragraph + .map { $0.trimmingCharacters(in: .whitespaces) } + .joined(separator: " ") + html += "

" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "

\n" + paragraph = [] + } + } + + func closeList() { + switch listType { + case .unordered: + html += "\n" + case .ordered: + html += "\n" + case nil: + break + } + listType = nil + } + + func flushTable() { + guard !tableRows.isEmpty else { return } + let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell) + let headerRow = tableRows.first ?? [] + let bodyRows: [[String]] + + html += "\n" + if hasHeaderSeparator { + html += "" + for cell in headerRow { + html += "" + } + html += "\n\n" + bodyRows = Array(tableRows.dropFirst(2)) + } else { + bodyRows = tableRows + } + + for row in bodyRows { + html += "" + for cell in row { + html += "" + } + html += "\n" + } + + if hasHeaderSeparator { + html += "\n" + } + html += "
" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "
" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "
\n" + tableRows = [] + } + + func flushPropertyDrawer() { + guard !propertyRows.isEmpty else { return } + html += "
\n" + for (key, value) in propertyRows { + html += "
" + escapeHTML(key) + "
" + html += "
" + processOrgInline(value, imageURLResolver: imageURLResolver) + "
\n" + } + html += "
\n" + propertyRows = [] + } + + func closeQuoteBlock() { + if inQuoteBlock { + flushParagraph() + html += "\n" + inQuoteBlock = false + } + } + + func closeSourceBlock() { + if srcLanguage != nil { + html += "\n" + srcLanguage = nil + } + } + + func flushBlockState() { + flushParagraph() + closeList() + flushTable() + flushPropertyDrawer() + } + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if srcLanguage != nil { + if trimmed.lowercased() == "#+end_src" { + closeSourceBlock() + } else { + html += escapeHTML(line) + "\n" + } + continue + } + + if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { + closeQuoteBlock() + continue + } + + if trimmed.lowercased().hasPrefix("#+begin_src") { + closeQuoteBlock() + flushBlockState() + let language = trimmed + .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + .dropFirst() + .first + .map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" + html += "
"
+            srcLanguage = language ?? ""
+            continue
+        }
+
+        if trimmed.lowercased() == "#+begin_quote" {
+            flushBlockState()
+            html += "
\n" + inQuoteBlock = true + continue + } + + if trimmed == ":PROPERTIES:" { + closeQuoteBlock() + flushBlockState() + inPropertyDrawer = true + continue + } + + if trimmed == ":END:", inPropertyDrawer { + flushPropertyDrawer() + inPropertyDrawer = false + continue + } + + if inPropertyDrawer, + trimmed.hasPrefix(":"), + let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") { + let keyStart = trimmed.index(after: trimmed.startIndex) + let key = String(trimmed[keyStart..\n" + continue + } + + // List items: - item + if trimmed.hasPrefix("- ") { + flushParagraph() + flushPropertyDrawer() + if listType != .unordered { + closeList() + html += "
    \n" + listType = .unordered + } + html += "
  • " + renderTaskListItem( + String(trimmed.dropFirst(2)), + inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } + ) + "
  • \n" + continue + } + + if let orderedItem = orderedListItem(in: trimmed) { + flushParagraph() + flushPropertyDrawer() + if listType != .ordered { + closeList() + html += "
      \n" + listType = .ordered + } + html += "
    1. " + renderTaskListItem( + orderedItem, + inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } + ) + "
    2. \n" + continue + } + + // Blank line + if trimmed.isEmpty { + if inQuoteBlock { + flushParagraph() + } else { + flushBlockState() + } + continue + } + + // Regular text + paragraph.append(line) + } + + closeSourceBlock() + closeQuoteBlock() + flushBlockState() + + return html +} + +nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { + var result = escapeHTML(text) + var protectedFragments: [String: String] = [:] + + result = protectMatches( + in: result, + pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#, + protectedFragments: &protectedFragments + ) { match, nsText in + let url = nsText.substring(with: match.range(at: 1)) + let label = nsText.substring(with: match.range(at: 2)) + if let imageHTML = makeOrgImageHTML( + source: url, + alt: label, + imageURLResolver: imageURLResolver + ) { + return imageHTML + } + return #"\#(label)"# + } + result = protectMatches( + in: result, + pattern: #"\[\[([^\]]+)\]\]"#, + protectedFragments: &protectedFragments + ) { match, nsText in + let url = nsText.substring(with: match.range(at: 1)) + if let imageHTML = makeOrgImageHTML( + source: url, + alt: nil, + imageURLResolver: imageURLResolver + ) { + return imageHTML + } + return #"\#(url)"# + } + result = protectMatches( + in: result, + pattern: #"(?\(codeText)" + } + + // Bold: *text* + result = result.replacingOccurrences( + of: #"(?$1", + options: .regularExpression + ) + // Italic: /text/ + result = result.replacingOccurrences( + of: #"(?$1", + options: .regularExpression + ) + + for (token, fragment) in protectedFragments { + result = result.replacingOccurrences(of: token, with: fragment) + } + + return result +} + +// MARK: - HTML Escaping + +nonisolated func escapeHTML(_ text: String) -> String { + text.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) +} + +nonisolated private func escapeHTMLAttribute(_ text: String) -> String { + escapeHTML(text).replacingOccurrences(of: "'", with: "'") +} + +nonisolated private func isOrgTableLine(_ line: String) -> Bool { + line.hasPrefix("|") && line.hasSuffix("|") +} + +nonisolated private func parseOrgTableRow(_ line: String) -> [String] { + line + .split(separator: "|", omittingEmptySubsequences: false) + .dropFirst() + .dropLast() + .map { String($0).trimmingCharacters(in: .whitespaces) } +} + +nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool { + let trimmed = cell.trimmingCharacters(in: .whitespaces) + return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" } +} + +private enum OrgListType { + case unordered + case ordered +} + +nonisolated private func orderedListItem(in line: String) -> String? { + guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } + return String(match.2) +} + +nonisolated private func protectMatches( + in text: String, + pattern: String, + protectedFragments: inout [String: String], + transform: (NSTextCheckingResult, NSString) -> String +) -> String { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } + var result = text + let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) + + for match in matches.reversed() { + let token = "__ORG_PROTECTED_\(protectedFragments.count)__" + let nsText = result as NSString + protectedFragments[token] = transform(match, nsText) + result = nsText.replacingCharacters(in: match.range, with: token) + } + + return result +} + +nonisolated private func replaceMatches( + in text: String, + pattern: String, + transform: (NSTextCheckingResult, NSString) -> String +) -> String { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } + var result = text + let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) + + for match in matches.reversed() { + let nsText = result as NSString + let replacement = transform(match, nsText) + result = nsText.replacingCharacters(in: match.range, with: replacement) + } + + return result +} + +nonisolated private func makeOrgImageHTML( + source: String, + alt: String?, + imageURLResolver: ((String) -> String?)? +) -> String? { + guard isRenderableImageSource(source) else { return nil } + let resolvedSource = imageURLResolver?(source) ?? source + let altText = escapeHTMLAttribute(alt ?? "") + return #"\#(altText)"# +} + +nonisolated private func isRenderableImageSource(_ source: String) -> Bool { + let lowercased = source.lowercased() + return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"] + .contains(where: { lowercased.hasSuffix($0) }) +} + +nonisolated func resolveRepositoryAssetURL( + _ source: String, + owner: String, + repositoryName: String, + readmePath: String? +) -> String? { + let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSource.isEmpty else { return nil } + + if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") { + return trimmedSource + } + + let relativePath: String + if trimmedSource.hasPrefix("/") { + relativePath = String(trimmedSource.dropFirst()) + } else { + let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? "" + relativePath = normalizeRepositoryPath( + (readmeDirectory as NSString).appendingPathComponent(trimmedSource) + ) + } + + guard !relativePath.isEmpty else { return nil } + return "https://git.sr.ht/\(owner)/\(repositoryName)/blob/HEAD/\(relativePath)" +} + +nonisolated private func normalizeRepositoryPath(_ path: String) -> String { + var components: [String] = [] + + for part in path.split(separator: "/") { + switch part { + case ".": + continue + case "..": + if !components.isEmpty { + components.removeLast() + } + default: + components.append(String(part)) + } + } + + return components.joined(separator: "/") +} + +nonisolated private func renderTaskListItem( + _ text: String, + inlineRenderer: (String) -> String +) -> String { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 4 else { + return inlineRenderer(text) + } + + let prefix = String(trimmed.prefix(4)) + let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces) + + switch prefix { + case "[ ] ": + return #" \#(inlineRenderer(remainder))"# + case "[x] ", "[X] ": + return #" \#(inlineRenderer(remainder))"# + default: + return inlineRenderer(text) + } +} + +// MARK: - WKWebView Wrapper + +/// A WKWebView wrapper that renders HTML inline and grows to fit its content. +struct HTMLWebView: View { + let html: String + let colorScheme: ColorScheme + var style: HTMLWebViewStyle = .readme + @State private var contentHeight: CGFloat = 1 + @State private var loadError: String? + @State private var reloadToken = 0 + + var body: some View { + Group { + if let loadError { + SRHTErrorStateView( + title: "Couldn't Render Content", + message: loadError, + retryAction: { + await MainActor.run { + self.loadError = nil + reloadToken += 1 + } + } + ) + } else { + HTMLWebViewRepresentable( + html: html, + colorScheme: colorScheme, + style: style, + dynamicHeight: $contentHeight, + loadError: $loadError, + reloadToken: reloadToken + ) + .frame(height: max(contentHeight, 1)) + } + } + } +} + +struct HTMLWebViewStyle: Sendable { + let bodyFontSize: Int + let lineHeight: Double + let codeFontSize: Int + let viewport: String + + static let readme = HTMLWebViewStyle( + bodyFontSize: 16, + lineHeight: 1.6, + codeFontSize: 13, + viewport: "width=device-width, initial-scale=1, maximum-scale=1" + ) + + static let commentPreview = HTMLWebViewStyle( + bodyFontSize: 15, + lineHeight: 1.5, + codeFontSize: 12, + viewport: "width=device-width, initial-scale=1, user-scalable=no" + ) +} + +private struct HTMLWebViewRepresentable: UIViewRepresentable { + let html: String + let colorScheme: ColorScheme + let style: HTMLWebViewStyle + @Binding var dynamicHeight: CGFloat + @Binding var loadError: String? + let reloadToken: Int + + func makeCoordinator() -> HTMLWebViewCoordinator { + HTMLWebViewCoordinator(parent: self) + } + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + config.defaultWebpagePreferences.allowsContentJavaScript = true + config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore + let webView = WKWebView(frame: .zero, configuration: config) + webView.isOpaque = false + webView.backgroundColor = .clear + webView.clipsToBounds = false + webView.scrollView.isScrollEnabled = false + webView.scrollView.contentInsetAdjustmentBehavior = .never + webView.scrollView.clipsToBounds = false + webView.navigationDelegate = context.coordinator + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + let textColor = colorScheme == .dark ? "#fff" : "#000" + let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc" + + let wrapped = """ + + + + + + + \(html) + + """ + + if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue { + let height = CGFloat(cachedHeight) + if abs(dynamicHeight - height) > 0.5 { + dynamicHeight = height + } + } + + guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return } + context.coordinator.lastHTML = wrapped + context.coordinator.lastReloadToken = reloadToken + if loadError != nil { + DispatchQueue.main.async { + self.loadError = nil + } + } + webView.loadHTMLString(wrapped, baseURL: nil) + } +} + +private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable { + static let websiteDataStore = WKWebsiteDataStore.nonPersistent() + static let heightCache = NSCache() + + let parent: HTMLWebViewRepresentable + var lastHTML: String? + var lastReloadToken = 0 + + init(parent: HTMLWebViewRepresentable) { + self.parent = parent + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + DispatchQueue.main.async { + self.parent.loadError = nil + } + updateHeight(for: webView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak webView] in + guard let self, let webView else { return } + self.updateHeight(for: webView) + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + handleLoadFailure(error) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + handleLoadFailure(error) + } + + private func handleLoadFailure(_ error: Error) { + let nsError = error as NSError + guard nsError.code != NSURLErrorCancelled else { return } + DispatchQueue.main.async { + self.parent.loadError = "The content could not be displayed right now." + } + } + + 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 + } + } + } + } +} diff --git a/Hutch/Views/Repositories/ReferencesListView.swift b/Hutch/Views/Repositories/ReferencesListView.swift new file mode 100644 index 0000000..615ba25 --- /dev/null +++ b/Hutch/Views/Repositories/ReferencesListView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +struct ReferencesListView: View { + let viewModel: RepositoryDetailViewModel + + var body: some View { + List { + if !viewModel.branches.isEmpty { + Section("Branches") { + ForEach(viewModel.branches, id: \.name) { ref in + ReferenceRow(reference: ref, prefix: "refs/heads/") + } + } + } + + if !viewModel.tags.isEmpty { + Section("Tags") { + ForEach(viewModel.tags, id: \.name) { ref in + ReferenceRow(reference: ref, prefix: "refs/tags/") + } + } + } + } + .listStyle(.insetGrouped) + .overlay { + if viewModel.isLoadingRefs, viewModel.branches.isEmpty, viewModel.tags.isEmpty { + SRHTLoadingStateView(message: "Loading references…") + } else if let error = viewModel.error, viewModel.branches.isEmpty, viewModel.tags.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load References", + message: error, + retryAction: { await viewModel.loadReferences() } + ) + } else if viewModel.branches.isEmpty, viewModel.tags.isEmpty { + ContentUnavailableView( + "No References", + systemImage: "arrow.triangle.branch", + description: Text("This repository has no branches or tags.") + ) + } + } + .task { + if viewModel.branches.isEmpty, viewModel.tags.isEmpty { + await viewModel.loadReferences() + } + } + .refreshable { + await viewModel.loadReferences() + } + } +} + +private struct ReferenceRow: View { + let reference: Reference + let prefix: String + + var body: some View { + HStack { + Label { + Text(shortName) + .font(.body.monospaced()) + } icon: { + Image(systemName: icon) + .foregroundStyle(iconColor) + } + + Spacer() + + Text(String((reference.target ?? "").prefix(8))) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + + private var shortName: String { + if reference.name.hasPrefix(prefix) { + String(reference.name.dropFirst(prefix.count)) + } else { + reference.name + } + } + + private var icon: String { + prefix.contains("tags") ? "tag" : "arrow.triangle.branch" + } + + private var iconColor: Color { + prefix.contains("tags") ? .orange : .blue + } +} diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift new file mode 100644 index 0000000..9044e56 --- /dev/null +++ b/Hutch/Views/Repositories/RepositoryDetailView.swift @@ -0,0 +1,107 @@ +import SwiftUI + +struct RepositoryDetailView: View { + var repository: RepositorySummary + var onDeleted: (() -> Void)? + + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var viewModel: RepositoryDetailViewModel? + @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary + @State private var showSettings = false + @State private var displayName: String + + init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) { + self.repository = repository + self.onDeleted = onDeleted + self._displayName = State(initialValue: repository.name) + } + + var body: some View { + if repository.service == .hg { + HgRepositoryDetailView(repository: repository, onDeleted: onDeleted) + } else { + Group { + if let viewModel { + detailContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading repository…") + } + } + .navigationTitle(displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) { + Image(systemName: "square.and.arrow.up") + } + + Button { + showSettings = true + } label: { + Image(systemName: "gear") + } + } + } + .sheet(isPresented: $showSettings) { + RepositorySettingsView( + repository: repository, + branches: viewModel?.branches ?? [], + client: appState.client, + onRenamed: { newName in + displayName = newName + }, + onDeleted: { + dismiss() + onDeleted?() + } + ) + } + .task { + if viewModel == nil { + viewModel = RepositoryDetailViewModel( + repository: repository, + client: appState.client + ) + } + } + } + } + + @ViewBuilder + private func detailContent(_ viewModel: RepositoryDetailViewModel) -> some View { + VStack(spacing: 0) { + Picker("Tab", selection: $selectedTab) { + ForEach(RepositoryDetailViewModel.Tab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + switch selectedTab { + case .summary: + ReadmeView(viewModel: viewModel) + case .tree: + FileTreeView( + repository: repository, + client: appState.client + ) + case .log: + CommitLogView(viewModel: viewModel) + case .refs: + ReferencesListView(viewModel: viewModel) + case .artifacts: + ArtifactsView(viewModel: viewModel) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .srhtErrorBanner(error: Binding( + get: { viewModel.error }, + set: { viewModel.error = $0 } + )) + } +} diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift new file mode 100644 index 0000000..afc748d --- /dev/null +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -0,0 +1,397 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct LogResponse: Decodable, Sendable { + let repository: LogRepository? +} + +private struct LogRepository: Decodable, Sendable { + let log: LogPage +} + +private struct LogPage: Decodable, Sendable { + let results: [CommitSummary] + let cursor: String? +} + +private struct RefsResponse: Decodable, Sendable { + let repository: RefsRepository? +} + +private struct RefsRepository: Decodable, Sendable { + let references: RefsPage +} + +private struct RefsPage: Decodable, Sendable { + let results: [Reference] + let cursor: String? +} + +private struct ReadmeResponse: Decodable, Sendable { + let repository: ReadmeRepository? +} + +private struct ReadmeRepository: Decodable, Sendable { + let readme: String? +} + +private struct PathResponse: Decodable, Sendable { + let repository: PathRepository? +} + +private struct PathRepository: Decodable, Sendable { + let readme: PathEntry? +} + +private struct PathEntry: Decodable, Sendable { + let object: PathObject? +} + +private struct PathObject: Decodable, Sendable { + let text: String? +} + +private struct ArtifactsResponse: Decodable, Sendable { + let repository: ArtifactsRepository? +} + +private struct ArtifactsRepository: Decodable, Sendable { + let references: ArtifactRefsPage +} + +private struct ArtifactRefsPage: Decodable, Sendable { + let results: [ArtifactRef] + let cursor: String? +} + +private struct ArtifactRef: Decodable, Sendable { + let name: String + let artifacts: ArtifactPage +} + +// MARK: - View Model + +@Observable +@MainActor +final class RepositoryDetailViewModel { + + enum Tab: String, CaseIterable { + case summary = "Summary" + case tree = "Tree" + case log = "Log" + case refs = "Refs" + case artifacts = "Artifacts" + } + + let repository: RepositorySummary + private var service: SRHTService { repository.service } + private let client: SRHTClient + + // MARK: - Commit log state + + private(set) var commits: [CommitSummary] = [] + private(set) var isLoadingCommits = false + private(set) var isLoadingMoreCommits = false + private var commitCursor: String? + private var hasMoreCommits = true + + // MARK: - References state + + private(set) var branches: [Reference] = [] + private(set) var tags: [Reference] = [] + private(set) var isLoadingRefs = false + + // MARK: - README state + + enum ReadmeContent { + case html(String) + case markdown(String) + case org(String) + case plainText(String) + } + + private(set) var readmeContent: ReadmeContent? + private(set) var readmePath: String? + private(set) var isLoadingReadme = false + private(set) var readmeLoaded = false + + // MARK: - Artifacts state + + private(set) var referenceArtifacts: [ReferenceWithArtifacts] = [] + private(set) var isLoadingArtifacts = false + + // MARK: - Error + + var error: String? + + init(repository: RepositorySummary, client: SRHTClient) { + self.repository = repository + self.client = client + } + + // MARK: - Commit log + + private static let logQuery = """ + query repoLog($rid: ID!, $cursor: Cursor) { + repository(rid: $rid) { + log(cursor: $cursor) { + results { + id + shortId + author { name email time } + message + } + cursor + } + } + } + """ + + func loadCommits() async { + guard !isLoadingCommits else { return } + isLoadingCommits = true + error = nil + commitCursor = nil + hasMoreCommits = true + + do { + let page = try await fetchCommitPage(cursor: nil) + commits = page.results + commitCursor = page.cursor + hasMoreCommits = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + + isLoadingCommits = false + } + + func loadMoreCommitsIfNeeded(currentItem: CommitSummary) async { + guard let last = commits.last, + last.id == currentItem.id, + hasMoreCommits, + !isLoadingMoreCommits else { + return + } + + isLoadingMoreCommits = true + + do { + let page = try await fetchCommitPage(cursor: commitCursor) + commits.append(contentsOf: page.results) + commitCursor = page.cursor + hasMoreCommits = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + + isLoadingMoreCommits = false + } + + private func fetchCommitPage(cursor: String?) async throws -> LogPage { + var variables: [String: any Sendable] = ["rid": repository.rid] + if let cursor { + variables["cursor"] = cursor + } + let result: LogResponse + do { + result = try await client.execute( + service: service, + query: Self.logQuery, + variables: variables, + responseType: LogResponse.self + ) + } catch { + if isMissingGitReferenceError(error) { + return LogPage(results: [], cursor: nil) + } + throw error + } + guard let repo = result.repository else { + return LogPage(results: [], cursor: nil) + } + return repo.log + } + + // MARK: - References + + private static let refsQuery = """ + query refs($rid: ID!) { + repository(rid: $rid) { + references { + results { name target } + cursor + } + } + } + """ + + func loadReferences() async { + guard !isLoadingRefs else { return } + isLoadingRefs = true + error = nil + + do { + let result = try await client.execute( + service: service, + query: Self.refsQuery, + variables: ["rid": repository.rid], + responseType: RefsResponse.self + ) + let allRefs = result.repository?.references.results ?? [] + branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") } + tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") } + } catch { + self.error = error.localizedDescription + } + + isLoadingRefs = false + } + + // MARK: - README + + private static let readmeQuery = """ + query readme($rid: ID!) { + repository(rid: $rid) { + readme + } + } + """ + + private static func readmeFileQuery(filename: String) -> String { + """ + query readmeFile($rid: ID!) { + repository(rid: $rid) { + readme: path(revspec: "HEAD", path: "\(filename)") { + object { + ... on TextBlob { text } + } + } + } + } + """ + } + + private static let readmeFilenames = [ + "README.md", "README.org", "README.txt", "README", + "readme.md", "readme.org" + ] + + func loadReadme() async { + guard !isLoadingReadme, !readmeLoaded else { return } + isLoadingReadme = true + defer { isLoadingReadme = false } + error = nil + + do { + // Step 1: Check the custom HTML readme set via the web UI + let result = try await client.execute( + service: service, + query: Self.readmeQuery, + variables: ["rid": repository.rid], + responseType: ReadmeResponse.self + ) + if let html = result.repository?.readme, !html.isEmpty { + readmePath = nil + readmeContent = .html(html) + readmeLoaded = true + return + } + + // Step 2: Try each README filename sequentially + for filename in Self.readmeFilenames { + let pathResult: PathResponse + do { + pathResult = try await client.execute( + service: service, + query: Self.readmeFileQuery(filename: filename), + variables: ["rid": repository.rid], + responseType: PathResponse.self + ) + } catch { + if isMissingGitReferenceError(error) { + readmeContent = nil + readmePath = nil + readmeLoaded = true + return + } + throw error + } + if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty { + readmePath = filename + if filename.hasSuffix(".md") { + readmeContent = .markdown(text) + } else if filename.hasSuffix(".org") { + readmeContent = .org(text) + } else { + readmeContent = .plainText(text) + } + readmeLoaded = true + return + } + } + + // Step 3: No readme found + readmeContent = nil + readmePath = nil + readmeLoaded = true + } catch { + self.error = error.localizedDescription + } + } + + private func isMissingGitReferenceError(_ error: Error) -> Bool { + guard let srhtError = error as? SRHTError else { return false } + guard case .graphQLErrors(let errors) = srhtError else { return false } + return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") } + } + + // MARK: - Artifacts + + private static let artifactsQuery = """ + query artifacts($rid: ID!) { + repository(rid: $rid) { + references { + results { + name + artifacts { + results { + id + filename + checksum + size + url + } + cursor + } + } + cursor + } + } + } + """ + + func loadArtifacts() async { + guard !isLoadingArtifacts else { return } + isLoadingArtifacts = true + error = nil + + do { + let result = try await client.execute( + service: service, + query: Self.artifactsQuery, + variables: ["rid": repository.rid], + responseType: ArtifactsResponse.self + ) + // Only include references that have at least one artifact. + referenceArtifacts = (result.repository?.references.results ?? []) + .filter { !$0.artifacts.results.isEmpty } + .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) } + } catch { + self.error = error.localizedDescription + } + + isLoadingArtifacts = false + } +} diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift new file mode 100644 index 0000000..6fcfafa --- /dev/null +++ b/Hutch/Views/Repositories/RepositoryListView.swift @@ -0,0 +1,224 @@ +import SwiftUI + +struct RepositoryListView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: RepositoryListViewModel? + @State private var searchTask: Task? + @State private var showCreateRepositorySheet = false + @State private var createdRepository: RepositorySummary? + + var body: some View { + Group { + if let viewModel { + listContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading repositories…") + } + } + .navigationTitle("Repositories") + .toolbar { + if viewModel != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + showCreateRepositorySheet = true + } label: { + Image(systemName: "plus") + } + } + } + } + .sheet(isPresented: $showCreateRepositorySheet) { + if let viewModel { + CreateRepositorySheet(viewModel: viewModel) { repository in + showCreateRepositorySheet = false + createdRepository = repository + } + } + } + .navigationDestination(isPresented: Binding( + get: { createdRepository != nil }, + set: { isPresented in + if !isPresented { + createdRepository = nil + } + } + )) { + if let createdRepository { + RepositoryDetailView(repository: createdRepository) { + viewModel?.removeRepository(id: createdRepository.id) + } + } + } + .task { + if viewModel == nil { + viewModel = RepositoryListViewModel(client: appState.client) + } + } + } + + @ViewBuilder + private func listContent(_ viewModel: RepositoryListViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.repositories) { repo in + NavigationLink(value: repo) { + RepositoryRowView(repository: repo) + } + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + .task { + await viewModel.loadMoreIfNeeded(currentItem: repo) + } + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories") + .overlay { + if viewModel.isLoading, viewModel.repositories.isEmpty { + SRHTLoadingStateView(message: "Loading repositories…") + } else if let error = viewModel.error, viewModel.repositories.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Repositories", + message: error, + retryAction: { await viewModel.loadRepositories() } + ) + } else if viewModel.repositories.isEmpty, viewModel.error == nil { + if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ContentUnavailableView( + "No Repositories", + systemImage: "book.closed", + description: Text("You don't have any repositories yet.") + ) + } else { + ContentUnavailableView.search + } + } + } + .connectivityOverlay(hasContent: !viewModel.repositories.isEmpty) { + await viewModel.loadRepositories() + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadRepositories() + } + .task { + await viewModel.loadRepositories() + } + .onChange(of: viewModel.searchText) { oldValue, newValue in + // Cancel previous search task + searchTask?.cancel() + + // Clear results immediately when search text is cleared + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + viewModel.resetSearch() + Task { + await viewModel.loadRepositories() + } + return + } + + // Debounce search to avoid excessive API calls + searchTask = Task { + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled else { return } + await viewModel.loadRepositories(search: newValue) + } + } + .navigationDestination(for: RepositorySummary.self) { repo in + RepositoryDetailView(repository: repo) { + viewModel.removeRepository(id: repo.id) + } + } + } +} + +private struct CreateRepositorySheet: View { + let viewModel: RepositoryListViewModel + let onCreated: (RepositorySummary) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var description = "" + @State private var cloneURL = "" + @State private var visibility: Visibility = .public + @State private var service: RepositoryCreationService = .git + + var body: some View { + NavigationStack { + Form { + Section("Repository Details") { + Picker("Version Control", selection: $service) { + ForEach(RepositoryCreationService.allCases) { service in + Text(service.displayName).tag(service) + } + } + TextField("Repository name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Short description (optional)", text: $description, axis: .vertical) + .lineLimit(2...4) + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) + } + } + + Section("Import Existing Repository") { + if service == .git { + TextField("Remote URL (optional)", text: $cloneURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + Text("Import an existing Git repository from a remote URL.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Text("Importing a Mercurial repository from a remote URL is not available through the public API.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle(service == .git ? "New Git Repository" : "New Mercurial Repository") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + if let repository = await viewModel.createRepository( + service: service, + name: name, + description: description, + visibility: visibility, + cloneURL: cloneURL + ) { + onCreated(repository) + } + } + } label: { + if viewModel.isCreatingRepository { + ProgressView() + .controlSize(.small) + } else { + Text("Create Repository") + } + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingRepository) + } + } + } + } +} diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift new file mode 100644 index 0000000..bba0108 --- /dev/null +++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift @@ -0,0 +1,548 @@ +import Foundation + +enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable { + case git + case hg + + var id: String { rawValue } + + var service: SRHTService { + switch self { + case .git: .git + case .hg: .hg + } + } + + var displayName: String { + switch self { + case .git: "Git" + case .hg: "Mercurial" + } + } +} + +/// View model for the repository list screen. +@Observable +@MainActor +final class RepositoryListViewModel { + + private(set) var repositories: [RepositorySummary] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var isRefreshing = false + var error: String? + + var searchText = "" + + private(set) var cursor: String? + private(set) var hasMore = false + private(set) var isSearching = false + private(set) var isCreatingRepository = false + private let client: SRHTClient + + private static let gitCacheKey = "git.repositories" + private static let hgCacheKey = "hg.repositories" + + init(client: SRHTClient) { + self.client = client + } + + // MARK: - Queries + + private static let gitQuery = """ + query repositories($cursor: Cursor, $filter: Filter) { + repositories(cursor: $cursor, filter: $filter) { + results { + id + rid + name + description + visibility + updated + owner { canonicalName } + HEAD { name } + } + cursor + } + } + """ + + private static let hgQuery = """ + query repositories($cursor: Cursor) { + repositories(cursor: $cursor) { + results { + id + rid + name + description + visibility + updated + owner { canonicalName } + tip { branch } + } + cursor + } + } + """ + + private static let createRepositoryMutation = """ + mutation createRepository($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) { + createRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + } + """ + + private static let createHgRepositoryMutation = """ + mutation createRepository($name: String!, $visibility: Visibility!, $description: String) { + createRepository(name: $name, visibility: $visibility, description: $description) { + id + rid + name + description + visibility + updated + owner { canonicalName } + tip { branch } + } + } + """ + + // MARK: - Public API + + /// Fetch the first page of repositories. Shows cached data instantly if available, + /// then refreshes from the network in the background. + /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`. + func loadRepositories(search: String? = nil) async { + let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines) + let isSearch = !query.isEmpty + + // Only use cache for non-search, initial loads + if !isSearch, repositories.isEmpty { + loadFromCache() + } + + // During search, never show the full-screen loading overlay (which + // would remove the List and dismiss the keyboard). Use "refreshing" + // instead so the list stays in the hierarchy. + if isSearch { + isRefreshing = true + isSearching = true + } else if repositories.isEmpty { + isLoading = true + isSearching = false + } else { + isRefreshing = true + isSearching = false + } + error = nil + cursor = nil + hasMore = false + + 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 + } + } else { + let repositories = try await fetchAllRepositories(useCache: true) + filteredResults = repositories + } + + repositories = filteredResults.sorted(by: repositorySortOrder) + } catch { + // Only show error if we have no cached data to fall back on + if repositories.isEmpty { + self.error = error.localizedDescription + } + } + + isLoading = false + isRefreshing = false + } + + /// Load the next page if available. Called when the user scrolls near the end. + /// Note: Pagination is disabled during search (client-side filtering). + func loadMoreIfNeeded(currentItem: RepositorySummary) async { + _ = currentItem + } + + /// Remove a repository from the local list (e.g. after deletion). + func removeRepository(id: Int) { + repositories.removeAll { $0.id == id } + } + + func createRepository( + service: RepositoryCreationService, + name: String, + description: String, + visibility: Visibility, + cloneURL: String + ) async -> RepositorySummary? { + guard !isCreatingRepository else { return nil } + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + error = "Enter a repository name." + return nil + } + + isCreatingRepository = true + error = nil + defer { isCreatingRepository = false } + + var variables: [String: any Sendable] = [ + "name": trimmedName, + "visibility": visibility.rawValue + ] + let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedDescription.isEmpty { + variables["description"] = trimmedDescription + } + let trimmedCloneURL = cloneURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedCloneURL.isEmpty { + variables["cloneUrl"] = trimmedCloneURL + } + + do { + let repository: RepositorySummary + switch service { + case .git: + let result = try await client.execute( + service: .git, + query: Self.createRepositoryMutation, + variables: variables, + responseType: CreateRepositoryResponse.self + ) + repository = result.createRepository + case .hg: + variables.removeValue(forKey: "cloneUrl") + let result = try await client.execute( + service: .hg, + query: Self.createHgRepositoryMutation, + variables: variables, + responseType: CreateHGRepositoryResponse.self + ) + repository = result.createRepository.repositorySummary(service: .hg) + } + repositories.insert(repository, at: 0) + return repository + } catch { + self.error = repositoryCreationErrorMessage(for: error) + return nil + } + } + + private func repositoryCreationErrorMessage(for error: Error) -> String { + let message: String + + if let srhtError = error as? SRHTError { + switch srhtError { + case .graphQLErrors(let errors): + message = errors.map(\.message).joined(separator: "\n") + default: + message = srhtError.localizedDescription + } + } else { + message = error.localizedDescription + } + + return "Couldn’t create the repository. \(message)" + } + + /// Fetch ALL repositories by paginating through all available pages. + /// Used for search functionality to ensure we search through the complete dataset. + private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] { + async let gitRepositories = fetchRepositories(for: .git, useCache: useCache) + async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache) + return try await gitRepositories + hgRepositories + } + + /// Reset search state and reload all repositories + func resetSearch() { + repositories = [] + cursor = nil + hasMore = false + isSearching = false + } + + // MARK: - Private + + /// Page shape matching the GraphQL response without generic constraints that + /// conflict with strict concurrency when used from a @MainActor context. + private struct Page: Decodable, Sendable { + let results: [RepositoryPayload] + let cursor: String? + } + + private struct RepositoriesResponse: Decodable, Sendable { + let repositories: Page? + } + + private struct CreateRepositoryResponse: Decodable, Sendable { + let createRepository: RepositorySummary + } + + private struct CreateHGRepositoryResponse: Decodable, Sendable { + let createRepository: HGRepositoryPayload + } + + private struct HGPage: Decodable, Sendable { + let results: [HGRepositoryPayload] + let cursor: String? + } + + private struct HGRepositoriesResponse: Decodable, Sendable { + let repositories: HGPage? + } + + private static let emptyPage = Page(results: [], cursor: nil) + + private struct RepositoryPayload: Decodable, Sendable { + let id: Int + let rid: String + let name: String + let description: String? + let visibility: Visibility + let updated: Date + let owner: Entity + let head: Reference? + + enum CodingKeys: String, CodingKey { + case id, rid, name, description, visibility, updated, owner + case head = "HEAD" + } + + func repositorySummary(service: SRHTService) -> RepositorySummary { + RepositorySummary( + id: id, + rid: rid, + service: service, + name: name, + description: description, + visibility: visibility, + updated: updated, + owner: owner, + head: head + ) + } + } + + private struct HGRepositoryPayload: Decodable, Sendable { + let id: Int + let rid: String + let name: String + let description: String? + let visibility: Visibility + let updated: Date + let owner: Entity + let tip: HGTipReference? + + func repositorySummary(service: SRHTService) -> RepositorySummary { + RepositorySummary( + id: id, + rid: rid, + service: service, + name: name, + description: description, + visibility: visibility, + updated: updated, + owner: owner, + head: tip.map { Reference(name: $0.branch, target: nil) } + ) + } + } + + private struct HGTipReference: Decodable, Sendable { + let branch: String + } + + private func fetchPage( + service: SRHTService, + cursor: String?, + search: String? = nil, + useCache: Bool + ) async throws -> Page { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + variables["filter"] = ["search": trimmed] as [String: any Sendable] + } + + if useCache && cursor == nil { + switch service { + case .git: + let result = try await client.executeAndCache( + service: service, + query: Self.gitQuery, + variables: variables.isEmpty ? nil : variables, + responseType: RepositoriesResponse.self, + cacheKey: cacheKey(for: service) + ) + return result.repositories ?? Self.emptyPage + case .hg: + let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } + let result = try await client.executeAndCache( + service: service, + query: Self.hgQuery, + variables: hgVariables, + responseType: HGRepositoriesResponse.self, + cacheKey: cacheKey(for: service) + ) + return Page( + results: result.repositories?.results.map { + RepositoryPayload( + id: $0.id, + rid: $0.rid, + name: $0.name, + description: $0.description, + visibility: $0.visibility, + updated: $0.updated, + owner: $0.owner, + head: $0.tip.map { Reference(name: $0.branch, target: nil) } + ) + } ?? [], + cursor: result.repositories?.cursor + ) + default: + let result = try await client.executeAndCache( + service: service, + query: Self.gitQuery, + variables: variables.isEmpty ? nil : variables, + responseType: RepositoriesResponse.self, + cacheKey: cacheKey(for: service) + ) + return result.repositories ?? Self.emptyPage + } + } else { + switch service { + case .git: + let result = try await client.execute( + service: service, + query: Self.gitQuery, + variables: variables.isEmpty ? nil : variables, + responseType: RepositoriesResponse.self + ) + return result.repositories ?? Self.emptyPage + case .hg: + let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } + let result = try await client.execute( + service: service, + query: Self.hgQuery, + variables: hgVariables, + responseType: HGRepositoriesResponse.self + ) + return Page( + results: result.repositories?.results.map { + RepositoryPayload( + id: $0.id, + rid: $0.rid, + name: $0.name, + description: $0.description, + visibility: $0.visibility, + updated: $0.updated, + owner: $0.owner, + head: $0.tip.map { Reference(name: $0.branch, target: nil) } + ) + } ?? [], + cursor: result.repositories?.cursor + ) + default: + let result = try await client.execute( + service: service, + query: Self.gitQuery, + variables: variables.isEmpty ? nil : variables, + responseType: RepositoriesResponse.self + ) + return result.repositories ?? Self.emptyPage + } + } + } + + private func loadFromCache() { + let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in + guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .srhtFlexible + switch service { + case .git: + if let response = try? decoder.decode( + GraphQLResponse.self, + from: data + ), let repos = response.data?.repositories { + return repos.results.map { $0.repositorySummary(service: service) } + } + case .hg: + if let response = try? decoder.decode( + GraphQLResponse.self, + from: data + ), let repos = response.data?.repositories { + return repos.results.map { $0.repositorySummary(service: service) } + } + default: + break + } + return [] + } + if !cachedRepositories.isEmpty { + repositories = cachedRepositories.sorted(by: repositorySortOrder) + } + } + + private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] { + var allRepositories: [RepositorySummary] = [] + var currentCursor: String? = nil + + while true { + let page = try await fetchPage( + service: service, + cursor: currentCursor, + search: nil, + useCache: useCache && currentCursor == nil + ) + allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) }) + guard let nextCursor = page.cursor else { break } + currentCursor = nextCursor + } + + return allRepositories + } + + private func cacheKey(for service: SRHTService) -> String { + switch service { + case .git: + Self.gitCacheKey + case .hg: + Self.hgCacheKey + default: + "\(service.rawValue).repositories" + } + } + + private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool { + if lhs.updated == rhs.updated { + if lhs.service == rhs.service { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return lhs.service.rawValue < rhs.service.rawValue + } + return lhs.updated > rhs.updated + } +} diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift new file mode 100644 index 0000000..9f8cd15 --- /dev/null +++ b/Hutch/Views/Repositories/RepositoryRowView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct RepositoryRowView: View { + let repository: RepositorySummary + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(repository.name) + .font(.headline) + + Spacer() + + if repository.service == .hg { + Text("HG") + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.cyan.opacity(0.15), in: Capsule()) + .foregroundStyle(.cyan) + } + + VisibilityBadge(visibility: repository.visibility) + } + + Text(repository.owner.canonicalName) + .font(.subheadline) + .foregroundStyle(.secondary) + + if let description = repository.description, + !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + HStack(spacing: 12) { + if let head = repository.head { + Label(head.name, systemImage: "arrow.triangle.branch") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(repository.updated.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + } +} + +// MARK: - VisibilityBadge + +struct VisibilityBadge: View { + let visibility: Visibility + + var body: some View { + Text(label) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.15), in: Capsule()) + .foregroundStyle(color) + } + + private var label: String { + switch visibility { + case .public: "PUBLIC" + case .unlisted: "UNLISTED" + case .private: "PRIVATE" + } + } + + private var color: Color { + switch visibility { + case .public: .green + case .unlisted: .orange + case .private: .red + } + } +} diff --git a/Hutch/Views/Repositories/RepositorySettingsView.swift b/Hutch/Views/Repositories/RepositorySettingsView.swift index 3db434c..5074138 100644 --- a/Hutch/Views/Repositories/RepositorySettingsView.swift +++ b/Hutch/Views/Repositories/RepositorySettingsView.swift @@ -10,6 +10,7 @@ struct RepositorySettingsView: View { @Environment(\.dismiss) private var dismiss @State private var viewModel: RepositorySettingsViewModel? @State private var showDeleteConfirmation = false + @State private var pendingACLDeletion: ACLEntry? var body: some View { NavigationStack { @@ -17,7 +18,7 @@ struct RepositorySettingsView: View { if let viewModel { settingsForm(viewModel) } else { - ProgressView() + SRHTLoadingStateView(message: "Loading settings…") } } .navigationTitle("Settings") @@ -51,13 +52,7 @@ struct RepositorySettingsView: View { accessSection(viewModel) deleteSection(viewModel) } - .alert("Error", isPresented: .constant(viewModel.error != nil)) { - Button("OK") { viewModel.error = nil } - } message: { - if let error = viewModel.error { - Text(error) - } - } + .srhtErrorBanner(error: $vm.error) .alert( "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?", isPresented: $showDeleteConfirmation @@ -75,6 +70,27 @@ struct RepositorySettingsView: View { } message: { Text("This cannot be undone.") } + .alert("Remove Access?", isPresented: Binding( + get: { pendingACLDeletion != nil }, + set: { isPresented in + if !isPresented { + pendingACLDeletion = nil + } + } + )) { + Button("Cancel", role: .cancel) {} + Button("Remove Access", role: .destructive) { + guard let entry = pendingACLDeletion else { return } + Task { + await viewModel.deleteACL(entry) + pendingACLDeletion = nil + } + } + } message: { + if let entry = pendingACLDeletion { + Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.") + } + } } // MARK: - Info Section @@ -82,6 +98,11 @@ struct RepositorySettingsView: View { @ViewBuilder private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View { Section("Info") { + LabeledContent("Name") { + Text(repository.name) + .font(.body.monospaced()) + } + TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) .lineLimit(3...6) @@ -107,7 +128,7 @@ struct RepositorySettingsView: View { ProgressView() .frame(maxWidth: .infinity) } else { - Text("Save") + Text("Save Changes") .frame(maxWidth: .infinity) } } @@ -120,7 +141,7 @@ struct RepositorySettingsView: View { @ViewBuilder private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View { Section { - TextField("Repository Name", text: Bindable(viewModel).editedName) + TextField("New repository name", text: Bindable(viewModel).editedName) .autocorrectionDisabled() .textInputAutocapitalization(.never) @@ -141,7 +162,7 @@ struct RepositorySettingsView: View { ProgressView() .frame(maxWidth: .infinity) } else { - Text("Rename") + Text("Rename Repository") .frame(maxWidth: .infinity) } } @@ -163,7 +184,7 @@ struct RepositorySettingsView: View { Spacer() } } else if viewModel.acls.isEmpty { - Text("No access control entries.") + Text("No access entries yet.") .foregroundStyle(.secondary) } else { ForEach(viewModel.acls) { entry in @@ -174,11 +195,11 @@ struct RepositorySettingsView: View { .font(.caption.monospaced()) .foregroundStyle(.secondary) } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { + .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { - Task { await viewModel.deleteACL(entry) } + pendingACLDeletion = entry } label: { - Label("Delete", systemImage: "trash") + Label("Remove Access", systemImage: "trash") } } } @@ -186,7 +207,7 @@ struct RepositorySettingsView: View { // Add ACL form HStack { - TextField("Username", text: Bindable(viewModel).newACLEntity) + TextField("Username or ~username", text: Bindable(viewModel).newACLEntity) .autocorrectionDisabled() .textInputAutocapitalization(.never) @@ -203,11 +224,14 @@ struct RepositorySettingsView: View { if viewModel.isAddingACL { ProgressView() } else { - Image(systemName: "plus.circle.fill") + Text("Add") } } .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + Text("Add a SourceHut user and choose read-only or read/write access.") + .font(.caption) + .foregroundStyle(.secondary) } header: { Text("Access") } diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift index bdae87f..d6f3300 100644 --- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift +++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift @@ -63,6 +63,7 @@ final class RepositorySettingsViewModel { let repositoryId: Int let repositoryRid: String + let service: SRHTService private let client: SRHTClient // MARK: - Info fields @@ -107,6 +108,7 @@ final class RepositorySettingsViewModel { ) { self.repositoryId = repository.id self.repositoryRid = repository.rid + self.service = repository.service self.client = client self.editedDescription = repository.description ?? "" self.editedVisibility = repository.visibility @@ -143,7 +145,7 @@ final class RepositorySettingsViewModel { "HEAD": editedHead ] _ = try await client.execute( - service: .git, + service: service, query: Self.updateRepoMutation, variables: ["id": repositoryId, "input": input], responseType: UpdateRepoResponse.self @@ -165,7 +167,7 @@ final class RepositorySettingsViewModel { "name": editedName ] let result = try await client.execute( - service: .git, + service: service, query: Self.updateRepoMutation, variables: ["id": repositoryId, "input": input], responseType: UpdateRepoResponse.self @@ -207,6 +209,16 @@ final class RepositorySettingsViewModel { } """ + private static let userLookupQuery = """ + query userLookup($username: String!) { + user(username: $username) { + id + username + canonicalName + } + } + """ + func loadACLs() async { guard !isLoadingACLs else { return } isLoadingACLs = true @@ -214,7 +226,7 @@ final class RepositorySettingsViewModel { do { let result = try await client.execute( - service: .git, + service: service, query: Self.aclsQuery, variables: ["rid": repositoryRid], responseType: ACLResponse.self @@ -234,7 +246,7 @@ final class RepositorySettingsViewModel { do { let result = try await client.execute( - service: .git, + service: service, query: Self.updateACLMutation, variables: [ "repoId": repositoryId, @@ -262,7 +274,7 @@ final class RepositorySettingsViewModel { do { _ = try await client.execute( - service: .git, + service: service, query: Self.deleteACLMutation, variables: ["id": entry.id], responseType: DeleteACLResponse.self @@ -288,7 +300,7 @@ final class RepositorySettingsViewModel { do { _ = try await client.execute( - service: .git, + service: service, query: Self.deleteRepoMutation, variables: ["id": repositoryId], responseType: DeleteRepoResponse.self diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift index 8ad3659..a2cf699 100644 --- a/Hutch/Views/Repositories/RepositorySummarySupport.swift +++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift @@ -39,31 +39,30 @@ func repositoryVisibilityLabel(_ visibility: Visibility) -> String { } } -struct RepositorySummaryCard: View { +struct SummaryMetadataRow: View { + let icon: String let title: String - @ViewBuilder let content: Content - - init(_ title: String, @ViewBuilder content: () -> Content) { - self.title = title - self.content = content() - } + var subtitle: String? = nil var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(title) - .font(.caption.weight(.semibold)) + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) .foregroundStyle(.secondary) - .textCase(.uppercase) + .frame(width: 18) - content + VStack(alignment: .leading, spacing: 2) { + Text(title) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } } -struct RepositorySummaryField: View { +struct SummaryDetailRow: View { let label: String let value: String var monospace: Bool = false @@ -79,23 +78,3 @@ struct RepositorySummaryField: View { } } } - -struct RepositorySummaryListRow: View { - let label: String - let values: [String] - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - - if values.isEmpty { - Text("None") - .foregroundStyle(.tertiary) - } else { - Text(values.joined(separator: ", ")) - } - } - } -} diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..5bbded8 --- /dev/null +++ b/Hutch/Views/Settings/SettingsView.swift @@ -0,0 +1,724 @@ +import PhotosUI +import SwiftUI + +struct SettingsView: View { + @Environment(AppState.self) private var appState + @Environment(\.colorScheme) private var colorScheme + @State private var viewModel: SettingsViewModel? + @State private var pendingDestructiveAction: SettingsDestructiveAction? + + var body: some View { + NavigationStack { + Group { + if let viewModel { + settingsContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading profile…") + } + } + .navigationTitle("Settings") + .task { + if viewModel == nil { + let vm = SettingsViewModel(client: appState.client) + viewModel = vm + await vm.loadProfile() + } + } + } + } + + @ViewBuilder + private func settingsContent(_ viewModel: SettingsViewModel) -> some View { + @Bindable var vm = viewModel + + Form { + if let profile = viewModel.profile { + // Profile section + profileSection(profile, viewModel: viewModel) + + // SSH Keys + sshKeysSection(viewModel) + + // PGP Keys + pgpKeysSection(viewModel) + + // Personal Access Tokens + patSection(viewModel) + } + + // Token / Sign Out + tokenSection() + + aboutSection() + } + .overlay { + if viewModel.isLoading, viewModel.profile == nil { + SRHTLoadingStateView(message: "Loading profile…") + } else if let error = viewModel.error, viewModel.profile == nil { + SRHTErrorStateView( + title: "Couldn't Load Profile", + message: error, + retryAction: { await viewModel.loadProfile() } + ) + } + } + .sheet(isPresented: $vm.isEditingProfile) { + if let profile = viewModel.profile { + EditProfileSheet( + profile: profile, + viewModel: viewModel + ) + } + } + .alert("Error", isPresented: Binding( + get: { viewModel.error != nil && viewModel.profile != nil }, + set: { isPresented in + if !isPresented { + viewModel.error = nil + } + } + )) { + Button("OK") { viewModel.error = nil } + } message: { + if let error = viewModel.error { + Text(error) + } + } + .alert( + pendingDestructiveAction?.title ?? "", + isPresented: Binding( + get: { pendingDestructiveAction != nil }, + set: { isPresented in + if !isPresented { + pendingDestructiveAction = nil + } + } + ) + ) { + Button("Cancel", role: .cancel) {} + Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) { + guard let action = pendingDestructiveAction else { return } + pendingDestructiveAction = nil + Task { + switch action { + case .resetAppData: + await appState.resetAppData() + case .signOut: + await appState.signOut() + case .deleteSSHKey(let key): + await viewModel.deleteSSHKey(key) + case .deletePGPKey(let key): + await viewModel.deletePGPKey(key) + } + } + } + } message: { + if let pendingDestructiveAction { + Text(pendingDestructiveAction.message) + } + } + .refreshable { + await viewModel.loadProfile() + } + } + + // MARK: - Profile Section + + @ViewBuilder + private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View { + Section("Profile") { + HStack(spacing: 12) { + AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + Image(systemName: "person.crop.circle.fill") + .resizable() + .foregroundStyle(.secondary) + } + } + .frame(width: 56, height: 56) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(profile.canonicalName) + .font(.headline) + Text(profile.email) + .font(.subheadline) + .foregroundStyle(.secondary) + if let userType = profile.userType { + Text(userType.capitalized) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + .padding(.vertical, 4) + + if let bio = profile.bio, !bio.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Bio") + .font(.caption) + .foregroundStyle(.secondary) + RenderedMarkupContentView( + content: .markdown(bio), + readmePath: nil, + colorScheme: colorScheme, + ownerCanonicalName: "", + repositoryName: "" + ) + } + } + + if let location = profile.location, !location.isEmpty { + LabeledContent("Location", value: location) + } + + if let url = profile.url, !url.isEmpty { + LabeledContent("URL", value: url) + } + + if let status = profile.paymentStatus { + LabeledContent("Payment", value: status.capitalized) + } + + if let sub = profile.subscription { + if let status = sub.status { + LabeledContent("Subscription", value: status.capitalized) + } + if let interval = sub.interval { + LabeledContent("Interval", value: interval.capitalized) + } + } + + Button("Edit Profile") { + viewModel.isEditingProfile = true + } + + SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) { + SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up") + } + } + } + + // MARK: - SSH Keys Section + + @ViewBuilder + private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View { + @Bindable var vm = viewModel + + Section { + ForEach(viewModel.sshKeys) { key in + VStack(alignment: .leading, spacing: 2) { + Text(key.fingerprint) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + + HStack { + if let comment = key.comment, !comment.isEmpty { + Text(comment) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + Text(key.created.relativeDescription) + .font(.caption2) + .foregroundStyle(.tertiary) + } + + if let lastUsed = key.lastUsed { + Text("Last used \(lastUsed.relativeDescription)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button("Delete", role: .destructive) { + pendingDestructiveAction = .deleteSSHKey(key) + } + } + } + + if viewModel.isAddingSSHKey { + TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical) + .font(.caption.monospaced()) + .lineLimit(3...6) + + HStack { + Button("Cancel") { + viewModel.isAddingSSHKey = false + viewModel.newSSHKey = "" + } + Spacer() + Button("Add") { + Task { await viewModel.addSSHKey() } + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } else { + Button { + viewModel.isAddingSSHKey = true + } label: { + SwiftUI.Label("Add SSH Key", systemImage: "key") + } + } + } header: { + Text("SSH Keys") + } footer: { + Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")") + } + } + + // MARK: - PGP Keys Section + + @ViewBuilder + private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View { + @Bindable var vm = viewModel + + Section { + ForEach(viewModel.pgpKeys) { key in + VStack(alignment: .leading, spacing: 2) { + Text(key.fingerprint) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + + Text(key.created.relativeDescription) + .font(.caption2) + .foregroundStyle(.tertiary) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button("Delete", role: .destructive) { + pendingDestructiveAction = .deletePGPKey(key) + } + } + } + + if viewModel.isAddingPGPKey { + TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical) + .font(.caption.monospaced()) + .lineLimit(3...6) + + HStack { + Button("Cancel") { + viewModel.isAddingPGPKey = false + viewModel.newPGPKey = "" + } + Spacer() + Button("Add") { + Task { await viewModel.addPGPKey() } + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } else { + Button { + viewModel.isAddingPGPKey = true + } label: { + SwiftUI.Label("Add PGP Key", systemImage: "key.fill") + } + } + } header: { + Text("PGP Keys") + } footer: { + Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")") + } + } + + // MARK: - Personal Access Tokens Section + + @ViewBuilder + private func patSection(_ viewModel: SettingsViewModel) -> some View { + Section { + if viewModel.isLoadingPATs { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if viewModel.personalAccessTokens.isEmpty { + Button("Load Tokens") { + Task { await viewModel.loadPersonalAccessTokens() } + } + } else { + ForEach(viewModel.personalAccessTokens) { token in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(token.comment ?? "Token #\(token.id)") + .font(.subheadline) + Spacer() + } + + HStack(spacing: 12) { + Text("Issued \(token.issued.relativeDescription)") + .font(.caption2) + .foregroundStyle(.secondary) + + if let expires = token.expires { + Text("Expires \(expires.relativeDescription)") + .font(.caption2) + .foregroundStyle(expires < Date.now ? .red : .secondary) + } + } + + if let grants = token.grants, !grants.isEmpty { + Text(grants) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .lineLimit(2) + } + } + } + } + } header: { + Text("Personal Access Tokens") + } footer: { + if !viewModel.personalAccessTokens.isEmpty { + Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")") + } + } + } + + // MARK: - Token / Sign Out Section + + @ViewBuilder + private func tokenSection() -> some View { + Section { + HStack { + Image(systemName: "key.fill") + .foregroundStyle(.secondary) + Text("Personal access token in use") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + + Button("Reset App Data", role: .destructive) { + pendingDestructiveAction = .resetAppData + } + + Button("Sign Out", role: .destructive) { + pendingDestructiveAction = .signOut + } + } header: { + Text("Authentication") + } footer: { + Text("Hutch stores your SourceHut token in the iOS keychain. Reset App Data removes saved token data, local settings, cached responses, cookies, and embedded web data on this device.") + } + } + + @ViewBuilder + private func aboutSection() -> some View { + Section("App") { + NavigationLink { + AboutView() + } label: { + SwiftUI.Label("About Hutch", systemImage: "info.circle") + } + } + } +} + +// MARK: - Edit Profile Sheet + +private struct EditProfileSheet: View { + let profile: UserProfile + let viewModel: SettingsViewModel + + @State private var email: String + @State private var url: String + @State private var location: String + @State private var bio: String + @State private var selectedPhoto: PhotosPickerItem? + @State private var avatarPreview: UIImage? + @State private var isShowingRemoveAvatarConfirmation = false + + @Environment(\.dismiss) private var dismiss + + init(profile: UserProfile, viewModel: SettingsViewModel) { + self.profile = profile + self.viewModel = viewModel + _email = State(initialValue: profile.email) + _url = State(initialValue: profile.url ?? "") + _location = State(initialValue: profile.location ?? "") + _bio = State(initialValue: profile.bio ?? "") + } + + var body: some View { + NavigationStack { + Form { + Section { + HStack { + Spacer() + VStack(spacing: 8) { + PhotosPicker(selection: $selectedPhoto, matching: .images) { + Group { + if let avatarPreview { + Image(uiImage: avatarPreview) + .resizable() + .scaledToFill() + } else { + AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + Image(systemName: "person.crop.circle.fill") + .resizable() + .foregroundStyle(.secondary) + } + } + } + } + .frame(width: 80, height: 80) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(.secondary.opacity(0.3), lineWidth: 1) + ) + } + + Text("Tap to change avatar") + .font(.caption) + .foregroundStyle(.secondary) + + if viewModel.isUploadingAvatar { + ProgressView() + .controlSize(.small) + } + } + Spacer() + } + .listRowBackground(Color.clear) + + if profile.avatar != nil || avatarPreview != nil { + HStack { + Spacer() + Button(role: .destructive) { + isShowingRemoveAvatarConfirmation = true + } label: { + Text("Remove Avatar") + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isUploadingAvatar) + Spacer() + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + + Section("Edit Profile") { + VStack(alignment: .leading, spacing: 4) { + Text("Email") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Enter email", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + VStack(alignment: .leading, spacing: 4) { + Text("URL") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Enter URL", text: $url) + .textContentType(.URL) + .keyboardType(.URL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Location") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Enter location", text: $location) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Bio") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Enter bio", text: $bio, axis: .vertical) + .lineLimit(3...6) + } + } + } + .navigationTitle("Edit Profile") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: selectedPhoto) { _, newItem in + guard let newItem else { return } + Task { + if let data = try? await newItem.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + avatarPreview = image + // Encode as JPEG and upload + if let jpegData = image.jpegData(compressionQuality: 0.85) { + await viewModel.uploadAvatar(jpegData: jpegData) + } + } + } + } + .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Remove Avatar", role: .destructive) { + Task { + await viewModel.removeAvatar() + if viewModel.error == nil { + avatarPreview = nil + selectedPhoto = nil + } + } + } + } message: { + Text("Your profile avatar will be removed from SourceHut.") + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await viewModel.saveProfile( + email: email, + url: url, + location: location, + bio: bio + ) + if viewModel.error == nil { + dismiss() + } + } + } label: { + if viewModel.isSavingProfile { + ProgressView() + .controlSize(.small) + } else { + Text("Save") + } + } + .disabled(viewModel.isSavingProfile) + } + } + } + } +} + +private enum SettingsDestructiveAction { + case resetAppData + case signOut + case deleteSSHKey(SSHKey) + case deletePGPKey(PGPKey) + + var title: String { + switch self { + case .resetAppData: + "Reset App Data?" + case .signOut: + "Sign Out?" + case .deleteSSHKey: + "Remove SSH Key?" + case .deletePGPKey: + "Remove PGP Key?" + } + } + + var confirmationLabel: String { + switch self { + case .resetAppData: + "Reset App Data" + case .signOut: + "Sign Out" + case .deleteSSHKey: + "Remove SSH Key" + case .deletePGPKey: + "Remove PGP Key" + } + } + + var message: String { + switch self { + case .resetAppData: + "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device." + case .signOut: + "This signs you out of Hutch and clears saved authentication state on this device." + case .deleteSSHKey(let key): + "Remove SSH key \(key.fingerprint) from your account?" + case .deletePGPKey(let key): + "Remove PGP key \(key.fingerprint) from your account?" + } + } +} + +private struct AboutView: View { + private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "Hutch" + private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? "Unknown" + private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + ?? "Unknown" + + var body: some View { + Form { + Section { + VStack(alignment: .leading, spacing: 6) { + Text(appName) + .font(.title2.weight(.semibold)) + Text("A native SourceHut client for iPhone.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + + LabeledContent("Version", value: version) + LabeledContent("Build", value: build) + } + + Section("Links") { + Link(destination: URL(string: "https://sr.ht")!) { + SwiftUI.Label("SourceHut", systemImage: "link") + } + Link(destination: URL(string: "https://man.sr.ht")!) { + SwiftUI.Label("SourceHut Manuals", systemImage: "book") + } + Link(destination: URL(string: "https://git.sr.ht/~ccleberg/Hutch")!) { + SwiftUI.Label("Project Repository", systemImage: "folder") + } + } + + Section("Support") { + Link(destination: URL(string: "mailto:hello@cleberg.net")!) { + SwiftUI.Label("Email Support", systemImage: "envelope") + } + } + + Section("Privacy") { + Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Section("Acknowledgements") { + Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iPhone.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .navigationTitle("About") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..204afbf --- /dev/null +++ b/Hutch/Views/Settings/SettingsViewModel.swift @@ -0,0 +1,394 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct MeProfileResponse: Decodable, Sendable { + let me: UserProfile +} + +private struct UpdateUserResponse: Decodable, Sendable { + let updateUser: UpdatedUser +} + +private struct UpdatedUser: Decodable, Sendable { + let username: String + let email: String + let url: String? + let location: String? + let bio: String? + let avatar: String? +} + +private struct CreateSSHKeyResponse: Decodable, Sendable { + let createSSHKey: SSHKey +} + +private struct DeleteSSHKeyResponse: Decodable, Sendable { + let deleteSSHKey: DeleteResult +} + +private struct CreatePGPKeyResponse: Decodable, Sendable { + let createPGPKey: PGPKey +} + +private struct DeletePGPKeyResponse: Decodable, Sendable { + let deletePGPKey: DeleteResult +} + +private struct DeleteResult: Decodable, Sendable { + let id: Int? +} + +private struct PATListResponse: Decodable, Sendable { + let personalAccessTokens: [PersonalAccessToken] +} + +// MARK: - View Model + +@Observable +@MainActor +final class SettingsViewModel { + + private(set) var profile: UserProfile? + private(set) var sshKeys: [SSHKey] = [] + private(set) var pgpKeys: [PGPKey] = [] + private(set) var personalAccessTokens: [PersonalAccessToken] = [] + + private(set) var isLoading = false + private(set) var isLoadingPATs = false + private(set) var isSavingProfile = false + private(set) var isUploadingAvatar = false + var error: String? + + var isEditingProfile = false + + // Add SSH key fields + var newSSHKey = "" + var isAddingSSHKey = false + + // Add PGP key fields + var newPGPKey = "" + var isAddingPGPKey = false + + private let client: SRHTClient + + init(client: SRHTClient) { + self.client = client + } + + // MARK: - Queries + + private static let profileQuery = """ + query me { + me { + username + canonicalName + email + url + location + bio + avatar + userType + sshKeys { + results { id fingerprint comment created lastUsed } + cursor + } + pgpKeys { + results { id fingerprint created } + cursor + } + paymentStatus + subscription { status autorenew interval } + } + } + """ + + private static let updateUserMutation = """ + mutation updateUser($input: UserInput!) { + updateUser(input: $input) { + username email url location bio avatar + } + } + """ + + private static let createSSHKeyMutation = """ + mutation createSSHKey($key: String!) { + createSSHKey(key: $key) { + id fingerprint comment created lastUsed + } + } + """ + + private static let deleteSSHKeyMutation = """ + mutation deleteSSHKey($id: Int!) { + deleteSSHKey(id: $id) { id } + } + """ + + private static let createPGPKeyMutation = """ + mutation createPGPKey($key: String!) { + createPGPKey(key: $key) { + id fingerprint created + } + } + """ + + private static let deletePGPKeyMutation = """ + mutation deletePGPKey($id: Int!) { + deletePGPKey(id: $id) { id } + } + """ + + private static let personalAccessTokensQuery = """ + query personalAccessTokens { + personalAccessTokens { id issued expires comment grants } + } + """ + + // MARK: - Load Profile + + func loadProfile() async { + guard !isLoading else { return } + isLoading = true + error = nil + + do { + let result = try await client.execute( + service: .meta, + query: Self.profileQuery, + responseType: MeProfileResponse.self + ) + profile = result.me + sshKeys = result.me.sshKeys.results + pgpKeys = result.me.pgpKeys.results + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + // MARK: - Update Profile + + func saveProfile(email: String, url: String, location: String, bio: String) async { + guard !isSavingProfile else { return } + isSavingProfile = true + error = nil + + do { + let input: [String: any Sendable] = [ + "email": email, + "url": url.isEmpty ? nil as String? as Any : url, + "location": location.isEmpty ? nil as String? as Any : location, + "bio": bio.isEmpty ? nil as String? as Any : bio + ] + let result = try await client.execute( + service: .meta, + query: Self.updateUserMutation, + variables: ["input": input], + responseType: UpdateUserResponse.self + ) + let updated = result.updateUser + if let p = profile { + profile = UserProfile( + username: p.username, + canonicalName: p.canonicalName, + email: updated.email, + url: updated.url, + location: updated.location, + bio: updated.bio, + avatar: updated.avatar ?? p.avatar, + userType: p.userType, + sshKeys: p.sshKeys, + pgpKeys: p.pgpKeys, + paymentStatus: p.paymentStatus, + subscription: p.subscription + ) + } + isEditingProfile = false + } catch { + self.error = error.localizedDescription + } + + isSavingProfile = false + } + + // MARK: - Avatar + + func uploadAvatar(jpegData: Data) async { + guard !isUploadingAvatar else { return } + isUploadingAvatar = true + error = nil + + do { + // The input variable has avatar set to null; the actual file + // is sent as a separate multipart part per graphql-multipart-request-spec. + let input: [String: any Sendable] = ["avatar": nil as String? as Any] + let result = try await client.executeMultipart( + service: .meta, + query: Self.updateUserMutation, + variables: ["input": input], + fileVariablePath: "input.avatar", + fileData: jpegData, + fileName: "avatar.jpg", + mimeType: "image/jpeg", + responseType: UpdateUserResponse.self + ) + let updated = result.updateUser + if let p = profile { + profile = UserProfile( + username: p.username, + canonicalName: p.canonicalName, + email: updated.email, + url: updated.url, + location: updated.location, + bio: updated.bio, + avatar: updated.avatar ?? p.avatar, + userType: p.userType, + sshKeys: p.sshKeys, + pgpKeys: p.pgpKeys, + paymentStatus: p.paymentStatus, + subscription: p.subscription + ) + } + } catch { + self.error = error.localizedDescription + } + + isUploadingAvatar = false + } + + func removeAvatar() async { + guard !isUploadingAvatar else { return } + isUploadingAvatar = true + error = nil + + do { + let input: [String: any Sendable] = ["avatar": nil as String? as Any] + let result = try await client.execute( + service: .meta, + query: Self.updateUserMutation, + variables: ["input": input], + responseType: UpdateUserResponse.self + ) + let updated = result.updateUser + if let p = profile { + profile = UserProfile( + username: p.username, + canonicalName: p.canonicalName, + email: updated.email, + url: updated.url, + location: updated.location, + bio: updated.bio, + avatar: nil, + userType: p.userType, + sshKeys: p.sshKeys, + pgpKeys: p.pgpKeys, + paymentStatus: p.paymentStatus, + subscription: p.subscription + ) + } + } catch { + self.error = error.localizedDescription + } + + isUploadingAvatar = false + } + + // MARK: - SSH Keys + + func addSSHKey() async { + let key = newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + error = nil + + do { + let result = try await client.execute( + service: .meta, + query: Self.createSSHKeyMutation, + variables: ["key": key], + responseType: CreateSSHKeyResponse.self + ) + sshKeys.append(result.createSSHKey) + newSSHKey = "" + isAddingSSHKey = false + } catch { + self.error = error.localizedDescription + } + } + + func deleteSSHKey(_ key: SSHKey) async { + error = nil + + do { + _ = try await client.execute( + service: .meta, + query: Self.deleteSSHKeyMutation, + variables: ["id": key.id], + responseType: DeleteSSHKeyResponse.self + ) + sshKeys.removeAll { $0.id == key.id } + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - PGP Keys + + func addPGPKey() async { + let key = newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + error = nil + + do { + let result = try await client.execute( + service: .meta, + query: Self.createPGPKeyMutation, + variables: ["key": key], + responseType: CreatePGPKeyResponse.self + ) + pgpKeys.append(result.createPGPKey) + newPGPKey = "" + isAddingPGPKey = false + } catch { + self.error = error.localizedDescription + } + } + + func deletePGPKey(_ key: PGPKey) async { + error = nil + + do { + _ = try await client.execute( + service: .meta, + query: Self.deletePGPKeyMutation, + variables: ["id": key.id], + responseType: DeletePGPKeyResponse.self + ) + pgpKeys.removeAll { $0.id == key.id } + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Personal Access Tokens + + func loadPersonalAccessTokens() async { + guard !isLoadingPATs else { return } + isLoadingPATs = true + + do { + let result = try await client.execute( + service: .meta, + query: Self.personalAccessTokensQuery, + responseType: PATListResponse.self + ) + personalAccessTokens = result.personalAccessTokens + } catch { + self.error = error.localizedDescription + } + + isLoadingPATs = false + } + +} diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift new file mode 100644 index 0000000..6b2be4f --- /dev/null +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -0,0 +1,828 @@ +import SwiftUI +import WebKit + +struct TicketDetailView: View { + let ownerUsername: String + let trackerName: String + let trackerId: Int + let trackerRid: String + let ticketId: Int + + @Environment(AppState.self) private var appState + @Environment(\.colorScheme) private var colorScheme + @State private var viewModel: TicketDetailViewModel? + + // Sheet state + @State private var showResolveSheet = false + @State private var showAssignSheet = false + @State private var showLabelsSheet = false + + // Comment composer mode + @State private var commentMode: CommentMode = .write + + private enum CommentMode: String, CaseIterable { + case write = "Write" + case preview = "Preview" + } + + var body: some View { + Group { + if let viewModel { + detailContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading ticket…") + } + } + .navigationTitle("#\(ticketId)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + SRHTShareButton(url: SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId), target: .ticket) { + Image(systemName: "square.and.arrow.up") + } + + if let viewModel, viewModel.ticket != nil { + actionsMenu(viewModel) + } + } + } + .task { + if viewModel == nil { + let vm = TicketDetailViewModel( + ownerUsername: ownerUsername, + trackerName: trackerName, + trackerId: trackerId, + trackerRid: trackerRid, + ticketId: ticketId, + client: appState.client + ) + viewModel = vm + await vm.loadTicket() + } + } + } + + // MARK: - Actions Menu + + @ViewBuilder + private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View { + Menu { + if let ticket = viewModel.ticket { + if ticket.status == .resolved { + Button { + Task { + await viewModel.updateStatus( + status: .reported, + resolution: .unresolved + ) + } + } label: { + SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward") + } + } else { + Button { + showResolveSheet = true + } label: { + SwiftUI.Label("Resolve", systemImage: "checkmark.circle") + } + } + } + + Button { + showAssignSheet = true + } label: { + SwiftUI.Label("Manage Assignees", systemImage: "person.badge.plus") + } + + Button { + showLabelsSheet = true + Task { await viewModel.loadTrackerLabels() } + } label: { + SwiftUI.Label("Manage Labels", systemImage: "tag") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .sheet(isPresented: $showResolveSheet) { + ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet) + .presentationDetents([.medium]) + } + .sheet(isPresented: $showAssignSheet) { + AssignSheet(viewModel: viewModel, isPresented: $showAssignSheet) + .presentationDetents([.medium]) + } + .sheet(isPresented: $showLabelsSheet) { + LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet) + .presentationDetents([.medium]) + } + } + + // MARK: - Detail Content + + @ViewBuilder + private func detailContent(_ viewModel: TicketDetailViewModel) -> some View { + @Bindable var vm = viewModel + + if viewModel.isLoading, viewModel.ticket == nil { + SRHTLoadingStateView(message: "Loading ticket…") + } else if let error = viewModel.error, viewModel.ticket == nil { + SRHTErrorStateView( + title: "Couldn't Load Ticket", + message: error, + retryAction: { await viewModel.loadTicket() } + ) + } else if let ticket = viewModel.ticket { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // Header + ticketHeader(ticket) + + Divider() + .padding(.vertical, 12) + + // Description + if let description = ticket.description, !description.isEmpty { + MarkdownContentView(markdown: description) + .padding(.horizontal) + .padding(.bottom, 16) + + Divider() + .padding(.bottom, 12) + } + + // Event timeline + if !viewModel.events.isEmpty { + Text("Activity") + .font(.headline) + .padding(.horizontal) + .padding(.bottom, 8) + + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(viewModel.events) { event in + EventRow( + event: event, + ticketSubmitter: viewModel.ticket?.submitter.canonicalName, + ticketAssignees: viewModel.ticket?.assignees.map { $0.canonicalName } + ) + if event.id != viewModel.events.last?.id { + Divider() + .padding(.leading, 40) + } + } + } + + Divider() + .padding(.vertical, 12) + } + + // Comment input + commentInput(viewModel) + } + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadTicket() + } + } + } + + // MARK: - Header + + @ViewBuilder + private func ticketHeader(_ ticket: TicketDetail) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(ticket.title) + .font(.title3.weight(.semibold)) + + HStack(spacing: 8) { + TicketStatusIcon(status: ticket.status) + Text(ticket.status.displayName) + .font(.subheadline.weight(.medium)) + + if ticket.status == .resolved, let resolution = ticket.resolution { + Text("(\(resolution.displayName))") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + HStack(spacing: 4) { + Text("Opened by") + .foregroundStyle(.secondary) + Text(ticket.submitter.canonicalName) + .fontWeight(.medium) + Text(ticket.created.relativeDescription) + .foregroundStyle(.tertiary) + } + .font(.caption) + + if !ticket.assignees.isEmpty { + HStack(spacing: 4) { + Image(systemName: "person.fill") + .font(.caption2) + .foregroundStyle(.secondary) + Text(ticket.assignees.map(\.canonicalName).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if !ticket.labels.isEmpty { + FlowLayout(spacing: 4) { + ForEach(ticket.labels) { label in + LabelPill(label: label) + } + } + } + } + .padding() + } + + // MARK: - Comment Input + + @ViewBuilder + private func commentInput(_ viewModel: TicketDetailViewModel) -> some View { + @Bindable var vm = viewModel + + VStack(alignment: .leading, spacing: 8) { + Text("New Comment") + .font(.headline) + + Picker("Mode", selection: $commentMode) { + ForEach(CommentMode.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + + if commentMode == .write { + TextField("Write your comment…", text: $vm.commentText, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(3...8) + } else { + // Markdown preview + if viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Nothing to preview") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 80, alignment: .center) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + MarkdownContentView(markdown: viewModel.commentText) + .frame(minHeight: 80, maxHeight: 200) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + + HStack { + Spacer() + Button { + Task { await viewModel.submitComment() } + } label: { + if viewModel.isSubmitting { + ProgressView() + .controlSize(.small) + } else { + Text("Post Comment") + } + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting) + } + } + .padding() + } +} + +// MARK: - Self-Sizing Markdown Web View + +/// Renders markdown as HTML in a WKWebView that auto-sizes its height to +/// fit the rendered content. Reuses the same `markdownToHTML` converter and +/// styling as the README renderer. +private struct MarkdownContentView: View { + let markdown: String + @Environment(\.colorScheme) private var colorScheme + @State private var renderedHTML: String? + + var body: some View { + Group { + if let renderedHTML { + HTMLWebView( + html: renderedHTML, + colorScheme: colorScheme, + style: .commentPreview + ) + } else { + SRHTLoadingStateView(message: "Preparing content…") + .frame(minHeight: 80) + } + } + .task(id: markdown) { + if renderedHTML != nil { + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled else { return } + } + + let html = await Task.detached(priority: .userInitiated) { + markdownToHTML(markdown) + }.value + guard !Task.isCancelled else { return } + renderedHTML = html + } + } +} + +// MARK: - Event Row + +private struct EventRow: View { + let event: TicketEvent + let ticketSubmitter: String? + let ticketAssignees: [String]? + @State private var isShowingSystemStatusInfo = false + + var body: some View { + ForEach(event.changes) { change in + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon(for: change)) + .foregroundStyle(color(for: change)) + .frame(width: 24) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 4) { + HStack { + if kind(for: change) == .comment { + Text(change.author?.canonicalName ?? "") + .font(.subheadline.weight(.medium)) + } else { + let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees) + if descriptionText.hasPrefix("System") { + HStack(spacing: 4) { + Text(descriptionText) + .font(.subheadline) + .foregroundStyle(.secondary) + Button { + isShowingSystemStatusInfo = true + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.gray) + } + .buttonStyle(.plain) + } + } else { + Text(descriptionText) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + Text(event.created.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + } + if kind(for: change) == .comment, let text = change.text { + MarkdownContentView(markdown: text) + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) { + Button("OK", role: .cancel) {} + } message: { + Text("This status change was recorded automatically or without a named user attached to the event.") + } + } + } + + private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String { + let eventKind = kind(for: change) + let authorName: String + + if let commentAuthor = event.changes.first(where: { + kind(for: $0) == .comment && $0.author != nil + })?.author?.canonicalName { + authorName = commentAuthor + } else { + switch eventKind { + case .created: + authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone" + case .statusChange: + authorName = "System" + case .labelAdded, .labelRemoved, .labelUpdated: + authorName = change.labeler?.canonicalName ?? "Someone" + case .assigned, .unassigned: + authorName = change.assigner?.canonicalName ?? "Someone" + case .comment: + authorName = change.author?.canonicalName ?? "Someone" + case .ticketMention, .userMention: + authorName = change.author?.canonicalName + ?? change.assigner?.canonicalName + ?? change.labeler?.canonicalName + ?? "Someone" + case .unknown: + authorName = change.author?.canonicalName + ?? change.assigner?.canonicalName + ?? change.labeler?.canonicalName + ?? change.assignee?.canonicalName + ?? change.mentioned?.canonicalName + ?? ticketAssignees?.first + ?? "Someone" + } + } + + switch eventKind { + case .statusChange: + let oldStatus = change.oldStatus?.displayName ?? "unknown" + let newStatus = change.newStatus?.displayName ?? "unknown" + return "\(authorName) changed status from \(oldStatus) to \(newStatus)" + case .labelUpdated, .labelAdded: + let labelName = change.label?.name ?? "a label" + let verb = eventKind == .labelAdded ? "added" : "updated" + return "\(authorName) \(verb) label \"\(labelName)\"" + case .labelRemoved: + let labelName = change.label?.name ?? "a label" + return "\(authorName) removed label \"\(labelName)\"" + case .assigned: + let assigneeName = change.assignee?.canonicalName ?? "someone" + return "\(authorName) assigned \(assigneeName)" + case .unassigned: + let assigneeName = change.assignee?.canonicalName ?? "someone" + return "\(authorName) unassigned \(assigneeName)" + case .ticketMention: + if let ticketId = change.mentioned?.id { + return "\(authorName) mentioned ticket #\(ticketId)" + } + return "\(authorName) mentioned another ticket" + case .userMention: + let user = change.mentioned?.canonicalName ?? "someone" + return "\(authorName) mentioned \(user)" + case .created: + return "\(authorName) opened this ticket" + case .comment: + return "\(authorName) commented" + case .unknown: + return "\(authorName) updated this ticket" + } + } + + private func icon(for change: EventChange) -> String { + switch kind(for: change) { + case .comment: + "text.bubble" + case .statusChange: + "arrow.triangle.2.circlepath" + case .labelAdded, .labelRemoved, .labelUpdated: + "tag" + case .assigned: + "person.badge.plus" + case .unassigned: + "person.badge.minus" + case .ticketMention, .userMention: + "at" + case .created: + "plus.circle" + case .unknown: + "circle.fill" + } + } + + private func color(for change: EventChange) -> Color { + switch kind(for: change) { + case .comment: + .blue + case .statusChange: + change.newStatus == .resolved ? .green : .orange + case .labelAdded, .labelRemoved, .labelUpdated: + .purple + case .assigned, .unassigned: + .cyan + case .ticketMention, .userMention: + .indigo + case .created: + .green + case .unknown: + .gray + } + } + + private func kind(for change: EventChange) -> EventKind { + switch change.eventType { + case "COMMENT", "Comment": + .comment + case "STATUS_CHANGE", "StatusChange": + .statusChange + case "LABEL_UPDATE", "LabelUpdate": + .labelUpdated + case "LABEL_ADDED", "LabelAdded": + .labelAdded + case "LABEL_REMOVED", "LabelRemoved": + .labelRemoved + case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser": + .assigned + case "UNASSIGNED_USER", "UnassignedUser": + .unassigned + case "TICKET_MENTION", "TicketMention": + .ticketMention + case "USER_MENTION", "UserMention": + .userMention + case "CREATED", "Created": + .created + default: + .unknown + } + } + + private enum EventKind: Equatable { + case comment + case statusChange + case labelUpdated + case labelAdded + case labelRemoved + case assigned + case unassigned + case ticketMention + case userMention + case created + case unknown + } +} + +// MARK: - Resolve Sheet + +private struct ResolveSheet: View { + let viewModel: TicketDetailViewModel + @Binding var isPresented: Bool + @State private var selectedResolution: TicketResolution = .fixed + + private static let resolutionOptions: [TicketResolution] = [ + .closed, .fixed, .implemented, .wontFix, + .byDesign, .invalid, .duplicate, .notOurBug + ] + + var body: some View { + NavigationStack { + Form { + Section("Resolution") { + Picker("Resolution", selection: $selectedResolution) { + ForEach(Self.resolutionOptions, id: \.self) { resolution in + Text(resolution.displayName).tag(resolution) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + } + .navigationTitle("Resolve Ticket") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { isPresented = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Mark Resolved") { + Task { + await viewModel.updateStatus( + status: .resolved, + resolution: selectedResolution + ) + if viewModel.error == nil { + isPresented = false + } + } + } + .disabled(viewModel.isPerformingAction) + } + } + .overlay { + if viewModel.isPerformingAction { + ProgressView() + } + } + } + } +} + +// MARK: - Assign Sheet + +private struct AssignSheet: View { + let viewModel: TicketDetailViewModel + @Binding var isPresented: Bool + @State private var username = "" + + var body: some View { + NavigationStack { + Form { + // Current assignees with remove buttons + if let ticket = viewModel.ticket, !ticket.assignees.isEmpty { + Section("Current Assignees") { + ForEach(ticket.assignees, id: \.canonicalName) { assignee in + HStack { + Text(assignee.canonicalName) + Spacer() + Button(role: .destructive) { + Task { + await viewModel.unassignUser( + username: assignee.canonicalName + ) + } + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } + } + } + + Section("Add Assignee") { + TextField("Username or ~username", text: $username) + .textContentType(.username) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + Button("Add Assignee") { + let name = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + Task { + await viewModel.assignUser(username: name) + username = "" + } + } + .disabled( + username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || viewModel.isPerformingAction + ) + } + } + .navigationTitle("Assignees") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { isPresented = false } + } + } + .overlay { + if viewModel.isPerformingAction { + ProgressView() + } + } + } + } +} + +// MARK: - Labels Sheet + +private struct LabelsSheet: View { + let viewModel: TicketDetailViewModel + @Binding var isPresented: Bool + @State private var showCreateLabel = false + + var body: some View { + NavigationStack { + Group { + if viewModel.trackerLabels.isEmpty { + if viewModel.isPerformingAction { + ProgressView() + } else { + ContentUnavailableView( + "No Labels", + systemImage: "tag", + description: Text("This tracker has no labels defined.") + ) + } + } else { + List { + ForEach(viewModel.trackerLabels) { label in + LabelToggleRow( + label: label, + isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false, + isLoading: viewModel.isPerformingAction + ) { shouldApply in + Task { + if shouldApply { + await viewModel.labelTicket(labelId: label.id) + } else { + await viewModel.unlabelTicket(labelId: label.id) + } + } + } + } + } + } + } + .navigationTitle("Labels") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { isPresented = false } + } + ToolbarItem(placement: .primaryAction) { + Button { + showCreateLabel = true + } label: { + SwiftUI.Label("New Label", systemImage: "plus") + } + } + } + .sheet(isPresented: $showCreateLabel) { + CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel) + .presentationDetents([.medium]) + } + } + } +} + +// MARK: - Create Label Sheet + +private struct CreateLabelSheet: View { + let viewModel: TicketDetailViewModel + @Binding var isPresented: Bool + @State private var labelName = "" + @State private var backgroundColor = Color.blue + @State private var foregroundColor = Color.white + + var body: some View { + NavigationStack { + Form { + Section("Label Details") { + TextField("Label name", text: $labelName) + .autocorrectionDisabled() + } + + Section("Colors") { + ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false) + ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false) + } + + Section("Preview") { + HStack { + Spacer() + Text(labelName.isEmpty ? "Label" : labelName) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(backgroundColor) + .foregroundStyle(foregroundColor) + .clipShape(Capsule()) + Spacer() + } + } + } + .navigationTitle("New Label") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { isPresented = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create Label") { + Task { + await viewModel.createLabel( + name: labelName.trimmingCharacters(in: .whitespacesAndNewlines), + backgroundColor: backgroundColor.hexString, + foregroundColor: foregroundColor.hexString + ) + if viewModel.error == nil { + isPresented = false + } + } + } + .disabled( + labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || viewModel.isPerformingAction + ) + } + } + .overlay { + if viewModel.isPerformingAction { + ProgressView() + } + } + } + } +} + +private struct LabelToggleRow: View { + let label: TicketLabel + let isApplied: Bool + let isLoading: Bool + let onToggle: (Bool) -> Void + + var body: some View { + Button { + onToggle(!isApplied) + } label: { + HStack { + LabelPill(label: label) + Spacer() + if isApplied { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + } + .disabled(isLoading) + } +} diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift new file mode 100644 index 0000000..70eaf0a --- /dev/null +++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift @@ -0,0 +1,551 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct TicketDetailResponse: Decodable, Sendable { + let user: UserTrackerTicketWrapper +} + +private struct UserTrackerTicketWrapper: Decodable, Sendable { + let tracker: TrackerTicketWrapper +} + +private struct TrackerTicketWrapper: Decodable, Sendable { + let ticket: TicketDetailPayload +} + +private struct TicketDetailPayload: Decodable, Sendable { + let id: Int + let created: Date + let updated: Date + let title: String + let description: String? + let status: TicketStatus + let resolution: TicketResolution? + let authenticity: Authenticity + let submitter: Entity + let assignees: [Entity] + let labels: [TicketLabel] + let events: EventsPage +} + +private struct EventsPage: Decodable, Sendable { + let results: [TicketEvent] + let cursor: String? +} + +private struct SubmitCommentResponse: Decodable, Sendable { + let submitComment: SubmittedEvent +} + +private struct SubmittedEvent: Decodable, Sendable { + let id: Int + let created: Date + let changes: [EventChange] +} + +private struct MutationEventResponse: Decodable, Sendable { + let id: Int +} + +private struct UpdateStatusResponse: Decodable, Sendable { + let updateTicketStatus: UpdatedStatusEvent +} + +private struct UpdatedStatusEvent: Decodable, Sendable { + let eventType: String +} + +private struct AssignUserResponse: Decodable, Sendable { + let assignUser: MutationEventResponse +} + +private struct UnassignUserResponse: Decodable, Sendable { + let unassignUser: MutationEventResponse +} + +private struct LabelTicketResponse: Decodable, Sendable { + let labelTicket: MutationEventResponse +} + +private struct UnlabelTicketResponse: Decodable, Sendable { + let unlabelTicket: MutationEventResponse +} + +private struct UserLookupResponse: Decodable, Sendable { + let user: UserIdPayload +} + +private struct UserIdPayload: Decodable, Sendable { + let id: Int +} + +private struct CreateLabelResponse: Decodable, Sendable { + let createLabel: TicketLabel +} + +private struct TrackerLabelsResponse: Decodable, Sendable { + let user: UserTrackerLabelsWrapper +} + +private struct UserTrackerLabelsWrapper: Decodable, Sendable { + let tracker: TrackerLabelsWrapper +} + +private struct TrackerLabelsWrapper: Decodable, Sendable { + let labels: LabelsPage +} + +private struct LabelsPage: Decodable, Sendable { + let results: [TicketLabel] +} + +// MARK: - View Model + +@Observable +@MainActor +final class TicketDetailViewModel { + + let ownerUsername: String + let trackerName: String + let trackerId: Int + let trackerRid: String + let ticketId: Int + + private(set) var ticket: TicketDetail? + private(set) var events: [TicketEvent] = [] + private(set) var isLoading = false + private(set) var isSubmitting = false + private(set) var isPerformingAction = false + private(set) var trackerLabels: [TicketLabel] = [] + var commentText = "" + var error: String? + + private let client: SRHTClient + + private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool { + if lhs.created == rhs.created { + return lhs.id < rhs.id + } + return lhs.created < rhs.created + } + + init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) { + self.ownerUsername = ownerUsername + self.trackerName = trackerName + self.trackerId = trackerId + self.trackerRid = trackerRid + self.ticketId = ticketId + self.client = client + } + + // MARK: - Queries + + private static let detailQuery = """ + query ticket($owner: String!, $tracker: String!, $ticketId: Int!) { + user(username: $owner) { + tracker(name: $tracker) { + ticket(id: $ticketId) { + id + created + updated + title: subject + description: body + status + resolution + authenticity + submitter { canonicalName } + assignees { canonicalName } + labels { id name backgroundColor foregroundColor } + events { + results { + id + created + changes { + eventType + ... on Comment { + author { canonicalName } + text + authenticity + } + ... on StatusChange { + oldStatus + newStatus + } + ... on LabelUpdate { + labeler { canonicalName } + label { name } + } + ... on Assignment { + assigner { canonicalName } + assignee { canonicalName } + } + ... on TicketMention { + mentioned { id } + } + ... on UserMention { + mentioned { canonicalName } + } + ... on Created { + author { canonicalName } + } + } + } + cursor + } + } + } + } + } + """ + + private static let submitCommentMutation = """ + mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) { + submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) { + id + created + changes { + eventType + ... on Comment { + author { canonicalName } + text + authenticity + } + } + } + } + """ + + private static let updateStatusMutation = """ + mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) { + updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) { + eventType: __typename + } + } + """ + + private static let assignUserMutation = """ + mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { + assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } + } + """ + + private static let unassignUserMutation = """ + mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { + unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } + } + """ + + private static let labelTicketMutation = """ + mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) { + labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id } + } + """ + + private static let unlabelTicketMutation = """ + mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) { + unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id } + } + """ + + private static let userLookupQuery = """ + query userLookup($username: String!) { + user(username: $username) { id } + } + """ + + private static let trackerLabelsQuery = """ + query trackerLabels($owner: String!, $tracker: String!) { + user(username: $owner) { + tracker(name: $tracker) { + labels { + results { id name backgroundColor foregroundColor } + } + } + } + } + """ + + private static let createLabelMutation = """ + mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) { + createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) { + id + name + backgroundColor + foregroundColor + } + } + """ + + // MARK: - Public API + + func loadTicket() async { + guard !isLoading else { return } + isLoading = true + error = nil + + do { + let result = try await client.execute( + service: .todo, + query: Self.detailQuery, + variables: [ + "owner": ownerUsername, + "tracker": trackerName, + "ticketId": ticketId + ], + responseType: TicketDetailResponse.self + ) + let payload = result.user.tracker.ticket + ticket = TicketDetail( + id: payload.id, + created: payload.created, + updated: payload.updated, + title: payload.title, + description: payload.description, + status: payload.status, + resolution: payload.resolution, + authenticity: payload.authenticity, + submitter: payload.submitter, + assignees: payload.assignees, + labels: payload.labels + ) + events = payload.events.results.sorted(by: Self.timelineOrder) + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + func submitComment() async { + let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isSubmitting else { return } + isSubmitting = true + error = nil + + do { + let input: [String: any Sendable] = ["text": text] + let result = try await client.execute( + service: .todo, + query: Self.submitCommentMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "input": input + ], + responseType: SubmitCommentResponse.self + ) + // Append the returned event so the comment shows immediately. + let submitted = result.submitComment + let event = TicketEvent( + id: submitted.id, + created: submitted.created, + changes: submitted.changes + ) + events.append(event) + events.sort(by: Self.timelineOrder) + commentText = "" + } catch { + self.error = error.localizedDescription + } + + isSubmitting = false + } + + func updateComment(commentId: Int, text: String) async { + _ = commentId + _ = text + error = "Comment editing is not available in todo.sr.ht's public GraphQL API." + } + + // MARK: - Ticket Actions + + func updateStatus(status: TicketStatus, resolution: TicketResolution) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + let input: [String: any Sendable] = [ + "status": status.rawValue, + "resolution": resolution.rawValue + ] + _ = try await client.execute( + service: .todo, + query: Self.updateStatusMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "input": input + ], + responseType: UpdateStatusResponse.self + ) + // Re-fetch the ticket to get updated status/resolution + await loadTicket() + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + + func assignUser(username: String) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + // Resolve username to user ID + let userResult = try await client.execute( + service: .todo, + query: Self.userLookupQuery, + variables: ["username": username], + responseType: UserLookupResponse.self + ) + let userId = userResult.user.id + + _ = try await client.execute( + service: .todo, + query: Self.assignUserMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "userId": userId + ], + responseType: AssignUserResponse.self + ) + // Reload to reflect the change + await loadTicket() + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + + func unassignUser(username: String) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + // Resolve username to user ID + let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username + let userResult = try await client.execute( + service: .todo, + query: Self.userLookupQuery, + variables: ["username": stripped], + responseType: UserLookupResponse.self + ) + let userId = userResult.user.id + + _ = try await client.execute( + service: .todo, + query: Self.unassignUserMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "userId": userId + ], + responseType: UnassignUserResponse.self + ) + // Reload to reflect the change + await loadTicket() + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + + func labelTicket(labelId: Int) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + _ = try await client.execute( + service: .todo, + query: Self.labelTicketMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "labelId": labelId + ], + responseType: LabelTicketResponse.self + ) + await loadTicket() + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + + func unlabelTicket(labelId: Int) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + _ = try await client.execute( + service: .todo, + query: Self.unlabelTicketMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "labelId": labelId + ], + responseType: UnlabelTicketResponse.self + ) + await loadTicket() + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + + func loadTrackerLabels() async { + do { + let result = try await client.execute( + service: .todo, + query: Self.trackerLabelsQuery, + variables: [ + "owner": ownerUsername, + "tracker": trackerName + ], + responseType: TrackerLabelsResponse.self + ) + trackerLabels = result.user.tracker.labels.results + } catch { + self.error = error.localizedDescription + } + } + + func createLabel(name: String, backgroundColor: String, foregroundColor: String) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + let result = try await client.execute( + service: .todo, + query: Self.createLabelMutation, + variables: [ + "trackerId": trackerId, + "name": name, + "backgroundColor": backgroundColor, + "foregroundColor": foregroundColor + ], + responseType: CreateLabelResponse.self + ) + trackerLabels.append(result.createLabel) + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + +} diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift new file mode 100644 index 0000000..7e16276 --- /dev/null +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -0,0 +1,389 @@ +import SwiftUI + +struct TicketListView: View { + let ownerUsername: String + let trackerName: String + let trackerId: Int + let trackerRid: String + + @Environment(AppState.self) private var appState + @State private var viewModel: TicketListViewModel? + @State private var showCreateTicketSheet = false + @State private var createdTicket: TicketSummary? + + var body: some View { + Group { + if let viewModel { + listContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading tickets…") + } + } + .navigationTitle(trackerName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + SRHTShareButton(url: SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: trackerName), target: .tracker) { + Image(systemName: "square.and.arrow.up") + } + + if viewModel != nil { + Button { + showCreateTicketSheet = true + } label: { + Image(systemName: "plus") + } + } + } + } + .sheet(isPresented: $showCreateTicketSheet) { + if let viewModel { + CreateTicketSheet(viewModel: viewModel) { ticket in + showCreateTicketSheet = false + createdTicket = ticket + } + } + } + .navigationDestination(isPresented: Binding( + get: { createdTicket != nil }, + set: { isPresented in + if !isPresented { + createdTicket = nil + } + } + )) { + if let createdTicket { + TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: createdTicket.id) + } + } + .task { + if viewModel == nil { + let vm = TicketListViewModel( + ownerUsername: ownerUsername, + trackerName: trackerName, + trackerId: trackerId, + client: appState.client + ) + viewModel = vm + await vm.loadTickets() + } + } + } + + @ViewBuilder + private func listContent(_ viewModel: TicketListViewModel) -> some View { + @Bindable var vm = viewModel + + List { + // Filter picker + Section { + Picker("Filter", selection: $vm.filter) { + ForEach(TicketFilter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) + } + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } + + // Tickets + ForEach(viewModel.filteredTickets) { ticket in + NavigationLink(value: ticket) { + TicketRowView(ticket: ticket) + } + .task { + await viewModel.loadMoreIfNeeded(currentItem: ticket) + } + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoading, viewModel.tickets.isEmpty { + SRHTLoadingStateView(message: "Loading tickets…") + } else if let error = viewModel.error, viewModel.tickets.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Tickets", + message: error, + retryAction: { await viewModel.loadTickets() } + ) + } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil { + ContentUnavailableView( + "No Tickets", + systemImage: "ticket", + description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.") + ) + } + } + .connectivityOverlay(hasContent: !viewModel.filteredTickets.isEmpty) { + await viewModel.loadTickets() + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadTickets() + } + .navigationDestination(for: TicketSummary.self) { ticket in + TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id) + } + } +} + +private struct CreateTicketSheet: View { + let viewModel: TicketListViewModel + let onCreated: (TicketSummary) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var subject = "" + @State private var descriptionText = "" + + var body: some View { + NavigationStack { + Form { + Section("Ticket Details") { + TextField("Title", text: $subject) + TextField("Description (optional)", text: $descriptionText, axis: .vertical) + .lineLimit(6...12) + } + } + .navigationTitle("New Ticket") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + if let ticket = await viewModel.createTicket(subject: subject, body: descriptionText) { + onCreated(ticket) + } + } + } label: { + if viewModel.isCreatingTicket { + ProgressView() + .controlSize(.small) + } else { + Text("Create Ticket") + } + } + .disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTicket) + } + } + } + } +} + +// MARK: - Ticket Row + +private struct TicketRowView: View { + let ticket: TicketSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { + TicketStatusIcon(status: ticket.status) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text("#\(ticket.id)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + + Text(" ") + + Text(ticket.title) + .font(.subheadline) + } + + Spacer() + } + + HStack(spacing: 8) { + Text(ticket.submitter.canonicalName) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Text(ticket.created.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + } + + if !ticket.labels.isEmpty { + FlowLayout(spacing: 4) { + ForEach(ticket.labels) { label in + LabelPill(label: label) + } + } + } + + if !ticket.assignees.isEmpty { + HStack(spacing: 4) { + Image(systemName: "person.fill") + .font(.caption2) + .foregroundStyle(.secondary) + Text(ticket.assignees.map(\.canonicalName).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Ticket Status Icon + +struct TicketStatusIcon: View { + let status: TicketStatus + + var body: some View { + Image(systemName: iconName) + .foregroundStyle(color) + } + + private var iconName: String { + switch status { + case .reported: "circle" + case .confirmed: "circle.inset.filled" + case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90" + case .pending: "clock.fill" + case .resolved: "checkmark.circle.fill" + } + } + + private var color: Color { + switch status { + case .reported: .gray + case .confirmed: .blue + case .inProgress: .yellow + case .pending: .orange + case .resolved: .green + } + } +} + +// MARK: - Label Pill + +struct LabelPill: View { + let label: TicketLabel + + var body: some View { + Text(label.name) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(backgroundColor) + .foregroundStyle(foregroundColor) + .clipShape(Capsule()) + } + + private var backgroundColor: Color { + Color(hex: label.backgroundColor) ?? .gray.opacity(0.2) + } + + private var foregroundColor: Color { + Color(hex: label.foregroundColor) ?? .primary + } +} + +// MARK: - Color from hex string + +extension Color { + init?(hex: String) { + var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if hexString.hasPrefix("#") { + hexString.removeFirst() + } + + guard hexString.count == 6, + let hexNumber = UInt64(hexString, radix: 16) else { + return nil + } + + let r = Double((hexNumber & 0xFF0000) >> 16) / 255 + let g = Double((hexNumber & 0x00FF00) >> 8) / 255 + let b = Double(hexNumber & 0x0000FF) / 255 + + self.init(red: r, green: g, blue: b) + } + + /// Returns a `#rrggbb` hex string for this color. + var hexString: String { + let resolved = resolve(in: .init()) + let r = Int(max(0, min(1, resolved.red)) * 255) + let g = Int(max(0, min(1, resolved.green)) * 255) + let b = Int(max(0, min(1, resolved.blue)) * 255) + return String(format: "#%02x%02x%02x", r, g, b) + } +} + +// MARK: - Flow Layout (for label pills) + +struct FlowLayout: Layout { + var spacing: CGFloat = 4 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let result = layoutSubviews(proposal: proposal, subviews: subviews) + return result.size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = layoutSubviews(proposal: proposal, subviews: subviews) + for (index, position) in result.positions.enumerated() { + subviews[index].place( + at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), + proposal: ProposedViewSize(result.sizes[index]) + ) + } + } + + private struct LayoutResult { + var positions: [CGPoint] + var sizes: [CGSize] + var size: CGSize + } + + private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult { + let maxWidth = proposal.width ?? .infinity + var positions: [CGPoint] = [] + var sizes: [CGSize] = [] + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var totalHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + sizes.append(size) + + if currentX + size.width > maxWidth, currentX > 0 { + currentX = 0 + currentY += lineHeight + spacing + lineHeight = 0 + } + + positions.append(CGPoint(x: currentX, y: currentY)) + lineHeight = max(lineHeight, size.height) + currentX += size.width + spacing + totalWidth = max(totalWidth, currentX - spacing) + totalHeight = currentY + lineHeight + } + + return LayoutResult( + positions: positions, + sizes: sizes, + size: CGSize(width: totalWidth, height: totalHeight) + ) + } +} diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift new file mode 100644 index 0000000..916037e --- /dev/null +++ b/Hutch/Views/Tickets/TicketListViewModel.swift @@ -0,0 +1,215 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct TrackerTicketsResponse: Decodable, Sendable { + let user: UserTrackerWrapper +} + +private struct UserTrackerWrapper: Decodable, Sendable { + let tracker: TrackerTicketsWrapper +} + +private struct TrackerTicketsWrapper: Decodable, Sendable { + let tickets: TicketsPage +} + +private struct TicketsPage: Decodable, Sendable { + let results: [TicketSummary] + let cursor: String? +} + +// MARK: - Filter + +enum TicketFilter: String, CaseIterable, Sendable { + case open = "Open" + case resolved = "Resolved" + case all = "All" +} + +// MARK: - View Model + +@Observable +@MainActor +final class TicketListViewModel { + let ownerUsername: String + let trackerName: String + let trackerId: Int + + private(set) var tickets: [TicketSummary] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var isCreatingTicket = false + var error: String? + var filter: TicketFilter = .open + + private var cursor: String? + private var hasMore = true + private let client: SRHTClient + + init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) { + self.ownerUsername = ownerUsername + self.trackerName = trackerName + self.trackerId = trackerId + self.client = client + } + + // MARK: - Query + + private static let query = """ + query tickets($owner: String!, $tracker: String!, $cursor: Cursor) { + user(username: $owner) { + tracker(name: $tracker) { + tickets(cursor: $cursor) { + results { + id + title: subject + status + resolution + created + submitter { canonicalName } + labels { id name backgroundColor foregroundColor } + assignees { canonicalName } + } + cursor + } + } + } + } + """ + + private static let submitTicketMutation = """ + mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) { + submitTicket(trackerId: $trackerId, input: $input) { + id + title: subject + status + resolution + created + submitter { canonicalName } + labels { id name backgroundColor foregroundColor } + assignees { canonicalName } + } + } + """ + + // MARK: - Computed + + /// Tickets filtered by the selected status filter. + var filteredTickets: [TicketSummary] { + switch filter { + case .open: + tickets.filter { $0.status.isOpen } + case .resolved: + tickets.filter { !$0.status.isOpen } + case .all: + tickets + } + } + + // MARK: - Public API + + func loadTickets() async { + isLoading = true + error = nil + cursor = nil + hasMore = true + + do { + let page = try await fetchPage(cursor: nil) + tickets = page.results + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + func loadMoreIfNeeded(currentItem: TicketSummary) async { + guard let last = tickets.last, + last.id == currentItem.id, + hasMore, + !isLoadingMore else { + return + } + + isLoadingMore = true + + do { + let page = try await fetchPage(cursor: cursor) + tickets.append(contentsOf: page.results) + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + + isLoadingMore = false + } + + func createTicket(subject: String, body: String) async -> TicketSummary? { + guard !isCreatingTicket else { return nil } + + let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSubject.isEmpty else { + error = "Enter a ticket title." + return nil + } + + isCreatingTicket = true + error = nil + defer { isCreatingTicket = false } + + var input: [String: any Sendable] = [ + "subject": trimmedSubject + ] + let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedBody.isEmpty { + input["body"] = trimmedBody + } + let variables: [String: any Sendable] = [ + "trackerId": trackerId, + "input": input + ] + + do { + let result = try await client.execute( + service: .todo, + query: Self.submitTicketMutation, + variables: variables, + responseType: SubmitTicketResponse.self + ) + let ticket = result.submitTicket + tickets.insert(ticket, at: 0) + return ticket + } catch { + self.error = "Couldn’t create the ticket. \(error.localizedDescription)" + return nil + } + } + + // MARK: - Private + + private func fetchPage(cursor: String?) async throws -> TicketsPage { + var variables: [String: any Sendable] = [ + "owner": ownerUsername, + "tracker": trackerName + ] + if let cursor { + variables["cursor"] = cursor + } + let result = try await client.execute( + service: .todo, + query: Self.query, + variables: variables, + responseType: TrackerTicketsResponse.self + ) + return result.user.tracker.tickets + } + + private struct SubmitTicketResponse: Decodable, Sendable { + let submitTicket: TicketSummary + } +} diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift new file mode 100644 index 0000000..d16246b --- /dev/null +++ b/Hutch/Views/Tickets/TrackerListView.swift @@ -0,0 +1,214 @@ +import SwiftUI + +struct TrackerListView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: TrackerListViewModel? + @State private var showCreateTrackerSheet = false + @State private var createdTracker: TrackerSummary? + + var body: some View { + Group { + if let viewModel { + listContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading trackers…") + } + } + .navigationTitle("Trackers") + .toolbar { + if viewModel != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + showCreateTrackerSheet = true + } label: { + Image(systemName: "plus") + } + } + } + } + .sheet(isPresented: $showCreateTrackerSheet) { + if let viewModel { + CreateTrackerSheet(viewModel: viewModel) { tracker in + showCreateTrackerSheet = false + createdTracker = tracker + } + } + } + .navigationDestination(isPresented: Binding( + get: { createdTracker != nil }, + set: { isPresented in + if !isPresented { + createdTracker = nil + } + } + )) { + if let createdTracker { + TicketListView( + ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()), + trackerName: createdTracker.name, + trackerId: createdTracker.id, + trackerRid: createdTracker.rid + ) + } + } + .task { + if viewModel == nil { + let vm = TrackerListViewModel(client: appState.client) + viewModel = vm + await vm.loadTrackers() + } + } + } + + @ViewBuilder + private func listContent(_ viewModel: TrackerListViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.trackers) { tracker in + NavigationLink(value: tracker) { + TrackerRowView(tracker: tracker) + } + .task { + await viewModel.loadMoreIfNeeded(currentItem: tracker) + } + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoading, viewModel.trackers.isEmpty { + SRHTLoadingStateView(message: "Loading trackers…") + } else if let error = viewModel.error, viewModel.trackers.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Trackers", + message: error, + retryAction: { await viewModel.loadTrackers() } + ) + } else if viewModel.trackers.isEmpty, viewModel.error == nil { + ContentUnavailableView( + "No Trackers", + systemImage: "checklist", + description: Text("Your bug trackers will appear here.") + ) + } + } + .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) { + await viewModel.loadTrackers() + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadTrackers() + } + .navigationDestination(for: TrackerSummary.self) { tracker in + TicketListView( + ownerUsername: String(tracker.owner.canonicalName.dropFirst()), + trackerName: tracker.name, + trackerId: tracker.id, + trackerRid: tracker.rid + ) + } + } +} + +private struct CreateTrackerSheet: View { + let viewModel: TrackerListViewModel + let onCreated: (TrackerSummary) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var description = "" + @State private var visibility: Visibility = .public + + var body: some View { + NavigationStack { + Form { + Section("Tracker Details") { + TextField("Tracker name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Short description (optional)", text: $description, axis: .vertical) + .lineLimit(2...4) + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) + } + } + } + .navigationTitle("New Tracker") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + if let tracker = await viewModel.createTracker( + name: name, + description: description, + visibility: visibility + ) { + onCreated(tracker) + } + } + } label: { + if viewModel.isCreatingTracker { + ProgressView() + .controlSize(.small) + } else { + Text("Create Tracker") + } + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker) + } + } + } + } +} + +// MARK: - Tracker Row + +private struct TrackerRowView: View { + let tracker: TrackerSummary + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(tracker.name) + .font(.subheadline.weight(.medium)) + + Spacer() + + VisibilityBadge(visibility: tracker.visibility) + } + + if let owner = tracker.owner.canonicalName.split(separator: "~").last { + Text("~\(owner)") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let description = tracker.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Text(tracker.updated.relativeDescription) + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 2) + } +} diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift new file mode 100644 index 0000000..9704071 --- /dev/null +++ b/Hutch/Views/Tickets/TrackerListViewModel.swift @@ -0,0 +1,166 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct TrackersResponse: Decodable, Sendable { + let trackers: TrackersPage +} + +private struct TrackersPage: Decodable, Sendable { + let results: [TrackerSummary] + let cursor: String? +} + +// MARK: - View Model + +@Observable +@MainActor +final class TrackerListViewModel { + + private(set) var trackers: [TrackerSummary] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var isCreatingTracker = false + var error: String? + + private var cursor: String? + private var hasMore = true + private let client: SRHTClient + + init(client: SRHTClient) { + self.client = client + } + + // MARK: - Query + + private static let query = """ + query trackers($cursor: Cursor) { + trackers(cursor: $cursor) { + results { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + cursor + } + } + """ + + private static let createTrackerMutation = """ + mutation createTracker($name: String!, $visibility: Visibility!, $description: String) { + createTracker(name: $name, visibility: $visibility, description: $description) { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + } + """ + + // MARK: - Public API + + func loadTrackers() async { + isLoading = true + error = nil + cursor = nil + hasMore = true + + do { + let page = try await fetchPage(cursor: nil) + trackers = page.results + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + func loadMoreIfNeeded(currentItem: TrackerSummary) async { + guard let last = trackers.last, + last.id == currentItem.id, + hasMore, + !isLoadingMore else { + return + } + + isLoadingMore = true + + do { + let page = try await fetchPage(cursor: cursor) + trackers.append(contentsOf: page.results) + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + + isLoadingMore = false + } + + func createTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? { + guard !isCreatingTracker else { return nil } + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + error = "Enter a tracker name." + return nil + } + + isCreatingTracker = true + error = nil + defer { isCreatingTracker = false } + + var variables: [String: any Sendable] = [ + "name": trimmedName, + "visibility": visibility.rawValue + ] + let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedDescription.isEmpty { + variables["description"] = trimmedDescription + } + + do { + let result = try await client.execute( + service: .todo, + query: Self.createTrackerMutation, + variables: variables, + responseType: CreateTrackerResponse.self + ) + let tracker = result.createTracker + trackers.insert(tracker, at: 0) + return tracker + } catch { + self.error = "Couldn’t create the tracker. \(error.localizedDescription)" + return nil + } + } + + // MARK: - Private + + private func fetchPage(cursor: String?) async throws -> TrackersPage { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + let result = try await client.execute( + service: .todo, + query: Self.query, + variables: variables.isEmpty ? nil : variables, + responseType: TrackersResponse.self + ) + return result.trackers + } + + private struct CreateTrackerResponse: Decodable, Sendable { + let createTracker: TrackerSummary + } +} -- cgit v1.2.3