krz/hutch

an ios client for sourcehut

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

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