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}