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