krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.10.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, Codable, Sendable {
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 private(set) var history: [LookupHistoryEntry]
78 var error: String?
79
80 private let client: SRHTClient
81 private let appState: AppState
82 private let defaults: UserDefaults
83
84 var resultBinding: Binding<LookupResult?> {
85 Binding(
86 get: { self.result },
87 set: { newValue in
88 if newValue == nil {
89 self.result = nil
90 }
91 }
92 )
93 }
94
95 init(
96 client: SRHTClient,
97 appState: AppState,
98 defaults: UserDefaults = .standard,
99 initialQuery: String = "",
100 initialType: LookupType? = nil
101 ) {
102 self.client = client
103 self.appState = appState
104 self.defaults = defaults
105 self.history = LookupHistoryStore.load(defaults: defaults)
106 self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines)
107 if let initialType {
108 self.selectedType = initialType
109 }
110 }
111
112 func lookup() async {
113 result = nil
114 error = nil
115 isLooking = true
116 defer { isLooking = false }
117
118 do {
119 switch selectedType {
120 case .user:
121 result = try await lookupUser()
122 case .gitRepo:
123 result = try await lookupRepository(service: .git)
124 case .hgRepo:
125 result = try await lookupRepository(service: .hg)
126 case .mailingList:
127 result = try await lookupMailingList()
128 case .tracker:
129 result = try await lookupTracker()
130 case .buildJob:
131 result = try await lookupBuildJob()
132 }
133 } catch LookupError.invalidInput {
134 return
135 } catch {
136 self.error = error.userFacingMessage
137 }
138 }
139
140 func rerun(_ entry: LookupHistoryEntry) async {
141 selectedType = entry.type
142 inputText = entry.query
143 await lookup()
144 }
145
146 func clearHistory() {
147 LookupHistoryStore.clear(defaults: defaults)
148 history = []
149 }
150
151 private func parseOwnerAndName() -> (owner: String, name: String)? {
152 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
153 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
154 let parts = normalized.split(separator: "/", omittingEmptySubsequences: false)
155
156 guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else {
157 error = "Enter a value in the format ~username/name."
158 return nil
159 }
160
161 return (String(parts[0]), String(parts[1]))
162 }
163
164 private func parseUsername() -> String? {
165 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
166 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
167
168 guard !normalized.isEmpty else {
169 error = "Enter a username."
170 return nil
171 }
172
173 return normalized
174 }
175
176 private func parseBuildJobId() -> Int? {
177 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
178
179 guard let jobId = Int(trimmed) else {
180 error = "Enter a numeric build job ID."
181 return nil
182 }
183
184 return jobId
185 }
186
187 private func lookupUser() async throws -> LookupResult {
188 guard let username = parseUsername() else { throw LookupError.invalidInput }
189 recordHistory(type: .user, query: "~\(username)")
190
191 struct Response: Decodable, Sendable {
192 let user: User
193 }
194
195 let query = """
196 query userLookup($username: String!) {
197 user: userByName(username: $username) {
198 id
199 created
200 updated
201 canonicalName
202 username
203 email
204 url
205 location
206 bio
207 avatar
208 pronouns
209 userType
210 }
211 }
212 """
213
214 let result: Response
215 do {
216 result = try await client.execute(
217 service: .meta,
218 query: query,
219 variables: ["username": username],
220 responseType: Response.self
221 )
222 } catch {
223 lookupLogger.error(
224 """
225 User lookup failed
226 username: \(username, privacy: .public)
227 query:
228 \(query, privacy: .public)
229 error:
230 \(String(describing: error), privacy: .public)
231 """
232 )
233 throw error
234 }
235
236 return .user(result.user)
237 }
238
239 private func lookupRepository(service: SRHTService) async throws -> LookupResult {
240 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
241 let type: LookupType = service == .git ? .gitRepo : .hgRepo
242 recordHistory(type: type, query: "~\(owner)/\(name)")
243
244 let repository = try await appState.resolveRepository(owner: owner, name: name, service: service)
245 let resolvedRepository = RepositorySummary(
246 fields: .init(
247 id: repository.id,
248 rid: repository.rid,
249 service: service,
250 name: repository.name,
251 description: repository.description,
252 visibility: repository.visibility,
253 updated: repository.updated,
254 owner: repository.owner,
255 head: repository.head
256 )
257 )
258
259 return .repository(resolvedRepository)
260 }
261
262 private func lookupMailingList() async throws -> LookupResult {
263 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
264 recordHistory(type: .mailingList, query: "~\(owner)/\(name)")
265
266 struct Response: Decodable, Sendable {
267 let user: UserWithList
268 }
269
270 struct UserWithList: Decodable, Sendable {
271 let mailingList: InboxMailingListReference
272 }
273
274 let query = """
275 query mailingListLookup($owner: String!, $name: String!) {
276 user(username: $owner) {
277 mailingList: list(name: $name) {
278 id rid name owner { canonicalName }
279 }
280 }
281 }
282 """
283
284 let result = try await client.execute(
285 service: .lists,
286 query: query,
287 variables: ["owner": owner, "name": name],
288 responseType: Response.self
289 )
290
291 return .mailingList(result.user.mailingList)
292 }
293
294 private func lookupTracker() async throws -> LookupResult {
295 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
296 recordHistory(type: .tracker, query: "~\(owner)/\(name)")
297 let tracker = try await appState.resolveTracker(owner: owner, name: name)
298 return .tracker(tracker)
299 }
300
301 private func lookupBuildJob() async throws -> LookupResult {
302 guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
303 recordHistory(type: .buildJob, query: String(jobId))
304
305 struct Response: Decodable, Sendable {
306 let job: JobIdOnly
307 }
308
309 struct JobIdOnly: Decodable, Sendable {
310 let id: Int
311 }
312
313 let query = """
314 query buildLookup($id: Int!) {
315 job(id: $id) { id }
316 }
317 """
318
319 _ = try await client.execute(
320 service: .builds,
321 query: query,
322 variables: ["id": jobId],
323 responseType: Response.self
324 )
325
326 return .buildJob(jobId)
327 }
328
329 private enum LookupError: Error {
330 case invalidInput
331 }
332
333 private func recordHistory(type: LookupType, query: String) {
334 LookupHistoryStore.record(type: type, query: query, defaults: defaults)
335 history = LookupHistoryStore.load(defaults: defaults)
336 }
337}
338
339struct LookupView: View {
340 @Environment(AppState.self) private var appState
341 @State private var viewModel: LookupViewModel?
342 private let initialQuery: String
343 private let initialType: LookupType?
344
345 init(initialQuery: String = "", initialType: LookupType? = nil) {
346 self.initialQuery = initialQuery
347 self.initialType = initialType
348 }
349
350 var body: some View {
351 Group {
352 if let viewModel {
353 content(viewModel)
354 } else {
355 SRHTLoadingStateView(message: "Preparing lookup…")
356 }
357 }
358 .navigationTitle("Look Up")
359 .task {
360 if viewModel == nil {
361 viewModel = LookupViewModel(
362 client: appState.client,
363 appState: appState,
364 defaults: appState.accountDefaults,
365 initialQuery: initialQuery,
366 initialType: initialType
367 )
368 }
369 }
370 }
371
372 @ViewBuilder
373 private func content(_ viewModel: LookupViewModel) -> some View {
374 @Bindable var vm = viewModel
375
376 Form {
377 Section {
378 Picker("Type", selection: $vm.selectedType) {
379 ForEach(LookupType.allCases) { type in
380 Text(type.rawValue).tag(type)
381 }
382 }
383 .pickerStyle(.menu)
384 .themedRow()
385
386 TextField(
387 vm.selectedType.inputLabel,
388 text: $vm.inputText,
389 prompt: Text(vm.selectedType.placeholder)
390 )
391 .textInputAutocapitalization(.never)
392 .autocorrectionDisabled()
393 .submitLabel(.search)
394 .onSubmit {
395 Task { await vm.lookup() }
396 }
397 .themedRow()
398 }
399
400 Section {
401 HStack {
402 Button("Look Up") {
403 Task { await vm.lookup() }
404 }
405 .disabled(vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.isLooking)
406
407 if vm.isLooking {
408 Spacer()
409 ProgressView()
410 }
411 }
412 .themedRow()
413 }
414
415 if !vm.history.isEmpty {
416 Section("Recent Searches") {
417 ForEach(vm.history) { entry in
418 Button {
419 Task { await vm.rerun(entry) }
420 } label: {
421 HStack {
422 VStack(alignment: .leading, spacing: 2) {
423 Text(entry.query)
424 .foregroundStyle(.primary)
425 Text(entry.type.rawValue)
426 .font(.caption)
427 .foregroundStyle(.secondary)
428 }
429 Spacer()
430 }
431 }
432 .disabled(vm.isLooking)
433 }
434 .themedRow()
435
436 Button("Clear History", role: .destructive) {
437 vm.clearHistory()
438 }
439 .disabled(vm.isLooking)
440 .themedRow()
441 }
442 }
443 }
444 .themedList()
445 .formStyle(.grouped)
446 .srhtErrorBanner(error: $vm.error)
447 .sheet(item: vm.resultBinding) { result in
448 NavigationStack {
449 lookupDestination(result)
450 }
451 .navigationDestination(for: MoreRoute.self) { route in
452 switch route {
453 case .lookup(let query, let type):
454 LookupView(initialQuery: query ?? "", initialType: type)
455 case .projects:
456 ProjectsListView()
457 case .lists:
458 MailingListListView()
459 case .pastes:
460 PasteListView()
461 case .profile:
462 ProfileView()
463 case .systemStatus:
464 SystemStatusView()
465 case .settings:
466 SettingsView()
467 case .about:
468 AboutView()
469 case .userProfile(let owner):
470 UserProfileDeepLinkView(owner: owner)
471 case .projectDashboard(let id, let title):
472 ProjectDashboardDeepLinkView(projectID: id, title: title)
473 case .mailingList(let mailingList):
474 MailingListDetailView(mailingList: mailingList)
475 case .thread(let thread):
476 ThreadDetailView(
477 thread: thread,
478 onViewed: {
479 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
480 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
481 },
482 onMarkRead: {
483 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
484 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
485 },
486 onMarkUnread: {
487 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: appState.accountDefaults)
488 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
489 }
490 )
491 case .manPageBrowser:
492 ManPageBrowserView()
493 case .manPage(let url):
494 ManPageDetailView(url: url)
495 }
496 }
497 .environment(appState)
498 }
499 }
500
501 @ViewBuilder
502 private func lookupDestination(_ result: LookupResult) -> some View {
503 switch result {
504 case .user(let user):
505 UserProfileView(user: user)
506 case .repository(let repository):
507 RepositoryDetailView(repository: repository)
508 case .mailingList(let mailingList):
509 MailingListDetailView(mailingList: mailingList)
510 case .tracker(let tracker):
511 TicketListView(tracker: tracker)
512 case .buildJob(let jobId):
513 BuildDetailView(jobId: jobId)
514 }
515 }
516}