Swift 98.3%
Shell 1.7%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// MenuBarView.swift — le MenuBarExtra « Ka » : pouls de l'écosystème en direct3// (compteurs 5 min), recherche universelle rapide (résultats compacts, clic →4// fiche web à la source), pastilles des 13 services, bouton « Ouvrir KA ».5import SwiftUI67struct MenuBarView: View {8 @EnvironmentObject private var pulse: EcosystemPulse9 @EnvironmentObject private var state: AppState10 @Environment(\.openWindow) private var openWindow11 @Environment(\.colorScheme) private var scheme1213 @State private var query = ""14 @State private var results: [KAItem] = []15 @State private var searching = false16 @State private var searched = ""1718 var body: some View {19 VStack(alignment: .leading, spacing: 10) {20 header21 KASearchField(prompt: "Recherche rapide partout…", text: $query) {22 Task { await quickSearch() }23 }24 .onChange(of: query) {25 if query.isEmpty { results = []; searched = "" }26 }27 if searching || !searched.isEmpty {28 searchResults29 } else {30 pulseGrid31 }32 Divider().opacity(0.5)33 statusStrip34 footer35 }36 .padding(12)37 .frame(width: 350)38 .background(KATheme.paper(scheme))39 .onAppear { state.openMain = { focusMainWindow(openWindow) } }40 }4142 // MARK: en-tête4344 private var header: some View {45 HStack {46 GroupeKAMark(size: 15)47 Spacer()48 Button {49 focusMainWindow(openWindow)50 } label: {51 Label("Ouvrir KA", systemImage: "macwindow")52 .font(.caption.weight(.bold))53 .padding(.horizontal, 10).padding(.vertical, 5)54 .background(KATheme.inkLight, in: Capsule())55 .foregroundStyle(KATheme.lime)56 }57 .buttonStyle(.plain)58 }59 }6061 // MARK: le pouls (compteurs des 12 univers)6263 private var pulseGrid: some View {64 VStack(alignment: .leading, spacing: 6) {65 Text("Le pouls de l'écosystème")66 .font(.system(.caption2, design: .monospaced).weight(.bold))67 .textCase(.uppercase)68 .foregroundStyle(.secondary)69 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) {70 ForEach(Ecosystem.all) { u in71 Button {72 state.selection = .universe(u.id)73 focusMainWindow(openWindow)74 } label: {75 HStack(spacing: 6) {76 KALogo(id: u.id, size: 14,77 fallbackSymbol: u.symbol, fallbackAccent: u.accent)78 KAWordmark(universe: u, size: 10)79 Spacer(minLength: 2)80 Text(pulse.totals[u.id].map { Double($0).compact } ?? "—")81 .font(.system(.caption2, design: .monospaced).weight(.bold))82 .foregroundStyle(pulse.totals[u.id] == nil ? .secondary : u.accent)83 .contentTransition(.numericText())84 }85 .padding(.horizontal, 8).padding(.vertical, 6)86 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous))87 .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)88 .strokeBorder(KATheme.ink(scheme).opacity(0.35), lineWidth: 1))89 }90 .buttonStyle(.plain)91 .help("\(u.name) — \(u.tagline)")92 }93 }94 }95 }9697 // MARK: résultats compacts multi-univers (clic → fiche web à la source)9899 @ViewBuilder100 private var searchResults: some View {101 ScrollView {102 VStack(alignment: .leading, spacing: 6) {103 if searching {104 HStack(spacing: 8) {105 ProgressView().controlSize(.small)106 Text("KA interroge les univers…").font(.caption).foregroundStyle(.secondary)107 }108 .padding(10)109 } else if results.isEmpty {110 Text("Rien trouvé pour « \(searched) ».")111 .font(.caption).foregroundStyle(.secondary).padding(10)112 } else {113 ForEach(results) { item in114 let u = Ecosystem.universe(item.universeID)115 Button {116 NSWorkspace.shared.open(item.url ?? u?.baseURL ?? Ecosystem.hubURL)117 } label: {118 HStack(spacing: 8) {119 RoundedRectangle(cornerRadius: 3)120 .fill(u?.accent ?? .gray)121 .frame(width: 4, height: 30)122 VStack(alignment: .leading, spacing: 1) {123 Text(item.title).font(.caption.weight(.semibold)).lineLimit(1)124 HStack(spacing: 6) {125 if let p = item.priceLabel {126 Text(p).font(.system(.caption2, design: .rounded).weight(.bold))127 .foregroundStyle(u?.accent ?? .primary)128 }129 Text(u?.wordmark ?? "")130 .font(.system(size: 9, weight: .bold, design: .monospaced))131 .foregroundStyle(.secondary)132 }133 }134 Spacer()135 Image(systemName: "arrow.up.right").font(.system(size: 9)).foregroundStyle(.tertiary)136 }137 .padding(.horizontal, 8).padding(.vertical, 5)138 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous))139 .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)140 .strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))141 }142 .buttonStyle(.plain)143 .help("Ouvrir la fiche à la source")144 }145 Button {146 focusMainWindow(openWindow)147 state.openUniversalSearch()148 } label: {149 Text("Recherche complète dans KA →")150 .font(.caption.weight(.semibold))151 .foregroundStyle(KATheme.green)152 .padding(.top, 2)153 }154 .buttonStyle(.plain)155 }156 }157 }158 .frame(maxHeight: 300)159 }160161 // MARK: pastilles des 13 services162163 private var statusStrip: some View {164 VStack(alignment: .leading, spacing: 6) {165 HStack {166 Text("Services")167 .font(.system(.caption2, design: .monospaced).weight(.bold))168 .textCase(.uppercase)169 .foregroundStyle(.secondary)170 Spacer()171 Text("\(pulse.servicesUp)/\(pulse.health.count) en ligne")172 .font(.system(.caption2, design: .monospaced).weight(.bold))173 .foregroundStyle(pulse.servicesUp == pulse.health.count ? KATheme.green : .orange)174 }175 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) {176 ForEach(pulse.health) { s in177 HStack(spacing: 5) {178 KAStatusDot(up: s.up)179 Text(s.name.replacingOccurrences(of: "Groupe-KA", with: "Hub"))180 .font(.system(size: 10, weight: .semibold))181 .lineLimit(1)182 Spacer(minLength: 0)183 }184 .help("\(s.id)\(s.latencyMs.map { " — \($0) ms" } ?? "")")185 }186 }187 }188 }189190 // MARK: pied191192 private var footer: some View {193 HStack {194 if let d = pulse.lastRefresh {195 Text("Actualisé \(d.formatted(.relative(presentation: .named, unitsStyle: .narrow)))")196 .font(.system(size: 10)).foregroundStyle(.tertiary)197 } else {198 Text("Première actualisation…").font(.system(size: 10)).foregroundStyle(.tertiary)199 }200 Spacer()201 Button("Quitter") { NSApp.terminate(nil) }202 .font(.system(size: 10))203 .buttonStyle(.plain)204 .foregroundStyle(.secondary)205 .keyboardShortcut("q")206 }207 }208209 private func quickSearch() async {210 let q = query.trimmingCharacters(in: .whitespaces)211 guard !q.isEmpty else { return }212 searching = true213 searched = q214 let targets = Ecosystem.all.filter { $0.map != nil }215 var found: [(Universe, [KAItem])] = []216 await withTaskGroup(of: (String, [KAItem]).self) { group in217 for u in targets {218 group.addTask {219 let items = (try? await UniverseService.fetch(u, query: q, limit: 3)) ?? []220 return (u.id, items)221 }222 }223 var dict: [String: [KAItem]] = [:]224 for await (id, items) in group { dict[id] = items }225 found = targets.map { ($0, dict[$0.id] ?? []) }226 }227 results = found228 .sorted {229 if $0.0.id == "trouve-ka" { return false }230 if $1.0.id == "trouve-ka" { return true }231 return $0.1.count > $1.1.count232 }233 .flatMap { $0.1.prefix(2) }234 searching = false235 }236}237