Files
steipete--codexbar/Sources/CodexBar/PreferencesMenuPane.swift
T
2026-07-13 12:22:33 +08:00

204 lines
8.0 KiB
Swift

import CodexBarCore
import SwiftUI
@MainActor
struct MenuPane: View {
@Bindable var settings: SettingsStore
@Bindable var store: UsageStore
var body: some View {
Form {
Section {
SettingsMenuPicker(
selection: self.$settings.usageBarsFillOption,
options: MenuSettingsMenuOptions.usageBarsFill,
label: { Text(L("usage_bars_fill_title")) },
optionLabel: { option in
Text(option.label)
})
Toggle(isOn: self.$settings.quotaWarningMarkersVisible) {
SettingsRowLabel(
L("show_quota_warning_markers_title"),
subtitle: L("show_quota_warning_markers_subtitle"))
}
SettingsMenuPicker(
selection: self.$settings.weeklyProgressWorkDays,
options: MenuSettingsMenuOptions.weeklyProgressWorkDays,
label: {
Text(L("weekly_progress_work_days_title"))
},
optionLabel: { workDays in
Text(MenuSettingsMenuOptions.weeklyProgressWorkDaysLabel(workDays))
})
SettingsMenuPicker(
selection: self.$settings.resetTimesOption,
options: MenuSettingsMenuOptions.resetTimes,
label: { Text(L("reset_times_title")) },
optionLabel: { option in
Text(option.label)
})
} header: {
Text(L("section_usage"))
}
Section {
Toggle(L("show_provider_changelog_links_title"), isOn: self.$settings.providerChangelogLinksEnabled)
Toggle(isOn: self.$settings.showOptionalCreditsAndExtraUsage) {
SettingsRowLabel(
L("show_credits_extra_usage_title"),
subtitle: L("show_credits_extra_usage_subtitle"))
}
SettingsMenuPicker(
selection: self.$settings.multiAccountMenuLayout,
options: MenuSettingsMenuOptions.multiAccountLayouts,
label: {
Text(L("multi_account_layout_title"))
},
optionLabel: { layout in
Text(layout.label)
})
} header: {
Text(L("section_content"))
}
CostSummarySettingsSection(settings: self.settings, store: self.store)
Section {
Toggle(isOn: self.$settings.agentSessionsEnabled) {
SettingsRowLabel(
L("agent_sessions_title"),
subtitle: L("agent_sessions_subtitle"))
}
TextField(L("agent_sessions_hosts_title"), text: self.$settings.agentSessionsManualHosts)
.disabled(!self.settings.agentSessionsEnabled)
} header: {
Text(L("section_agent_sessions"))
} footer: {
SettingsSectionFooter(L("agent_sessions_footer"))
}
}
.formStyle(.grouped)
.toggleStyle(.switch)
.scrollContentBackground(.hidden)
.background(FocusResigningBackground())
}
}
/// Cost summary settings grouped-form section, including per-provider fetch status in the footer.
@MainActor
struct CostSummarySettingsSection: View {
@Bindable var settings: SettingsStore
@Bindable var store: UsageStore
var body: some View {
Section {
SettingsMenuPicker(
selection: self.$settings.costSummaryOption,
options: MenuSettingsMenuOptions.costSummaries,
label: {
SettingsRowLabel(L("cost_summary_title"), subtitle: L("show_cost_summary_subtitle"))
},
optionLabel: { option in
Text(option.label)
})
if self.settings.costUsageEnabled {
CostHistoryDaysEditor(settings: self.settings)
Toggle(isOn: self.$settings.costComparisonPeriodsEnabled) {
SettingsRowLabel(
L("cost_comparison_periods_title"),
subtitle: L("cost_comparison_periods_subtitle"))
}
}
} header: {
Text(L("section_cost_summary"))
} footer: {
if self.settings.costUsageEnabled {
SettingsSectionFooter {
VStack(alignment: .leading, spacing: 3) {
Text(L("cost_auto_refresh_info"))
self.costStatusLine(provider: .claude)
self.costStatusLine(provider: .codex)
}
}
}
}
}
private func costStatusLine(provider: UsageProvider) -> Text {
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
guard provider == .claude || provider == .codex else {
return Text(String(format: L("cost_status_unsupported"), name))
}
if self.store.isTokenRefreshInFlight(for: provider) {
let elapsed: String = {
guard let startedAt = self.store.tokenLastAttemptAt(for: provider) else { return "" }
let seconds = max(0, Date().timeIntervalSince(startedAt))
let formatter = DateComponentsFormatter()
formatter.allowedUnits = seconds < 60 ? [.second] : [.minute, .second]
formatter.unitsStyle = .abbreviated
return formatter.string(from: seconds).map { " (\($0))" } ?? ""
}()
return Text(String(format: L("cost_status_fetching"), name, elapsed))
}
if let snapshot = self.store.tokenSnapshot(for: provider) {
let updated = UsageFormatter.updatedString(from: snapshot.updatedAt)
let cost = snapshot.last30DaysCostUSD
.map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—"
let window = snapshot.historyLabel ?? (snapshot.historyDays == 1 ? "today" : "\(snapshot.historyDays)d")
return Text(String(format: L("cost_status_snapshot"), name, updated, window, cost))
}
if let error = self.store.tokenError(for: provider), !error.isEmpty {
let truncated = UsageFormatter.truncatedSingleLine(error, max: 120)
return Text(String(format: L("cost_status_error"), name, truncated))
}
if let lastAttempt = self.store.tokenLastAttemptAt(for: provider) {
let rel = RelativeDateTimeFormatter()
rel.locale = Locale(identifier: "en_US")
rel.unitsStyle = .abbreviated
let when = rel.localizedString(for: lastAttempt, relativeTo: Date())
return Text(String(format: L("cost_status_last_attempt"), name, when))
}
return Text(String(format: L("cost_status_no_data"), name))
}
}
@MainActor
struct CostHistoryDaysEditor: View {
@Bindable var settings: SettingsStore
static func title(days: Int) -> String {
String(format: L("cost_history_days_title"), days)
}
var body: some View {
LabeledContent(Self.title(days: self.settings.costUsageHistoryDays)) {
HStack(spacing: 8) {
TextField(
Self.title(days: self.settings.costUsageHistoryDays),
value: self.$settings.costUsageHistoryDays,
format: .number)
.labelsHidden()
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.trailing)
.monospacedDigit()
.frame(width: 64)
Stepper(value: self.$settings.costUsageHistoryDays, in: 1...365, step: 1) {
EmptyView()
}
.labelsHidden()
}
}
}
}