diff options
| author | Christian Cleberg <[email protected]> | 2026-04-02 00:17:30 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-02 00:17:30 -0500 |
| commit | 7f79cadf99ecf014834855e2e4a5c7d60dd709ac (patch) | |
| tree | e1435c08b416390064e0d2772d6a7703a1737db1 | |
| parent | cd19a6fb24ebd5550068a28c0daefd07697bebda (diff) | |
| download | hutch-7f79cadf99ecf014834855e2e4a5c7d60dd709ac.tar.gz hutch-7f79cadf99ecf014834855e2e4a5c7d60dd709ac.tar.bz2 hutch-7f79cadf99ecf014834855e2e4a5c7d60dd709ac.zip | |
feat: browse user repositories and trackers from profilev2.9.0
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileView.swift | 98 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileViewModel.swift | 151 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserRepositoriesView.swift | 28 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserTrackersView.swift | 28 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 19 | ||||
| -rw-r--r-- | codex-user-resource-browsing.md | 249 |
7 files changed, 571 insertions, 18 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 57dcf3e..b1e277e 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -539,7 +539,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -556,7 +556,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8.2; + MARKETING_VERSION = 2.9.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -576,7 +576,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -593,7 +593,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8.2; + MARKETING_VERSION = 2.9.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -656,7 +656,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -666,7 +666,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.8.2; + MARKETING_VERSION = 2.8.3; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -685,7 +685,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -695,7 +695,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.8.2; + MARKETING_VERSION = 2.8.3; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift index 4c91244..dc9197c 100644 --- a/Hutch/Views/Lookup/UserProfileView.swift +++ b/Hutch/Views/Lookup/UserProfileView.swift @@ -1,7 +1,10 @@ import SwiftUI struct UserProfileView: View { + @Environment(AppState.self) private var appState + let user: User + @State private var profileViewModel: UserProfileViewModel? private static let iso8601Formatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() @@ -86,10 +89,70 @@ struct UserProfileView: View { } } } + + if let viewModel = profileViewModel { + Section { + if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { + ProgressView() + } else if viewModel.repositories.isEmpty { + Text("No public repositories.") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.repositories.prefix(4)) { repo in + NavigationLink { + RepositoryDetailView(repository: repo) + } label: { + RepositoryRowView(repository: repo, buildStatus: .none) + } + } + if viewModel.repositories.count > 4 { + NavigationLink("See All") { + UserRepositoriesView(viewModel: viewModel) + } + } + } + } header: { + Text("Repositories") + } + + Section { + if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty { + ProgressView() + } else if viewModel.trackers.isEmpty { + Text("No public trackers.") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.trackers.prefix(4)) { tracker in + NavigationLink { + TicketListView(tracker: tracker) + } label: { + UserProfileTrackerRowView(tracker: tracker) + } + } + if viewModel.trackers.count > 4 { + NavigationLink("See All") { + UserTrackersView(viewModel: viewModel) + } + } + } + } header: { + Text("Trackers") + } + } } .listStyle(.insetGrouped) .navigationTitle(user.canonicalName) .navigationBarTitleDisplayMode(.inline) + .task { + let owner = user.canonicalName.hasPrefix("~") + ? String(user.canonicalName.dropFirst()) + : user.canonicalName + let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client) + profileViewModel = vm + async let repos: () = vm.loadRepositories() + async let trackers: () = vm.loadTrackers() + _ = await (repos, trackers) + } } private func formattedTimestamp(_ value: String) -> String { @@ -100,3 +163,38 @@ struct UserProfileView: View { return date.formatted(date: .abbreviated, time: .shortened) } } + +struct UserProfileTrackerRowView: 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/Lookup/UserProfileViewModel.swift b/Hutch/Views/Lookup/UserProfileViewModel.swift new file mode 100644 index 0000000..bec79fb --- /dev/null +++ b/Hutch/Views/Lookup/UserProfileViewModel.swift @@ -0,0 +1,151 @@ +import Foundation + +@Observable +@MainActor +final class UserProfileViewModel { + private(set) var repositories: [RepositorySummary] = [] + private(set) var trackers: [TrackerSummary] = [] + private(set) var isLoadingRepositories = false + private(set) var isLoadingTrackers = false + var repositoriesError: String? + var trackersError: String? + + private let client: SRHTClient + let ownerUsername: String + + init(ownerUsername: String, client: SRHTClient) { + self.ownerUsername = ownerUsername + self.client = client + } + + func loadRepositories() async { + isLoadingRepositories = true + repositoriesError = nil + defer { isLoadingRepositories = false } + + do { + let result = try await client.execute( + service: .git, + query: Self.repositoriesQuery, + variables: ["owner": ownerUsername], + responseType: UserRepositoriesResponse.self + ) + repositories = result.user.repositories.results.map { $0.repositorySummary(service: .git) } + } catch { + repositoriesError = error.userFacingMessage + } + } + + func loadTrackers() async { + isLoadingTrackers = true + trackersError = nil + defer { isLoadingTrackers = false } + + do { + let result = try await client.execute( + service: .todo, + query: Self.trackersQuery, + variables: ["owner": ownerUsername], + responseType: UserTrackersResponse.self + ) + trackers = result.user.trackers.results + } catch { + trackersError = error.userFacingMessage + } + } + + private static let repositoriesQuery = """ + query userRepositories($owner: String!) { + user(username: $owner) { + repositories { + results { + id + rid + name + description + visibility + updated + owner { canonicalName } + HEAD { name target } + } + cursor + } + } + } + """ + + private static let trackersQuery = """ + query userTrackers($owner: String!) { + user(username: $owner) { + trackers { + results { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + cursor + } + } + } + """ + + private struct UserRepositoriesResponse: Decodable, Sendable { + let user: UserRepositoriesContainer + } + + private struct UserRepositoriesContainer: Decodable, Sendable { + let repositories: RepositoriesPage + } + + private struct RepositoriesPage: Decodable, Sendable { + let results: [RepositoryPayload] + let cursor: String? + } + + 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 UserTrackersResponse: Decodable, Sendable { + let user: UserTrackersContainer + } + + private struct UserTrackersContainer: Decodable, Sendable { + let trackers: TrackersPage + } + + private struct TrackersPage: Decodable, Sendable { + let results: [TrackerSummary] + let cursor: String? + } +} diff --git a/Hutch/Views/Lookup/UserRepositoriesView.swift b/Hutch/Views/Lookup/UserRepositoriesView.swift new file mode 100644 index 0000000..52d3c15 --- /dev/null +++ b/Hutch/Views/Lookup/UserRepositoriesView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct UserRepositoriesView: View { + let viewModel: UserProfileViewModel + + var body: some View { + List { + ForEach(viewModel.repositories) { repo in + NavigationLink { + RepositoryDetailView(repository: repo) + } label: { + RepositoryRowView(repository: repo, buildStatus: .none) + } + } + } + .listStyle(.plain) + .navigationTitle("Repositories") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { + SRHTLoadingStateView(message: "Loading repositories…") + } + } + .refreshable { + await viewModel.loadRepositories() + } + } +} diff --git a/Hutch/Views/Lookup/UserTrackersView.swift b/Hutch/Views/Lookup/UserTrackersView.swift new file mode 100644 index 0000000..17916da --- /dev/null +++ b/Hutch/Views/Lookup/UserTrackersView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct UserTrackersView: View { + let viewModel: UserProfileViewModel + + var body: some View { + List { + ForEach(viewModel.trackers) { tracker in + NavigationLink { + TicketListView(tracker: tracker) + } label: { + UserProfileTrackerRowView(tracker: tracker) + } + } + } + .listStyle(.plain) + .navigationTitle("Trackers") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty { + SRHTLoadingStateView(message: "Loading trackers…") + } + } + .refreshable { + await viewModel.loadTrackers() + } + } +} diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index a1cea6f..4a48377 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -190,7 +190,15 @@ struct TicketListView: View { // Tickets ForEach(viewModel.filteredTickets) { ticket in - NavigationLink(value: ticket) { + NavigationLink { + TicketDetailView( + ownerUsername: String(tracker.owner.canonicalName.dropFirst()), + trackerName: tracker.name, + trackerId: tracker.id, + trackerRid: tracker.rid, + ticketId: ticket.id + ) + } label: { TicketRowView(ticket: ticket) } .swipeActions(edge: .leading, allowsFullSwipe: true) { @@ -250,15 +258,6 @@ struct TicketListView: View { .refreshable { await viewModel.loadTickets() } - .navigationDestination(for: TicketSummary.self) { ticket in - TicketDetailView( - ownerUsername: String(tracker.owner.canonicalName.dropFirst()), - trackerName: tracker.name, - trackerId: tracker.id, - trackerRid: tracker.rid, - ticketId: ticket.id - ) - } } private var trackerActionsMenu: some View { diff --git a/codex-user-resource-browsing.md b/codex-user-resource-browsing.md new file mode 100644 index 0000000..c37be38 --- /dev/null +++ b/codex-user-resource-browsing.md @@ -0,0 +1,249 @@ +# feat: User resource browsing from profile + +## Goal + +Extend `UserProfileView` so that after looking up a user, their public +repositories and trackers are shown as browsable sections below the existing +profile metadata. This mirrors the sr.ht `~username` page. + +--- + +## Context + +**Entry point:** `Hutch/Views/Lookup/LookupView.swift` +Looking up a user opens `UserProfileView` in a sheet. Currently the view only +shows static metadata fields from the `User` model. + +**Owner identifier:** `user.canonicalName` (e.g. `~username`). Strip the leading +`~` when passing to GraphQL `username` parameters — see the existing pattern in +`AppState.resolveRepository(owner:name:)`. + +**Existing row views to reuse:** +- `RepositoryRowView` in `Hutch/Views/Repositories/RepositoryRowView.swift` +- Tracker row style from `TrackerListView` private `TrackerRowView` + +**API pattern for user-scoped queries** (from `AppState.swift`): +```graphql +query repoLookup($owner: String!, $name: String!) { + user(username: $owner) { + repository(name: $name) { ... } + } +} +``` +The same `user(username:)` root field supports `repositories` and `trackers` +paginated collections on git.sr.ht and todo.sr.ht respectively. + +--- + +## New File: `Hutch/Views/Lookup/UserProfileViewModel.swift` + +Create an `@Observable @MainActor` view model following the same pattern as +`RepositoryListViewModel` and `TrackerListViewModel`. + +```swift +@Observable +@MainActor +final class UserProfileViewModel { + private(set) var repositories: [RepositorySummary] = [] + private(set) var trackers: [TrackerSummary] = [] + private(set) var isLoadingRepositories = false + private(set) var isLoadingTrackers = false + var repositoriesError: String? + var trackersError: String? + + private let client: SRHTClient + let ownerUsername: String // without leading ~ + + init(ownerUsername: String, client: SRHTClient) { ... } + + func loadRepositories() async { ... } + func loadTrackers() async { ... } +} +``` + +**GraphQL queries:** + +Repositories (execute against `.git` service): +```graphql +query userRepositories($owner: String!) { + user(username: $owner) { + repositories { + results { + id rid name description visibility updated + owner { canonicalName } + HEAD { name target } + } + cursor + } + } +} +``` + +Trackers (execute against `.todo` service): +```graphql +query userTrackers($owner: String!) { + user(username: $owner) { + trackers { + results { + id rid name description visibility updated + owner { canonicalName } + } + cursor + } + } +} +``` + +Decode using private response structs identical to those in +`RepositoryListViewModel` and `TrackerListViewModel`. Map results to +`RepositorySummary` and `TrackerSummary` exactly as those view models do. + +--- + +## Modified File: `Hutch/Views/Lookup/UserProfileView.swift` + +### View model instantiation + +Add `@State private var profileViewModel: UserProfileViewModel?` and initialise +it in `.task` using `user.canonicalName` with the leading `~` stripped: + +```swift +.task { + let owner = user.canonicalName.hasPrefix("~") + ? String(user.canonicalName.dropFirst()) + : user.canonicalName + let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client) + profileViewModel = vm + async let repos: () = vm.loadRepositories() + async let trackers: () = vm.loadTrackers() + _ = await (repos, trackers) +} +``` + +Restore `@Environment(AppState.self) private var appState` (it was removed in a +recent commit but is needed for `client` access). + +### Repositories section + +Add after the existing metadata sections: + +```swift +Section { + if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { + ProgressView() + } else if viewModel.repositories.isEmpty { + Text("No public repositories.") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.repositories.prefix(4)) { repo in + NavigationLink { + RepositoryDetailView(repository: repo) + } label: { + RepositoryRowView(repository: repo, buildStatus: .none) + } + } + if viewModel.repositories.count > 4 { + NavigationLink("See All") { + UserRepositoriesView(viewModel: viewModel) + } + } + } +} header: { + Text("Repositories") +} +``` + +### Trackers section + +Immediately after the Repositories section: + +```swift +Section { + if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty { + ProgressView() + } else if viewModel.trackers.isEmpty { + Text("No public trackers.") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.trackers.prefix(4)) { tracker in + NavigationLink { + TicketListView(tracker: tracker) + } label: { + TrackerRowView(tracker: tracker) + } + } + if viewModel.trackers.count > 4 { + NavigationLink("See All") { + UserTrackersView(viewModel: viewModel) + } + } + } +} header: { + Text("Trackers") +} +``` + +`TrackerRowView` — use the same VStack layout as the private `TrackerRowView` +in `TrackerListView.swift`. Define it as a private struct in +`UserProfileView.swift` rather than duplicating from `TrackerListView`. + +--- + +## New File: `Hutch/Views/Lookup/UserRepositoriesView.swift` + +A simple full-list view for "See All" repositories: + +```swift +struct UserRepositoriesView: View { + let viewModel: UserProfileViewModel + + var body: some View { + List { + ForEach(viewModel.repositories) { repo in + NavigationLink { + RepositoryDetailView(repository: repo) + } label: { + RepositoryRowView(repository: repo, buildStatus: .none) + } + } + } + .listStyle(.plain) + .navigationTitle("Repositories") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { + SRHTLoadingStateView(message: "Loading repositories…") + } + } + .refreshable { + await viewModel.loadRepositories() + } + } +} +``` + +## New File: `Hutch/Views/Lookup/UserTrackersView.swift` + +Same pattern as `UserRepositoriesView` but for trackers, navigating to +`TicketListView(tracker:)`. + +--- + +## Navigation context + +`UserProfileView` is always presented inside a `NavigationStack` via the sheet +in `LookupView`. `NavigationLink` destinations push correctly within that stack. +No changes to `LookupView` or `MoreRoute` are needed. + +--- + +## Verification + +1. Look up a user with public repositories and trackers. Confirm both sections + appear below the profile metadata. +2. Tap a repository row — confirm `RepositoryDetailView` pushes correctly. +3. Tap a tracker row — confirm `TicketListView` pushes correctly. +4. For users with more than 4 items, confirm "See All" pushes the full list. +5. Look up a user with no public resources — confirm the empty-state text + renders in each section rather than crashing. +6. Build with no warnings. |
