krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.8.1: Hutch/Views/Lookup/LookupView.swift · raw
1import SwiftUI
2
3enum LookupType: String, CaseIterable, Identifiable {
4 case user = "User"
5 case gitRepo = "Git Repo"
6 case hgRepo = "Hg Repo"
7 case mailingList = "Mailing List"
8 case tracker = "Tracker"
9 case buildJob = "Build Job"
10
11 var id: String { rawValue }
12
13 var placeholder: String {
14 switch self {
15 case .user:
16 "~username"
17 case .gitRepo, .hgRepo:
18 "~username/repo-name"
19 case .mailingList:
20 "~username/list-name"
21 case .tracker:
22 "~username/tracker-name"
23 case .buildJob:
24 "Job ID (e.g. 123456)"
25 }
26 }
27
28 var inputLabel: String {
29 switch self {
30 case .user:
31 "Username"
32 case .gitRepo, .hgRepo:
33 "Repository"
34 case .mailingList:
35 "Mailing List"
36 case .tracker:
37 "Tracker"
38 case .buildJob:
39 "Build Job ID"
40 }
41 }
42}
43
44enum LookupResult: Identifiable {
45 case user(User)
46 case repository(RepositorySummary)
47 case mailingList(InboxMailingListReference)
48 case tracker(TrackerSummary)
49 case buildJob(Int)
50
51 var id: String {
52 switch self {
53 case .user(let user):
54 "user:\(user.id)"
55 case .repository(let repository):
56 "repo:\(repository.id)"
57 case .mailingList(let mailingList):
58 "list:\(mailingList.id)"
59 case .tracker(let tracker):
60 "tracker:\(tracker.id)"
61 case .buildJob(let jobId):
62 "job:\(jobId)"
63 }
64 }
65}
66
67@Observable
68@MainActor
69final class LookupViewModel {
70 var selectedType: LookupType = .user
71 var inputText: String = ""
72 private(set) var result: LookupResult?
73 private(set) var isLooking = false
74 var error: String?
75
76 private let client: SRHTClient
77 private let appState: AppState
78
79 var resultBinding: Binding<LookupResult?> {
80 Binding(
81 get: { self.result },
82 set: { newValue in
83 if newValue == nil {
84 self.result = nil
85 }
86 }
87 )
88 }
89
90 init(client: SRHTClient, appState: AppState) {
91 self.client = client
92 self.appState = appState
93 }
94
95 func lookup() async {
96 result = nil
97 error = nil
98 isLooking = true
99 defer { isLooking = false }
100
101 do {
102 switch selectedType {
103 case .user:
104 result = try await lookupUser()
105 case .gitRepo:
106 result = try await lookupRepository(service: .git)
107 case .hgRepo:
108 result = try await lookupRepository(service: .hg)
109 case .mailingList:
110 result = try await lookupMailingList()
111 case .tracker:
112 result = try await lookupTracker()
113 case .buildJob:
114 result = try await lookupBuildJob()
115 }
116 } catch LookupError.invalidInput {
117 return
118 } catch {
119 self.error = error.userFacingMessage
120 }
121 }
122
123 private func parseOwnerAndName() -> (owner: String, name: String)? {
124 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
125 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
126 let parts = normalized.split(separator: "/", omittingEmptySubsequences: false)
127
128 guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else {
129 error = "Enter a value in the format ~username/name."
130 return nil
131 }
132
133 return (String(parts[0]), String(parts[1]))
134 }
135
136 private func parseUsername() -> String? {
137 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
138 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
139
140 guard !normalized.isEmpty else {
141 error = "Enter a username."
142 return nil
143 }
144
145 return normalized
146 }
147
148 private func parseBuildJobId() -> Int? {
149 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
150
151 guard let jobId = Int(trimmed) else {
152 error = "Enter a numeric build job ID."
153 return nil
154 }
155
156 return jobId
157 }
158
159 private func lookupUser() async throws -> LookupResult {
160 guard let username = parseUsername() else { throw LookupError.invalidInput }
161
162 struct Response: Decodable, Sendable {
163 let user: User
164 }
165
166 let query = """
167 query userLookup($username: String!) {
168 user: userByName(username: $username) {
169 id username canonicalName email avatar
170 }
171 }
172 """
173
174 let result = try await client.execute(
175 service: .meta,
176 query: query,
177 variables: ["username": username],
178 responseType: Response.self
179 )
180
181 return .user(result.user)
182 }
183
184 private func lookupRepository(service: SRHTService) async throws -> LookupResult {
185 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
186
187 let repository = try await appState.resolveRepository(owner: owner, name: name, service: service)
188 let resolvedRepository = RepositorySummary(
189 id: repository.id,
190 rid: repository.rid,
191 service: service,
192 name: repository.name,
193 description: repository.description,
194 visibility: repository.visibility,
195 updated: repository.updated,
196 owner: repository.owner,
197 head: repository.head
198 )
199
200 return .repository(resolvedRepository)
201 }
202
203 private func lookupMailingList() async throws -> LookupResult {
204 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
205
206 struct Response: Decodable, Sendable {
207 let user: UserWithList
208 }
209
210 struct UserWithList: Decodable, Sendable {
211 let mailingList: InboxMailingListReference
212 }
213
214 let query = """
215 query mailingListLookup($owner: String!, $name: String!) {
216 user(username: $owner) {
217 mailingList: list(name: $name) {
218 id rid name owner { canonicalName }
219 }
220 }
221 }
222 """
223
224 let result = try await client.execute(
225 service: .lists,
226 query: query,
227 variables: ["owner": owner, "name": name],
228 responseType: Response.self
229 )
230
231 return .mailingList(result.user.mailingList)
232 }
233
234 private func lookupTracker() async throws -> LookupResult {
235 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
236 let tracker = try await appState.resolveTracker(owner: owner, name: name)
237 return .tracker(tracker)
238 }
239
240 private func lookupBuildJob() async throws -> LookupResult {
241 guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
242
243 struct Response: Decodable, Sendable {
244 let job: JobIdOnly
245 }
246
247 struct JobIdOnly: Decodable, Sendable {
248 let id: Int
249 }
250
251 let query = """
252 query buildLookup($id: Int!) {
253 job(id: $id) { id }
254 }
255 """
256
257 _ = try await client.execute(
258 service: .builds,
259 query: query,
260 variables: ["id": jobId],
261 responseType: Response.self
262 )
263
264 return .buildJob(jobId)
265 }
266
267 private enum LookupError: Error {
268 case invalidInput
269 }
270}
271
272struct LookupView: View {
273 @Environment(AppState.self) private var appState
274 @State private var viewModel: LookupViewModel?
275
276 var body: some View {
277 Group {
278 if let viewModel {
279 content(viewModel)
280 } else {
281 SRHTLoadingStateView(message: "Preparing lookup…")
282 }
283 }
284 .navigationTitle("Look Up")
285 .task {
286 if viewModel == nil {
287 viewModel = LookupViewModel(client: appState.client, appState: appState)
288 }
289 }
290 }
291
292 @ViewBuilder
293 private func content(_ viewModel: LookupViewModel) -> some View {
294 @Bindable var vm = viewModel
295
296 Form {
297 Section {
298 Picker("Type", selection: $vm.selectedType) {
299 ForEach(LookupType.allCases) { type in
300 Text(type.rawValue).tag(type)
301 }
302 }
303 .pickerStyle(.menu)
304
305 TextField(
306 vm.selectedType.inputLabel,
307 text: $vm.inputText,
308 prompt: Text(vm.selectedType.placeholder)
309 )
310 .textInputAutocapitalization(.never)
311 .autocorrectionDisabled()
312 .submitLabel(.search)
313 .onSubmit {
314 Task { await vm.lookup() }
315 }
316 }
317
318 Section {
319 HStack {
320 Button("Look Up") {
321 Task { await vm.lookup() }
322 }
323 .disabled(vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.isLooking)
324
325 if vm.isLooking {
326 Spacer()
327 ProgressView()
328 }
329 }
330 }
331 }
332 .formStyle(.grouped)
333 .srhtErrorBanner(error: $vm.error)
334 .sheet(item: vm.resultBinding) { result in
335 NavigationStack {
336 lookupDestination(result)
337 }
338 .navigationDestination(for: MoreRoute.self) { route in
339 switch route {
340 case .lookup:
341 LookupView()
342 case .lists:
343 MailingListListView()
344 case .pastes:
345 PasteListView()
346 case .settings:
347 SettingsView()
348 case .mailingList(let mailingList):
349 MailingListDetailView(mailingList: mailingList)
350 case .thread(let thread):
351 ThreadDetailView(
352 thread: thread,
353 onViewed: {
354 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
355 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
356 },
357 onMarkRead: {
358 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
359 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
360 },
361 onMarkUnread: {
362 InboxReadStateStore.markUnread(for: thread.id)
363 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
364 }
365 )
366 }
367 }
368 .environment(appState)
369 }
370 }
371
372 @ViewBuilder
373 private func lookupDestination(_ result: LookupResult) -> some View {
374 switch result {
375 case .user(let user):
376 UserProfileView(user: user)
377 case .repository(let repository):
378 RepositoryDetailView(repository: repository)
379 case .mailingList(let mailingList):
380 MailingListDetailView(mailingList: mailingList)
381 case .tracker(let tracker):
382 TicketListView(tracker: tracker)
383 case .buildJob(let jobId):
384 BuildDetailView(jobId: jobId)
385 }
386 }
387}