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
111 changes: 90 additions & 21 deletions Sources/CodexBar/CodexAccountReconciliation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import Foundation
struct CodexVisibleAccount: Equatable, Sendable, Identifiable {
let id: String
let email: String
let workspaceLabel: String?
let storedAccountID: UUID?
let selectionSource: CodexActiveSource
let isActive: Bool
let isLive: Bool
let canReauthenticate: Bool
let canRemove: Bool

var displayName: String {
guard let workspaceLabel, !workspaceLabel.isEmpty else { return self.email }
return "\(self.email) — \(workspaceLabel)"
}
}

struct CodexVisibleAccountProjection: Equatable, Sendable {
Expand Down Expand Up @@ -40,11 +46,15 @@ enum CodexActiveSourceResolver {
.liveSystem
case let .managedAccount(id):
if let activeStoredAccount = snapshot.activeStoredAccount {
self.matchesLiveSystemAccountEmail(
self.matchesLiveSystemAccountIdentity(
storedAccount: activeStoredAccount,
liveSystemAccount: snapshot.liveSystemAccount) ? .liveSystem : .managedAccount(id: id)
} else if snapshot.liveSystemAccount != nil {
.liveSystem
} else if let soleStoredAccount = self.soleStoredManagedAccount(from: snapshot) {
.managedAccount(id: soleStoredAccount.id)
} else {
snapshot.liveSystemAccount != nil ? .liveSystem : .managedAccount(id: id)
.managedAccount(id: id)
}
}

Expand All @@ -53,16 +63,25 @@ enum CodexActiveSourceResolver {
resolvedSource: resolvedSource)
}

private static func matchesLiveSystemAccountEmail(
private static func matchesLiveSystemAccountIdentity(
storedAccount: ManagedCodexAccount,
liveSystemAccount: ObservedSystemCodexAccount?) -> Bool
{
guard let liveSystemAccount else { return false }
return Self.normalizeEmail(storedAccount.email) == Self.normalizeEmail(liveSystemAccount.email)
return ManagedCodexAccount.identityKey(
email: storedAccount.email,
workspaceAccountID: storedAccount.workspaceAccountID,
workspaceLabel: storedAccount.workspaceLabel) == ManagedCodexAccount.identityKey(
email: liveSystemAccount.email,
workspaceAccountID: liveSystemAccount.workspaceAccountID,
workspaceLabel: liveSystemAccount.workspaceLabel)
}

private static func normalizeEmail(_ email: String) -> String {
email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
private static func soleStoredManagedAccount(
from snapshot: CodexAccountReconciliationSnapshot) -> ManagedCodexAccount?
{
guard snapshot.storedAccounts.count == 1 else { return nil }
return snapshot.storedAccounts.first
}
}

Expand Down Expand Up @@ -114,7 +133,10 @@ struct DefaultCodexAccountReconciler {
nil
}
let matchingStoredAccountForLiveSystemAccount = liveSystemAccount.flatMap {
accounts.account(email: $0.email)
accounts.account(
email: $0.email,
workspaceAccountID: $0.workspaceAccountID,
workspaceLabel: $0.workspaceLabel)
}

return CodexAccountReconciliationSnapshot(
Expand Down Expand Up @@ -150,6 +172,8 @@ struct DefaultCodexAccountReconciler {
}
return ObservedSystemCodexAccount(
email: normalizedEmail,
workspaceLabel: ManagedCodexAccount.normalizeWorkspaceLabel(account.workspaceLabel),
workspaceAccountID: ManagedCodexAccount.normalizeWorkspaceAccountID(account.workspaceAccountID),
codexHomePath: account.codexHomePath,
observedAt: account.observedAt)
} catch {
Expand All @@ -165,13 +189,20 @@ struct DefaultCodexAccountReconciler {
extension CodexVisibleAccountProjection {
static func make(from snapshot: CodexAccountReconciliationSnapshot) -> CodexVisibleAccountProjection {
let resolvedActiveSource = CodexActiveSourceResolver.resolve(from: snapshot).resolvedSource
var visibleByEmail: [String: CodexVisibleAccount] = [:]
var visibleByID: [String: CodexVisibleAccount] = [:]

for storedAccount in snapshot.storedAccounts {
let normalizedEmail = Self.normalizeVisibleEmail(storedAccount.email)
visibleByEmail[normalizedEmail] = CodexVisibleAccount(
id: normalizedEmail,
let workspaceLabel = ManagedCodexAccount.normalizeWorkspaceLabel(storedAccount.workspaceLabel)
let workspaceAccountID = ManagedCodexAccount.normalizeWorkspaceAccountID(storedAccount.workspaceAccountID)
let visibleID = Self.visibleAccountID(
email: normalizedEmail,
workspaceAccountID: workspaceAccountID,
workspaceLabel: workspaceLabel)
visibleByID[visibleID] = CodexVisibleAccount(
id: visibleID,
email: normalizedEmail,
workspaceLabel: workspaceLabel,
storedAccountID: storedAccount.id,
selectionSource: .managedAccount(id: storedAccount.id),
isActive: false,
Expand All @@ -182,20 +213,29 @@ extension CodexVisibleAccountProjection {

if let liveSystemAccount = snapshot.liveSystemAccount {
let normalizedEmail = Self.normalizeVisibleEmail(liveSystemAccount.email)
if let existing = visibleByEmail[normalizedEmail] {
visibleByEmail[normalizedEmail] = CodexVisibleAccount(
let workspaceLabel = ManagedCodexAccount.normalizeWorkspaceLabel(liveSystemAccount.workspaceLabel)
let workspaceAccountID = ManagedCodexAccount
.normalizeWorkspaceAccountID(liveSystemAccount.workspaceAccountID)
let visibleID = Self.visibleAccountID(
email: normalizedEmail,
workspaceAccountID: workspaceAccountID,
workspaceLabel: workspaceLabel)
if let existing = visibleByID[visibleID] {
visibleByID[visibleID] = CodexVisibleAccount(
id: existing.id,
email: existing.email,
workspaceLabel: workspaceLabel ?? existing.workspaceLabel,
storedAccountID: existing.storedAccountID,
selectionSource: .liveSystem,
isActive: existing.isActive,
isLive: true,
canReauthenticate: existing.canReauthenticate,
canRemove: existing.canRemove)
} else {
visibleByEmail[normalizedEmail] = CodexVisibleAccount(
id: normalizedEmail,
visibleByID[visibleID] = CodexVisibleAccount(
id: visibleID,
email: normalizedEmail,
workspaceLabel: workspaceLabel,
storedAccountID: nil,
selectionSource: .liveSystem,
isActive: false,
Expand All @@ -205,17 +245,28 @@ extension CodexVisibleAccountProjection {
}
}

let activeEmail: String? = switch resolvedActiveSource {
let activeVisibleID: String? = switch resolvedActiveSource {
case let .managedAccount(id):
snapshot.storedAccounts.first { $0.id == id }.map { Self.normalizeVisibleEmail($0.email) }
snapshot.storedAccounts.first { $0.id == id }.map {
Self.visibleAccountID(
email: Self.normalizeVisibleEmail($0.email),
workspaceAccountID: $0.workspaceAccountID,
workspaceLabel: $0.workspaceLabel)
}
case .liveSystem:
snapshot.liveSystemAccount.map { Self.normalizeVisibleEmail($0.email) }
snapshot.liveSystemAccount.map {
Self.visibleAccountID(
email: Self.normalizeVisibleEmail($0.email),
workspaceAccountID: $0.workspaceAccountID,
workspaceLabel: $0.workspaceLabel)
}
}

if let activeEmail, let current = visibleByEmail[activeEmail] {
visibleByEmail[activeEmail] = CodexVisibleAccount(
if let activeVisibleID, let current = visibleByID[activeVisibleID] {
visibleByID[activeVisibleID] = CodexVisibleAccount(
id: current.id,
email: current.email,
workspaceLabel: current.workspaceLabel,
storedAccountID: current.storedAccountID,
selectionSource: current.selectionSource,
isActive: true,
Expand All @@ -224,8 +275,11 @@ extension CodexVisibleAccountProjection {
canRemove: current.canRemove)
}

let visibleAccounts = visibleByEmail.values.sorted { lhs, rhs in
lhs.email < rhs.email
let visibleAccounts = visibleByID.values.sorted { lhs, rhs in
if lhs.email == rhs.email {
return (lhs.workspaceLabel ?? "") < (rhs.workspaceLabel ?? "")
}
return lhs.email < rhs.email
}

return CodexVisibleAccountProjection(
Expand All @@ -238,11 +292,24 @@ extension CodexVisibleAccountProjection {
private static func normalizeVisibleEmail(_ email: String) -> String {
email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}

private static func visibleAccountID(
email: String,
workspaceAccountID: String?,
workspaceLabel: String?) -> String
{
ManagedCodexAccount.identityKey(
email: email,
workspaceAccountID: workspaceAccountID,
workspaceLabel: workspaceLabel)
}
}

private struct AccountIdentity: Equatable {
let id: UUID
let email: String
let workspaceLabel: String?
let workspaceAccountID: String?
let managedHomePath: String
let createdAt: TimeInterval
let updatedAt: TimeInterval
Expand All @@ -251,6 +318,8 @@ private struct AccountIdentity: Equatable {
init(_ account: ManagedCodexAccount) {
self.id = account.id
self.email = account.email
self.workspaceLabel = account.workspaceLabel
self.workspaceAccountID = account.workspaceAccountID
self.managedHomePath = account.managedHomePath
self.createdAt = account.createdAt
self.updatedAt = account.updatedAt
Expand Down
126 changes: 126 additions & 0 deletions Sources/CodexBar/CodexAccountSortControlView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import AppKit
import Foundation

final class CodexAccountSortControlView: NSView {
private let onStep: (Int) -> Void
private let currentMode: CodexMenuAccountSortMode
private let titleLabel: NSTextField
private let modeLabel: NSTextField
private let previousButton: NSButton
private let nextButton: NSButton

init(mode: CodexMenuAccountSortMode, width: CGFloat, onStep: @escaping (Int) -> Void) {
self.currentMode = mode
self.onStep = onStep
self.titleLabel = NSTextField(labelWithString: "Sort")
self.modeLabel = NSTextField(labelWithString: mode.compactTitle)
self.previousButton = NSButton(title: "", target: nil, action: nil)
self.nextButton = NSButton(title: "", target: nil, action: nil)
super.init(frame: NSRect(x: 0, y: 0, width: width, height: 30))
self.wantsLayer = true
self.buildUI()
}

@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}

override var intrinsicContentSize: NSSize {
NSSize(width: self.frame.width, height: 30)
}

override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}

override func layout() {
super.layout()
let bounds = self.bounds.insetBy(dx: 10, dy: 0)
let centerY = bounds.midY
let buttonSize = NSSize(width: 20, height: 20)

self.nextButton.frame = NSRect(
x: bounds.maxX - buttonSize.width,
y: centerY - buttonSize.height / 2,
width: buttonSize.width,
height: buttonSize.height)

self.previousButton.frame = NSRect(
x: self.nextButton.frame.minX - 6 - buttonSize.width,
y: centerY - buttonSize.height / 2,
width: buttonSize.width,
height: buttonSize.height)

self.titleLabel.sizeToFit()
self.titleLabel.frame = NSRect(
x: bounds.minX,
y: centerY - 9,
width: min(60, self.titleLabel.frame.width),
height: 18)

let modeX = self.titleLabel.frame.maxX + 8
let modeWidth = max(40, self.previousButton.frame.minX - 8 - modeX)
self.modeLabel.frame = NSRect(
x: modeX,
y: centerY - 9,
width: modeWidth,
height: 18)
}

private func buildUI() {
self.titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold)
self.titleLabel.textColor = .labelColor
self.titleLabel.alignment = .left
self.titleLabel.lineBreakMode = .byClipping

self.modeLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
self.modeLabel.textColor = .secondaryLabelColor
self.modeLabel.alignment = .right
self.modeLabel.lineBreakMode = .byTruncatingTail

self.configureButton(self.previousButton, action: #selector(self.previousMode))
self.configureButton(self.nextButton, action: #selector(self.nextMode))

if let previousImage = NSImage(
systemSymbolName: "chevron.left",
accessibilityDescription: "Previous sort mode")
{
previousImage.isTemplate = true
self.previousButton.image = previousImage
} else {
self.previousButton.title = "◀"
}
if let nextImage = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: "Next sort mode") {
nextImage.isTemplate = true
self.nextButton.image = nextImage
} else {
self.nextButton.title = "▶"
}

self.addSubview(self.titleLabel)
self.addSubview(self.modeLabel)
self.addSubview(self.previousButton)
self.addSubview(self.nextButton)
}

private func configureButton(_ button: NSButton, action: Selector) {
button.target = self
button.action = action
button.isBordered = true
button.bezelStyle = .texturedRounded
button.font = NSFont.systemFont(ofSize: 10, weight: .semibold)
button.contentTintColor = .secondaryLabelColor
button.setButtonType(.momentaryPushIn)
button.imagePosition = .imageOnly
button.imageScaling = .scaleProportionallyDown
}

@objc private func previousMode() {
self.onStep(-1)
}

@objc private func nextMode() {
self.onStep(1)
}
}
36 changes: 36 additions & 0 deletions Sources/CodexBar/CodexMenuAccountSortMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

/// Sorting is part of the multi-account Codex workflow: once several accounts are visible
/// together, it becomes useful to order them by reset time or remaining quota, not just by name.
enum CodexMenuAccountSortMode: String, CaseIterable, Sendable {
case accountNameAscending = "account-name-ascending"
case accountNameDescending = "account-name-descending"
case sessionLeftHighToLow = "session-left-high-to-low"
case sessionResetSoonestFirst = "session-reset-soonest-first"
case weeklyLeftHighToLow = "weekly-left-high-to-low"
case weeklyResetSoonestFirst = "weekly-reset-soonest-first"

static let `default`: Self = .accountNameAscending

var menuTitle: String {
switch self {
case .accountNameAscending: "Name A–Z"
case .accountNameDescending: "Name Z–A"
case .sessionLeftHighToLow: "Session left ↓"
case .sessionResetSoonestFirst: "Session reset soonest"
case .weeklyLeftHighToLow: "Weekly left ↓"
case .weeklyResetSoonestFirst: "Weekly reset soonest"
}
}

var compactTitle: String {
switch self {
case .accountNameAscending: "Name A–Z"
case .accountNameDescending: "Name Z–A"
case .sessionLeftHighToLow: "Session ↓"
case .sessionResetSoonestFirst: "Session reset soonest"
case .weeklyLeftHighToLow: "Weekly ↓"
case .weeklyResetSoonestFirst: "Weekly reset soonest"
}
}
}
Loading