From 3e6e740256e9d7f34608f6087a559d8ce6b46d0b Mon Sep 17 00:00:00 2001 From: xuelongmu Date: Tue, 8 Sep 2026 00:12:35 -0400 Subject: [PATCH 1/2] Switch saved accounts from tray menus --- .../src-tauri/src/commands/claude_accounts.rs | 2 + .../src-tauri/src/commands/codex_accounts.rs | 2 + .../src-tauri/src/tray_bridge.rs | 147 +++++++++++++- apps/desktop-tauri/src-tauri/src/tray_menu.rs | 184 ++++++++++++++++++ .../components/ClaudeAccountsMenu.test.tsx | 71 +++++++ .../src/components/ClaudeAccountsMenu.tsx | 98 ++++++++++ .../desktop-tauri/src/components/MenuCard.tsx | 4 + 7 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx create mode 100644 apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index 0186abeebb..27fa2af84d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -17,6 +17,8 @@ pub fn claude_accounts_list() -> Result, String> { fn changed(app: &tauri::AppHandle) { let _emit = app.emit("claude-accounts-updated", ()); + let handle = app.clone(); + let _dispatch = app.run_on_main_thread(move || crate::tray_bridge::rebuild_tray_menu(&handle)); } #[tauri::command] diff --git a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs index 54065088e4..ff7e5b9a5f 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs @@ -417,6 +417,8 @@ fn refresh_persisted_accounts(app: tauri::AppHandle) -> Result<(), String> { fn accounts_changed(app: &tauri::AppHandle) { events::emit_codex_accounts_updated(app); + let handle = app.clone(); + let _ = app.run_on_main_thread(move || crate::tray_bridge::rebuild_tray_menu(&handle)); } fn into_user_message(error: CodexAccountManagerError) -> String { diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 53580a36cf..09445ad691 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -20,7 +20,9 @@ use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; #[cfg(test)] use crate::tray_menu::build_tray_menu; -use crate::tray_menu::{TrayMenuEntry, build_tray_menu_with}; +use crate::tray_menu::{ + TrayMenuEntry, build_tray_menu_with, claude_accounts_menu, codex_accounts_menu, +}; #[derive(Debug, Clone, Copy)] struct MonitorScaleInfo { @@ -130,13 +132,34 @@ fn build_native_tray_menu( ) -> tauri::Result> { let settings = Settings::load(); let enabled = settings.enabled_providers.clone(); - let spec = build_tray_menu_with( + let mut spec = build_tray_menu_with( providers, status_labels, &enabled, settings.float_bar_enabled, settings.ui_language, ); + let accounts = crate::commands::load_codex_accounts().unwrap_or_default(); + let active = + codexbar::codex_accounts::CodexAccountManager::new().discover_ambient_account(&accounts); + spec.insert( + 0, + codex_accounts_menu( + &accounts, + active.as_ref(), + settings.ui_language, + settings.hide_personal_info, + ), + ); + let claude_accounts = crate::commands::claude_accounts_list().unwrap_or_default(); + spec.insert( + 1, + claude_accounts_menu( + &claude_accounts, + settings.ui_language, + settings.hide_personal_info, + ), + ); let entries = spec .iter() .map(|entry| build_native_menu_entry(app, entry)) @@ -184,6 +207,12 @@ enum MenuAction { ToggleProvider(String), /// Toggle the floating bar window on/off. ToggleFloatBar, + AddCodexAccount, + AddClaudeAccount, + SaveClaudeAccount, + CancelClaudeLogin, + SwitchClaudeAccount(String), + SwitchCodexAccount(String), Quit, } @@ -201,6 +230,19 @@ fn resolve_menu_action(id: &str) -> Option { "about" => Some(MenuAction::OpenSettings("about".into())), "toggle_float_bar" => Some(MenuAction::ToggleFloatBar), "pop_out" => Some(MenuAction::OpenFlyout), + "add_codex_account" => Some(MenuAction::AddCodexAccount), + "add_claude_account" => Some(MenuAction::AddClaudeAccount), + "save_claude_account" => Some(MenuAction::SaveClaudeAccount), + "cancel_claude_login" => Some(MenuAction::CancelClaudeLogin), + _ if id.starts_with("switch_claude_account:") => { + let id = id.strip_prefix("switch_claude_account:")?; + (!id.is_empty()).then(|| MenuAction::SwitchClaudeAccount(id.to_string())) + } + _ if id.starts_with("switch_codex_account:") => { + let id = id.strip_prefix("switch_codex_account:")?; + uuid::Uuid::parse_str(id).ok()?; + Some(MenuAction::SwitchCodexAccount(id.to_string())) + } _ if id.starts_with("toggle_provider:") => { let provider_id = id["toggle_provider:".len()..].to_string(); Some(MenuAction::ToggleProvider(provider_id)) @@ -328,6 +370,79 @@ fn schedule_tray_promotion_retries(app_handle: AppHandle) { /// Route a native menu-item click to the corresponding shell action. fn handle_menu_event(app: &AppHandle, id: &str) { match resolve_menu_action(id) { + Some(MenuAction::AddCodexAccount) => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + match crate::commands::codex_account_add(handle.clone()).await { + Ok(_) => show_account_message(&handle, "Codex account added."), + Err(error) => show_account_message(&handle, &error), + } + }); + } + Some(MenuAction::SwitchCodexAccount(id)) => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + match crate::commands::codex_account_switch(handle.clone(), id).await { + Ok(result) => { + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; + if result.desktop_session_restore_path.is_some() { + let dialog_handle = handle.clone(); + let restart = tauri::async_runtime::spawn_blocking(move || { + dialog_handle.dialog() + .message("Account switched. Restart Codex Desktop to use it? This stops running desktop tasks.") + .title("Codex Accounts") + .buttons(MessageDialogButtons::OkCancelCustom("Restart".into(), "Later".into())) + .blocking_show() + }).await.unwrap_or(false); + if restart { + let result = crate::commands::codex_account_restart_desktop( + handle.clone(), + result.switch_id.to_string(), + ) + .await; + if let Err(error) = result { + show_account_message(&handle, &error); + } + } + } else { + show_account_message(&handle, "Codex account switched."); + } + } + Err(error) => show_account_message(&handle, &error), + } + }); + } + Some(MenuAction::CancelClaudeLogin) => crate::commands::claude_account_cancel_login(), + Some( + action @ (MenuAction::AddClaudeAccount + | MenuAction::SaveClaudeAccount + | MenuAction::SwitchClaudeAccount(_)), + ) => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + use tauri_plugin_dialog::DialogExt; + let (result, message) = match action { + MenuAction::AddClaudeAccount => ( + crate::commands::claude_account_add(handle.clone()).await, + "Claude Code account added. Select it to switch.", + ), + MenuAction::SaveClaudeAccount => ( + crate::commands::claude_account_save_current(handle.clone()).await, + "Current Claude Code account saved.", + ), + MenuAction::SwitchClaudeAccount(id) => ( + crate::commands::claude_account_switch(handle.clone(), id).await, + "Claude Code account switched. Reopen the Claude Code CLI to use it.", + ), + _ => unreachable!(), + }; + handle + .dialog() + .message(result.err().unwrap_or_else(|| message.to_string())) + .title("Claude Code accounts") + .show(|_| {}); + }); + } Some(MenuAction::Transition(request)) => { crate::auto_refresh::note_menu_open(); match resolve_menu_transition_dispatch(id, request) { @@ -396,6 +511,14 @@ fn handle_menu_event(app: &AppHandle, id: &str) { } } +fn show_account_message(app: &AppHandle, message: &str) { + use tauri_plugin_dialog::DialogExt; + app.dialog() + .message(message) + .title("Codex Accounts") + .show(|_| {}); +} + /// Rebuild the native tray menu from current provider + settings state. pub(crate) fn rebuild_tray_menu(app: &AppHandle) { let catalog = crate::commands::get_provider_catalog(); @@ -833,6 +956,26 @@ mod tests { assert!(menu_contains(&menu, "quit")); } + #[test] + fn claude_account_actions_are_distinct_from_codex_and_reject_empty_ids() { + assert!(matches!( + resolve_menu_action("add_claude_account"), + Some(MenuAction::AddClaudeAccount) + )); + assert!(matches!( + resolve_menu_action("save_claude_account"), + Some(MenuAction::SaveClaudeAccount) + )); + assert!(matches!( + resolve_menu_action("cancel_claude_login"), + Some(MenuAction::CancelClaudeLogin) + )); + assert!( + matches!(resolve_menu_action("switch_claude_account:a:org"), Some(MenuAction::SwitchClaudeAccount(id)) if id == "a:org") + ); + assert!(resolve_menu_action("switch_claude_account:").is_none()); + } + #[test] fn toggle_float_bar_routes_to_toggle_action() { let action = resolve_menu_action("toggle_float_bar").expect("float bar action"); diff --git a/apps/desktop-tauri/src-tauri/src/tray_menu.rs b/apps/desktop-tauri/src-tauri/src/tray_menu.rs index d4ed60feac..8552a08e3c 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_menu.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_menu.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use crate::commands::ProviderCatalogEntry; +use codexbar::codex_accounts::CodexAccount; use codexbar::locale::{self, LocaleKey}; use codexbar::settings::Language; @@ -16,6 +17,111 @@ pub(crate) struct TrayMenuEntry { pub(crate) checked: Option, } +pub(crate) fn codex_accounts_menu( + accounts: &[CodexAccount], + active: Option<&CodexAccount>, + lang: Language, + hide_personal_info: bool, +) -> TrayMenuEntry { + let text = |key| locale::get_text(lang, key); + let mut children: Vec<_> = accounts + .iter() + .map(|account| { + let is_active = active.is_some_and(|current| current.matches(account)); + let mut entry = TrayMenuEntry::check_item( + format!("switch_codex_account:{}", account.id), + if hide_personal_info + && account + .nickname + .as_deref() + .is_none_or(|n| n.trim().is_empty()) + { + codexbar::core::PersonalInfoRedactor::partial_redact_email( + account.email_hint.as_deref(), + true, + ) + } else { + account.display_name() + }, + is_active, + ); + entry.disabled = is_active; + entry + }) + .collect(); + if children.is_empty() { + children.push(TrayMenuEntry::status_row( + "codex_accounts_empty", + text(LocaleKey::CodexAccountsEmpty), + )); + } + children.push(TrayMenuEntry::separator()); + children.push(TrayMenuEntry::item( + "add_codex_account", + text(LocaleKey::CodexAccountsAddButton), + )); + TrayMenuEntry::submenu( + "codex_accounts", + text(LocaleKey::CodexAccountsTitle), + children, + ) +} + +pub(crate) fn claude_accounts_menu( + accounts: &[codexbar::providers::claude::accounts::ClaudeAccount], + lang: Language, + hide_personal_info: bool, +) -> TrayMenuEntry { + let text = |key| locale::get_text(lang, key); + let mut children: Vec<_> = accounts + .iter() + .map(|account| { + let label = if hide_personal_info { + codexbar::core::PersonalInfoRedactor::partial_redact_email( + Some(&account.email), + true, + ) + } else { + account.organization.as_ref().map_or_else( + || account.email.clone(), + |org| format!("{} ({org})", account.email), + ) + }; + let mut entry = TrayMenuEntry::check_item( + format!("switch_claude_account:{}", account.id), + label, + account.is_active, + ); + entry.disabled = account.is_active || !account.is_saved; + entry + }) + .collect(); + if children.is_empty() { + children.push(TrayMenuEntry::status_row( + "claude_accounts_empty", + text(LocaleKey::ClaudeAccountsEmpty), + )); + } + children.push(TrayMenuEntry::separator()); + children.push(TrayMenuEntry::item( + "add_claude_account", + text(LocaleKey::CodexAccountsAddButton), + )); + children.push(TrayMenuEntry::item( + "save_claude_account", + text(LocaleKey::ClaudeAccountsSaveCurrent), + )); + children.push(TrayMenuEntry::item( + "cancel_claude_login", + text(LocaleKey::ClaudeAccountsCancelLogin), + )); + TrayMenuEntry::submenu( + "claude_accounts", + text(LocaleKey::ClaudeAccountsTitle), + children, + ) +} + impl TrayMenuEntry { fn item(id: impl Into, label: impl Into) -> Self { Self { @@ -164,6 +270,84 @@ pub(crate) fn build_tray_menu_with( mod tests { use super::*; + #[test] + fn codex_accounts_can_be_switched_or_added_from_tray() { + use codexbar::codex_accounts::{CodexAccountSource, utc_now}; + let make = |name: &str| { + CodexAccount::new( + uuid::Uuid::new_v4(), + Some(name.into()), + None, + None, + Some(name.into()), + std::path::PathBuf::from(name), + CodexAccountSource::ManagedByApp, + utc_now(), + utc_now(), + None, + ) + }; + let first = make("Personal"); + let second = make("Work"); + let menu = codex_accounts_menu( + &[first.clone(), second.clone()], + Some(&first), + Language::English, + false, + ); + assert_eq!(menu.children[0].checked, Some(true)); + assert!(menu.children[0].disabled); + assert_eq!( + menu.children[1].id.as_deref(), + Some(format!("switch_codex_account:{}", second.id).as_str()) + ); + assert_eq!(menu.children[1].checked, Some(false)); + assert!(!menu.children[1].disabled); + assert!(menu_contains(&menu.children, "add_codex_account")); + let empty = codex_accounts_menu(&[], None, Language::English, false); + assert!(menu_contains(&empty.children, "add_codex_account")); + let mut email_account = second; + email_account.nickname = None; + email_account.email_hint = Some("private@example.com".into()); + let private = codex_accounts_menu(&[email_account.clone()], None, Language::English, true); + assert!(!private.children[0].label.contains("private@example.com")); + let visible = codex_accounts_menu(&[email_account], None, Language::English, false); + assert_eq!(visible.children[0].label, "private@example.com"); + } + + #[test] + fn claude_menu_checks_current_account_and_routes_saved_accounts() { + use codexbar::providers::claude::accounts::ClaudeAccount; + let current = ClaudeAccount { + id: "a:org".into(), + email: "a@example.com".into(), + organization: None, + plan: None, + is_active: true, + is_saved: false, + }; + let saved = ClaudeAccount { + id: "b:org".into(), + is_active: false, + is_saved: true, + ..current.clone() + }; + let menu = claude_accounts_menu(&[current, saved], Language::English, false); + assert_eq!(menu.id.as_deref(), Some("claude_accounts")); + assert_eq!(menu.children[0].checked, Some(true)); + assert!(menu.children[0].disabled); + assert_eq!( + menu.children[1].id.as_deref(), + Some("switch_claude_account:b:org") + ); + assert!(!menu.children[1].disabled); + assert!(menu_contains(&menu.children, "add_claude_account")); + assert!(menu_contains( + &claude_accounts_menu(&[], Language::English, false).children, + "add_claude_account" + )); + } + fn menu_contains(menu: &[TrayMenuEntry], id: &str) -> bool { menu.iter().any(|entry| { entry.id.as_deref() == Some(id) diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx new file mode 100644 index 0000000000..b403a87540 --- /dev/null +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.test.tsx @@ -0,0 +1,71 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClaudeAccount } from "../types/bridge"; + +const mocks = vi.hoisted(() => ({ claudeAccountsList: vi.fn(), claudeAccountSwitch: vi.fn(), refreshProviders: vi.fn() })); +vi.mock("../lib/tauri", () => mocks); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) })); +vi.mock("../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => key }) })); +import ClaudeAccountsMenu from "./ClaudeAccountsMenu"; + +const first: ClaudeAccount = { id: "first:org", email: "first@example.com", organization: "Personal", plan: "max", isActive: true, isSaved: true }; +const second: ClaudeAccount = { ...first, id: "second:org", email: "second@example.com", organization: "Work", isActive: false }; + +describe("ClaudeAccountsMenu", () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.claudeAccountsList.mockResolvedValue([first, second]); + mocks.refreshProviders.mockResolvedValue(undefined); + }); + + it("marks the current account and switches the selected saved account", async () => { + render(); + await screen.findByText(first.email); + const buttons = screen.getAllByText("CodexAccountsSwitchButton") as HTMLButtonElement[]; + expect(buttons[0].disabled).toBe(true); + expect(buttons[1].disabled).toBe(false); + await act(async () => fireEvent.click(buttons[1])); + expect(mocks.claudeAccountSwitch).toHaveBeenCalledWith(second.id); + expect(screen.getByRole("status").textContent).toBe("ClaudeAccountsSwitched"); + }); + + it("shows a single inactive saved account so the first login can be activated", async () => { + mocks.claudeAccountsList.mockResolvedValue([second]); + render(); + await screen.findByText(second.email); + const button = screen.getByText("CodexAccountsSwitchButton"); + expect(button).not.toBeDisabled(); + await act(async () => fireEvent.click(button)); + expect(mocks.claudeAccountSwitch).toHaveBeenCalledWith(second.id); + }); + + it("masks emails, including tooltips, when hideEmail is enabled", async () => { + mocks.claudeAccountsList.mockResolvedValue([first, { ...second, organization: `${second.email}'s Organization` }]); + const { container } = render(); + await screen.findByText("ClaudeAccountsTitle"); + expect(container.textContent).not.toContain(first.email); + expect(container.innerHTML).not.toContain(second.email); + }); + + it("shows switch failures and leaves the current account marked active", async () => { + mocks.claudeAccountSwitch.mockRejectedValue("Close Claude Code first."); + render(); + await screen.findByText(first.email); + await act(async () => fireEvent.click(screen.getAllByText("CodexAccountsSwitchButton")[1])); + expect(screen.getByRole("alert").textContent).toContain("Close Claude Code first."); + expect(screen.queryByRole("status")).toBeNull(); + expect(mocks.refreshProviders).not.toHaveBeenCalled(); + }); + + it("keeps account loading failures discoverable and retries when the window gains focus", async () => { + mocks.claudeAccountsList.mockRejectedValueOnce("Account storage unavailable."); + const onLayoutChange = vi.fn(); + render(); + await screen.findByText("Account storage unavailable."); + expect(screen.getByText("ClaudeAccountsTitle")).toBeInTheDocument(); + await act(async () => window.dispatchEvent(new Event("focus"))); + expect(await screen.findByText(second.email)).toBeInTheDocument(); + expect(screen.queryByText("Account storage unavailable.")).toBeNull(); + expect(onLayoutChange).toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx new file mode 100644 index 0000000000..eccc12b2b5 --- /dev/null +++ b/apps/desktop-tauri/src/components/ClaudeAccountsMenu.tsx @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import type { ClaudeAccount } from "../types/bridge"; +import { claudeAccountsList, claudeAccountSwitch } from "../lib/tauri"; +import { useLocale } from "../hooks/useLocale"; +import { maskEmail } from "./MenuCard"; + +export default function ClaudeAccountsMenu({ hideEmail, onLayoutChange }: { + hideEmail: boolean; + onLayoutChange?: () => void; +}) { + const { t } = useLocale(); + const [accounts, setAccounts] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [switched, setSwitched] = useState(false); + const mounted = useRef(false); + const load = useCallback(async () => { + const next = await claudeAccountsList(); + if (mounted.current) { + setAccounts(next); + setError(null); + } + }, []); + useEffect(() => { + mounted.current = true; + const reload = () => { + void load().catch(e => { if (mounted.current) setError(String(e)); }); + }; + reload(); + window.addEventListener("focus", reload); + const unlisten = listen("claude-accounts-updated", reload); + return () => { + mounted.current = false; + window.removeEventListener("focus", reload); + void unlisten.then(fn => fn()).catch(() => {}); + }; + }, [load]); + useEffect(() => { + onLayoutChange?.(); + }, [accounts.length, error, switched, onLayoutChange]); + + const switchAccount = async (id: string) => { + setBusy(true); + setError(null); + setSwitched(false); + try { + await claudeAccountSwitch(id); + await load(); + if (mounted.current) setSwitched(true); + } catch (e) { + if (mounted.current) setError(String(e)); + } finally { + if (mounted.current) setBusy(false); + } + }; + + const hasSwitchableAccount = accounts.some(account => account.isSaved && !account.isActive); + if (accounts.length <= 1 && !hasSwitchableAccount && !error) return null; + return ( +
+ + {t("ClaudeAccountsTitle")} + {accounts.length} + + {error &&
{error}
} + {switched &&

{t("ClaudeAccountsSwitched")}

} +
    + {accounts.map(account => { + const email = hideEmail ? maskEmail(account.email) : account.email; + return ( +
  • +
    +
    + + {email} + {account.isActive && {t("TokenAccountActive")}} + + {!hideEmail && account.organization && !account.organization.includes(account.email) && ( + {account.organization} + )} +
    + +
    +
  • + ); + })} +
+
+ ); +} diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 46aa3fec14..77cfe62044 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -11,6 +11,7 @@ import type { LocaleKey } from "../i18n/keys"; import { providerSupportsChartData } from "../lib/providerCharts"; import MenuCardDetails, { describeCard, type MetricEntry } from "./MenuCardDetails"; import CodexAccountsMenu from "./CodexAccountsMenu"; +import ClaudeAccountsMenu from "./ClaudeAccountsMenu"; import { DEEPSEEK_PRICING_EVENT } from "../hooks/useDeepSeekPricingStatus"; import { getDeepSeekPricingStatus } from "../lib/tauri"; import type { DeepSeekPricingStatus } from "../types/bridge"; @@ -326,6 +327,9 @@ export default function MenuCard({ resetTimeRelative={resetTimeRelative} /> )} + {provider.providerId === "claude" && ( + + )} ); } From daf37c0656fad9ecec7e0b167ee910b140afeef4 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:13:08 +0700 Subject: [PATCH 2/2] Extract tray account controller --- apps/desktop-tauri/src-tauri/src/main.rs | 1 + .../src-tauri/src/tray_accounts.rs | 373 ++++++++++++++++++ .../src-tauri/src/tray_bridge.rs | 151 +------ apps/desktop-tauri/src-tauri/src/tray_menu.rs | 200 +--------- 4 files changed, 393 insertions(+), 332 deletions(-) create mode 100644 apps/desktop-tauri/src-tauri/src/tray_accounts.rs diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index dc139423cd..131d854840 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -15,6 +15,7 @@ mod shortcut_bridge; mod state; mod surface; mod surface_target; +mod tray_accounts; mod tray_bridge; mod tray_menu; mod tray_visibility; diff --git a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs new file mode 100644 index 0000000000..44b6355303 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs @@ -0,0 +1,373 @@ +//! Account-specific native tray menu construction and dispatch. +//! +//! Keep provider/account workflows out of the generic tray shell so adding a +//! new account action does not grow `tray_bridge.rs` into another controller. + +use codexbar::codex_accounts::CodexAccount; +use codexbar::locale::{self, LocaleKey}; +use codexbar::settings::{Language, Settings}; +use tauri::AppHandle; + +use crate::tray_menu::TrayMenuEntry; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AccountMenuAction { + AddCodexAccount, + AddClaudeAccount, + SaveClaudeAccount, + CancelClaudeLogin, + SwitchClaudeAccount(String), + SwitchCodexAccount(String), +} + +pub(crate) fn prepend_account_menus(spec: &mut Vec, settings: &Settings) { + let accounts = crate::commands::load_codex_accounts().unwrap_or_default(); + let active = + codexbar::codex_accounts::CodexAccountManager::new().discover_ambient_account(&accounts); + spec.insert( + 0, + codex_accounts_menu( + &accounts, + active.as_ref(), + settings.ui_language, + settings.hide_personal_info, + ), + ); + + let claude_accounts = crate::commands::claude_accounts_list().unwrap_or_default(); + spec.insert( + 1, + claude_accounts_menu( + &claude_accounts, + settings.ui_language, + settings.hide_personal_info, + ), + ); +} + +pub(crate) fn resolve_action(id: &str) -> Option { + match id { + "add_codex_account" => Some(AccountMenuAction::AddCodexAccount), + "add_claude_account" => Some(AccountMenuAction::AddClaudeAccount), + "save_claude_account" => Some(AccountMenuAction::SaveClaudeAccount), + "cancel_claude_login" => Some(AccountMenuAction::CancelClaudeLogin), + _ if id.starts_with("switch_claude_account:") => { + let id = id.strip_prefix("switch_claude_account:")?; + (!id.is_empty()).then(|| AccountMenuAction::SwitchClaudeAccount(id.to_string())) + } + _ if id.starts_with("switch_codex_account:") => { + let id = id.strip_prefix("switch_codex_account:")?; + uuid::Uuid::parse_str(id).ok()?; + Some(AccountMenuAction::SwitchCodexAccount(id.to_string())) + } + _ => None, + } +} + +pub(crate) fn handle_action(app: &AppHandle, action: AccountMenuAction) { + match action { + AccountMenuAction::AddCodexAccount => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + match crate::commands::codex_account_add(handle.clone()).await { + Ok(_) => show_codex_message(&handle, "Codex account added."), + Err(error) => show_codex_message(&handle, &error), + } + }); + } + AccountMenuAction::SwitchCodexAccount(id) => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + match crate::commands::codex_account_switch(handle.clone(), id).await { + Ok(result) => { + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; + if result.desktop_session_restore_path.is_some() { + let dialog_handle = handle.clone(); + let restart = tauri::async_runtime::spawn_blocking(move || { + dialog_handle + .dialog() + .message("Account switched. Restart Codex Desktop to use it? This stops running desktop tasks.") + .title("Codex Accounts") + .buttons(MessageDialogButtons::OkCancelCustom( + "Restart".into(), + "Later".into(), + )) + .blocking_show() + }) + .await + .unwrap_or(false); + if restart { + let restart_result = + crate::commands::codex_account_restart_desktop( + handle.clone(), + result.switch_id.to_string(), + ) + .await; + if let Err(error) = restart_result { + show_codex_message(&handle, &error); + } + } + } else { + show_codex_message(&handle, "Codex account switched."); + } + } + Err(error) => show_codex_message(&handle, &error), + } + }); + } + AccountMenuAction::CancelClaudeLogin => crate::commands::claude_account_cancel_login(), + action @ (AccountMenuAction::AddClaudeAccount + | AccountMenuAction::SaveClaudeAccount + | AccountMenuAction::SwitchClaudeAccount(_)) => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + use tauri_plugin_dialog::DialogExt; + let (result, message) = match action { + AccountMenuAction::AddClaudeAccount => ( + crate::commands::claude_account_add(handle.clone()).await, + "Claude Code account added. Select it to switch.", + ), + AccountMenuAction::SaveClaudeAccount => ( + crate::commands::claude_account_save_current(handle.clone()).await, + "Current Claude Code account saved.", + ), + AccountMenuAction::SwitchClaudeAccount(id) => ( + crate::commands::claude_account_switch(handle.clone(), id).await, + "Claude Code account switched. Reopen the Claude Code CLI to use it.", + ), + _ => unreachable!(), + }; + handle + .dialog() + .message(result.err().unwrap_or_else(|| message.to_string())) + .title("Claude Code accounts") + .show(|_| {}); + }); + } + } +} + +fn show_codex_message(app: &AppHandle, message: &str) { + use tauri_plugin_dialog::DialogExt; + app.dialog() + .message(message) + .title("Codex Accounts") + .show(|_| {}); +} + +fn codex_accounts_menu( + accounts: &[CodexAccount], + active: Option<&CodexAccount>, + lang: Language, + hide_personal_info: bool, +) -> TrayMenuEntry { + let text = |key| locale::get_text(lang, key); + let mut children: Vec<_> = accounts + .iter() + .map(|account| { + let is_active = active.is_some_and(|current| current.matches(account)); + let mut entry = TrayMenuEntry::check_item( + format!("switch_codex_account:{}", account.id), + if hide_personal_info + && account + .nickname + .as_deref() + .is_none_or(|n| n.trim().is_empty()) + { + codexbar::core::PersonalInfoRedactor::partial_redact_email( + account.email_hint.as_deref(), + true, + ) + } else { + account.display_name() + }, + is_active, + ); + entry.disabled = is_active; + entry + }) + .collect(); + if children.is_empty() { + children.push(TrayMenuEntry::status_row( + "codex_accounts_empty", + text(LocaleKey::CodexAccountsEmpty), + )); + } + children.push(TrayMenuEntry::separator()); + children.push(TrayMenuEntry::item( + "add_codex_account", + text(LocaleKey::CodexAccountsAddButton), + )); + TrayMenuEntry::submenu( + "codex_accounts", + text(LocaleKey::CodexAccountsTitle), + children, + ) +} + +fn claude_accounts_menu( + accounts: &[codexbar::providers::claude::accounts::ClaudeAccount], + lang: Language, + hide_personal_info: bool, +) -> TrayMenuEntry { + let text = |key| locale::get_text(lang, key); + let mut children: Vec<_> = accounts + .iter() + .map(|account| { + let label = if hide_personal_info { + codexbar::core::PersonalInfoRedactor::partial_redact_email( + Some(&account.email), + true, + ) + } else { + account.organization.as_ref().map_or_else( + || account.email.clone(), + |org| format!("{} ({org})", account.email), + ) + }; + let mut entry = TrayMenuEntry::check_item( + format!("switch_claude_account:{}", account.id), + label, + account.is_active, + ); + entry.disabled = account.is_active || !account.is_saved; + entry + }) + .collect(); + if children.is_empty() { + children.push(TrayMenuEntry::status_row( + "claude_accounts_empty", + text(LocaleKey::ClaudeAccountsEmpty), + )); + } + children.push(TrayMenuEntry::separator()); + children.push(TrayMenuEntry::item( + "add_claude_account", + text(LocaleKey::CodexAccountsAddButton), + )); + children.push(TrayMenuEntry::item( + "save_claude_account", + text(LocaleKey::ClaudeAccountsSaveCurrent), + )); + children.push(TrayMenuEntry::item( + "cancel_claude_login", + text(LocaleKey::ClaudeAccountsCancelLogin), + )); + TrayMenuEntry::submenu( + "claude_accounts", + text(LocaleKey::ClaudeAccountsTitle), + children, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn menu_contains(menu: &[TrayMenuEntry], id: &str) -> bool { + menu.iter().any(|entry| { + entry.id.as_deref() == Some(id) + || (!entry.children.is_empty() && menu_contains(&entry.children, id)) + }) + } + + #[test] + fn account_action_ids_are_typed_and_validated() { + assert_eq!( + resolve_action("add_claude_account"), + Some(AccountMenuAction::AddClaudeAccount) + ); + assert_eq!( + resolve_action("save_claude_account"), + Some(AccountMenuAction::SaveClaudeAccount) + ); + assert_eq!( + resolve_action("cancel_claude_login"), + Some(AccountMenuAction::CancelClaudeLogin) + ); + assert_eq!( + resolve_action("switch_claude_account:a:org"), + Some(AccountMenuAction::SwitchClaudeAccount("a:org".into())) + ); + assert!(resolve_action("switch_claude_account:").is_none()); + assert!(resolve_action("switch_codex_account:not-a-uuid").is_none()); + } + + #[test] + fn codex_accounts_can_be_switched_or_added_from_tray() { + use codexbar::codex_accounts::{CodexAccountSource, utc_now}; + let make = |name: &str| { + CodexAccount::new( + uuid::Uuid::new_v4(), + Some(name.into()), + None, + None, + Some(name.into()), + std::path::PathBuf::from(name), + CodexAccountSource::ManagedByApp, + utc_now(), + utc_now(), + None, + ) + }; + let first = make("Personal"); + let second = make("Work"); + let menu = codex_accounts_menu( + &[first.clone(), second.clone()], + Some(&first), + Language::English, + false, + ); + assert_eq!(menu.children[0].checked, Some(true)); + assert!(menu.children[0].disabled); + assert_eq!( + menu.children[1].id.as_deref(), + Some(format!("switch_codex_account:{}", second.id).as_str()) + ); + assert_eq!(menu.children[1].checked, Some(false)); + assert!(!menu.children[1].disabled); + assert!(menu_contains(&menu.children, "add_codex_account")); + let empty = codex_accounts_menu(&[], None, Language::English, false); + assert!(menu_contains(&empty.children, "add_codex_account")); + let mut email_account = second; + email_account.nickname = None; + email_account.email_hint = Some("private@example.com".into()); + let private = codex_accounts_menu(&[email_account.clone()], None, Language::English, true); + assert!(!private.children[0].label.contains("private@example.com")); + let visible = codex_accounts_menu(&[email_account], None, Language::English, false); + assert_eq!(visible.children[0].label, "private@example.com"); + } + + #[test] + fn claude_menu_checks_current_account_and_routes_saved_accounts() { + use codexbar::providers::claude::accounts::ClaudeAccount; + let current = ClaudeAccount { + id: "a:org".into(), + email: "a@example.com".into(), + organization: None, + plan: None, + is_active: true, + is_saved: false, + }; + let saved = ClaudeAccount { + id: "b:org".into(), + is_active: false, + is_saved: true, + ..current.clone() + }; + let menu = claude_accounts_menu(&[current, saved], Language::English, false); + assert_eq!(menu.id.as_deref(), Some("claude_accounts")); + assert_eq!(menu.children[0].checked, Some(true)); + assert!(menu.children[0].disabled); + assert_eq!( + menu.children[1].id.as_deref(), + Some("switch_claude_account:b:org") + ); + assert!(!menu.children[1].disabled); + assert!(menu_contains(&menu.children, "add_claude_account")); + assert!(menu_contains( + &claude_accounts_menu(&[], Language::English, false).children, + "add_claude_account" + )); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index bd6cdf31c8..00ba7aff36 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -20,9 +20,7 @@ use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; #[cfg(test)] use crate::tray_menu::build_tray_menu; -use crate::tray_menu::{ - TrayMenuEntry, build_tray_menu_with, claude_accounts_menu, codex_accounts_menu, -}; +use crate::tray_menu::{TrayMenuEntry, build_tray_menu_with}; #[derive(Debug, Clone, Copy)] struct MonitorScaleInfo { @@ -139,27 +137,7 @@ fn build_native_tray_menu( settings.float_bar_enabled, settings.ui_language, ); - let accounts = crate::commands::load_codex_accounts().unwrap_or_default(); - let active = - codexbar::codex_accounts::CodexAccountManager::new().discover_ambient_account(&accounts); - spec.insert( - 0, - codex_accounts_menu( - &accounts, - active.as_ref(), - settings.ui_language, - settings.hide_personal_info, - ), - ); - let claude_accounts = crate::commands::claude_accounts_list().unwrap_or_default(); - spec.insert( - 1, - claude_accounts_menu( - &claude_accounts, - settings.ui_language, - settings.hide_personal_info, - ), - ); + crate::tray_accounts::prepend_account_menus(&mut spec, &settings); let entries = spec .iter() .map(|entry| build_native_menu_entry(app, entry)) @@ -207,12 +185,7 @@ enum MenuAction { ToggleProvider(String), /// Toggle the floating bar window on/off. ToggleFloatBar, - AddCodexAccount, - AddClaudeAccount, - SaveClaudeAccount, - CancelClaudeLogin, - SwitchClaudeAccount(String), - SwitchCodexAccount(String), + Account(crate::tray_accounts::AccountMenuAction), Quit, } @@ -222,6 +195,9 @@ enum MenuTransitionDispatch { } fn resolve_menu_action(id: &str) -> Option { + if let Some(action) = crate::tray_accounts::resolve_action(id) { + return Some(MenuAction::Account(action)); + } match id { "refresh" => Some(MenuAction::Refresh), "check_for_updates" => Some(MenuAction::CheckForUpdates), @@ -230,19 +206,6 @@ fn resolve_menu_action(id: &str) -> Option { "about" => Some(MenuAction::OpenSettings("about".into())), "toggle_float_bar" => Some(MenuAction::ToggleFloatBar), "pop_out" => Some(MenuAction::OpenFlyout), - "add_codex_account" => Some(MenuAction::AddCodexAccount), - "add_claude_account" => Some(MenuAction::AddClaudeAccount), - "save_claude_account" => Some(MenuAction::SaveClaudeAccount), - "cancel_claude_login" => Some(MenuAction::CancelClaudeLogin), - _ if id.starts_with("switch_claude_account:") => { - let id = id.strip_prefix("switch_claude_account:")?; - (!id.is_empty()).then(|| MenuAction::SwitchClaudeAccount(id.to_string())) - } - _ if id.starts_with("switch_codex_account:") => { - let id = id.strip_prefix("switch_codex_account:")?; - uuid::Uuid::parse_str(id).ok()?; - Some(MenuAction::SwitchCodexAccount(id.to_string())) - } _ if id.starts_with("toggle_provider:") => { let provider_id = id["toggle_provider:".len()..].to_string(); Some(MenuAction::ToggleProvider(provider_id)) @@ -370,79 +333,7 @@ fn schedule_tray_promotion_retries(app_handle: AppHandle) { /// Route a native menu-item click to the corresponding shell action. fn handle_menu_event(app: &AppHandle, id: &str) { match resolve_menu_action(id) { - Some(MenuAction::AddCodexAccount) => { - let handle = app.clone(); - tauri::async_runtime::spawn(async move { - match crate::commands::codex_account_add(handle.clone()).await { - Ok(_) => show_account_message(&handle, "Codex account added."), - Err(error) => show_account_message(&handle, &error), - } - }); - } - Some(MenuAction::SwitchCodexAccount(id)) => { - let handle = app.clone(); - tauri::async_runtime::spawn(async move { - match crate::commands::codex_account_switch(handle.clone(), id).await { - Ok(result) => { - use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; - if result.desktop_session_restore_path.is_some() { - let dialog_handle = handle.clone(); - let restart = tauri::async_runtime::spawn_blocking(move || { - dialog_handle.dialog() - .message("Account switched. Restart Codex Desktop to use it? This stops running desktop tasks.") - .title("Codex Accounts") - .buttons(MessageDialogButtons::OkCancelCustom("Restart".into(), "Later".into())) - .blocking_show() - }).await.unwrap_or(false); - if restart { - let result = crate::commands::codex_account_restart_desktop( - handle.clone(), - result.switch_id.to_string(), - ) - .await; - if let Err(error) = result { - show_account_message(&handle, &error); - } - } - } else { - show_account_message(&handle, "Codex account switched."); - } - } - Err(error) => show_account_message(&handle, &error), - } - }); - } - Some(MenuAction::CancelClaudeLogin) => crate::commands::claude_account_cancel_login(), - Some( - action @ (MenuAction::AddClaudeAccount - | MenuAction::SaveClaudeAccount - | MenuAction::SwitchClaudeAccount(_)), - ) => { - let handle = app.clone(); - tauri::async_runtime::spawn(async move { - use tauri_plugin_dialog::DialogExt; - let (result, message) = match action { - MenuAction::AddClaudeAccount => ( - crate::commands::claude_account_add(handle.clone()).await, - "Claude Code account added. Select it to switch.", - ), - MenuAction::SaveClaudeAccount => ( - crate::commands::claude_account_save_current(handle.clone()).await, - "Current Claude Code account saved.", - ), - MenuAction::SwitchClaudeAccount(id) => ( - crate::commands::claude_account_switch(handle.clone(), id).await, - "Claude Code account switched. Reopen the Claude Code CLI to use it.", - ), - _ => unreachable!(), - }; - handle - .dialog() - .message(result.err().unwrap_or_else(|| message.to_string())) - .title("Claude Code accounts") - .show(|_| {}); - }); - } + Some(MenuAction::Account(action)) => crate::tray_accounts::handle_action(app, action), Some(MenuAction::Transition(request)) => { crate::auto_refresh::note_menu_open(); match resolve_menu_transition_dispatch(id, request) { @@ -511,14 +402,6 @@ fn handle_menu_event(app: &AppHandle, id: &str) { } } -fn show_account_message(app: &AppHandle, message: &str) { - use tauri_plugin_dialog::DialogExt; - app.dialog() - .message(message) - .title("Codex Accounts") - .show(|_| {}); -} - /// Rebuild the native tray menu from current provider + settings state. pub(crate) fn rebuild_tray_menu(app: &AppHandle) { let catalog = crate::commands::get_provider_catalog(); @@ -964,26 +847,6 @@ mod tests { assert!(menu_contains(&menu, "quit")); } - #[test] - fn claude_account_actions_are_distinct_from_codex_and_reject_empty_ids() { - assert!(matches!( - resolve_menu_action("add_claude_account"), - Some(MenuAction::AddClaudeAccount) - )); - assert!(matches!( - resolve_menu_action("save_claude_account"), - Some(MenuAction::SaveClaudeAccount) - )); - assert!(matches!( - resolve_menu_action("cancel_claude_login"), - Some(MenuAction::CancelClaudeLogin) - )); - assert!( - matches!(resolve_menu_action("switch_claude_account:a:org"), Some(MenuAction::SwitchClaudeAccount(id)) if id == "a:org") - ); - assert!(resolve_menu_action("switch_claude_account:").is_none()); - } - #[test] fn toggle_float_bar_routes_to_toggle_action() { let action = resolve_menu_action("toggle_float_bar").expect("float bar action"); diff --git a/apps/desktop-tauri/src-tauri/src/tray_menu.rs b/apps/desktop-tauri/src-tauri/src/tray_menu.rs index 8552a08e3c..688e56b42b 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_menu.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_menu.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; use crate::commands::ProviderCatalogEntry; -use codexbar::codex_accounts::CodexAccount; use codexbar::locale::{self, LocaleKey}; use codexbar::settings::Language; @@ -17,113 +16,8 @@ pub(crate) struct TrayMenuEntry { pub(crate) checked: Option, } -pub(crate) fn codex_accounts_menu( - accounts: &[CodexAccount], - active: Option<&CodexAccount>, - lang: Language, - hide_personal_info: bool, -) -> TrayMenuEntry { - let text = |key| locale::get_text(lang, key); - let mut children: Vec<_> = accounts - .iter() - .map(|account| { - let is_active = active.is_some_and(|current| current.matches(account)); - let mut entry = TrayMenuEntry::check_item( - format!("switch_codex_account:{}", account.id), - if hide_personal_info - && account - .nickname - .as_deref() - .is_none_or(|n| n.trim().is_empty()) - { - codexbar::core::PersonalInfoRedactor::partial_redact_email( - account.email_hint.as_deref(), - true, - ) - } else { - account.display_name() - }, - is_active, - ); - entry.disabled = is_active; - entry - }) - .collect(); - if children.is_empty() { - children.push(TrayMenuEntry::status_row( - "codex_accounts_empty", - text(LocaleKey::CodexAccountsEmpty), - )); - } - children.push(TrayMenuEntry::separator()); - children.push(TrayMenuEntry::item( - "add_codex_account", - text(LocaleKey::CodexAccountsAddButton), - )); - TrayMenuEntry::submenu( - "codex_accounts", - text(LocaleKey::CodexAccountsTitle), - children, - ) -} - -pub(crate) fn claude_accounts_menu( - accounts: &[codexbar::providers::claude::accounts::ClaudeAccount], - lang: Language, - hide_personal_info: bool, -) -> TrayMenuEntry { - let text = |key| locale::get_text(lang, key); - let mut children: Vec<_> = accounts - .iter() - .map(|account| { - let label = if hide_personal_info { - codexbar::core::PersonalInfoRedactor::partial_redact_email( - Some(&account.email), - true, - ) - } else { - account.organization.as_ref().map_or_else( - || account.email.clone(), - |org| format!("{} ({org})", account.email), - ) - }; - let mut entry = TrayMenuEntry::check_item( - format!("switch_claude_account:{}", account.id), - label, - account.is_active, - ); - entry.disabled = account.is_active || !account.is_saved; - entry - }) - .collect(); - if children.is_empty() { - children.push(TrayMenuEntry::status_row( - "claude_accounts_empty", - text(LocaleKey::ClaudeAccountsEmpty), - )); - } - children.push(TrayMenuEntry::separator()); - children.push(TrayMenuEntry::item( - "add_claude_account", - text(LocaleKey::CodexAccountsAddButton), - )); - children.push(TrayMenuEntry::item( - "save_claude_account", - text(LocaleKey::ClaudeAccountsSaveCurrent), - )); - children.push(TrayMenuEntry::item( - "cancel_claude_login", - text(LocaleKey::ClaudeAccountsCancelLogin), - )); - TrayMenuEntry::submenu( - "claude_accounts", - text(LocaleKey::ClaudeAccountsTitle), - children, - ) -} - impl TrayMenuEntry { - fn item(id: impl Into, label: impl Into) -> Self { + pub(crate) fn item(id: impl Into, label: impl Into) -> Self { Self { id: Some(id.into()), label: label.into(), @@ -135,7 +29,11 @@ impl TrayMenuEntry { } /// A checkbox menu item. `checked` mirrors the provider's enabled state. - fn check_item(id: impl Into, label: impl Into, checked: bool) -> Self { + pub(crate) fn check_item( + id: impl Into, + label: impl Into, + checked: bool, + ) -> Self { Self { id: Some(id.into()), label: label.into(), @@ -146,7 +44,11 @@ impl TrayMenuEntry { } } - fn submenu(id: impl Into, label: impl Into, children: Vec) -> Self { + pub(crate) fn submenu( + id: impl Into, + label: impl Into, + children: Vec, + ) -> Self { Self { id: Some(id.into()), label: label.into(), @@ -157,7 +59,7 @@ impl TrayMenuEntry { } } - fn separator() -> Self { + pub(crate) fn separator() -> Self { Self { id: None, label: String::new(), @@ -270,84 +172,6 @@ pub(crate) fn build_tray_menu_with( mod tests { use super::*; - #[test] - fn codex_accounts_can_be_switched_or_added_from_tray() { - use codexbar::codex_accounts::{CodexAccountSource, utc_now}; - let make = |name: &str| { - CodexAccount::new( - uuid::Uuid::new_v4(), - Some(name.into()), - None, - None, - Some(name.into()), - std::path::PathBuf::from(name), - CodexAccountSource::ManagedByApp, - utc_now(), - utc_now(), - None, - ) - }; - let first = make("Personal"); - let second = make("Work"); - let menu = codex_accounts_menu( - &[first.clone(), second.clone()], - Some(&first), - Language::English, - false, - ); - assert_eq!(menu.children[0].checked, Some(true)); - assert!(menu.children[0].disabled); - assert_eq!( - menu.children[1].id.as_deref(), - Some(format!("switch_codex_account:{}", second.id).as_str()) - ); - assert_eq!(menu.children[1].checked, Some(false)); - assert!(!menu.children[1].disabled); - assert!(menu_contains(&menu.children, "add_codex_account")); - let empty = codex_accounts_menu(&[], None, Language::English, false); - assert!(menu_contains(&empty.children, "add_codex_account")); - let mut email_account = second; - email_account.nickname = None; - email_account.email_hint = Some("private@example.com".into()); - let private = codex_accounts_menu(&[email_account.clone()], None, Language::English, true); - assert!(!private.children[0].label.contains("private@example.com")); - let visible = codex_accounts_menu(&[email_account], None, Language::English, false); - assert_eq!(visible.children[0].label, "private@example.com"); - } - - #[test] - fn claude_menu_checks_current_account_and_routes_saved_accounts() { - use codexbar::providers::claude::accounts::ClaudeAccount; - let current = ClaudeAccount { - id: "a:org".into(), - email: "a@example.com".into(), - organization: None, - plan: None, - is_active: true, - is_saved: false, - }; - let saved = ClaudeAccount { - id: "b:org".into(), - is_active: false, - is_saved: true, - ..current.clone() - }; - let menu = claude_accounts_menu(&[current, saved], Language::English, false); - assert_eq!(menu.id.as_deref(), Some("claude_accounts")); - assert_eq!(menu.children[0].checked, Some(true)); - assert!(menu.children[0].disabled); - assert_eq!( - menu.children[1].id.as_deref(), - Some("switch_claude_account:b:org") - ); - assert!(!menu.children[1].disabled); - assert!(menu_contains(&menu.children, "add_claude_account")); - assert!(menu_contains( - &claude_accounts_menu(&[], Language::English, false).children, - "add_claude_account" - )); - } - fn menu_contains(menu: &[TrayMenuEntry], id: &str) -> bool { menu.iter().any(|entry| { entry.id.as_deref() == Some(id)