krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.16.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 id: repository.id,
237 rid: repository.rid,
238 service: service,
239 name: repository.name,
240 description: repository.description,
241 visibility: repository.visibility,
242 updated: repository.updated,
243 owner: repository.owner,
244 head: repository.head
245 )
246
247 return .repository(resolvedRepository)
248 }
249
250 private func lookupMailingList() async throws -> LookupResult {
251 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
252 recordHistory(type: .mailingList, query: "~\(owner)/\(name)")
253
254 struct Response: Decodable, Sendable {
255 let user: UserWithList
256 }
257
258 struct UserWithList: Decodable, Sendable {
259 let mailingList: InboxMailingListReference
260 }
261
262 let query = """
263 query mailingListLookup($owner: String!, $name: String!) {
264 user(username: $owner) {
265 mailingList: list(name: $name) {
266 id rid name owner { canonicalName }
267 }
268 }
269 }
270 """
271
272 let result = try await client.execute(
273 service: .lists,
274 query: query,
275 variables: ["owner": owner, "name": name],
276 responseType: Response.self
277 )
278
279 return .mailingList(result.user.mailingList)
280 }
281
282 private func lookupTracker() async throws -> LookupResult {
283 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
284 recordHistory(type: .tracker, query: "~\(owner)/\(name)")
285 let tracker = try await appState.resolveTracker(owner: owner, name: name)
286 return .tracker(tracker)
287 }
288
289 private func lookupBuildJob() async throws -> LookupResult {
290 guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
291 recordHistory(type: .buildJob, query: String(jobId))
292
293 struct Response: Decodable, Sendable {
294 let job: JobIdOnly
295 }
296
297 struct JobIdOnly: Decodable, Sendable {
298 let id: Int
299 }
300
301 let query = """
302 query buildLookup($id: Int!) {
303 job(id: $id) { id }
304 }
305 """
306
307 _ = try await client.execute(
308 service: .builds,
309 query: query,
310 variables: ["id": jobId],
311 responseType: Response.self
312 )
313
314 return .buildJob(jobId)
315 }
316
317 private enum LookupError: Error {
318 case invalidInput
319 }
320
321 private func recordHistory(type: LookupType, query: String) {
322 LookupHistoryStore.record(type: type, query: query, defaults: defaults)
323 history = LookupHistoryStore.load(defaults: defaults)
324 }
325}
326
327struct LookupView: View {
328 @Environment(AppState.self) private var appState
329 @State private var viewModel: LookupViewModel?
330
331 var body: some View {
332 Group {
333 if let viewModel {
334 content(viewModel)
335 } else {
336 SRHTLoadingStateView(message: "Preparing lookup…")
337 }
338 }
339 .navigationTitle("Look Up")
340 .task {
341 if viewModel == nil {
342 viewModel = LookupViewModel(client: appState.client, appState: appState)
343 }
344 }
345 }
346
347 @ViewBuilder
348 private func content(_ viewModel: LookupViewModel) -> some View {
349 @Bindable var vm = viewModel
350
351 Form {
352 Section {
353 Picker("Type", selection: $vm.selectedType) {
354 ForEach(LookupType.allCases) { type in
355 Text(type.rawValue).tag(type)
356 }
357 }
358 .pickerStyle(.menu)
359
360 TextField(
361 vm.selectedType.inputLabel,
362 text: $vm.inputText,
363 prompt: Text(vm.selectedType.placeholder)
364 )
365 .textInputAutocapitalization(.never)
366 .autocorrectionDisabled()
367 .submitLabel(.search)
368 .onSubmit {
369 Task { await vm.lookup() }
370 }
371 }
372
373 Section {
374 HStack {
375 Button("Look Up") {
376 Task { await vm.lookup() }
377 }
378 .disabled(vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.isLooking)
379
380 if vm.isLooking {
381 Spacer()
382 ProgressView()
383 }
384 }
385 }
386
387 if !vm.history.isEmpty {
388 Section("Recent Searches") {
389 ForEach(vm.history) { entry in
390 Button {
391 Task { await vm.rerun(entry) }
392 } label: {
393 HStack {
394 VStack(alignment: .leading, spacing: 2) {
395 Text(entry.query)
396 .foregroundStyle(.primary)
397 Text(entry.type.rawValue)
398 .font(.caption)
399 .foregroundStyle(.secondary)
400 }
401 Spacer()
402 }
403 }
404 .disabled(vm.isLooking)
405 }
406
407 Button("Clear History", role: .destructive) {
408 vm.clearHistory()
409 }
410 .disabled(vm.isLooking)
411 }
412 }
413 }
414 .formStyle(.grouped)
415 .srhtErrorBanner(error: $vm.error)
416 .sheet(item: vm.resultBinding) { result in
417 NavigationStack {
418 lookupDestination(result)
419 }
420 .navigationDestination(for: MoreRoute.self) { route in
421 switch route {
422 case .lookup:
423 LookupView()
424 case .lists:
425 MailingListListView()
426 case .pastes:
427 PasteListView()
428 case .profile:
429 ProfileView()
430 case .systemStatus:
431 SystemStatusView()
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}