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