krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.9.0: Hutch/Views/Lookup/LookupView.swift · raw
1import SwiftUI
2import os
3
4private let lookupLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Lookup")
5
6enum LookupType: String, CaseIterable, Identifiable {
7 case user = "User"
8 case gitRepo = "Git Repo"
9 case hgRepo = "Hg Repo"
10 case mailingList = "Mailing List"
11 case tracker = "Tracker"
12 case buildJob = "Build Job"
13
14 var id: String { rawValue }
15
16 var placeholder: String {
17 switch self {
18 case .user:
19 "~username"
20 case .gitRepo, .hgRepo:
21 "~username/repo-name"
22 case .mailingList:
23 "~username/list-name"
24 case .tracker:
25 "~username/tracker-name"
26 case .buildJob:
27 "Job ID (e.g. 123456)"
28 }
29 }
30
31 var inputLabel: String {
32 switch self {
33 case .user:
34 "Username"
35 case .gitRepo, .hgRepo:
36 "Repository"
37 case .mailingList:
38 "Mailing List"
39 case .tracker:
40 "Tracker"
41 case .buildJob:
42 "Build Job ID"
43 }
44 }
45}
46
47enum LookupResult: Identifiable {
48 case user(User)
49 case repository(RepositorySummary)
50 case mailingList(InboxMailingListReference)
51 case tracker(TrackerSummary)
52 case buildJob(Int)
53
54 var id: String {
55 switch self {
56 case .user(let user):
57 "user:\(user.id)"
58 case .repository(let repository):
59 "repo:\(repository.id)"
60 case .mailingList(let mailingList):
61 "list:\(mailingList.id)"
62 case .tracker(let tracker):
63 "tracker:\(tracker.id)"
64 case .buildJob(let jobId):
65 "job:\(jobId)"
66 }
67 }
68}
69
70@Observable
71@MainActor
72final class LookupViewModel {
73 var selectedType: LookupType = .user
74 var inputText: String = ""
75 private(set) var result: LookupResult?
76 private(set) var isLooking = false
77 var error: String?
78
79 private let client: SRHTClient
80 private let appState: AppState
81
82 var resultBinding: Binding<LookupResult?> {
83 Binding(
84 get: { self.result },
85 set: { newValue in
86 if newValue == nil {
87 self.result = nil
88 }
89 }
90 )
91 }
92
93 init(client: SRHTClient, appState: AppState) {
94 self.client = client
95 self.appState = appState
96 }
97
98 func lookup() async {
99 result = nil
100 error = nil
101 isLooking = true
102 defer { isLooking = false }
103
104 do {
105 switch selectedType {
106 case .user:
107 result = try await lookupUser()
108 case .gitRepo:
109 result = try await lookupRepository(service: .git)
110 case .hgRepo:
111 result = try await lookupRepository(service: .hg)
112 case .mailingList:
113 result = try await lookupMailingList()
114 case .tracker:
115 result = try await lookupTracker()
116 case .buildJob:
117 result = try await lookupBuildJob()
118 }
119 } catch LookupError.invalidInput {
120 return
121 } catch {
122 self.error = error.userFacingMessage
123 }
124 }
125
126 private func parseOwnerAndName() -> (owner: String, name: String)? {
127 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
128 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
129 let parts = normalized.split(separator: "/", omittingEmptySubsequences: false)
130
131 guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else {
132 error = "Enter a value in the format ~username/name."
133 return nil
134 }
135
136 return (String(parts[0]), String(parts[1]))
137 }
138
139 private func parseUsername() -> String? {
140 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
141 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
142
143 guard !normalized.isEmpty else {
144 error = "Enter a username."
145 return nil
146 }
147
148 return normalized
149 }
150
151 private func parseBuildJobId() -> Int? {
152 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
153
154 guard let jobId = Int(trimmed) else {
155 error = "Enter a numeric build job ID."
156 return nil
157 }
158
159 return jobId
160 }
161
162 private func lookupUser() async throws -> LookupResult {
163 guard let username = parseUsername() else { throw LookupError.invalidInput }
164
165 struct Response: Decodable, Sendable {
166 let user: User
167 }
168
169 let query = """
170 query userLookup($username: String!) {
171 user: userByName(username: $username) {
172 id
173 created
174 updated
175 canonicalName
176 username
177 email
178 url
179 location
180 bio
181 avatar
182 pronouns
183 userType
184 }
185 }
186 """
187
188 lookupLogger.debug("Looking up user profile for username: \(username, privacy: .public)")
189
190 let result: Response
191 do {
192 result = try await client.execute(
193 service: .meta,
194 query: query,
195 variables: ["username": username],
196 responseType: Response.self
197 )
198 } catch {
199 lookupLogger.error(
200 """
201 User lookup failed
202 username: \(username, privacy: .public)
203 query:
204 \(query, privacy: .public)
205 error:
206 \(String(describing: error), privacy: .public)
207 """
208 )
209 throw error
210 }
211
212 lookupLogger.debug("User lookup succeeded for username: \(username, privacy: .public)")
213
214 return .user(result.user)
215 }
216
217 private func lookupRepository(service: SRHTService) async throws -> LookupResult {
218 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
219
220 let repository = try await appState.resolveRepository(owner: owner, name: name, service: service)
221 let resolvedRepository = RepositorySummary(
222 id: repository.id,
223 rid: repository.rid,
224 service: service,
225 name: repository.name,
226 description: repository.description,
227 visibility: repository.visibility,
228 updated: repository.updated,
229 owner: repository.owner,
230 head: repository.head
231 )
232
233 return .repository(resolvedRepository)
234 }
235
236 private func lookupMailingList() async throws -> LookupResult {
237 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
238
239 struct Response: Decodable, Sendable {
240 let user: UserWithList
241 }
242
243 struct UserWithList: Decodable, Sendable {
244 let mailingList: InboxMailingListReference
245 }
246
247 let query = """
248 query mailingListLookup($owner: String!, $name: String!) {
249 user(username: $owner) {
250 mailingList: list(name: $name) {
251 id rid name owner { canonicalName }
252 }
253 }
254 }
255 """
256
257 let result = try await client.execute(
258 service: .lists,
259 query: query,
260 variables: ["owner": owner, "name": name],
261 responseType: Response.self
262 )
263
264 return .mailingList(result.user.mailingList)
265 }
266
267 private func lookupTracker() async throws -> LookupResult {
268 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
269 let tracker = try await appState.resolveTracker(owner: owner, name: name)
270 return .tracker(tracker)
271 }
272
273 private func lookupBuildJob() async throws -> LookupResult {
274 guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
275
276 struct Response: Decodable, Sendable {
277 let job: JobIdOnly
278 }
279
280 struct JobIdOnly: Decodable, Sendable {
281 let id: Int
282 }
283
284 let query = """
285 query buildLookup($id: Int!) {
286 job(id: $id) { id }
287 }
288 """
289
290 _ = try await client.execute(
291 service: .builds,
292 query: query,
293 variables: ["id": jobId],
294 responseType: Response.self
295 )
296
297 return .buildJob(jobId)
298 }
299
300 private enum LookupError: Error {
301 case invalidInput
302 }
303}
304
305struct LookupView: View {
306 @Environment(AppState.self) private var appState
307 @State private var viewModel: LookupViewModel?
308
309 var body: some View {
310 Group {
311 if let viewModel {
312 content(viewModel)
313 } else {
314 SRHTLoadingStateView(message: "Preparing lookup…")
315 }
316 }
317 .navigationTitle("Look Up")
318 .task {
319 if viewModel == nil {
320 viewModel = LookupViewModel(client: appState.client, appState: appState)
321 }
322 }
323 }
324
325 @ViewBuilder
326 private func content(_ viewModel: LookupViewModel) -> some View {
327 @Bindable var vm = viewModel
328
329 Form {
330 Section {
331 Picker("Type", selection: $vm.selectedType) {
332 ForEach(LookupType.allCases) { type in
333 Text(type.rawValue).tag(type)
334 }
335 }
336 .pickerStyle(.menu)
337
338 TextField(
339 vm.selectedType.inputLabel,
340 text: $vm.inputText,
341 prompt: Text(vm.selectedType.placeholder)
342 )
343 .textInputAutocapitalization(.never)
344 .autocorrectionDisabled()
345 .submitLabel(.search)
346 .onSubmit {
347 Task { await vm.lookup() }
348 }
349 }
350
351 Section {
352 HStack {
353 Button("Look Up") {
354 Task { await vm.lookup() }
355 }
356 .disabled(vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.isLooking)
357
358 if vm.isLooking {
359 Spacer()
360 ProgressView()
361 }
362 }
363 }
364 }
365 .formStyle(.grouped)
366 .srhtErrorBanner(error: $vm.error)
367 .sheet(item: vm.resultBinding) { result in
368 NavigationStack {
369 lookupDestination(result)
370 }
371 .navigationDestination(for: MoreRoute.self) { route in
372 switch route {
373 case .lookup:
374 LookupView()
375 case .lists:
376 MailingListListView()
377 case .pastes:
378 PasteListView()
379 case .settings:
380 SettingsView()
381 case .mailingList(let mailingList):
382 MailingListDetailView(mailingList: mailingList)
383 case .thread(let thread):
384 ThreadDetailView(
385 thread: thread,
386 onViewed: {
387 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
388 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
389 },
390 onMarkRead: {
391 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
392 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
393 },
394 onMarkUnread: {
395 InboxReadStateStore.markUnread(for: thread.id)
396 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
397 }
398 )
399 }
400 }
401 .environment(appState)
402 }
403 }
404
405 @ViewBuilder
406 private func lookupDestination(_ result: LookupResult) -> some View {
407 switch result {
408 case .user(let user):
409 UserProfileView(user: user)
410 case .repository(let repository):
411 RepositoryDetailView(repository: repository)
412 case .mailingList(let mailingList):
413 MailingListDetailView(mailingList: mailingList)
414 case .tracker(let tracker):
415 TicketListView(tracker: tracker)
416 case .buildJob(let jobId):
417 BuildDetailView(jobId: jobId)
418 }
419 }
420}