krz/hutch

an ios client for sourcehut

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

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