krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

7f79cadf99ecf014834855e2e4a5c7d60dd709ac

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-02T05:17:30Z

feat: browse user repositories and trackers from profile
 Hutch.xcodeproj/project.pbxproj               |  16 +-
 Hutch/Views/Lookup/UserProfileView.swift      |  98 ++++++++++
 Hutch/Views/Lookup/UserProfileViewModel.swift | 151 ++++++++++++++++
 Hutch/Views/Lookup/UserRepositoriesView.swift |  28 +++
 Hutch/Views/Lookup/UserTrackersView.swift     |  28 +++
 Hutch/Views/Tickets/TicketListView.swift      |  19 +-
 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.