krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.4.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(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 let result: Response
205 do {
206 result = try await client.execute(
207 service: .meta,
208 query: query,
209 variables: ["username": username],
210 responseType: Response.self
211 )
212 } catch {
213 lookupLogger.error(
214 """
215 User lookup failed
216 username: \(username, privacy: .public)
217 query:
218 \(query, privacy: .public)
219 error:
220 \(String(describing: error), privacy: .public)
221 """
222 )
223 throw error
224 }
225
226 return .user(result.user)
227 }
228
229 private func lookupRepository(service: SRHTService) async throws -> LookupResult {
230 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
231 let type: LookupType = service == .git ? .gitRepo : .hgRepo
232 recordHistory(type: type, query: "~\(owner)/\(name)")
233
234 let repository = try await appState.resolveRepository(owner: owner, name: name, service: service)
235 let resolvedRepository = RepositorySummary(
236 fields: .init(
237 id: repository.id,
238 rid: repository.rid,
239 service: service,
240 name: repository.name,
241 description: repository.description,
242 visibility: repository.visibility,
243 updated: repository.updated,
244 owner: repository.owner,
245 head: repository.head
246 )
247 )
248
249 return .repository(resolvedRepository)
250 }
251
252 private func lookupMailingList() async throws -> LookupResult {
253 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
254 recordHistory(type: .mailingList, query: "~\(owner)/\(name)")
255
256 struct Response: Decodable, Sendable {
257 let user: UserWithList
258 }
259
260 struct UserWithList: Decodable, Sendable {
261 let mailingList: InboxMailingListReference
262 }
263
264 let query = """
265 query mailingListLookup($owner: String!, $name: String!) {
266 user(username: $owner) {
267 mailingList: list(name: $name) {
268 id rid name owner { canonicalName }
269 }
270 }
271 }
272 """
273
274 let result = try await client.execute(
275 service: .lists,
276 query: query,
277 variables: ["owner": owner, "name": name],
278 responseType: Response.self
279 )
280
281 return .mailingList(result.user.mailingList)
282 }
283
284 private func lookupTracker() async throws -> LookupResult {
285 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
286 recordHistory(type: .tracker, query: "~\(owner)/\(name)")
287 let tracker = try await appState.resolveTracker(owner: owner, name: name)
288 return .tracker(tracker)
289 }
290
291 private func lookupBuildJob() async throws -> LookupResult {
292 guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
293 recordHistory(type: .buildJob, query: String(jobId))
294
295 struct Response: Decodable, Sendable {
296 let job: JobIdOnly
297 }
298
299 struct JobIdOnly: Decodable, Sendable {
300 let id: Int
301 }
302
303 let query = """
304 query buildLookup($id: Int!) {
305 job(id: $id) { id }
306 }
307 """
308
309 _ = try await client.execute(
310 service: .builds,
311 query: query,
312 variables: ["id": jobId],
313 responseType: Response.self
314 )
315
316 return .buildJob(jobId)
317 }
318
319 private enum LookupError: Error {
320 case invalidInput
321 }
322
323 private func recordHistory(type: LookupType, query: String) {
324 LookupHistoryStore.record(type: type, query: query, defaults: defaults)
325 history = LookupHistoryStore.load(defaults: defaults)
326 }
327}
328
329struct LookupView: View {
330 @Environment(AppState.self) private var appState
331 @State private var viewModel: LookupViewModel?
332
333 var body: some View {
334 Group {
335 if let viewModel {
336 content(viewModel)
337 } else {
338 SRHTLoadingStateView(message: "Preparing lookup…")
339 }
340 }
341 .navigationTitle("Look Up")
342 .task {
343 if viewModel == nil {
344 viewModel = LookupViewModel(client: appState.client, appState: appState, defaults: appState.accountDefaults)
345 }
346 }
347 }
348
349 @ViewBuilder
350 private func content(_ viewModel: LookupViewModel) -> some View {
351 @Bindable var vm = viewModel
352
353 Form {
354 Section {
355 Picker("Type", selection: $vm.selectedType) {
356 ForEach(LookupType.allCases) { type in
357 Text(type.rawValue).tag(type)
358 }
359 }
360 .pickerStyle(.menu)
361 .themedRow()
362
363 TextField(
364 vm.selectedType.inputLabel,
365 text: $vm.inputText,
366 prompt: Text(vm.selectedType.placeholder)
367 )
368 .textInputAutocapitalization(.never)
369 .autocorrectionDisabled()
370 .submitLabel(.search)
371 .onSubmit {
372 Task { await vm.lookup() }
373 }
374 .themedRow()
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 .themedRow()
390 }
391
392 if !vm.history.isEmpty {
393 Section("Recent Searches") {
394 ForEach(vm.history) { entry in
395 Button {
396 Task { await vm.rerun(entry) }
397 } label: {
398 HStack {
399 VStack(alignment: .leading, spacing: 2) {
400 Text(entry.query)
401 .foregroundStyle(.primary)
402 Text(entry.type.rawValue)
403 .font(.caption)
404 .foregroundStyle(.secondary)
405 }
406 Spacer()
407 }
408 }
409 .disabled(vm.isLooking)
410 }
411 .themedRow()
412
413 Button("Clear History", role: .destructive) {
414 vm.clearHistory()
415 }
416 .disabled(vm.isLooking)
417 .themedRow()
418 }
419 }
420 }
421 .themedList()
422 .formStyle(.grouped)
423 .srhtErrorBanner(error: $vm.error)
424 .sheet(item: vm.resultBinding) { result in
425 NavigationStack {
426 lookupDestination(result)
427 }
428 .navigationDestination(for: MoreRoute.self) { route in
429 switch route {
430 case .lookup:
431 LookupView()
432 case .projects:
433 ProjectsListView()
434 case .lists:
435 MailingListListView()
436 case .pastes:
437 PasteListView()
438 case .profile:
439 ProfileView()
440 case .systemStatus:
441 SystemStatusView()
442 case .settings:
443 SettingsView()
444 case .about:
445 AboutView()
446 case .userProfile(let owner):
447 UserProfileDeepLinkView(owner: owner)
448 case .mailingList(let mailingList):
449 MailingListDetailView(mailingList: mailingList)
450 case .thread(let thread):
451 ThreadDetailView(
452 thread: thread,
453 onViewed: {
454 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
455 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
456 },
457 onMarkRead: {
458 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
459 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
460 },
461 onMarkUnread: {
462 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
463 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
464 }
465 )
466 case .manPageBrowser:
467 ManPageBrowserView()
468 case .manPage(let url):
469 ManPageDetailView(url: url)
470 }
471 }
472 .environment(appState)
473 }
474 }
475
476 @ViewBuilder
477 private func lookupDestination(_ result: LookupResult) -> some View {
478 switch result {
479 case .user(let user):
480 UserProfileView(user: user)
481 case .repository(let repository):
482 RepositoryDetailView(repository: repository)
483 case .mailingList(let mailingList):
484 MailingListDetailView(mailingList: mailingList)
485 case .tracker(let tracker):
486 TicketListView(tracker: tracker)
487 case .buildJob(let jobId):
488 BuildDetailView(jobId: jobId)
489 }
490 }
491}