summaryrefslogtreecommitdiff
path: root/Hutch/Views/Lookup/LookupView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Lookup/LookupView.swift')
-rw-r--r--Hutch/Views/Lookup/LookupView.swift387
1 files changed, 387 insertions, 0 deletions
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
new file mode 100644
index 0000000..9f42d9e
--- /dev/null
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -0,0 +1,387 @@
+import SwiftUI
+
+enum LookupType: String, CaseIterable, Identifiable {
+ case user = "User"
+ case gitRepo = "Git Repo"
+ case hgRepo = "Hg Repo"
+ case mailingList = "Mailing List"
+ case tracker = "Tracker"
+ case buildJob = "Build Job"
+
+ var id: String { rawValue }
+
+ var placeholder: String {
+ switch self {
+ case .user:
+ "~username"
+ case .gitRepo, .hgRepo:
+ "~username/repo-name"
+ case .mailingList:
+ "~username/list-name"
+ case .tracker:
+ "~username/tracker-name"
+ case .buildJob:
+ "Job ID (e.g. 123456)"
+ }
+ }
+
+ var inputLabel: String {
+ switch self {
+ case .user:
+ "Username"
+ case .gitRepo, .hgRepo:
+ "Repository"
+ case .mailingList:
+ "Mailing List"
+ case .tracker:
+ "Tracker"
+ case .buildJob:
+ "Build Job ID"
+ }
+ }
+}
+
+enum LookupResult: Identifiable {
+ case user(User)
+ case repository(RepositorySummary)
+ case mailingList(InboxMailingListReference)
+ case tracker(TrackerSummary)
+ case buildJob(Int)
+
+ var id: String {
+ switch self {
+ case .user(let user):
+ "user:\(user.id)"
+ case .repository(let repository):
+ "repo:\(repository.id)"
+ case .mailingList(let mailingList):
+ "list:\(mailingList.id)"
+ case .tracker(let tracker):
+ "tracker:\(tracker.id)"
+ case .buildJob(let jobId):
+ "job:\(jobId)"
+ }
+ }
+}
+
+@Observable
+@MainActor
+final class LookupViewModel {
+ var selectedType: LookupType = .user
+ var inputText: String = ""
+ private(set) var result: LookupResult?
+ private(set) var isLooking = false
+ var error: String?
+
+ private let client: SRHTClient
+ private let appState: AppState
+
+ var resultBinding: Binding<LookupResult?> {
+ Binding(
+ get: { self.result },
+ set: { newValue in
+ if newValue == nil {
+ self.result = nil
+ }
+ }
+ )
+ }
+
+ init(client: SRHTClient, appState: AppState) {
+ self.client = client
+ self.appState = appState
+ }
+
+ func lookup() async {
+ result = nil
+ error = nil
+ isLooking = true
+ defer { isLooking = false }
+
+ do {
+ switch selectedType {
+ case .user:
+ result = try await lookupUser()
+ case .gitRepo:
+ result = try await lookupRepository(service: .git)
+ case .hgRepo:
+ result = try await lookupRepository(service: .hg)
+ case .mailingList:
+ result = try await lookupMailingList()
+ case .tracker:
+ result = try await lookupTracker()
+ case .buildJob:
+ result = try await lookupBuildJob()
+ }
+ } catch LookupError.invalidInput {
+ return
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+
+ private func parseOwnerAndName() -> (owner: String, name: String)? {
+ let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
+ let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+ let parts = normalized.split(separator: "/", omittingEmptySubsequences: false)
+
+ guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else {
+ error = "Enter a value in the format ~username/name."
+ return nil
+ }
+
+ return (String(parts[0]), String(parts[1]))
+ }
+
+ private func parseUsername() -> String? {
+ let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
+ let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+
+ guard !normalized.isEmpty else {
+ error = "Enter a username."
+ return nil
+ }
+
+ return normalized
+ }
+
+ private func parseBuildJobId() -> Int? {
+ let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ guard let jobId = Int(trimmed) else {
+ error = "Enter a numeric build job ID."
+ return nil
+ }
+
+ return jobId
+ }
+
+ private func lookupUser() async throws -> LookupResult {
+ guard let username = parseUsername() else { throw LookupError.invalidInput }
+
+ struct Response: Decodable, Sendable {
+ let user: User
+ }
+
+ let query = """
+ query userLookup($username: String!) {
+ user: userByName(username: $username) {
+ id username canonicalName email avatar
+ }
+ }
+ """
+
+ let result = try await client.execute(
+ service: .meta,
+ query: query,
+ variables: ["username": username],
+ responseType: Response.self
+ )
+
+ return .user(result.user)
+ }
+
+ private func lookupRepository(service: SRHTService) async throws -> LookupResult {
+ guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
+
+ let repository = try await appState.resolveRepository(owner: owner, name: name, service: service)
+ let resolvedRepository = RepositorySummary(
+ id: repository.id,
+ rid: repository.rid,
+ service: service,
+ name: repository.name,
+ description: repository.description,
+ visibility: repository.visibility,
+ updated: repository.updated,
+ owner: repository.owner,
+ head: repository.head
+ )
+
+ return .repository(resolvedRepository)
+ }
+
+ private func lookupMailingList() async throws -> LookupResult {
+ guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
+
+ struct Response: Decodable, Sendable {
+ let user: UserWithList
+ }
+
+ struct UserWithList: Decodable, Sendable {
+ let mailingList: InboxMailingListReference
+ }
+
+ let query = """
+ query mailingListLookup($owner: String!, $name: String!) {
+ user(username: $owner) {
+ mailingList: list(name: $name) {
+ id rid name owner { canonicalName }
+ }
+ }
+ }
+ """
+
+ let result = try await client.execute(
+ service: .lists,
+ query: query,
+ variables: ["owner": owner, "name": name],
+ responseType: Response.self
+ )
+
+ return .mailingList(result.user.mailingList)
+ }
+
+ private func lookupTracker() async throws -> LookupResult {
+ guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
+ let tracker = try await appState.resolveTracker(owner: owner, name: name)
+ return .tracker(tracker)
+ }
+
+ private func lookupBuildJob() async throws -> LookupResult {
+ guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
+
+ struct Response: Decodable, Sendable {
+ let job: JobIdOnly
+ }
+
+ struct JobIdOnly: Decodable, Sendable {
+ let id: Int
+ }
+
+ let query = """
+ query buildLookup($id: Int!) {
+ job(id: $id) { id }
+ }
+ """
+
+ _ = try await client.execute(
+ service: .builds,
+ query: query,
+ variables: ["id": jobId],
+ responseType: Response.self
+ )
+
+ return .buildJob(jobId)
+ }
+
+ private enum LookupError: Error {
+ case invalidInput
+ }
+}
+
+struct LookupView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: LookupViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Preparing lookup…")
+ }
+ }
+ .navigationTitle("Look Up")
+ .task {
+ if viewModel == nil {
+ viewModel = LookupViewModel(client: appState.client, appState: appState)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: LookupViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Form {
+ Section {
+ Picker("Type", selection: $vm.selectedType) {
+ ForEach(LookupType.allCases) { type in
+ Text(type.rawValue).tag(type)
+ }
+ }
+ .pickerStyle(.menu)
+
+ TextField(
+ vm.selectedType.inputLabel,
+ text: $vm.inputText,
+ prompt: Text(vm.selectedType.placeholder)
+ )
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .submitLabel(.search)
+ .onSubmit {
+ Task { await vm.lookup() }
+ }
+ }
+
+ Section {
+ HStack {
+ Button("Look Up") {
+ Task { await vm.lookup() }
+ }
+ .disabled(vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.isLooking)
+
+ if vm.isLooking {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .srhtErrorBanner(error: $vm.error)
+ .sheet(item: vm.resultBinding) { result in
+ NavigationStack {
+ lookupDestination(result)
+ }
+ .navigationDestination(for: MoreRoute.self) { route in
+ switch route {
+ case .lookup:
+ LookupView()
+ case .lists:
+ MailingListListView()
+ case .pastes:
+ PasteListView()
+ case .settings:
+ SettingsView()
+ case .mailingList(let mailingList):
+ MailingListDetailView(mailingList: mailingList)
+ case .thread(let thread):
+ ThreadDetailView(
+ thread: thread,
+ onViewed: {
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
+ },
+ onMarkRead: {
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
+ },
+ onMarkUnread: {
+ InboxReadStateStore.markUnread(for: thread.id)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
+ }
+ )
+ }
+ }
+ .environment(appState)
+ }
+ }
+
+ @ViewBuilder
+ private func lookupDestination(_ result: LookupResult) -> some View {
+ switch result {
+ case .user(let user):
+ UserProfileView(user: user)
+ case .repository(let repository):
+ RepositoryDetailView(repository: repository)
+ case .mailingList(let mailingList):
+ MailingListDetailView(mailingList: mailingList)
+ case .tracker(let tracker):
+ TicketListView(tracker: tracker)
+ case .buildJob(let jobId):
+ BuildDetailView(jobId: jobId)
+ }
+ }
+}