mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
Mirror the richer analytics payload (quotaStatus, per-account last-active, bySurface) in the Codable models. Add a per-surface usage section (Claude Code, Codex, Droid, CLIProxy) with proportional bars beside the existing spend grid, sparkline, and top models. Rebuild the menu-bar title chain so a lifetime dollar can never sit in the always-on title (it read as live spend): quota% then today spend then account count. Rebuild the accounts rows (default badge, tri-state quota, honest no-data cost, health), pivot to an honest idle hero when no recent data, hide the scroll indicator, and tighten spacing.
296 lines
9.0 KiB
Swift
296 lines
9.0 KiB
Swift
import SwiftUI
|
||
import AppKit
|
||
import CCSBarCore
|
||
|
||
/// Dropdown content for the menu bar: a CCS-branded header, usage analytics,
|
||
/// per-account rows + actions, an offline state when CCS isn't running, and
|
||
/// footer controls.
|
||
struct BarMenuView: View {
|
||
@ObservedObject var viewModel: BarViewModel
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
header
|
||
Divider()
|
||
|
||
if viewModel.offline {
|
||
offlineState.padding(14)
|
||
} else {
|
||
// Scrollbar is hidden: the panel chrome already constrains height and a
|
||
// visible scrollbar track adds visual clutter in an always-on-screen widget.
|
||
// Overflow is still fully scrollable — the indicator is just not shown.
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
if let analytics = viewModel.analytics {
|
||
BarAnalyticsView(analytics: analytics)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
SectionLabel("Accounts")
|
||
if let error = viewModel.lastError {
|
||
ErrorBanner(message: error)
|
||
}
|
||
if viewModel.rows.isEmpty {
|
||
Text("No accounts configured")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(viewModel.rows) { row in
|
||
BarRowView(row: row, viewModel: viewModel)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(12)
|
||
}
|
||
.scrollIndicators(.hidden)
|
||
// 580 gives room for the full layout (2×2 grid + sparkline + surface section
|
||
// + top models + accounts) without wasted whitespace on a typical 1-4 account
|
||
// setup. The scroll still triggers gracefully when content overflows.
|
||
.frame(maxHeight: 580)
|
||
}
|
||
|
||
Divider()
|
||
footer
|
||
}
|
||
.frame(width: 340)
|
||
.onAppear { viewModel.onOpen() }
|
||
}
|
||
|
||
private var header: some View {
|
||
HStack(spacing: 8) {
|
||
Image(nsImage: MenuBarIcon.headerImage())
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text("CCS").font(.headline)
|
||
Text("usage & accounts").font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
if viewModel.isRefreshing {
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
}
|
||
|
||
private var offlineState: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Label("CCS is not running", systemImage: "bolt.slash.fill")
|
||
.font(.body)
|
||
Text("Start CCS, then reopen this menu.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
|
||
private var footer: some View {
|
||
HStack(spacing: 10) {
|
||
Button {
|
||
openDashboard()
|
||
} label: {
|
||
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
||
}
|
||
Button {
|
||
viewModel.toggleIconStyle()
|
||
} label: {
|
||
Label(
|
||
viewModel.iconStyle == .color ? "Color" : "Mono",
|
||
systemImage: viewModel.iconStyle == .color ? "paintpalette" : "circle.lefthalf.filled"
|
||
)
|
||
}
|
||
.help("Toggle the menu-bar icon between color and monochrome")
|
||
Spacer()
|
||
Button {
|
||
viewModel.onOpen()
|
||
} label: {
|
||
Image(systemName: "arrow.clockwise")
|
||
}
|
||
.help("Refresh")
|
||
Button {
|
||
NSApplication.shared.terminate(nil)
|
||
} label: {
|
||
Image(systemName: "power")
|
||
}
|
||
.help("Quit CCS Bar")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.font(.caption)
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 9)
|
||
}
|
||
|
||
private func openDashboard() {
|
||
if case .success(let discovery) = BarDiscovery.load(), let url = discovery.resolvedURL {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One account row — the strongest section of the glance.
|
||
///
|
||
/// Top line: health dot, name, default/paused/reauth badges. Subline: provider +
|
||
/// tier chips, the honest tri-state quota label (NN% / "no quota" / "quota ?"),
|
||
/// and a per-account "Last active <date>" caption. Trailing: today's cost (or a
|
||
/// muted "no data" when unknown vs a real "$0.00"), a visible pause/resume
|
||
/// toggle, and the overflow menu (set-default / solo / tier-lock).
|
||
struct BarRowView: View {
|
||
let row: BarSummaryRow
|
||
@ObservedObject var viewModel: BarViewModel
|
||
|
||
var body: some View {
|
||
HStack(alignment: .top, spacing: 9) {
|
||
Circle()
|
||
.fill(healthColor)
|
||
.frame(width: 8, height: 8)
|
||
.padding(.top, 5)
|
||
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 6) {
|
||
Text(row.displayName ?? row.accountId)
|
||
.font(.system(.body, design: .default).weight(.medium))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
if row.isDefault {
|
||
Chip("default", tint: BarTheme.accent)
|
||
}
|
||
if row.paused {
|
||
Chip("paused", tint: .secondary)
|
||
}
|
||
if row.needsReauth {
|
||
Chip("reauth", tint: .red)
|
||
}
|
||
}
|
||
HStack(spacing: 6) {
|
||
Chip(row.provider, tint: BarTheme.accent)
|
||
if let tier = row.tier { Chip(tier, tint: .secondary) }
|
||
Text(BarFormatting.quotaLabel(percentage: row.quotaPercentage, status: row.quotaStatus))
|
||
.font(.caption2)
|
||
.foregroundStyle(quotaColor)
|
||
}
|
||
if let lastActive = BarFormatting.lastActiveLabel(
|
||
iso: row.lastActivityAt, daysSince: nil)
|
||
{
|
||
Text(lastActive)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
Spacer(minLength: 4)
|
||
|
||
VStack(alignment: .trailing, spacing: 3) {
|
||
costView
|
||
HStack(spacing: 2) {
|
||
pauseToggle
|
||
overflowMenu
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 6)
|
||
.padding(.horizontal, 8)
|
||
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
/// Today's cost: a real "$x.xx" when known (including a genuine $0.00), a muted
|
||
/// "no data" when the value is null (no usage record on a possibly-stale snapshot).
|
||
@ViewBuilder private var costView: some View {
|
||
if let cost = row.todayCost {
|
||
Text(BarFormatting.money(cost))
|
||
.font(.system(.caption, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
Text("no data")
|
||
.font(.caption2)
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
|
||
/// Visible primary action: one tap to pause or resume the account.
|
||
private var pauseToggle: some View {
|
||
Button {
|
||
if row.paused { viewModel.resume(row) } else { viewModel.pause(row) }
|
||
} label: {
|
||
Image(systemName: row.paused ? "play.circle" : "pause.circle")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.help(row.paused ? "Resume account" : "Pause account")
|
||
}
|
||
|
||
private var overflowMenu: some View {
|
||
Menu {
|
||
Button("Set as default") { viewModel.setDefault(row) }
|
||
Button("Solo (pause others)") { viewModel.solo(row) }
|
||
Divider()
|
||
if let tier = row.tier {
|
||
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
||
}
|
||
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
||
} label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.menuIndicator(.hidden)
|
||
.frame(width: 24)
|
||
}
|
||
|
||
/// Health dot. With the corrected backend, "unsupported" providers (ghcp/kiro)
|
||
/// arrive as health "ok" (green) — no permanent orange dot. Orange is reserved
|
||
/// for genuine transient fetch failures, red for accounts needing reauth.
|
||
private var healthColor: Color {
|
||
switch row.health {
|
||
case "error": return .red
|
||
case "warning": return .orange
|
||
default: return .green
|
||
}
|
||
}
|
||
|
||
/// Quota label color: muted for "no quota", warning-tinted for "quota ?".
|
||
private var quotaColor: Color {
|
||
switch row.quotaStatus {
|
||
case "unsupported": return .secondary
|
||
case "error": return .orange
|
||
default: return .secondary
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Inline banner surfacing the last failed action so it is visible rather than
|
||
/// silently swallowed. Success is confirmed by the default/paused badge updating.
|
||
struct ErrorBanner: View {
|
||
let message: String
|
||
var body: some View {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(.orange)
|
||
Text(message)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(2)
|
||
}
|
||
.padding(.vertical, 5)
|
||
.padding(.horizontal, 8)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
|
||
}
|
||
}
|
||
|
||
/// Small pill label used in account sublines.
|
||
struct Chip: View {
|
||
let text: String
|
||
let tint: Color
|
||
init(_ text: String, tint: Color) {
|
||
self.text = text
|
||
self.tint = tint
|
||
}
|
||
var body: some View {
|
||
Text(text)
|
||
.font(.system(size: 9, weight: .semibold))
|
||
.padding(.horizontal, 5)
|
||
.padding(.vertical, 1)
|
||
.background(tint.opacity(0.16), in: Capsule())
|
||
.foregroundStyle(tint == .secondary ? Color.secondary : tint)
|
||
}
|
||
}
|