// // DiscoverView.swift // Zyquo MLX // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// Models › Discover: Featured catalog + live mlx-community search with /// resumable downloads (charter Phase 6). struct DiscoverView: View { @Bindable var model: AppModel @State private var query = "" @State private var results: [HubModel] = [] @State private var isSearching = false @State private var searchError: String? @State private var downloads: [String: DownloadProgress] = [:] @State private var downloadErrors: [String: String] = [:] private var installedIDs: Set { Set(model.models.compactMap(\.repoID)) } var body: some View { VStack(spacing: 0) { searchField .padding(.horizontal, ZyquoTheme.spacing20) .padding(.bottom, ZyquoTheme.spacing12) ScrollView { LazyVStack(alignment: .leading, spacing: ZyquoTheme.spacing8) { if let searchError { Text(searchError) .font(ZyquoTheme.captionFont) .foregroundStyle(ZyquoTheme.danger) } if query.isEmpty { Text("Featured — live-verified for this Mac") .font(ZyquoTheme.headlineFont) .foregroundStyle(ZyquoTheme.textPrimary) .padding(.bottom, ZyquoTheme.spacing4) ForEach(Catalog.featured) { entry in DiscoverRow( repoID: entry.id, title: entry.name, subtitle: "\(entry.params) · \(entry.quant) · \(ByteCountFormatter.string(fromByteCount: entry.weightBytes, countStyle: .file)) — \(entry.blurb)", type: entry.type, verdict: entry.verdict, installed: installedIDs.contains(entry.id), progress: downloads[entry.id], error: downloadErrors[entry.id], download: { download(repo: entry.id) }, cancel: { Task { await DownloadManager.shared.cancel(repo: entry.id) } }) } } else if isSearching { HStack { ProgressView().controlSize(.small) Text("Searching mlx models…") .font(ZyquoTheme.captionFont) .foregroundStyle(ZyquoTheme.textSecondary) } .frame(maxWidth: .infinity) .padding(.top, ZyquoTheme.spacing32) } else { ForEach(results) { result in DiscoverRow( repoID: result.id, title: result.id, subtitle: "\(result.downloads.formatted()) downloads · \(result.likes) likes" + (result.gated ? " · gated" : ""), type: result.modelType, verdict: nil, installed: installedIDs.contains(result.id), progress: downloads[result.id], error: downloadErrors[result.id], download: { download(repo: result.id) }, cancel: { Task { await DownloadManager.shared.cancel(repo: result.id) } }) } if results.isEmpty { Text("No MLX models match “\(query)”.") .font(ZyquoTheme.bodyFont) .foregroundStyle(ZyquoTheme.textSecondary) .frame(maxWidth: .infinity) .padding(.top, ZyquoTheme.spacing32) } } } .padding(.horizontal, ZyquoTheme.spacing20) .padding(.bottom, ZyquoTheme.spacing20) } } .task(id: query) { guard !query.isEmpty else { return } isSearching = true searchError = nil try? await Task.sleep(for: .milliseconds(350)) // debounce guard !Task.isCancelled else { return } do { results = try await HubService.shared.search(query: query) } catch { searchError = error.localizedDescription } isSearching = false } } private var searchField: some View { HStack(spacing: ZyquoTheme.spacing8) { Image(systemName: "magnifyingglass") .foregroundStyle(ZyquoTheme.textTertiary) TextField("Search mlx-community models…", text: $query) .textFieldStyle(.plain) .font(ZyquoTheme.bodyFont) } .padding(.horizontal, ZyquoTheme.spacing12) .padding(.vertical, ZyquoTheme.spacing8) .background(ZyquoTheme.surfaceSecondary) .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall)) } private func download(repo: String) { downloadErrors[repo] = nil Task { let events = await DownloadManager.shared.download(repo: repo) for await event in events { switch event { case .progress(let progress): downloads[repo] = progress case .finished: downloads[repo] = nil await model.refresh() case .failed(let message): downloads[repo] = nil downloadErrors[repo] = message } } } } } private struct DiscoverRow: View { let repoID: String let title: String let subtitle: String let type: ModelType let verdict: MemoryVerdict? let installed: Bool let progress: DownloadProgress? let error: String? let download: () -> Void let cancel: () -> Void var body: some View { HStack(spacing: ZyquoTheme.spacing12) { VStack(alignment: .leading, spacing: ZyquoTheme.spacing2) { Text(title) .font(ZyquoTheme.bodyFont.weight(.medium)) .foregroundStyle(ZyquoTheme.textPrimary) .lineLimit(1) Text(error ?? subtitle) .font(ZyquoTheme.captionFont) .foregroundStyle(error != nil ? ZyquoTheme.danger : ZyquoTheme.textSecondary) .lineLimit(2) } Spacer() TypeBadge(type: type) if let verdict { VerdictBadge(verdict: verdict) } if installed { StatusPill(text: "Installed", color: ZyquoTheme.success) } else if let progress { HStack(spacing: ZyquoTheme.spacing8) { ProgressView(value: progress.fraction) .frame(width: 90) Text("\(Int(progress.fraction * 100))%") .font(ZyquoTheme.monoSmallFont) .foregroundStyle(ZyquoTheme.textSecondary) Button(action: cancel) { Image(systemName: "pause.circle") .foregroundStyle(ZyquoTheme.textSecondary) } .buttonStyle(.plain) .help("Pause (resumes from the same byte)") } } else { Button(action: download) { Image(systemName: "arrow.down.circle.fill") .font(.system(size: 20)) .foregroundStyle(ZyquoTheme.accent) } .buttonStyle(.plain) .help("Download") } } .padding(ZyquoTheme.spacing12) .zyquoCard() } }