Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions Sources/CrawlBar/CrawlerCatalog/CrawlBarCrawlerClassifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import CrawlBarCore
import Foundation

enum CrawlBarCrawlerCategory: CaseIterable, Hashable {
case my
case suggested
case more

var title: String {
switch self {
case .my:
"My Crawlers"
case .suggested:
"Suggested"
case .more:
"More Crawlers"
}
}
}

enum CrawlBarCrawlerClassifier {
@MainActor
static func category(
app: CrawlBarAppConfig?,
installation: CrawlAppInstallation?)
-> CrawlBarCrawlerCategory
{
if self.isMyCrawler(app: app, installation: installation) {
return .my
}
guard let installation, self.matchesSuggestion(installation: installation) else {
return .more
}
return .suggested
}

nonisolated static func isMyCrawler(
app: CrawlBarAppConfig?,
installation: CrawlAppInstallation?)
-> Bool
{
installation?.binaryPath != nil || self.hasUserCrawlerConfig(app)
}

nonisolated static func statusInstallations(
_ installations: [CrawlAppInstallation],
appConfigsByID: [CrawlAppID: CrawlBarAppConfig])
-> [CrawlAppInstallation]
{
installations.filter { installation in
guard installation.manifest.availability == .available else { return false }
guard let config = appConfigsByID[installation.id], config.enabled else { return false }
return self.isMyCrawler(app: config, installation: installation)
}
}

@MainActor
static func matchesSuggestion(installation: CrawlAppInstallation) -> Bool {
guard let suggestion = installation.manifest.suggestion else { return false }
switch suggestion.kind {
case .always:
return true
case .app:
return suggestion.bundleIDs.contains { bundleID in
CrawlBarNativeAppLocator.url(for: bundleID) != nil
}
}
}

nonisolated private static func hasUserCrawlerConfig(_ app: CrawlBarAppConfig?) -> Bool {
guard let app else { return false }
return app.binaryPath?.nilIfBlank != nil
|| app.configPath?.nilIfBlank != nil
|| app.refreshFrequency != nil
|| app.autoRefreshEnabled
|| app.shareEnabled
|| app.shareAfterRefresh
|| app.preferredRefreshAction != "refresh"
|| app.preferredShareAction != "publish"
|| app.preferredUpdateAction != "update"
|| !app.configValues.isEmpty
}
}
62 changes: 62 additions & 0 deletions Sources/CrawlBar/Menu/CrawlBarMenuViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct CrawlBarMenuHeaderView: View {
let statuses: [CrawlAppID: CrawlAppStatus]
let isRefreshing: Bool
let refreshFrequency: RefreshFrequency
let suggestedCount: Int

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Expand Down Expand Up @@ -57,6 +58,12 @@ struct CrawlBarMenuHeaderView: View {
let current = self.installations.filter {
self.effectiveState(for: $0, status: self.statuses[$0.id]) == .current
}.count
if self.installations.isEmpty, self.suggestedCount > 0 {
return "\(self.suggestedCount) suggested · \(CrawlBarFrequencyLabel.text(for: self.refreshFrequency))"
}
if self.suggestedCount > 0 {
return "\(current) current · \(self.suggestedCount) suggested"
}
return "\(current) current · \(CrawlBarFrequencyLabel.text(for: self.refreshFrequency))"
}

Expand All @@ -72,6 +79,61 @@ struct CrawlBarMenuHeaderView: View {
}
}

struct CrawlBarMenuSectionHeaderView: View {
let title: String

var body: some View {
Text(self.title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, CrawlBarMenuStyle.cardHorizontalPadding)
.padding(.top, 4)
.padding(.bottom, 1)
.frame(maxWidth: .infinity, alignment: .leading)
}
}

struct CrawlBarMenuSuggestionView: View {
let installation: CrawlAppInstallation
let onOpen: () -> Void
@Environment(\.crawlBarMenuItemHighlighted) private var isHighlighted

var body: some View {
HStack(alignment: .center, spacing: 10) {
CrawlBarBrandIcon(manifest: self.installation.manifest, appID: self.installation.id)
.frame(width: 26, height: 26)
.opacity(0.82)
VStack(alignment: .leading, spacing: 3) {
Text(CrawlBarCrawlerTitle.text(for: self.installation.id, manifest: self.installation.manifest))
.font(.subheadline)
.lineLimit(1)
Text(self.detailText)
.font(.caption)
.foregroundStyle(CrawlBarMenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(.horizontal, CrawlBarMenuStyle.cardHorizontalPadding)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(perform: self.onOpen)
}

private var detailText: String {
guard let suggestion = self.installation.manifest.suggestion else {
return "Connect \(self.installation.manifest.binary.name)"
}
switch suggestion.kind {
case .always:
return "Connect \(self.installation.manifest.binary.name)"
case .app:
return "\(suggestion.name) is installed"
}
}
}

struct CrawlBarMenuCardView: View {
let installation: CrawlAppInstallation
let status: CrawlAppStatus?
Expand Down
70 changes: 64 additions & 6 deletions Sources/CrawlBar/Menu/CrawlBarStatusMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,45 @@ struct CrawlBarStatusMenuBuilder {
selectors: CrawlBarStatusMenuActionSelectors,
openSettings: @escaping (CrawlAppID?) -> Void)
{
let visibleInstallations = model.visibleInstallations
let myInstallations = model.myMenuInstallations
let suggestedInstallations = model.suggestedMenuInstallations

menu.autoenablesItems = false
menu.removeAllItems()

menu.addItem(self.viewItem(for: CrawlBarMenuHeaderView(
installations: visibleInstallations,
installations: myInstallations,
statuses: model.statuses,
isRefreshing: model.isRefreshing,
refreshFrequency: model.refreshFrequency), enabled: false))
refreshFrequency: model.refreshFrequency,
suggestedCount: suggestedInstallations.count), enabled: false))

self.addCrawlers(
visibleInstallations,
self.addMyCrawlers(
myInstallations,
statuses: model.statuses,
to: menu,
target: target,
selector: selectors.showSettingsForApp,
openSettings: openSettings)

self.addSuggestedCrawlers(
suggestedInstallations,
hasMyCrawlers: !myInstallations.isEmpty,
to: menu,
target: target,
selector: selectors.showSettingsForApp,
openSettings: openSettings)

menu.addItem(.separator())
if model.moreCrawlerCount > 0 {
menu.addItem(self.actionItem(
title: "More Crawlers in Settings...",
action: selectors.showSettings,
target: target,
systemImage: "square.grid.2x2"))
menu.addItem(.separator())
}

let refreshTitle = model.isRefreshing ? "Refreshing..." : "Refresh All"
let refreshItem = self.actionItem(
title: refreshTitle,
Expand Down Expand Up @@ -78,7 +97,7 @@ struct CrawlBarStatusMenuBuilder {
self.itemFactory.clearHighlights(in: menu)
}

private func addCrawlers(
private func addMyCrawlers(
_ installations: [CrawlAppInstallation],
statuses: [CrawlAppID: CrawlAppStatus],
to menu: NSMenu,
Expand All @@ -101,6 +120,28 @@ struct CrawlBarStatusMenuBuilder {
}
}

private func addSuggestedCrawlers(
_ installations: [CrawlAppInstallation],
hasMyCrawlers: Bool,
to menu: NSMenu,
target: AnyObject,
selector: Selector,
openSettings: @escaping (CrawlAppID?) -> Void)
{
guard !installations.isEmpty else { return }
if hasMyCrawlers {
menu.addItem(self.viewItem(for: CrawlBarMenuSeparatorRowView(), enabled: false))
}
menu.addItem(self.viewItem(for: CrawlBarMenuSectionHeaderView(title: "Suggested"), enabled: false))
for installation in installations {
menu.addItem(self.suggestedMenuItem(
for: installation,
target: target,
selector: selector,
openSettings: openSettings))
}
}

private func appMenuItem(
for installation: CrawlAppInstallation,
status: CrawlAppStatus?,
Expand All @@ -120,6 +161,23 @@ struct CrawlBarStatusMenuBuilder {
selector: selector)
}

private func suggestedMenuItem(
for installation: CrawlAppInstallation,
target: AnyObject,
selector: Selector,
openSettings: @escaping (CrawlAppID?) -> Void)
-> NSMenuItem
{
let row = CrawlBarMenuSuggestionView(
installation: installation,
onOpen: { openSettings(installation.id) })
return self.crawlerItem(
for: row,
installation: installation,
target: target,
selector: selector)
}

private func crawlerItem(
for content: some View,
installation: CrawlAppInstallation,
Expand Down
34 changes: 25 additions & 9 deletions Sources/CrawlBar/Menu/MenuModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,37 @@ final class CrawlBarMenuModel: NSObject {
NotificationCenter.default.removeObserver(self)
}

var visibleInstallations: [CrawlAppInstallation] {
var myMenuInstallations: [CrawlAppInstallation] {
self.installations.filter { installation in
guard installation.manifest.availability == .available else { return false }
return self.appConfigs[installation.id]?.showInMenuBar ?? true
guard let config = self.appConfigs[installation.id] else { return false }
guard config.enabled, config.showInMenuBar else { return false }
return CrawlBarCrawlerClassifier.isMyCrawler(app: config, installation: installation)
}
}

var statusTargetInstallations: [CrawlAppInstallation] {
var suggestedMenuInstallations: [CrawlAppInstallation] {
self.installations.filter { installation in
guard installation.manifest.availability == .available else { return false }
return self.appConfigs[installation.id]?.enabled ?? installation.enabled
guard let config = self.appConfigs[installation.id] else { return false }
guard config.enabled, config.showInMenuBar else { return false }
return CrawlBarCrawlerClassifier.category(app: config, installation: installation) == .suggested
}
}

var moreCrawlerCount: Int {
self.installations.filter { installation in
guard let config = self.appConfigs[installation.id] else { return false }
return CrawlBarCrawlerClassifier.category(app: config, installation: installation) == .more
}.count
}

var statusTargetInstallations: [CrawlAppInstallation] {
CrawlBarCrawlerClassifier.statusInstallations(
self.installations,
appConfigsByID: self.appConfigs)
}

func appConfig(for id: CrawlAppID) -> CrawlBarAppConfig? {
self.appConfigs[id]
}
Expand All @@ -72,15 +89,14 @@ final class CrawlBarMenuModel: NSObject {
let generation = UUID()
self.refreshGeneration = generation
self.isRefreshing = true
let appConfigs = self.appConfigs
let registry = self.registry
let statusService = self.statusService
let appConfigs = self.appConfigs
self.refreshTask = Task.detached {
let installations = (try? registry.installationsForStatus(includeDisabled: true)) ?? []
let statusInstallations = installations.filter { installation in
guard installation.manifest.availability == .available else { return false }
return appConfigs[installation.id]?.enabled ?? installation.enabled
}
let statusInstallations = CrawlBarCrawlerClassifier.statusInstallations(
installations,
appConfigsByID: appConfigs)
await MainActor.run {
guard self.refreshGeneration == generation else { return }
self.installations = installations
Expand Down
18 changes: 5 additions & 13 deletions Sources/CrawlBar/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ struct CrawlBarGeneralSettingsView: View {
CrawlBarPanel(title: "Crawler Inventory") {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
GridRow {
CrawlBarFact(label: "Ready", value: "\(self.readyCount)")
CrawlBarFact(label: "Missing CLI", value: "\(self.missingCount)")
CrawlBarFact(label: "Coming Soon", value: "\(self.comingSoonCount)")
CrawlBarFact(label: "My Crawlers", value: "\(self.count(.my))")
CrawlBarFact(label: "Suggested", value: "\(self.count(.suggested))")
CrawlBarFact(label: "More Crawlers", value: "\(self.count(.more))")
}
}
}
Expand All @@ -118,15 +118,7 @@ struct CrawlBarGeneralSettingsView: View {
.padding(.vertical, CrawlBarSettingsLayout.detailVerticalPadding)
}

private var readyCount: Int {
self.model.installations.values.filter { $0.manifest.availability == .available && $0.binaryPath != nil }.count
}

private var missingCount: Int {
self.model.installations.values.filter { $0.manifest.availability == .available && $0.binaryPath == nil }.count
}

private var comingSoonCount: Int {
self.model.installations.values.filter { $0.manifest.availability == .comingSoon }.count
private func count(_ section: CrawlBarCrawlerCategory) -> Int {
self.model.crawlerSections.first { $0.kind == section }?.apps.count ?? 0
}
}
13 changes: 13 additions & 0 deletions Sources/CrawlBar/Settings/SettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ final class CrawlBarSettingsModel: NSObject, ObservableObject {
NotificationCenter.default.removeObserver(self)
}

var crawlerSections: [CrawlBarCrawlerSection] {
var grouped: [CrawlBarCrawlerCategory: [CrawlBarAppConfig]] = [:]
for app in self.apps {
let installation = self.installations[app.id]
let section = CrawlBarCrawlerClassifier.category(app: app, installation: installation)
grouped[section, default: []].append(app)
}
return CrawlBarCrawlerCategory.allCases.compactMap { section in
guard let apps = grouped[section], !apps.isEmpty else { return nil }
return CrawlBarCrawlerSection(kind: section, apps: apps)
}
}

var sidebarSelectionIsValid: Bool {
switch self.selectedSidebarItem {
case .general:
Expand Down
Loading
Loading