krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.1.6: 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 .mailingList(let mailingList):
447                    MailingListDetailView(mailingList: mailingList)
448                case .thread(let thread):
449                    ThreadDetailView(
450                        thread: thread,
451                        onViewed: {
452                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
453                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
454                        },
455                        onMarkRead: {
456                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
457                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
458                        },
459                        onMarkUnread: {
460                            InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
461                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
462                        }
463                    )
464                case .manPageBrowser:
465                    ManPageBrowserView()
466                case .manPage(let url):
467                    ManPageDetailView(url: url)
468                }
469            }
470            .environment(appState)
471        }
472    }
473
474    @ViewBuilder
475    private func lookupDestination(_ result: LookupResult) -> some View {
476        switch result {
477        case .user(let user):
478            UserProfileView(user: user)
479        case .repository(let repository):
480            RepositoryDetailView(repository: repository)
481        case .mailingList(let mailingList):
482            MailingListDetailView(mailingList: mailingList)
483        case .tracker(let tracker):
484            TicketListView(tracker: tracker)
485        case .buildJob(let jobId):
486            BuildDetailView(jobId: jobId)
487        }
488    }
489}