622 lines
25 KiB
Swift
622 lines
25 KiB
Swift
import CodexBarCore
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
struct ProviderSettingsToggleRowView: View {
|
|
let toggle: ProviderSettingsToggleDescriptor
|
|
|
|
var body: some View {
|
|
let isEnabled = self.toggle.isEnabled?() ?? true
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(L(self.toggle.title))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(isEnabled ? .primary : .tertiary)
|
|
Text(L(self.toggle.subtitle))
|
|
.font(.footnote)
|
|
.foregroundStyle(isEnabled ? .secondary : .tertiary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Spacer(minLength: 8)
|
|
Toggle("", isOn: self.toggle.binding)
|
|
.labelsHidden()
|
|
.toggleStyle(.switch)
|
|
}
|
|
|
|
if self.toggle.binding.wrappedValue {
|
|
if let status = self.toggle.statusText?(), !status.isEmpty {
|
|
Text(status)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(4)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
let actions = self.toggle.actions.filter { $0.isVisible?() ?? true }
|
|
if !actions.isEmpty {
|
|
HStack(spacing: 10) {
|
|
ForEach(actions) { action in
|
|
Button(L(action.title)) {
|
|
Task { @MainActor in
|
|
await action.perform()
|
|
}
|
|
}
|
|
.applyProviderSettingsButtonStyle(action.style)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.disabled(!isEnabled)
|
|
.onChange(of: self.toggle.binding.wrappedValue) { _, enabled in
|
|
guard let onChange = self.toggle.onChange else { return }
|
|
Task { @MainActor in
|
|
await onChange(enabled)
|
|
}
|
|
}
|
|
.task(id: self.toggle.binding.wrappedValue) {
|
|
guard self.toggle.binding.wrappedValue else { return }
|
|
guard let onAppear = self.toggle.onAppearWhenEnabled else { return }
|
|
await onAppear()
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct ProviderSettingsPickerRowView: View {
|
|
let picker: ProviderSettingsPickerDescriptor
|
|
|
|
var body: some View {
|
|
let isEnabled = self.picker.isEnabled?() ?? true
|
|
let subtitle = self.picker.dynamicSubtitle?() ?? self.picker.subtitle
|
|
let trimmedSubtitle = subtitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
LabeledContent {
|
|
HStack(spacing: 8) {
|
|
if let trailingText = self.picker.trailingText?(), !trailingText.isEmpty {
|
|
Text(trailingText)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
|
|
Picker("", selection: self.picker.binding) {
|
|
ForEach(self.picker.options) { option in
|
|
Text(L(option.title)).tag(option.id)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
.fixedSize()
|
|
}
|
|
} label: {
|
|
SettingsRowLabel(
|
|
L(self.picker.title),
|
|
subtitle: trimmedSubtitle.isEmpty ? nil : L(trimmedSubtitle))
|
|
}
|
|
.disabled(!isEnabled)
|
|
.onChange(of: self.picker.binding.wrappedValue) { _, selection in
|
|
guard let onChange = self.picker.onChange else { return }
|
|
Task { @MainActor in
|
|
await onChange(selection)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Renders a provider settings field descriptor as its own grouped-form section:
|
|
/// title becomes the header, subtitle/footer text become the footer, and the
|
|
/// placeholder stays inside the field.
|
|
@MainActor
|
|
struct ProviderSettingsFieldRowView: View {
|
|
let field: ProviderSettingsFieldDescriptor
|
|
|
|
var body: some View {
|
|
let trimmedTitle = self.field.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
Section {
|
|
self.fieldView
|
|
|
|
let actions = self.field.actions.filter { $0.isVisible?() ?? true }
|
|
if !actions.isEmpty {
|
|
HStack(spacing: 10) {
|
|
ForEach(actions) { action in
|
|
Button(L(action.title)) {
|
|
Task { @MainActor in
|
|
await action.perform()
|
|
}
|
|
}
|
|
.applyProviderSettingsButtonStyle(action.style)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
if !trimmedTitle.isEmpty {
|
|
Text(L(trimmedTitle))
|
|
}
|
|
} footer: {
|
|
self.footerView
|
|
}
|
|
}
|
|
|
|
private var fieldView: some View {
|
|
let prompt = (self.field.placeholder?.isEmpty == false) ? Text(L(self.field.placeholder ?? "")) : nil
|
|
return Group {
|
|
switch self.field.kind {
|
|
case .plain:
|
|
TextField(text: self.field.binding, prompt: prompt) {
|
|
EmptyView()
|
|
}
|
|
case .secure:
|
|
SecureField(text: self.field.binding, prompt: prompt) {
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.textFieldStyle(.plain)
|
|
.onTapGesture { self.field.onActivate?() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var footerView: some View {
|
|
let trimmedSubtitle = self.field.subtitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let footer = (self.field.footerText?.isEmpty == false) ? self.field.footerText : nil
|
|
if !trimmedSubtitle.isEmpty || footer != nil {
|
|
SettingsSectionFooter {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
if !trimmedSubtitle.isEmpty {
|
|
Text(L(trimmedSubtitle))
|
|
}
|
|
if let footer {
|
|
Text(L(footer))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct ProviderSettingsActionsRowView: View {
|
|
let descriptor: ProviderSettingsActionsDescriptor
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(L(self.descriptor.title))
|
|
.font(.subheadline.weight(.semibold))
|
|
|
|
if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
Text(L(self.descriptor.subtitle))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
let actions = self.descriptor.actions.filter { $0.isVisible?() ?? true }
|
|
if !actions.isEmpty {
|
|
HStack(spacing: 10) {
|
|
ForEach(actions) { action in
|
|
Button(L(action.title)) {
|
|
Task { @MainActor in
|
|
await action.perform()
|
|
}
|
|
}
|
|
.applyProviderSettingsButtonStyle(action.style)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct ProviderSettingsTokenAccountsRowView: View {
|
|
struct TeamAccountDraft: Equatable {
|
|
var teamMode: Bool
|
|
var organizationID: String
|
|
var projectID: String
|
|
|
|
func normalizedForPersistence() -> Self {
|
|
guard self.teamMode else {
|
|
return Self(teamMode: false, organizationID: "", projectID: "")
|
|
}
|
|
return Self(
|
|
teamMode: true,
|
|
organizationID: self.organizationID.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
projectID: self.projectID.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
}
|
|
}
|
|
|
|
let descriptor: ProviderSettingsTokenAccountsDescriptor
|
|
@State private var newLabel: String = ""
|
|
@State private var newToken: String = ""
|
|
@State private var newOrgID: String = ""
|
|
@State private var newProjectID: String = ""
|
|
@State private var newTeamMode = false
|
|
@State private var teamDrafts: [UUID: TeamAccountDraft] = [:]
|
|
|
|
var body: some View {
|
|
Section {
|
|
let accounts = self.descriptor.accounts()
|
|
if accounts.isEmpty {
|
|
Text(L("No token accounts yet."))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .center, spacing: 10) {
|
|
Button {
|
|
self.descriptor.setActiveIndex(index)
|
|
} label: {
|
|
HStack(alignment: .center, spacing: 8) {
|
|
Image(systemName: self.isActive(index: index, accountCount: accounts.count) ?
|
|
"checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(self.isActive(index: index, accountCount: accounts.count) ?
|
|
Color.accentColor : Color.secondary)
|
|
Text(account.displayName)
|
|
.font(
|
|
.footnote.weight(
|
|
self.isActive(index: index, accountCount: accounts.count) ?
|
|
.semibold : .regular))
|
|
.foregroundStyle(.primary)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button(L("Remove")) {
|
|
self.descriptor.removeAccount(account.id)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
if self.descriptor.showsTeamModeControls {
|
|
self.teamModeEditor(account: account)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.descriptor.primaryAddAction == nil {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
TextField(text: self.$newLabel, prompt: Text(L("Label"))) {
|
|
EmptyView()
|
|
}
|
|
.labelsHidden()
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.footnote)
|
|
.frame(maxWidth: 160)
|
|
SecureField(text: self.$newToken, prompt: Text(L(self.descriptor.placeholder))) {
|
|
EmptyView()
|
|
}
|
|
.labelsHidden()
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.footnote)
|
|
Button(L("Add")) {
|
|
let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !label.isEmpty, !token.isEmpty else { return }
|
|
let orgID = self.descriptor.showsOrganizationField
|
|
? self.newOrgID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
: ""
|
|
let teamOrgID = self.newOrgID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let projectID = self.newProjectID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let usageScope = self.descriptor.showsTeamModeControls
|
|
? (self.newTeamMode ? "team" : "personal")
|
|
: nil
|
|
let accountOrganizationID = if self.newTeamMode {
|
|
teamOrgID.isEmpty ? nil : teamOrgID
|
|
} else {
|
|
orgID.isEmpty ? nil : orgID
|
|
}
|
|
let accountWorkspaceID = self.newTeamMode && !projectID.isEmpty ? projectID : nil
|
|
self.descriptor.addAccount(
|
|
label,
|
|
token,
|
|
usageScope,
|
|
accountOrganizationID,
|
|
accountWorkspaceID)
|
|
self.newLabel = ""
|
|
self.newToken = ""
|
|
self.newOrgID = ""
|
|
self.newProjectID = ""
|
|
self.newTeamMode = false
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(Self.isAddDisabled(
|
|
label: self.newLabel,
|
|
token: self.newToken,
|
|
showsTeamModeControls: self.descriptor.showsTeamModeControls,
|
|
teamMode: self.newTeamMode,
|
|
teamContext: (organizationID: self.newOrgID, projectID: self.newProjectID)))
|
|
}
|
|
if self.descriptor.showsOrganizationField {
|
|
TextField(text: self.$newOrgID, prompt: Text(L("Org ID (optional)"))) {
|
|
EmptyView()
|
|
}
|
|
.labelsHidden()
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.footnote)
|
|
.help(
|
|
L("Optional organization ID for accounts linked to multiple Anthropic organizations."))
|
|
}
|
|
if self.descriptor.showsTeamModeControls {
|
|
Toggle(L("Team mode"), isOn: self.$newTeamMode)
|
|
.toggleStyle(.checkbox)
|
|
.font(.footnote)
|
|
if self.newTeamMode {
|
|
HStack(spacing: 8) {
|
|
TextField(text: self.$newOrgID, prompt: Text(L("Organization ID"))) {
|
|
EmptyView()
|
|
}
|
|
.labelsHidden()
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.footnote)
|
|
TextField(text: self.$newProjectID, prompt: Text(L("Project ID"))) {
|
|
EmptyView()
|
|
}
|
|
.labelsHidden()
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.footnote)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
Button(L("Open token file")) {
|
|
self.descriptor.openConfigFile()
|
|
}
|
|
.buttonStyle(.link)
|
|
.controlSize(.small)
|
|
Button(L("Reload")) {
|
|
self.descriptor.reloadFromDisk()
|
|
}
|
|
.buttonStyle(.link)
|
|
.controlSize(.small)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
if let title = self.descriptor.primaryAddActionTitle,
|
|
let action = self.descriptor.primaryAddAction
|
|
{
|
|
Button(L(title)) {
|
|
Task { @MainActor in
|
|
await action()
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
} header: {
|
|
Text(L(self.descriptor.title))
|
|
} footer: {
|
|
if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
SettingsSectionFooter(L(self.descriptor.subtitle))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isActive(index: Int, accountCount: Int) -> Bool {
|
|
guard accountCount > 0 else { return false }
|
|
let selectedIndex = min(self.descriptor.activeIndex(), max(0, accountCount - 1))
|
|
return selectedIndex == index
|
|
}
|
|
|
|
private func teamModeEditor(account: ProviderTokenAccount) -> some View {
|
|
let draft = self.teamDraft(for: account)
|
|
let original = Self.teamAccountDraft(for: account)
|
|
return VStack(alignment: .leading, spacing: 6) {
|
|
Toggle(L("Team mode"), isOn: self.teamModeDraftBinding(account: account))
|
|
.toggleStyle(.checkbox)
|
|
.font(.footnote)
|
|
if draft.teamMode {
|
|
HStack(spacing: 8) {
|
|
TextField(
|
|
text: self.organizationIDDraftBinding(account: account),
|
|
prompt: Text(L("Organization ID")))
|
|
{
|
|
EmptyView()
|
|
}
|
|
.labelsHidden()
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.footnote)
|
|
TextField(
|
|
text: self.projectIDDraftBinding(account: account),
|
|
prompt: Text(L("Project ID")))
|
|
{
|
|
EmptyView()
|
|
}
|
|
.labelsHidden()
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.footnote)
|
|
}
|
|
}
|
|
Button(L("apply")) {
|
|
self.applyTeamDraft(account: account)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(Self.isTeamDraftApplyDisabled(draft: draft, original: original))
|
|
}
|
|
.padding(.leading, 24)
|
|
}
|
|
|
|
private func teamModeDraftBinding(account: ProviderTokenAccount) -> Binding<Bool> {
|
|
Binding(
|
|
get: { self.teamDraft(for: account).teamMode },
|
|
set: { enabled in
|
|
self.updateTeamDraft(account: account) { draft in
|
|
draft.teamMode = enabled
|
|
}
|
|
})
|
|
}
|
|
|
|
private func organizationIDDraftBinding(account: ProviderTokenAccount) -> Binding<String> {
|
|
Binding(
|
|
get: { self.teamDraft(for: account).organizationID },
|
|
set: { value in
|
|
self.updateTeamDraft(account: account) { draft in
|
|
draft.organizationID = value
|
|
}
|
|
})
|
|
}
|
|
|
|
private func projectIDDraftBinding(account: ProviderTokenAccount) -> Binding<String> {
|
|
Binding(
|
|
get: { self.teamDraft(for: account).projectID },
|
|
set: { value in
|
|
self.updateTeamDraft(account: account) { draft in
|
|
draft.projectID = value
|
|
}
|
|
})
|
|
}
|
|
|
|
private func teamDraft(for account: ProviderTokenAccount) -> TeamAccountDraft {
|
|
self.teamDrafts[account.id] ?? Self.teamAccountDraft(for: account)
|
|
}
|
|
|
|
private func updateTeamDraft(
|
|
account: ProviderTokenAccount,
|
|
mutate: (inout TeamAccountDraft) -> Void)
|
|
{
|
|
var draft = self.teamDraft(for: account)
|
|
mutate(&draft)
|
|
self.teamDrafts[account.id] = draft
|
|
}
|
|
|
|
private func applyTeamDraft(account: ProviderTokenAccount) {
|
|
let draft = self.teamDraft(for: account)
|
|
let original = Self.teamAccountDraft(for: account)
|
|
guard !Self.isTeamDraftApplyDisabled(draft: draft, original: original) else { return }
|
|
let normalized = draft.normalizedForPersistence()
|
|
self.descriptor.updateAccount(
|
|
account.id,
|
|
normalized.teamMode ? "team" : "personal",
|
|
normalized.teamMode ? normalized.organizationID : nil,
|
|
normalized.teamMode ? normalized.projectID : nil)
|
|
self.teamDrafts[account.id] = nil
|
|
}
|
|
|
|
static func teamAccountDraft(for account: ProviderTokenAccount) -> TeamAccountDraft {
|
|
let teamMode = account.sanitizedUsageScope?.lowercased() == "team"
|
|
return TeamAccountDraft(
|
|
teamMode: teamMode,
|
|
organizationID: teamMode ? (account.sanitizedOrganizationID ?? "") : "",
|
|
projectID: teamMode ? (account.sanitizedWorkspaceID ?? "") : "")
|
|
}
|
|
|
|
static func isTeamDraftApplyDisabled(draft: TeamAccountDraft, original: TeamAccountDraft) -> Bool {
|
|
let draft = draft.normalizedForPersistence()
|
|
let original = original.normalizedForPersistence()
|
|
guard draft != original else { return true }
|
|
guard draft.teamMode else { return false }
|
|
return draft.organizationID.isEmpty || draft.projectID.isEmpty
|
|
}
|
|
|
|
static func isAddDisabled(
|
|
label: String,
|
|
token: String,
|
|
showsTeamModeControls: Bool,
|
|
teamMode: Bool,
|
|
teamContext: (organizationID: String, projectID: String)) -> Bool
|
|
{
|
|
let label = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let token = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !label.isEmpty, !token.isEmpty else { return true }
|
|
guard showsTeamModeControls, teamMode else { return false }
|
|
return teamContext.organizationID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
|
teamContext.projectID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
@ViewBuilder
|
|
fileprivate func applyProviderSettingsButtonStyle(_ style: ProviderSettingsActionDescriptor.Style) -> some View {
|
|
switch style {
|
|
case .bordered:
|
|
self.buttonStyle(.bordered)
|
|
case .link:
|
|
self.buttonStyle(.link)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct ProviderSettingsOrganizationsRowView: View {
|
|
let descriptor: ProviderSettingsOrganizationsDescriptor
|
|
@State private var errorMessage: String?
|
|
@State private var isRefreshing = false
|
|
|
|
var body: some View {
|
|
Section {
|
|
let entries = self.descriptor.entries()
|
|
if entries.allSatisfy(\.isLocked) {
|
|
Text(L("No organizations loaded. Click Refresh after setting your API key."))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(entries) { entry in
|
|
Toggle(isOn: Binding(
|
|
get: { entry.isEnabled },
|
|
set: { newValue in
|
|
self.descriptor.onToggle(entry.id, newValue)
|
|
})) {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(entry.localizesTitle ? L(entry.title) : entry.title)
|
|
if let subtitle = entry.subtitle,
|
|
!subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
{
|
|
Text(entry.localizesSubtitle ? L(subtitle) : subtitle)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.toggleStyle(.switch)
|
|
.controlSize(.small)
|
|
.disabled(entry.isLocked)
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
Button(L("Refresh organizations")) {
|
|
Task { @MainActor in
|
|
self.isRefreshing = true
|
|
let result = await self.descriptor.onRefresh()
|
|
self.isRefreshing = false
|
|
self.errorMessage = result.success ? nil : result.errorMessage
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(!self.descriptor.canRefresh() || self.isRefreshing)
|
|
if let errorMessage = self.errorMessage, !errorMessage.isEmpty {
|
|
Text(errorMessage)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
} header: {
|
|
Text(L(self.descriptor.title))
|
|
} footer: {
|
|
if let subtitle = self.descriptor.subtitle,
|
|
!subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
{
|
|
SettingsSectionFooter(L(subtitle))
|
|
}
|
|
}
|
|
}
|
|
}
|