Commit e2749ef0c0

e2749ef0c01171b5ff48edf6ffe0d79490f32b10

parent: 06fe48b7bc

Unsigned

cmc <hello@cleberg.net> · 2026-04-02 05:17 UTC

feat: browse user repositories and trackers from profile

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -539,7 +539,7 @@
539539 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
540540 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
541541 CODE_SIGN_STYLE = Automatic;
542 CURRENT_PROJECT_VERSION = 21;
542 CURRENT_PROJECT_VERSION = 22;
543543 DEVELOPMENT_TEAM = ZCNAX3VL9D;
544544 ENABLE_PREVIEWS = YES;
545545 GENERATE_INFOPLIST_FILE = YES;
@@ -556,7 +556,7 @@
556556 "$(inherited)",
557557 "@executable_path/Frameworks",
558558 );
559 MARKETING_VERSION = 2.8.2;
559 MARKETING_VERSION = 2.9.0;
560560 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
561561 PRODUCT_NAME = "$(TARGET_NAME)";
562562 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
576576 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
577577 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
578578 CODE_SIGN_STYLE = Automatic;
579 CURRENT_PROJECT_VERSION = 21;
579 CURRENT_PROJECT_VERSION = 22;
580580 DEVELOPMENT_TEAM = ZCNAX3VL9D;
581581 ENABLE_PREVIEWS = YES;
582582 GENERATE_INFOPLIST_FILE = YES;
@@ -593,7 +593,7 @@
593593 "$(inherited)",
594594 "@executable_path/Frameworks",
595595 );
596 MARKETING_VERSION = 2.8.2;
596 MARKETING_VERSION = 2.9.0;
597597 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
598598 PRODUCT_NAME = "$(TARGET_NAME)";
599599 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -656,7 +656,7 @@
656656 APPLICATION_EXTENSION_API_ONLY = YES;
657657 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
658658 CODE_SIGN_STYLE = Automatic;
659 CURRENT_PROJECT_VERSION = 21;
659 CURRENT_PROJECT_VERSION = 22;
660660 DEVELOPMENT_TEAM = ZCNAX3VL9D;
661661 GENERATE_INFOPLIST_FILE = NO;
662662 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -666,7 +666,7 @@
666666 "@executable_path/Frameworks",
667667 "@executable_path/../../Frameworks",
668668 );
669 MARKETING_VERSION = 2.8.2;
669 MARKETING_VERSION = 2.8.3;
670670 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
671671 PRODUCT_NAME = "$(TARGET_NAME)";
672672 SKIP_INSTALL = YES;
@@ -685,7 +685,7 @@
685685 APPLICATION_EXTENSION_API_ONLY = YES;
686686 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
687687 CODE_SIGN_STYLE = Automatic;
688 CURRENT_PROJECT_VERSION = 21;
688 CURRENT_PROJECT_VERSION = 22;
689689 DEVELOPMENT_TEAM = ZCNAX3VL9D;
690690 GENERATE_INFOPLIST_FILE = NO;
691691 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -695,7 +695,7 @@
695695 "@executable_path/Frameworks",
696696 "@executable_path/../../Frameworks",
697697 );
698 MARKETING_VERSION = 2.8.2;
698 MARKETING_VERSION = 2.8.3;
699699 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
700700 PRODUCT_NAME = "$(TARGET_NAME)";
701701 SKIP_INSTALL = YES;
Hutch/Views/Lookup/UserProfileView.swift +98
@@ -1,7 +1,10 @@
11import SwiftUI
22
33struct UserProfileView: View {
4 @Environment(AppState.self) private var appState
5
46 let user: User
7 @State private var profileViewModel: UserProfileViewModel?
58
69 private static let iso8601Formatter: ISO8601DateFormatter = {
710 let formatter = ISO8601DateFormatter()
@@ -86,10 +89,70 @@ struct UserProfileView: View {
8689 }
8790 }
8891 }
92
93 if let viewModel = profileViewModel {
94 Section {
95 if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
96 ProgressView()
97 } else if viewModel.repositories.isEmpty {
98 Text("No public repositories.")
99 .foregroundStyle(.secondary)
100 } else {
101 ForEach(viewModel.repositories.prefix(4)) { repo in
102 NavigationLink {
103 RepositoryDetailView(repository: repo)
104 } label: {
105 RepositoryRowView(repository: repo, buildStatus: .none)
106 }
107 }
108 if viewModel.repositories.count > 4 {
109 NavigationLink("See All") {
110 UserRepositoriesView(viewModel: viewModel)
111 }
112 }
113 }
114 } header: {
115 Text("Repositories")
116 }
117
118 Section {
119 if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty {
120 ProgressView()
121 } else if viewModel.trackers.isEmpty {
122 Text("No public trackers.")
123 .foregroundStyle(.secondary)
124 } else {
125 ForEach(viewModel.trackers.prefix(4)) { tracker in
126 NavigationLink {
127 TicketListView(tracker: tracker)
128 } label: {
129 UserProfileTrackerRowView(tracker: tracker)
130 }
131 }
132 if viewModel.trackers.count > 4 {
133 NavigationLink("See All") {
134 UserTrackersView(viewModel: viewModel)
135 }
136 }
137 }
138 } header: {
139 Text("Trackers")
140 }
141 }
89142 }
90143 .listStyle(.insetGrouped)
91144 .navigationTitle(user.canonicalName)
92145 .navigationBarTitleDisplayMode(.inline)
146 .task {
147 let owner = user.canonicalName.hasPrefix("~")
148 ? String(user.canonicalName.dropFirst())
149 : user.canonicalName
150 let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client)
151 profileViewModel = vm
152 async let repos: () = vm.loadRepositories()
153 async let trackers: () = vm.loadTrackers()
154 _ = await (repos, trackers)
155 }
93156 }
94157
95158 private func formattedTimestamp(_ value: String) -> String {
@@ -100,3 +163,38 @@ struct UserProfileView: View {
100163 return date.formatted(date: .abbreviated, time: .shortened)
101164 }
102165}
166
167struct UserProfileTrackerRowView: View {
168 let tracker: TrackerSummary
169
170 var body: some View {
171 VStack(alignment: .leading, spacing: 4) {
172 HStack {
173 Text(tracker.name)
174 .font(.subheadline.weight(.medium))
175
176 Spacer()
177
178 VisibilityBadge(visibility: tracker.visibility)
179 }
180
181 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
182 Text("~\(owner)")
183 .font(.caption)
184 .foregroundStyle(.secondary)
185 }
186
187 if let description = tracker.description, !description.isEmpty {
188 Text(description)
189 .font(.caption)
190 .foregroundStyle(.secondary)
191 .lineLimit(2)
192 }
193
194 Text(tracker.updated.relativeDescription)
195 .font(.caption2)
196 .foregroundStyle(.tertiary)
197 }
198 .padding(.vertical, 2)
199 }
200}
Hutch/Views/Lookup/UserProfileViewModel.swift added +151
@@ -0,0 +1,151 @@
1import Foundation
2
3@Observable
4@MainActor
5final class UserProfileViewModel {
6 private(set) var repositories: [RepositorySummary] = []
7 private(set) var trackers: [TrackerSummary] = []
8 private(set) var isLoadingRepositories = false
9 private(set) var isLoadingTrackers = false
10 var repositoriesError: String?
11 var trackersError: String?
12
13 private let client: SRHTClient
14 let ownerUsername: String
15
16 init(ownerUsername: String, client: SRHTClient) {
17 self.ownerUsername = ownerUsername
18 self.client = client
19 }
20
21 func loadRepositories() async {
22 isLoadingRepositories = true
23 repositoriesError = nil
24 defer { isLoadingRepositories = false }
25
26 do {
27 let result = try await client.execute(
28 service: .git,
29 query: Self.repositoriesQuery,
30 variables: ["owner": ownerUsername],
31 responseType: UserRepositoriesResponse.self
32 )
33 repositories = result.user.repositories.results.map { $0.repositorySummary(service: .git) }
34 } catch {
35 repositoriesError = error.userFacingMessage
36 }
37 }
38
39 func loadTrackers() async {
40 isLoadingTrackers = true
41 trackersError = nil
42 defer { isLoadingTrackers = false }
43
44 do {
45 let result = try await client.execute(
46 service: .todo,
47 query: Self.trackersQuery,
48 variables: ["owner": ownerUsername],
49 responseType: UserTrackersResponse.self
50 )
51 trackers = result.user.trackers.results
52 } catch {
53 trackersError = error.userFacingMessage
54 }
55 }
56
57 private static let repositoriesQuery = """
58 query userRepositories($owner: String!) {
59 user(username: $owner) {
60 repositories {
61 results {
62 id
63 rid
64 name
65 description
66 visibility
67 updated
68 owner { canonicalName }
69 HEAD { name target }
70 }
71 cursor
72 }
73 }
74 }
75 """
76
77 private static let trackersQuery = """
78 query userTrackers($owner: String!) {
79 user(username: $owner) {
80 trackers {
81 results {
82 id
83 rid
84 name
85 description
86 visibility
87 updated
88 owner { canonicalName }
89 }
90 cursor
91 }
92 }
93 }
94 """
95
96 private struct UserRepositoriesResponse: Decodable, Sendable {
97 let user: UserRepositoriesContainer
98 }
99
100 private struct UserRepositoriesContainer: Decodable, Sendable {
101 let repositories: RepositoriesPage
102 }
103
104 private struct RepositoriesPage: Decodable, Sendable {
105 let results: [RepositoryPayload]
106 let cursor: String?
107 }
108
109 private struct RepositoryPayload: Decodable, Sendable {
110 let id: Int
111 let rid: String
112 let name: String
113 let description: String?
114 let visibility: Visibility
115 let updated: Date
116 let owner: Entity
117 let head: Reference?
118
119 enum CodingKeys: String, CodingKey {
120 case id, rid, name, description, visibility, updated, owner
121 case head = "HEAD"
122 }
123
124 func repositorySummary(service: SRHTService) -> RepositorySummary {
125 RepositorySummary(
126 id: id,
127 rid: rid,
128 service: service,
129 name: name,
130 description: description,
131 visibility: visibility,
132 updated: updated,
133 owner: owner,
134 head: head
135 )
136 }
137 }
138
139 private struct UserTrackersResponse: Decodable, Sendable {
140 let user: UserTrackersContainer
141 }
142
143 private struct UserTrackersContainer: Decodable, Sendable {
144 let trackers: TrackersPage
145 }
146
147 private struct TrackersPage: Decodable, Sendable {
148 let results: [TrackerSummary]
149 let cursor: String?
150 }
151}
Hutch/Views/Lookup/UserRepositoriesView.swift added +28
@@ -0,0 +1,28 @@
1import SwiftUI
2
3struct UserRepositoriesView: View {
4 let viewModel: UserProfileViewModel
5
6 var body: some View {
7 List {
8 ForEach(viewModel.repositories) { repo in
9 NavigationLink {
10 RepositoryDetailView(repository: repo)
11 } label: {
12 RepositoryRowView(repository: repo, buildStatus: .none)
13 }
14 }
15 }
16 .listStyle(.plain)
17 .navigationTitle("Repositories")
18 .navigationBarTitleDisplayMode(.inline)
19 .overlay {
20 if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
21 SRHTLoadingStateView(message: "Loading repositories…")
22 }
23 }
24 .refreshable {
25 await viewModel.loadRepositories()
26 }
27 }
28}
Hutch/Views/Lookup/UserTrackersView.swift added +28
@@ -0,0 +1,28 @@
1import SwiftUI
2
3struct UserTrackersView: View {
4 let viewModel: UserProfileViewModel
5
6 var body: some View {
7 List {
8 ForEach(viewModel.trackers) { tracker in
9 NavigationLink {
10 TicketListView(tracker: tracker)
11 } label: {
12 UserProfileTrackerRowView(tracker: tracker)
13 }
14 }
15 }
16 .listStyle(.plain)
17 .navigationTitle("Trackers")
18 .navigationBarTitleDisplayMode(.inline)
19 .overlay {
20 if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty {
21 SRHTLoadingStateView(message: "Loading trackers…")
22 }
23 }
24 .refreshable {
25 await viewModel.loadTrackers()
26 }
27 }
28}
Hutch/Views/Tickets/TicketListView.swift +9 −10
@@ -190,7 +190,15 @@ struct TicketListView: View {
190190
191191 // Tickets
192192 ForEach(viewModel.filteredTickets) { ticket in
193 NavigationLink(value: ticket) {
193 NavigationLink {
194 TicketDetailView(
195 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
196 trackerName: tracker.name,
197 trackerId: tracker.id,
198 trackerRid: tracker.rid,
199 ticketId: ticket.id
200 )
201 } label: {
194202 TicketRowView(ticket: ticket)
195203 }
196204 .swipeActions(edge: .leading, allowsFullSwipe: true) {
@@ -250,15 +258,6 @@ struct TicketListView: View {
250258 .refreshable {
251259 await viewModel.loadTickets()
252260 }
253 .navigationDestination(for: TicketSummary.self) { ticket in
254 TicketDetailView(
255 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
256 trackerName: tracker.name,
257 trackerId: tracker.id,
258 trackerRid: tracker.rid,
259 ticketId: ticket.id
260 )
261 }
262261 }
263262
264263 private var trackerActionsMenu: some View {
codex-user-resource-browsing.md added +249
@@ -0,0 +1,249 @@
1# feat: User resource browsing from profile
2
3## Goal
4
5Extend `UserProfileView` so that after looking up a user, their public
6repositories and trackers are shown as browsable sections below the existing
7profile metadata. This mirrors the sr.ht `~username` page.
8
9---
10
11## Context
12
13**Entry point:** `Hutch/Views/Lookup/LookupView.swift`
14Looking up a user opens `UserProfileView` in a sheet. Currently the view only
15shows static metadata fields from the `User` model.
16
17**Owner identifier:** `user.canonicalName` (e.g. `~username`). Strip the leading
18`~` when passing to GraphQL `username` parameters — see the existing pattern in
19`AppState.resolveRepository(owner:name:)`.
20
21**Existing row views to reuse:**
22- `RepositoryRowView` in `Hutch/Views/Repositories/RepositoryRowView.swift`
23- Tracker row style from `TrackerListView` private `TrackerRowView`
24
25**API pattern for user-scoped queries** (from `AppState.swift`):
26```graphql
27query repoLookup($owner: String!, $name: String!) {
28 user(username: $owner) {
29 repository(name: $name) { ... }
30 }
31}
32```
33The same `user(username:)` root field supports `repositories` and `trackers`
34paginated collections on git.sr.ht and todo.sr.ht respectively.
35
36---
37
38## New File: `Hutch/Views/Lookup/UserProfileViewModel.swift`
39
40Create an `@Observable @MainActor` view model following the same pattern as
41`RepositoryListViewModel` and `TrackerListViewModel`.
42
43```swift
44@Observable
45@MainActor
46final class UserProfileViewModel {
47 private(set) var repositories: [RepositorySummary] = []
48 private(set) var trackers: [TrackerSummary] = []
49 private(set) var isLoadingRepositories = false
50 private(set) var isLoadingTrackers = false
51 var repositoriesError: String?
52 var trackersError: String?
53
54 private let client: SRHTClient
55 let ownerUsername: String // without leading ~
56
57 init(ownerUsername: String, client: SRHTClient) { ... }
58
59 func loadRepositories() async { ... }
60 func loadTrackers() async { ... }
61}
62```
63
64**GraphQL queries:**
65
66Repositories (execute against `.git` service):
67```graphql
68query userRepositories($owner: String!) {
69 user(username: $owner) {
70 repositories {
71 results {
72 id rid name description visibility updated
73 owner { canonicalName }
74 HEAD { name target }
75 }
76 cursor
77 }
78 }
79}
80```
81
82Trackers (execute against `.todo` service):
83```graphql
84query userTrackers($owner: String!) {
85 user(username: $owner) {
86 trackers {
87 results {
88 id rid name description visibility updated
89 owner { canonicalName }
90 }
91 cursor
92 }
93 }
94}
95```
96
97Decode using private response structs identical to those in
98`RepositoryListViewModel` and `TrackerListViewModel`. Map results to
99`RepositorySummary` and `TrackerSummary` exactly as those view models do.
100
101---
102
103## Modified File: `Hutch/Views/Lookup/UserProfileView.swift`
104
105### View model instantiation
106
107Add `@State private var profileViewModel: UserProfileViewModel?` and initialise
108it in `.task` using `user.canonicalName` with the leading `~` stripped:
109
110```swift
111.task {
112 let owner = user.canonicalName.hasPrefix("~")
113 ? String(user.canonicalName.dropFirst())
114 : user.canonicalName
115 let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client)
116 profileViewModel = vm
117 async let repos: () = vm.loadRepositories()
118 async let trackers: () = vm.loadTrackers()
119 _ = await (repos, trackers)
120}
121```
122
123Restore `@Environment(AppState.self) private var appState` (it was removed in a
124recent commit but is needed for `client` access).
125
126### Repositories section
127
128Add after the existing metadata sections:
129
130```swift
131Section {
132 if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
133 ProgressView()
134 } else if viewModel.repositories.isEmpty {
135 Text("No public repositories.")
136 .foregroundStyle(.secondary)
137 } else {
138 ForEach(viewModel.repositories.prefix(4)) { repo in
139 NavigationLink {
140 RepositoryDetailView(repository: repo)
141 } label: {
142 RepositoryRowView(repository: repo, buildStatus: .none)
143 }
144 }
145 if viewModel.repositories.count > 4 {
146 NavigationLink("See All") {
147 UserRepositoriesView(viewModel: viewModel)
148 }
149 }
150 }
151} header: {
152 Text("Repositories")
153}
154```
155
156### Trackers section
157
158Immediately after the Repositories section:
159
160```swift
161Section {
162 if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty {
163 ProgressView()
164 } else if viewModel.trackers.isEmpty {
165 Text("No public trackers.")
166 .foregroundStyle(.secondary)
167 } else {
168 ForEach(viewModel.trackers.prefix(4)) { tracker in
169 NavigationLink {
170 TicketListView(tracker: tracker)
171 } label: {
172 TrackerRowView(tracker: tracker)
173 }
174 }
175 if viewModel.trackers.count > 4 {
176 NavigationLink("See All") {
177 UserTrackersView(viewModel: viewModel)
178 }
179 }
180 }
181} header: {
182 Text("Trackers")
183}
184```
185
186`TrackerRowView` — use the same VStack layout as the private `TrackerRowView`
187in `TrackerListView.swift`. Define it as a private struct in
188`UserProfileView.swift` rather than duplicating from `TrackerListView`.
189
190---
191
192## New File: `Hutch/Views/Lookup/UserRepositoriesView.swift`
193
194A simple full-list view for "See All" repositories:
195
196```swift
197struct UserRepositoriesView: View {
198 let viewModel: UserProfileViewModel
199
200 var body: some View {
201 List {
202 ForEach(viewModel.repositories) { repo in
203 NavigationLink {
204 RepositoryDetailView(repository: repo)
205 } label: {
206 RepositoryRowView(repository: repo, buildStatus: .none)
207 }
208 }
209 }
210 .listStyle(.plain)
211 .navigationTitle("Repositories")
212 .navigationBarTitleDisplayMode(.inline)
213 .overlay {
214 if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
215 SRHTLoadingStateView(message: "Loading repositories…")
216 }
217 }
218 .refreshable {
219 await viewModel.loadRepositories()
220 }
221 }
222}
223```
224
225## New File: `Hutch/Views/Lookup/UserTrackersView.swift`
226
227Same pattern as `UserRepositoriesView` but for trackers, navigating to
228`TicketListView(tracker:)`.
229
230---
231
232## Navigation context
233
234`UserProfileView` is always presented inside a `NavigationStack` via the sheet
235in `LookupView`. `NavigationLink` destinations push correctly within that stack.
236No changes to `LookupView` or `MoreRoute` are needed.
237
238---
239
240## Verification
241
2421. Look up a user with public repositories and trackers. Confirm both sections
243 appear below the profile metadata.
2442. Tap a repository row — confirm `RepositoryDetailView` pushes correctly.
2453. Tap a tracker row — confirm `TicketListView` pushes correctly.
2464. For users with more than 4 items, confirm "See All" pushes the full list.
2475. Look up a user with no public resources — confirm the empty-state text
248 renders in each section rather than crashing.
2496. Build with no warnings.