Skip to content
Merged
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
131 changes: 131 additions & 0 deletions Sources/App/APIKeyFreeSettingsRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import AppKit
import Core
import SwiftUI

@MainActor
struct APIKeyFreeSettingsRow: View {
let provider: ProviderInfo
let state: ProviderState
let isEnabled: Bool
let canInstall: Bool
let credentialImportActionTitle: String?
let onEnabledChange: @MainActor @Sendable (Bool) -> Void
let onInstall: () -> Void
let onRemove: () -> Void
let onImportCredentials: () -> Void

@Environment(\.colorScheme) private var colorScheme

var body: some View {
VStack(alignment: .leading, spacing: 12) {
SettingsCardHeader(
provider: provider,
status: isEnabled ? ProviderStatusPresentation.apiKeyFree(state) : .disabled,
isEnabled: isEnabled,
onEnabledChange: onEnabledChange
)

Divider()

if !isEnabled {
Text(String(localized: "Enable this provider to view setup options."))
.font(.caption)
.foregroundColor(.secondary)
} else {
switch state {
case .loading:
HStack(spacing: 6) {
ProgressView()
.controlSize(.small)
Text(String(localized: "Working…"))
.font(.caption)
.foregroundColor(.secondary)
}
case let .setup(reason):
VStack(alignment: .leading, spacing: 8) {
if canInstall {
installPromptView(reason: reason)
} else {
setupReasonView(reason: reason)
}
credentialImportButton
}
case .loaded:
removeHelperView
case let .error(message):
VStack(alignment: .leading, spacing: 8) {
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(ProviderVisualStyle.tierColor(.critical, scheme: colorScheme))
credentialImportButton
}
case .unconfigured:
Text(String(localized: "Not configured"))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}

private func installPromptView(reason: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
setupReasonView(reason: reason)

Text(String(localized: "Filbert reads your Claude Code usage by hooking into its status line."))
.font(.caption)
.foregroundColor(.secondary)
Text(String(localized: "This adds a small helper script to ~/.claude/."))
.font(.caption)
.foregroundColor(.secondary)

Button(String(localized: "Install Helper")) {
onInstall()
}
.buttonStyle(.borderedProminent)
}
}

private func setupReasonView(reason: String) -> some View {
HStack(spacing: 6) {
if let setupHelp = provider.setupHelp {
Button {
NSWorkspace.shared.open(setupHelp.url)
} label: {
Label(setupHelp.linkLabel, systemImage: "arrow.up.right")
}
.font(.caption)
.buttonStyle(.link)
.layoutPriority(1)
}

Text(reason)
.font(.caption)
.foregroundColor(.secondary)

Spacer(minLength: 0)
}
}

@ViewBuilder
private var credentialImportButton: some View {
if let credentialImportActionTitle {
Button(credentialImportActionTitle) {
onImportCredentials()
}
.buttonStyle(.bordered)
}
}

private var removeHelperView: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "Helper installed and active."))
.font(.caption)
.foregroundColor(.secondary)

Button(String(localized: "Remove Helper"), role: .destructive) {
onRemove()
}
}
}
}
140 changes: 140 additions & 0 deletions Sources/App/QuotaViewModel+Lifecycle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import Core
import Foundation

extension QuotaViewModel {
func startAutoRefresh(for providerId: String) {
guard isReadyToFetch(providerId) else { return }
stopAutoRefresh(for: providerId)
let interval = refreshInterval
refreshLoops[providerId] = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(interval))
guard !Task.isCancelled else { break }
await MainActor.run { [weak self] in
self?.fetchQuota(for: providerId)
}
}
}
}

func stopAutoRefresh(for providerId: String) {
refreshLoops[providerId]?.cancel()
refreshLoops[providerId] = nil
}

func isEnabled(_ providerId: String) -> Bool {
enabledProviderIds.contains(providerId)
}

func setProviderEnabled(_ enabled: Bool, for providerId: String) {
guard providerInfo(for: providerId) != nil else { return }
registry.setEnabled(enabled, for: providerId)

var ids = enabledProviderIds
if enabled {
ids.insert(providerId)
} else {
ids.remove(providerId)
}
enabledProviderIds = ids

invalidateProviderWork(for: providerId)
if enabled {
startEnabledProvider(for: providerId)
} else if case .loading = providerStates[providerId] {
setState(.unconfigured, for: providerId)
}
refreshDerived()
}

func startEnabledProvider(for providerId: String) {
guard isEnabled(providerId), let info = providerInfo(for: providerId) else { return }

switch info.authShape {
case .apiKey:
guard registry.isConfigured(providerId) else {
setState(.unconfigured, for: providerId)
refreshDerived()
return
}
startAutoRefresh(for: providerId)
performFetch(for: providerId)
case .apiKeyFree:
let revision = lifecycleRevisions[providerId, default: 0]
setupTasks[providerId]?.cancel()
setupTasks[providerId] = Task { @MainActor [weak self] in
await self?.resolveSetupState(for: providerId, expectedRevision: revision)
}
}
}

func resolveSetupState(for providerId: String, expectedRevision: Int) async {
let setupState = await registry.refreshSetupState(for: providerId)
guard !Task.isCancelled,
isEnabled(providerId),
lifecycleRevisions[providerId, default: 0] == expectedRevision
else {
return
}

if let setupState {
setState(setupState, for: providerId)
refreshDerived()
return
}

guard registry.isConfigured(providerId) else {
setState(.unconfigured, for: providerId)
refreshDerived()
return
}

setState(.loading, for: providerId)
refreshDerived()
startAutoRefresh(for: providerId)
performFetch(for: providerId)
}

func invalidateProviderWork(for providerId: String) {
lifecycleRevisions[providerId, default: 0] += 1
stopAutoRefresh(for: providerId)
fetchTasks[providerId]?.cancel()
fetchTasks[providerId] = nil
setupTasks[providerId]?.cancel()
setupTasks[providerId] = nil
setRefreshing(false, for: providerId)
setRefreshError(nil, for: providerId)
}

func isReadyToFetch(_ providerId: String) -> Bool {
isEnabled(providerId) && registry.isConfigured(providerId)
}

/// Copy-write-back forces @Observable's setter to fire — dictionary subscript
/// mutations only invoke the getter, so the UI would never see the change.
func setState(_ state: ProviderState, for providerId: String) {
var copy = providerStates
copy[providerId] = state
providerStates = copy
}

func refreshDerived() {
let byId = Dictionary(
uniqueKeysWithValues: registry.registeredProviders.map { ($0.id, $0) }
)
let ids = orderedProviderIds
.compactMap { byId[$0] }
.filter { info in
isEnabled(info.id) && Self.isConfiguredState(providerStates[info.id])
}
.map(\.id)
configuredProviderIds = ids
hasAnyConfiguredProvider = !ids.isEmpty
log("refreshDerived: configuredProviderIds=\(ids) hasAny=\(hasAnyConfiguredProvider)")
}

func log(_ message: @autoclosure () -> String) {
FileHandle.standardError.write(Data("[QuotaViewModel] \(message())\n".utf8))
}
}
51 changes: 51 additions & 0 deletions Sources/App/QuotaViewModel+Presentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Core
import SwiftUI

extension QuotaViewModel {
func providerInfo(for providerId: String) -> ProviderInfo? {
registry.registeredProviders.first { $0.id == providerId }
}

func isCollapsed(_ providerId: String) -> Bool {
_ = collapseStateRevision
return Self.resolvedCollapseState(
providerId: providerId,
topProviderId: configuredProviderIds.first,
savedState: ProviderCollapseState.collapsedState(for: providerId)
)
}

func toggleCollapsed(_ providerId: String) {
ProviderCollapseState.setCollapsed(!isCollapsed(providerId), for: providerId)
collapseStateRevision += 1
}

static func resolvedCollapseState(
providerId: String,
topProviderId: String?,
savedState: Bool?
) -> Bool {
savedState ?? (providerId != topProviderId)
}

func moveProvider(from source: IndexSet, to destination: Int) {
var ids = orderedProviderIds
ids.move(fromOffsets: source, toOffset: destination)
ProviderOrder.setOrder(ids)
orderedProviderIds = ids
refreshDerived()
}

func persistOrder(_ ids: [String]) {
ProviderOrder.setOrder(ids)
orderedProviderIds = ids
refreshDerived()
}

func recomputeOrderedProviderIds() {
let sortedByName = registry.registeredProviders.sorted {
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
}
orderedProviderIds = ProviderOrder.effectiveOrder(for: sortedByName.map(\.id))
}
}
56 changes: 56 additions & 0 deletions Sources/App/QuotaViewModel+Results.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Core

extension QuotaViewModel {
func setRefreshing(_ refreshing: Bool, for providerId: String) {
var copy = isRefreshing
copy[providerId] = refreshing
isRefreshing = copy
}

func setRefreshError(_ message: String?, for providerId: String) {
var copy = refreshErrors
if let message {
copy[providerId] = message
} else {
copy.removeValue(forKey: providerId)
}
refreshErrors = copy
}

func applyResults(
_ results: [String: Result<ProviderQuota, Error>],
expectedRevisions: [String: Int]
) {
log("applyResults: got \(results.count) result(s)")
for (id, result) in results {
guard expectedRevisions[id, default: 0] == lifecycleRevisions[id, default: 0],
isReadyToFetch(id)
else {
log("applyResults: provider=\(id) no longer ready, skipping")
continue
}
setRefreshing(false, for: id)

switch result {
case let .success(quota):
log("applyResults: provider=\(id) success, headline=\(quota.headline)")
setRefreshError(nil, for: id)
setState(.loaded(quota), for: id)
case let .failure(error):
log("applyResults: provider=\(id) failed: \(error.localizedDescription)")
if error is KeychainError {
// Key deleted externally — genuine state change, not a refresh failure.
setRefreshError(nil, for: id)
setState(.unconfigured, for: id)
stopAutoRefresh(for: id)
} else if case .loaded = providerStates[id] {
setRefreshError(error.localizedDescription, for: id)
} else {
setRefreshError(nil, for: id)
setState(.error(error.localizedDescription), for: id)
}
}
}
refreshDerived()
}
}
Loading
Loading