Skip to content
Open
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
2 changes: 2 additions & 0 deletions Sources/CodexBar/MenuContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ struct MenuContent: View {
self.actions.openStatusPage()
case .addCodexAccount:
self.actions.addCodexAccount()
case let .addProviderAccount(provider):
self.actions.switchAccount(provider)
case let .switchAccount(provider):
self.actions.switchAccount(provider)
case let .openTerminal(command):
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ struct MenuDescriptor {
case dashboard
case statusPage
case addCodexAccount
case addProviderAccount(UsageProvider)
case switchAccount(UsageProvider)
case openTerminal(command: String)
case loginToProvider(url: String)
Expand Down Expand Up @@ -466,7 +467,7 @@ extension MenuDescriptor.MenuAction {
case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue
case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue
case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue
case .addCodexAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue
case .addCodexAccount, .addProviderAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue
case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue
case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue
case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue
Expand Down
114 changes: 76 additions & 38 deletions Sources/CodexBar/PreferencesProviderSettingsRows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,23 @@ struct ProviderSettingsTokenAccountsRowView: View {
@State private var newToken: String = ""

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(self.descriptor.title)
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 12) {
Text(self.descriptor.title)
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
if let title = self.descriptor.primaryAddActionTitle,
let action = self.descriptor.primaryAddAction
{
Button(title) {
Task { @MainActor in
await action()
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}

if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text(self.descriptor.subtitle)
Expand All @@ -224,46 +238,64 @@ struct ProviderSettingsTokenAccountsRowView: View {
.font(.footnote)
.foregroundStyle(.secondary)
} else {
let selectedIndex = min(self.descriptor.activeIndex(), max(0, accounts.count - 1))
Picker("", selection: Binding(
get: { selectedIndex },
set: { index in self.descriptor.setActiveIndex(index) }))
{
ForEach(Array(accounts.enumerated()), id: \.offset) { index, account in
Text(account.displayName).tag(index)
}
}
.labelsHidden()
.pickerStyle(.menu)
.controlSize(.small)
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in
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("Remove selected account") {
let account = accounts[selectedIndex]
self.descriptor.removeAccount(account.id)
Button("Remove") {
self.descriptor.removeAccount(account.id)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
if index < accounts.count - 1 {
Divider()
}
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}

HStack(spacing: 8) {
TextField("Label", text: self.$newLabel)
.textFieldStyle(.roundedBorder)
.font(.footnote)
SecureField(self.descriptor.placeholder, text: self.$newToken)
.textFieldStyle(.roundedBorder)
.font(.footnote)
Button("Add") {
let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines)
let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty, !token.isEmpty else { return }
self.descriptor.addAccount(label, token)
self.newLabel = ""
self.newToken = ""
if self.descriptor.primaryAddAction == nil {
HStack(spacing: 8) {
TextField("Label", text: self.$newLabel)
.textFieldStyle(.roundedBorder)
.font(.footnote)
SecureField(self.descriptor.placeholder, text: self.$newToken)
.textFieldStyle(.roundedBorder)
.font(.footnote)
Button("Add") {
let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines)
let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty, !token.isEmpty else { return }
self.descriptor.addAccount(label, token)
self.newLabel = ""
self.newToken = ""
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}

HStack(spacing: 10) {
Expand All @@ -280,6 +312,12 @@ struct ProviderSettingsTokenAccountsRowView: View {
}
}
}

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
}
}

extension View {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/PreferencesProvidersPane+Testing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ enum ProvidersPaneTestHarness {
setActiveIndex: { _ in },
addAccount: { _, _ in },
removeAccount: { _ in },
primaryAddActionTitle: nil,
primaryAddAction: nil,
openConfigFile: {},
reloadFromDisk: {})

Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,13 @@ struct ProvidersPane: View {
}
}
},
primaryAddActionTitle: provider == .copilot ? "Add Account" : nil,
primaryAddAction: provider == .copilot ? {
await CopilotLoginFlow.run(settings: self.settings)
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await self.store.refreshProvider(provider, allowDisabled: true)
}
} : nil,
openConfigFile: {
self.settings.openTokenAccountsFile()
},
Expand Down
42 changes: 40 additions & 2 deletions Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,52 @@ struct CopilotLoginFlow {

switch tokenResult {
case let .success(token):
settings.copilotAPIToken = token
// Fetch username for account label
var label: String
do {
let username = try await CopilotUsageFetcher.fetchGitHubUsername(token: token)
let planSuffix: String
do {
let fetcher = CopilotUsageFetcher(token: token)
let usage = try await fetcher.fetch()
let plan = usage.identity(for: .copilot)?.loginMethod ?? ""
planSuffix = plan.isEmpty ? "" : " (\(plan))"
} catch {
planSuffix = ""
}
label = "\(username)\(planSuffix)"
} catch {
let count = settings.tokenAccounts(for: .copilot).count
label = "Account \(count + 1)"
}

// Check for duplicate — same username means same GitHub user
let existingAccounts = settings.tokenAccounts(for: .copilot)
let usernamePrefix = label.components(separatedBy: " (").first ?? label
let wasRefresh = existingAccounts.contains(where: {
let existingPrefix = $0.label.components(separatedBy: " (").first ?? $0.label
return existingPrefix == usernamePrefix
})
if let existing = existingAccounts.first(where: {
let existingPrefix = $0.label.components(separatedBy: " (").first ?? $0.label
return existingPrefix == usernamePrefix
}) {
settings.updateTokenAccount(
provider: .copilot,
accountID: existing.id,
label: label,
token: token)
} else {
settings.addTokenAccount(provider: .copilot, label: label, token: token)
}
settings.setProviderEnabled(
provider: .copilot,
metadata: ProviderRegistry.shared.metadata[.copilot]!,
enabled: true)

let success = NSAlert()
success.messageText = "Login Successful"
success.messageText = wasRefresh ? "Token Refreshed" : "Account Added"
success.informativeText = label
success.runModal()
case let .failure(error):
guard !(error is CancellationError) else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,38 @@ struct CopilotProviderImplementation: ProviderImplementation {

@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
_ = context
return .copilot(context.settings.copilotSettingsSnapshot())
.copilot(context.settings.copilotSettingsSnapshot())
}

@MainActor
func loginMenuAction(context _: ProviderMenuLoginContext)
-> (label: String, action: MenuDescriptor.MenuAction)?
{
("Add Account...", .addProviderAccount(.copilot))
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "copilot-api-token",
id: "copilot-add-account",
title: "GitHub Login",
subtitle: "Requires authentication via GitHub Device Flow.",
kind: .secure,
placeholder: "Sign in via button below",
binding: context.stringBinding(\.copilotAPIToken),
subtitle: "Add accounts via GitHub OAuth Device Flow.",
kind: .plain,
placeholder: nil,
binding: .constant(""),
actions: [
ProviderSettingsActionDescriptor(
id: "copilot-login",
title: "Sign in with GitHub",
id: "copilot-add-account-action",
title: "Add Account",
style: .bordered,
isVisible: { context.settings.copilotAPIToken.isEmpty },
perform: {
await CopilotLoginFlow.run(settings: context.settings)
}),
ProviderSettingsActionDescriptor(
id: "copilot-relogin",
title: "Sign in again",
style: .link,
isVisible: { !context.settings.copilotAPIToken.isEmpty },
isVisible: { true },
perform: {
await CopilotLoginFlow.run(settings: context.settings)
}),
],
isVisible: nil,
onActivate: { context.settings.ensureCopilotAPITokenLoaded() }),
onActivate: nil),
]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable {
let setActiveIndex: (Int) -> Void
let addAccount: (_ label: String, _ token: String) -> Void
let removeAccount: (_ accountID: UUID) -> Void
let primaryAddActionTitle: String?
let primaryAddAction: (() async -> Void)?
let openConfigFile: () -> Void
let reloadFromDisk: () -> Void
}
Expand Down
51 changes: 49 additions & 2 deletions Sources/CodexBar/SettingsStore+TokenAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,65 @@ extension SettingsStore {
])
}

func updateTokenAccount(
provider: UsageProvider,
accountID: UUID,
label: String? = nil,
token: String? = nil)
{
guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return }
guard let index = data.accounts.firstIndex(where: { $0.id == accountID }) else { return }

let trimmedLabel = label?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmedToken, trimmedToken.isEmpty { return }

let existing = data.accounts[index]
let updatedAccount = ProviderTokenAccount(
id: existing.id,
label: (trimmedLabel?.isEmpty == false) ? trimmedLabel! : existing.label,
token: trimmedToken ?? existing.token,
addedAt: existing.addedAt,
lastUsed: existing.lastUsed)

var accounts = data.accounts
accounts[index] = updatedAccount
let updated = ProviderTokenAccountData(
version: data.version,
accounts: accounts,
activeIndex: data.clampedActiveIndex())
self.updateProviderConfig(provider: provider) { entry in
entry.tokenAccounts = updated
}
self.applyTokenAccountCookieSourceIfNeeded(provider: provider)
CodexBarLog.logger(LogCategories.tokenAccounts).info(
"Token account updated",
metadata: [
"provider": provider.rawValue,
"count": "\(updated.accounts.count)",
])
}

func removeTokenAccount(provider: UsageProvider, accountID: UUID) {
guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return }
let activeAccountID = data.accounts[data.clampedActiveIndex()].id
guard let removedIndex = data.accounts.firstIndex(where: { $0.id == accountID }) else { return }
let filtered = data.accounts.filter { $0.id != accountID }
self.updateProviderConfig(provider: provider) { entry in
if filtered.isEmpty {
entry.tokenAccounts = nil
} else {
let clamped = min(max(data.activeIndex, 0), filtered.count - 1)
let nextActiveIndex = if activeAccountID != accountID,
let preservedIndex = filtered.firstIndex(where: { $0.id == activeAccountID })
{
preservedIndex
} else {
min(removedIndex, filtered.count - 1)
}
entry.tokenAccounts = ProviderTokenAccountData(
version: data.version,
accounts: filtered,
activeIndex: clamped)
activeIndex: nextActiveIndex)
}
}
CodexBarLog.logger(LogCategories.tokenAccounts).info(
Expand Down
Loading