diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c4d12..785325e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added per-account COSMIC panel visibility, with marked accounts displayed + side-by-side as compact panel cells. - Added Grok subscription usage tracking with browser OAuth, explicit Grok CLI credential import/restore, managed accounts, and host Active matching. - Added Z.AI Coding Plan usage tracking with managed API keys, five-hour, weekly, diff --git a/README.md b/README.md index 94831d0..717e33a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ YapCap lives in your COSMIC panel and shows how much of your AI coding quota you - **Kimi for Coding** — API key usage tracking with weekly and rate-limit windows - **OpenCode Go** — API key usage tracking with 5-hour, weekly, and monthly windows - **Grok** — subscription usage shown as a weekly window + available prepaid credits -- 👥 **Multi-account support** — add, switch, and remove accounts per provider. The popup pages through stored accounts one at a time, while the panel remains fixed-width for the active account. +- 👥 **Multi-account support** — add, switch, and remove accounts per provider. The popup pages through stored accounts one at a time, while accounts marked for panel visibility can appear side-by-side as compact panel cells. - 🔎 **Automatic discovery** — detected providers appear automatically, provider availability updates live, and an empty setup opens Manage providers. Gemini remains opt-in and must be enabled manually. - 🔐 **In-app login** — guided browser login for Codex, Claude, Antigravity, Gemini, Copilot, and Grok; API-key forms for Minimax, Z.AI, Kimi, and OpenCode Go; Cursor scans the local IDE state. - 🔑 **OpenCode integration** — compatible keys can optionally prefill Minimax, Z.AI, Kimi, and OpenCode Go forms; Codex and Copilot offer explicit OAuth imports. Credentials are copied only after confirmation and are never synchronized with OpenCode. @@ -183,9 +183,17 @@ just install Each provider supports multiple accounts. Select its popup tab and open **Manage accounts** on the account card, or use the setup action when no account has been added yet. - **Add account** — starts Codex, Claude, Antigravity, Gemini, or Grok browser OAuth, GitHub Copilot device login, Minimax/Z.AI/Kimi/OpenCode Go API-key entry, or Cursor IDE scanning. Claude asks you to paste the browser's authorization code back into YapCap. Grok also offers explicit import from the Grok CLI. -- **Switch account** — select an account row or use the account card's arrows; the panel and popup follow that selection. This does not switch the host tool's account or its Active badge. +- **Switch account** — select an account row or use the account card's arrows to change the active account shown in the popup. This does not switch the host tool's account or its Active badge. - **Remove account** — deletes only YapCap's copy of the credentials. Provider accounts and host app configs are never touched. +To show accounts in the COSMIC panel, open **Settings → Accounts** and enable +**Show this account in the panel** for each account you want to display. Each +marked account appears as a compact cell beside the others. Panel visibility +is separate from the active account radio selection: the radio selection +controls the active account and popup, while the per-account toggle controls +which accounts appear in the panel. If no accounts are selected, the panel +shows the YapCap app icon. + Codex, Claude, Cursor, Antigravity, and Gemini keep at most one account per provider identity. Copilot keeps at most one account per GitHub numeric user id and displays the current GitHub username. Minimax, Z.AI, Kimi, and OpenCode Go use unique user-provided labels and reject duplicate API keys. ### Grok diff --git a/i18n/en/yapcap.ftl b/i18n/en/yapcap.ftl index a1f3bc4..fd52ed0 100644 --- a/i18n/en/yapcap.ftl +++ b/i18n/en/yapcap.ftl @@ -60,6 +60,7 @@ account-add-another = Add another account-cancel = Cancel account-current-tooltip = Current account account-delete-tooltip = Delete account +account-panel-flag-tooltip = Show this account in the panel account-dismiss = Dismiss account-select = Select account-selected = Selected diff --git a/i18n/pl/yapcap.ftl b/i18n/pl/yapcap.ftl index c2d9a61..6a0a845 100644 --- a/i18n/pl/yapcap.ftl +++ b/i18n/pl/yapcap.ftl @@ -60,6 +60,7 @@ account-add-another = Dodaj jeszcze jedno account-cancel = Anuluj account-current-tooltip = Obecne konto account-delete-tooltip = Usuń konto +account-panel-flag-tooltip = Pokazuj to konto w panelu account-dismiss = Odrzuć account-select = Wybierz account-selected = Wybrane diff --git a/src/app/applet/cells.rs b/src/app/applet/cells.rs new file mode 100644 index 0000000..042d7d3 --- /dev/null +++ b/src/app/applet/cells.rs @@ -0,0 +1,96 @@ +use super::{ + AppletBarLayout, applet_bar_layout, applet_button_size, applet_fallback_button_size, + applet_fallback_indicator, applet_indicator, applet_paddings, +}; +use crate::app::{ + APPLET_CELL_SPACING, Alignment, AppState, Config, Element, Message, PanelIconStyle, ProviderId, + row, +}; +use crate::model::ProviderAccountRuntimeState; + +pub(in crate::app) struct PanelCell<'a> { + pub account: &'a ProviderAccountRuntimeState, + pub layout: AppletBarLayout, +} + +pub(in crate::app) fn panel_cells<'a>(state: &'a AppState, config: &Config) -> Vec> { + let now = chrono::Utc::now(); + let mut cells = Vec::new(); + for provider in ProviderId::ALL { + let Some(runtime) = state.provider(provider).filter(|runtime| runtime.enabled) else { + continue; + }; + let flagged = config.panel_account_ids(provider); + for account in state.accounts_for(provider) { + if !flagged.contains(&account.account_id) { + continue; + } + let snapshot = account + .snapshot + .as_ref() + .or(runtime.legacy_display_snapshot.as_ref()); + cells.push(PanelCell { + account, + layout: applet_bar_layout( + snapshot.and_then(|snapshot| snapshot.applet_windows()), + now, + config.usage_amount_format, + ), + }); + } + } + cells +} + +pub(in crate::app) fn panel_indicator<'a>( + state: &AppState, + config: &Config, + core: &cosmic::Core, +) -> Element<'a, Message> { + let cells = panel_cells(state, config); + let indicator = |cell: &PanelCell<'_>| { + applet_indicator( + cell.account.provider, + cell.layout, + config.panel_icon_style, + core, + ) + }; + match cells.as_slice() { + [] => applet_fallback_indicator(core), + [cell] => indicator(cell), + _ => row(cells.iter().map(indicator)) + .spacing(APPLET_CELL_SPACING) + .align_y(Alignment::Center) + .into(), + } +} + +pub(in crate::app) fn panel_button_size( + core: &cosmic::Core, + state: &AppState, + config: &Config, +) -> (f32, f32) { + panel_cells_size( + core, + config.panel_icon_style, + panel_cells(state, config).len(), + ) +} + +pub(super) fn panel_cells_size( + core: &cosmic::Core, + style: PanelIconStyle, + count: usize, +) -> (f32, f32) { + if count == 0 { + return applet_fallback_button_size(core); + } + let (single_width, height) = applet_button_size(core, style); + let (horizontal_padding, _) = applet_paddings(core); + let cell_width = single_width - f32::from(2 * horizontal_padding); + let width = (1..count).fold(single_width, |width, _| { + width + cell_width + APPLET_CELL_SPACING + }); + (width, height) +} diff --git a/src/app/applet.rs b/src/app/applet/mod.rs similarity index 79% rename from src/app/applet.rs rename to src/app/applet/mod.rs index 820b6b0..a83d27b 100644 --- a/src/app/applet.rs +++ b/src/app/applet/mod.rs @@ -1,3 +1,9 @@ +mod cells; + +#[cfg(test)] +pub(super) use cells::panel_cells; +pub(super) use cells::{panel_button_size, panel_indicator}; + use super::provider_assets::app_icon_handle; use super::{ APPLET_BAR_WIDTH_HEIGHT_MULTIPLIER, APPLET_ICON_GAP, APPLET_PERCENT_CELL_HORIZONTAL_PAD, @@ -55,15 +61,15 @@ pub(crate) fn applet_settings() -> cosmic::app::Settings { }) .unwrap_or_default(); let detection = crate::detection::startup_snapshot(crate::config::host_user_home_dir()); - let no_enabled_provider_has_selected_accounts = ProviderId::ALL.iter().all(|&p| { - !crate::provider_enablement::provider_enabled(&config, &detection, p) - || config.selected_account_ids(p).is_empty() - }); - let (width, height) = if no_enabled_provider_has_selected_accounts { - applet_fallback_button_size(&preview_core) - } else { - applet_button_size(&preview_core, config.panel_icon_style) - }; + let cell_count = ProviderId::ALL + .into_iter() + .filter(|&provider| { + crate::provider_enablement::provider_enabled(&config, &detection, provider) + }) + .map(|provider| config.panel_account_ids(provider).len()) + .sum(); + let (width, height) = + cells::panel_cells_size(&preview_core, config.panel_icon_style, cell_count); cosmic::app::Settings::default() .size(Size::new(width, height)) @@ -80,11 +86,10 @@ pub(crate) fn applet_settings() -> cosmic::app::Settings { .transparent(true) } -pub(super) fn applet_indicator<'a>( - state: &AppState, - selected_provider: ProviderId, +fn applet_indicator<'a>( + provider: ProviderId, + layout: AppletBarLayout, style: PanelIconStyle, - usage_amount_format: UsageAmountFormat, core: &cosmic::Core, ) -> Element<'a, Message> { let (suggested_w, suggested_h) = core.applet.suggested_size(false); @@ -92,26 +97,23 @@ pub(super) fn applet_indicator<'a>( let logo_size_px = compact_px.saturating_sub(8).max(11); let logo_size = f32::from(logo_size_px); let bar_width = applet_bar_width(suggested_w, suggested_h); - let layout = selected_provider_bar_layout(state, selected_provider, usage_amount_format); let bars = applet_bar_column(layout, bar_width); let percent = account_percent(layout); match style { - PanelIconStyle::LogoAndBars => row![ - provider_logo(selected_provider, logo_size_px, logo_size), - bars, - ] - .spacing(6) - .align_y(Alignment::Center) - .into(), + PanelIconStyle::LogoAndBars => { + row![provider_logo(provider, logo_size_px, logo_size), bars,] + .spacing(6) + .align_y(Alignment::Center) + .into() + } PanelIconStyle::BarsOnly => bars, - PanelIconStyle::LogoAndPercent => row![ - provider_logo(selected_provider, logo_size_px, logo_size), - percent, - ] - .spacing(6) - .align_y(Alignment::Center) - .into(), + PanelIconStyle::LogoAndPercent => { + row![provider_logo(provider, logo_size_px, logo_size), percent,] + .spacing(6) + .align_y(Alignment::Center) + .into() + } PanelIconStyle::PercentOnly => percent, } } @@ -128,14 +130,6 @@ pub(super) fn provider_logo<'a>( .into() } -pub(super) fn panel_fallback_active(state: &AppState) -> bool { - !state.provider_accounts.iter().any(|account| { - state - .provider(account.provider) - .is_some_and(|provider| provider.enabled) - }) -} - pub(super) fn applet_fallback_indicator<'a>(core: &cosmic::Core) -> Element<'a, Message> { let icon_px = applet_fallback_icon_px(core); let icon_size = f32::from(icon_px); @@ -160,18 +154,6 @@ fn applet_fallback_icon_px(core: &cosmic::Core) -> u16 { suggested_w.min(suggested_h) } -pub(super) fn panel_button_size( - core: &cosmic::Core, - state: &AppState, - style: PanelIconStyle, -) -> (f32, f32) { - if panel_fallback_active(state) { - applet_fallback_button_size(core) - } else { - applet_button_size(core, style) - } -} - pub(super) fn applet_button<'a>( core: &cosmic::Core, (width, height): (f32, f32), @@ -269,27 +251,6 @@ fn account_percent(layout: AppletBarLayout) -> Element<'static, Message> { .into() } -pub(super) fn selected_provider_bar_layout( - state: &AppState, - selected_provider: ProviderId, - usage_amount_format: UsageAmountFormat, -) -> AppletBarLayout { - let now = chrono::Utc::now(); - let snapshot = state - .active_account(selected_provider) - .and_then(|account| account.snapshot.as_ref()) - .or_else(|| { - state - .provider(selected_provider) - .and_then(|provider| provider.legacy_display_snapshot.as_ref()) - }); - applet_bar_layout( - snapshot.and_then(|snapshot| snapshot.applet_windows()), - now, - usage_amount_format, - ) -} - pub(super) fn applet_bar_layout( windows: Option>, now: chrono::DateTime, diff --git a/src/app/login/login_flow_tests/support.rs b/src/app/login/login_flow_tests/support.rs index 37794fb..d0ef19a 100644 --- a/src/app/login/login_flow_tests/support.rs +++ b/src/app/login/login_flow_tests/support.rs @@ -31,6 +31,8 @@ pub(super) fn test_app() -> AppModel { lock_path, }, refresh_owner: None, + refresh_batches: Vec::new(), + next_refresh_batch_token: 1, codex_login: None, codex_login_handle: None, claude_login: None, diff --git a/src/app/mod.rs b/src/app/mod.rs index b1deecb..a6d6113 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -16,16 +16,13 @@ mod tests; mod window; pub(crate) use self::applet::applet_settings; -use self::applet::{ - applet_button, applet_fallback_indicator, applet_indicator, panel_button_size, - panel_fallback_active, select_provider, -}; +use self::applet::{applet_button, panel_button_size, panel_indicator, select_provider}; use self::popup_view::ProviderLoginStates; use self::provider_assets::{provider_icon_handle, provider_icon_variant}; use self::refresh::{ - RefreshSkipDiagnostics, automatic_refresh_provider_tasks_for_process, - refresh_provider_account_statuses_task, refresh_provider_task_for_process, - selected_account_refresh_due, + eligible_flagged_refresh_account_ids, eligible_refresh_account_ids, provider_refresh_due, + reconcile_host_active_accounts, refresh_provider_account_statuses_task, + refresh_provider_task_for_process, }; use self::window::{ format_retry_delay, open_url, update_check_task, update_retry_delay, update_retry_task, @@ -83,6 +80,7 @@ use std::time::Duration; const AUTOMATIC_REFRESH_POLL_INTERVAL_SECS: u64 = 10; const APPLET_BAR_WIDTH_HEIGHT_MULTIPLIER: u16 = 2; const APPLET_ICON_GAP: f32 = 6.0; +const APPLET_CELL_SPACING: f32 = 6.0; const APPLET_PERCENT_GLYPH_WIDTH: f32 = 7.25; const APPLET_PERCENT_CELL_HORIZONTAL_PAD: f32 = 8.0; const UPDATE_RETRY_INITIAL_SECS: u64 = 15; @@ -116,6 +114,8 @@ pub struct AppModel { shared_control: SharedControlState, process_info: ProcessInfo, refresh_owner: Option, + refresh_batches: Vec, + next_refresh_batch_token: u64, codex_login: Option, codex_login_handle: Option, claude_login: Option, @@ -140,6 +140,15 @@ pub struct AppModel { zai_login_handle: Option, } +#[derive(Clone)] +struct ProviderRefreshBatch { + provider: ProviderId, + token: u64, + started_at: chrono::DateTime, + pending_account_ids: Vec, + request: Option, +} + impl Drop for AppModel { fn drop(&mut self) { tracing::info!( @@ -188,6 +197,7 @@ pub enum Message { NavigateTo(PopupRoute), SetProviderEnabled(ProviderId, bool), ToggleAccountSelection(ProviderId, String), + ToggleAccountPanelFlag(ProviderId, String), PageProviderAccount(PagerDirection), PageProviderViewport(PagerDirection), DeleteAccount(ProviderId, String), @@ -248,6 +258,7 @@ impl cosmic::Application for AppModel { Err((_errors, cfg)) => cfg, }; let mut changed = crate::config::migrate_provider_enablement(&ctx, &mut config); + changed |= crate::config::migrate_panel_account_flags(&ctx, &mut config); changed |= registry::startup_sync(&mut config); changed |= demo_env::strip_leaked_state(&mut config); if changed { @@ -283,8 +294,7 @@ impl cosmic::Application for AppModel { crate::debug_env::apply(&mut state); demo_env::apply(&initial_config, &mut state); let selected_provider = select_provider(initial_config.selected_provider, &state); - let (applet_width, applet_height) = - panel_button_size(&core, &state, initial_config.panel_icon_style); + let (applet_width, applet_height) = panel_button_size(&core, &state, &initial_config); core.applet.suggested_bounds = Some(Size::new(applet_width, applet_height)); let mut app = AppModel { core, @@ -301,6 +311,8 @@ impl cosmic::Application for AppModel { shared_control, process_info, refresh_owner, + refresh_batches: Vec::new(), + next_refresh_batch_token: 1, codex_login: None, codex_login_handle: None, claude_login: None, @@ -368,18 +380,8 @@ impl cosmic::Application for AppModel { } fn view(&self) -> Element<'_, Self::Message> { - let indicator = if panel_fallback_active(&self.state) { - applet_fallback_indicator(&self.core) - } else { - applet_indicator( - &self.state, - self.selected_provider, - self.config.panel_icon_style, - self.config.usage_amount_format, - &self.core, - ) - }; - let size = panel_button_size(&self.core, &self.state, self.config.panel_icon_style); + let indicator = panel_indicator(&self.state, &self.config, &self.core); + let size = panel_button_size(&self.core, &self.state, &self.config); let button: Element<'_, Message> = applet_button(&self.core, size, indicator) .on_press(Message::TogglePopup) .into(); @@ -459,7 +461,7 @@ impl AppModel { fn handle_message_task(&mut self, message: Message) -> Option> { match message { Message::UpdateConfig(config, keys) => { - self.on_config_update(*config, &keys); + return Some(self.on_config_update(*config, &keys)); } Message::UpdateSharedRuntime(shared_runtime, keys) => { if keys.is_empty() || keys.contains(&"app_state") { @@ -553,6 +555,9 @@ impl AppModel { Message::ToggleAccountSelection(provider, account_id) => { return Some(self.toggle_account_selection(provider, &account_id)); } + Message::ToggleAccountPanelFlag(provider, account_id) => { + return Some(self.handle_toggle_account_panel_flag(provider, &account_id)); + } Message::DeleteAccount(provider, account_id) => { return Some(self.delete_account(provider, &account_id)); } @@ -695,15 +700,36 @@ impl AppModel { } fn automatic_refresh_task(&mut self) -> Task { - let refresh_process = self.refresh_task_process(); - owner_automatic_refresh_task( - self.refresh_owner.as_ref(), - &self.process_info, - self.owner_status(), - &self.config, - &mut self.state, - refresh_process, - ) + if self.refresh_owner.is_none() { + return Task::none(); + } + reconcile_host_active_accounts(&self.config, &mut self.state); + let stale_providers = runtime::resolve_stale_refreshes(&mut self.state); + let mut tasks = stale_providers + .iter() + .copied() + .map(|provider| self.terminate_refresh_batch(provider)) + .filter(|task| task.units() > 0) + .collect::>(); + if !stale_providers.is_empty() { + self.persist_runtime_if_owner("stale_refresh_resolved"); + } + let due_providers = ProviderId::ALL + .into_iter() + .filter(|provider| provider_refresh_due(&self.config, &self.state, *provider)) + .collect::>(); + tasks.extend( + due_providers + .into_iter() + .map(|provider| self.schedule_provider_refresh(provider, false, None)) + .filter(|task| task.units() > 0), + ); + if tasks.is_empty() { + Task::none() + } else { + self.persist_runtime_if_owner("automatic_refresh_started"); + Task::batch(tasks) + } } fn handle_refresh_now(&mut self) -> Task { @@ -757,20 +783,7 @@ impl AppModel { "shared control observed" ); self.shared_control = shared_control; - let refresh_process = self.refresh_task_process(); - let (task, consumed_providers) = owner_shared_control_refresh_task( - self.refresh_owner.as_ref(), - &self.process_info, - self.owner_status(), - &self.config, - &mut self.state, - &self.shared_control, - refresh_process, - ); - if !consumed_providers.is_empty() { - self.consume_shared_refresh_requests(&consumed_providers); - } - task + self.schedule_shared_control_refreshes() } fn handle_provider_account_statuses_refreshed( @@ -792,6 +805,172 @@ impl AppModel { self.sync_panel_suggested_bounds(); self.persist_runtime_if_owner("account_status_refresh"); } + + fn schedule_shared_control_refreshes(&mut self) -> Task { + if self.refresh_owner.is_none() { + return Task::none(); + } + let mut consumed_requests = Vec::new(); + let tasks = self + .shared_control + .requests + .clone() + .into_iter() + .filter_map(|request| { + let provider = request.provider; + if !self + .state + .provider(provider) + .is_some_and(|entry| entry.enabled) + { + consumed_requests.push(request); + return None; + } + if self + .refresh_batches + .iter() + .any(|batch| batch.provider == provider) + { + return None; + } + let force = matches!( + request.reason, + RefreshRequestReason::User | RefreshRequestReason::AccountAction + ); + let adoption_pending = self.config.selected_account_ids(provider).is_empty() + && !eligible_refresh_account_ids(&self.config, provider, &self.state, force) + .is_empty(); + let task = self.schedule_provider_refresh(provider, force, Some(request.clone())); + if task.units() > 0 { + Some(task) + } else { + if !adoption_pending { + consumed_requests.push(request); + } + None + } + }) + .collect::>(); + for request in consumed_requests { + self.consume_shared_refresh_request_if_matches(Some(&request)); + } + if tasks.is_empty() { + Task::none() + } else { + self.persist_runtime_if_owner("shared_refresh_started"); + Task::batch(tasks) + } + } + + fn schedule_provider_refresh( + &mut self, + provider: ProviderId, + force: bool, + request: Option, + ) -> Task { + let Some(entry) = self.state.provider(provider) else { + return Task::none(); + }; + if !entry.enabled + || entry.is_refreshing + || self + .refresh_batches + .iter() + .any(|batch| batch.provider == provider) + { + return Task::none(); + } + let mut account_ids = + eligible_refresh_account_ids(&self.config, provider, &self.state, force); + if account_ids.is_empty() { + return Task::none(); + } + if self.config.selected_account_ids(provider).is_empty() { + let selected_id = account_ids[0].clone(); + if !self.write_config(|config| { + config.selected_account_ids_mut(provider).push(selected_id); + }) { + return Task::none(); + } + runtime::reconcile_provider(&self.config, &self.detection, &mut self.state, provider); + account_ids = eligible_refresh_account_ids(&self.config, provider, &self.state, force); + if account_ids.is_empty() { + return Task::none(); + } + } + let flagged_ids = + eligible_flagged_refresh_account_ids(&self.config, provider, &self.state, force); + if !self.state.provider(provider).is_some_and(|entry| { + entry.account_status == AccountSelectionStatus::Ready || !flagged_ids.is_empty() + }) { + return Task::none(); + } + let token = self.next_refresh_batch_token; + self.next_refresh_batch_token = self.next_refresh_batch_token.wrapping_add(1).max(1); + let Some(started_at) = self.state.begin_provider_refresh(provider) else { + return Task::none(); + }; + self.refresh_batches.push(ProviderRefreshBatch { + provider, + token, + started_at, + pending_account_ids: account_ids.clone(), + request, + }); + let process = self.refresh_task_process(); + refresh_provider_task_for_process( + &self.config, + &mut self.state, + provider, + Some(process), + force, + token, + account_ids, + ) + } + + fn terminate_refresh_batch(&mut self, provider: ProviderId) -> Task { + let Some(batch_index) = self + .refresh_batches + .iter() + .position(|batch| batch.provider == provider) + else { + return Task::none(); + }; + let batch = self.refresh_batches.remove(batch_index); + self.state.finish_provider_refresh(provider); + self.consume_shared_refresh_request_if_matches(batch.request.as_ref()); + self.schedule_shared_control_refreshes() + } + + fn consume_shared_refresh_request_if_matches( + &mut self, + request: Option<&ProviderRefreshRequest>, + ) { + let Some(request) = request else { + return; + }; + if self + .shared_control + .requests + .iter() + .any(|current| current == request) + { + self.consume_shared_refresh_request(request.provider); + } + } + + fn restore_refresh_batch_markers(&self, state: &mut AppState) { + if self.refresh_owner.is_none() { + return; + } + for batch in &self.refresh_batches { + if let Some(provider) = state.provider_mut(batch.provider) { + provider.is_refreshing = true; + provider.refresh_started_at = Some(batch.started_at); + } + } + } } fn startup_task( @@ -813,141 +992,16 @@ fn startup_task( } } -fn owner_automatic_refresh_task( - refresh_owner: Option<&RefreshOwner>, - process_info: &ProcessInfo, - owner_status: &'static str, - config: &Config, - state: &mut AppState, - process: RefreshProcessContext, -) -> Task { - if refresh_owner.is_none() { - return Task::none(); - } - if runtime::resolve_stale_refreshes(state) { - runtime::persist_state_as( - state, - "stale_refresh_resolved", - Some(SharedStateWriter { - process_id: &process_info.id, - owner_status, - }), - ); - } - let task = automatic_refresh_provider_tasks_for_process(config, state, Some(process)); - if task.units() > 0 { - runtime::persist_state_as( - state, - "automatic_refresh_started", - Some(SharedStateWriter { - process_id: &process_info.id, - owner_status, - }), - ); - } - task -} - -fn owner_shared_control_refresh_task( - refresh_owner: Option<&RefreshOwner>, - process_info: &ProcessInfo, - owner_status: &'static str, - config: &Config, - state: &mut AppState, - shared_control: &SharedControlState, - process: RefreshProcessContext, -) -> (Task, Vec) { - if refresh_owner.is_none() { - return (Task::none(), Vec::new()); - } - - let mut consumed_providers = Vec::new(); - let request_count = shared_control.requests.len(); - let mut evaluation = SharedRefreshEvaluationLog::from_requests(shared_control); - let providers = shared_control - .requests - .iter() - .filter_map(|request| { - let force = matches!( - request.reason, - RefreshRequestReason::User | RefreshRequestReason::AccountAction - ); - if !state - .provider(request.provider) - .is_some_and(|entry| entry.enabled) - { - evaluation.record_outcome(request.provider, "disabled"); - consumed_providers.push(request.provider); - return None; - } - let Some(provider_state) = state.provider(request.provider) else { - evaluation.record_outcome(request.provider, "missing_provider_state"); - return Some((request.provider, force)); - }; - if provider_state.is_refreshing { - evaluation.record_outcome(request.provider, "already_refreshing"); - consumed_providers.push(request.provider); - return None; - } - if provider_state.account_status != AccountSelectionStatus::Ready { - let diagnostics = RefreshSkipDiagnostics::for_provider(state, request.provider); - let skip_reason = diagnostics.not_ready_reason(); - evaluation.record_outcome(request.provider, skip_reason); - consumed_providers.push(request.provider); - return None; - } - Some((request.provider, force)) - }) - .collect::>(); - - let tasks = providers - .into_iter() - .map(|(provider, force)| { - refresh_provider_task_for_process(config, state, provider, Some(process.clone()), force) - }) - .filter(|task| task.units() > 0) - .collect::>(); - - tracing::info!( - process_id = %process_info.id, - owner_status, - generation = shared_control.generation, - request_count, - scheduled_provider_count = tasks.len(), - skipped_provider_count = consumed_providers.len(), - unresolved_provider_count = request_count - .saturating_sub(tasks.len()) - .saturating_sub(consumed_providers.len()), - request_reasons = %evaluation.request_reasons(), - requesters = %evaluation.requesters(), - outcomes = %evaluation.outcomes(), - "owner evaluated shared refresh requests" - ); - - if tasks.is_empty() { - (Task::none(), consumed_providers) - } else { - runtime::persist_state_as( - state, - "shared_refresh_started", - Some(SharedStateWriter { - process_id: &process_info.id, - owner_status, - }), - ); - (Task::batch(tasks), consumed_providers) - } -} - +#[cfg(test)] #[derive(Default)] struct SharedRefreshEvaluationLog { user_request_count: usize, account_action_request_count: usize, provider_selected_request_count: usize, requesters: Vec, - outcomes: Vec, } +#[cfg(test)] impl SharedRefreshEvaluationLog { fn from_requests(shared_control: &SharedControlState) -> Self { let mut summary = Self::default(); @@ -961,56 +1015,15 @@ impl SharedRefreshEvaluationLog { RefreshRequestReason::User => summary.user_request_count += 1, RefreshRequestReason::AccountAction => summary.account_action_request_count += 1, RefreshRequestReason::ProviderSelected => { - summary.provider_selected_request_count += 1; + summary.provider_selected_request_count += 1 } } } summary } - fn record_outcome(&mut self, provider: ProviderId, outcome: &str) { - self.outcomes - .push(format!("{}:{outcome}", provider.label())); - } - - fn request_reasons(&self) -> String { - let mut reasons = Vec::new(); - if self.user_request_count > 0 { - reasons.push(format!("user:{}", self.user_request_count)); - } - if self.account_action_request_count > 0 { - reasons.push(format!( - "account_action:{}", - self.account_action_request_count - )); - } - if self.provider_selected_request_count > 0 { - reasons.push(format!( - "provider_selected:{}", - self.provider_selected_request_count - )); - } - if reasons.is_empty() { - "none".to_string() - } else { - reasons.join(",") - } - } - fn requesters(&self) -> String { - if self.requesters.is_empty() { - "none".to_string() - } else { - self.requesters.join(",") - } - } - - fn outcomes(&self) -> String { - if self.outcomes.is_empty() { - "none".to_string() - } else { - self.outcomes.join(",") - } + self.requesters.join(",") } } diff --git a/src/app/popup_view/settings/accounts.rs b/src/app/popup_view/settings/accounts.rs index e0d0402..1e75a9f 100644 --- a/src/app/popup_view/settings/accounts.rs +++ b/src/app/popup_view/settings/accounts.rs @@ -140,6 +140,7 @@ pub(super) fn provider_settings_view<'a>( state, provider_id, account_facts: &account_facts, + panel_ids: config.panel_account_ids(provider_id), enabled, login_active, login_controls, @@ -161,6 +162,7 @@ struct AccountSettingsContext<'a, 'd> { state: &'a AppState, provider_id: ProviderId, account_facts: &'d [ProviderAccountFacts], + panel_ids: &'a [String], enabled: bool, login_active: bool, login_controls: Element<'a, Message>, @@ -175,6 +177,7 @@ fn account_settings_section<'a, 'd>( state, provider_id, account_facts, + panel_ids, enabled, login_active, login_controls, @@ -217,6 +220,7 @@ fn account_settings_section<'a, 'd>( provider_id, account, &selected_ids, + panel_ids.contains(&account.account_id), active_id, enabled, AccountRowPosition { diff --git a/src/app/popup_view/settings/accounts/rows.rs b/src/app/popup_view/settings/accounts/rows.rs index 1518657..e86ca58 100644 --- a/src/app/popup_view/settings/accounts/rows.rs +++ b/src/app/popup_view/settings/accounts/rows.rs @@ -40,6 +40,7 @@ pub(super) fn account_settings_row( provider: ProviderId, account: &ProviderAccountFacts, selected_ids: &[&str], + panel_flagged: bool, active_id: Option<&str>, enabled: bool, position: AccountRowPosition, @@ -115,9 +116,12 @@ pub(super) fn account_settings_row( let can_delete = account.supports_action(ProviderAccountAction::Delete); let delete_press = (enabled && can_delete).then_some(Message::DeleteAccount(provider, account_id.clone())); - let mut actions = row![account_selected_marker(is_selected, enabled)] - .spacing(0) - .align_y(Alignment::Center); + let mut actions = row![ + account_panel_toggler(provider, account_id.clone(), panel_flagged, enabled), + account_selected_marker(is_selected, enabled), + ] + .spacing(0) + .align_y(Alignment::Center); if can_reauthenticate { actions = actions.push(account_action_icon_button( "view-refresh-symbolic", @@ -192,6 +196,28 @@ fn active_badge(enabled: bool) -> Element<'static, Message> { } } +fn account_panel_toggler( + provider: ProviderId, + account_id: String, + flagged: bool, + enabled: bool, +) -> Element<'static, Message> { + widget::tooltip::tooltip( + container( + widget::toggler(flagged) + .size(18) + .width(Length::Shrink) + .on_toggle_maybe(enabled.then_some(move |_| { + Message::ToggleAccountPanelFlag(provider, account_id.clone()) + })), + ) + .padding([4, 6]), + widget::text(fl!("account-panel-flag-tooltip")).size(12), + widget::tooltip::Position::Top, + ) + .into() +} + fn account_selected_marker(selected: bool, enabled: bool) -> Element<'static, Message> { if !selected { return cosmic::iced::widget::Space::new() diff --git a/src/app/provider_actions.rs b/src/app/provider_actions.rs index d9d5cfd..92e9dcd 100644 --- a/src/app/provider_actions.rs +++ b/src/app/provider_actions.rs @@ -14,41 +14,83 @@ impl AppModel { &mut self, refresh_result: ProviderRefreshResult, ) -> Task { - let ProviderRefreshResult { provider, accounts } = refresh_result; + let ProviderRefreshResult { + batch_token, + account_id, + provider, + accounts, + } = refresh_result; let refreshed_provider = provider.provider; - let refreshed_selected_ids = provider.selected_account_ids.clone(); + let Some(batch_index) = self.refresh_batches.iter().position(|batch| { + batch.provider == refreshed_provider + && batch.token == batch_token + && batch.pending_account_ids.contains(&account_id) + }) else { + return Task::none(); + }; + let was_selected = self + .state + .provider(refreshed_provider) + .is_some_and(|entry| entry.selected_account_ids.contains(&account_id)); + let discovered = registry::discover_accounts(refreshed_provider, &self.config); + let account_is_current = discovered + .iter() + .any(|account| account.account_id == account_id); + let accepted_accounts = accounts + .into_iter() + .filter(|account| account.account_id == account_id && account_is_current) + .collect::>(); tracing::info!( process_id = %self.process_info.id, owner_status = self.owner_status(), provider = refreshed_provider.label(), - account_count = accounts.len(), + account_count = accepted_accounts.len(), "provider refresh finished" ); - self.state.upsert_provider(provider); - for account in accounts { + for account in accepted_accounts { self.state.upsert_account(account); } - super::session::sync_metadata_after_refresh(self, refreshed_provider); - if self.config.selected_account_ids(refreshed_provider) != refreshed_selected_ids.as_slice() - { - self.write_config(|new_config| { - new_config - .selected_account_ids_mut(refreshed_provider) - .clone_from(&refreshed_selected_ids); - }); + if was_selected && let Some(current) = self.state.provider_mut(refreshed_provider) { + current.account_status = provider.account_status; + current.error = provider.error; } + self.refresh_batches[batch_index] + .pending_account_ids + .retain(|pending_id| pending_id != &account_id); + let final_slot = self.refresh_batches[batch_index] + .pending_account_ids + .is_empty(); + if !final_slot { + self.persist_runtime_if_owner("provider_refresh_account_finished"); + self.sync_panel_suggested_bounds(); + return Task::none(); + } + let batch = self.refresh_batches.remove(batch_index); + self.state.finish_provider_refresh(refreshed_provider); + super::session::sync_metadata_after_refresh(self, refreshed_provider); self.persist_runtime_if_owner("provider_refresh_finished"); - self.consume_shared_refresh_request(refreshed_provider); + if batch.request.is_some_and(|request| { + self.shared_control + .requests + .iter() + .any(|current| current == &request) + }) { + self.consume_shared_refresh_request(refreshed_provider); + } self.selected_provider = select_provider(self.selected_provider, &self.state); self.sync_panel_suggested_bounds(); + let next_refresh = self.schedule_shared_control_refreshes(); if refreshed_provider == ProviderId::Cursor { - return refresh_provider_account_statuses_task( - &self.config, - &self.state, - ProviderId::Cursor, - ); + return Task::batch([ + next_refresh, + refresh_provider_account_statuses_task( + &self.config, + &self.state, + ProviderId::Cursor, + ), + ]); } - Task::none() + next_refresh } pub(super) fn consume_shared_refresh_request(&mut self, provider: ProviderId) { @@ -133,7 +175,7 @@ impl AppModel { } pub(super) fn sync_panel_suggested_bounds(&mut self) { - let (w, h) = panel_button_size(&self.core, &self.state, self.config.panel_icon_style); + let (w, h) = panel_button_size(&self.core, &self.state, &self.config); self.core.applet.suggested_bounds = Some(Size::new(w, h)); } @@ -181,7 +223,7 @@ impl AppModel { } fn request_refresh_for_selected_provider(&mut self, provider: ProviderId) -> Task { - if !super::selected_account_refresh_due(&self.config, &self.state, provider) { + if !super::provider_refresh_due(&self.config, &self.state, provider) { return Task::none(); } self.request_provider_refresh(provider, RefreshRequestReason::ProviderSelected) @@ -323,6 +365,9 @@ impl AppModel { self.write_config(|new_config| { new_config.set_provider_enabled(provider, enabled); new_config.selected_provider = selected_provider; + if !enabled { + new_config.panel_account_ids_mut(provider).clear(); + } }); tracing::info!( process_id = %self.process_info.id, @@ -332,6 +377,11 @@ impl AppModel { selected_provider = self.selected_provider.label(), "provider enabled setting changed" ); + let termination = if enabled { + Task::none() + } else { + self.terminate_refresh_batch(provider) + }; runtime::reconcile_provider(&self.config, &self.detection, &mut self.state, provider); self.sync_panel_suggested_bounds(); if enabled @@ -340,10 +390,13 @@ impl AppModel { .provider(provider) .is_some_and(|entry| entry.account_status == AccountSelectionStatus::Ready) { - return self.request_provider_refresh(provider, RefreshRequestReason::AccountAction); + return Task::batch([ + termination, + self.request_provider_refresh(provider, RefreshRequestReason::AccountAction), + ]); } self.persist_runtime_if_owner("provider_setting_changed"); - Task::none() + termination } pub(super) fn set_refresh_interval(&mut self, interval_seconds: u64) -> Task { @@ -443,12 +496,12 @@ impl AppModel { self.sync_panel_suggested_bounds(); } - pub(super) fn on_config_update(&mut self, update: Config, keys: &[&str]) { + pub(super) fn on_config_update(&mut self, update: Config, keys: &[&str]) -> Task { let mut config = self.config.clone(); config.apply_watcher_update(update, keys); demo_env::apply_config(&mut config); if config == self.config { - return; + return Task::none(); } tracing::info!( process_id = %self.process_info.id, @@ -462,10 +515,21 @@ impl AppModel { ); self.config = config; runtime::reconcile_state(&self.config, &self.detection, &mut self.state); + let mut termination_tasks = Vec::new(); + for provider in ProviderId::ALL { + if !self + .state + .provider(provider) + .is_some_and(|entry| entry.enabled) + { + termination_tasks.push(self.terminate_refresh_batch(provider)); + } + } demo_env::apply(&self.config, &mut self.state); self.selected_provider = select_provider(self.config.selected_provider, &self.state); self.persist_runtime_if_owner("external_config_update"); self.sync_panel_suggested_bounds(); + Task::batch(termination_tasks) } pub(super) fn on_shared_runtime_update( @@ -475,6 +539,7 @@ impl AppModel { let mut next_state = shared_runtime.app_state; runtime::reconcile_shared_state(&self.config, &self.detection, &mut next_state); demo_env::apply(&self.config, &mut next_state); + self.restore_refresh_batch_markers(&mut next_state); if self.state == next_state { return; } @@ -494,6 +559,36 @@ impl AppModel { self.sync_panel_suggested_bounds(); } + pub(super) fn handle_toggle_account_panel_flag( + &mut self, + provider: ProviderId, + account_id: &str, + ) -> Task { + if !self + .state + .provider(provider) + .is_some_and(|entry| entry.enabled) + { + return Task::none(); + } + if !self.write_config(|new_config| { + registry::toggle_account_panel_flag(provider, new_config, account_id); + }) { + return Task::none(); + } + self.sync_panel_suggested_bounds(); + if self + .config + .panel_account_ids(provider) + .iter() + .any(|id| id == account_id) + { + self.request_provider_refresh(provider, RefreshRequestReason::AccountAction) + } else { + Task::none() + } + } + pub(super) fn toggle_account_selection( &mut self, provider: ProviderId, diff --git a/src/app/refresh.rs b/src/app/refresh.rs index f2fb12d..f551d9d 100644 --- a/src/app/refresh.rs +++ b/src/app/refresh.rs @@ -83,6 +83,7 @@ pub fn automatic_refresh_provider_tasks(config: &Config, state: &mut AppState) - automatic_refresh_provider_tasks_for_process(config, state, None) } +#[cfg(test)] pub(super) fn automatic_refresh_provider_tasks_for_process( config: &Config, state: &mut AppState, @@ -92,12 +93,20 @@ pub(super) fn automatic_refresh_provider_tasks_for_process( let providers = ProviderId::ALL .into_iter() - .filter(|provider| selected_account_refresh_due(config, state, *provider)) + .filter(|provider| provider_refresh_due(config, state, *provider)) .collect::>(); let tasks = providers .into_iter() .map(|provider| { - refresh_provider_task_for_process(config, state, provider, process.clone(), false) + refresh_provider_task_for_process( + config, + state, + provider, + process.clone(), + false, + 0, + eligible_refresh_account_ids(config, provider, state, false), + ) }) .filter(|task| task.units() > 0) .collect::>(); @@ -109,7 +118,7 @@ pub(super) fn automatic_refresh_provider_tasks_for_process( } } -fn reconcile_host_active_accounts(config: &Config, state: &mut AppState) { +pub(super) fn reconcile_host_active_accounts(config: &Config, state: &mut AppState) { if demo_env::is_active() { return; } @@ -121,7 +130,7 @@ fn reconcile_host_active_accounts(config: &Config, state: &mut AppState) { } } -pub(super) fn selected_account_refresh_due( +pub(super) fn provider_refresh_due( config: &Config, state: &AppState, provider: ProviderId, @@ -130,11 +139,17 @@ pub(super) fn selected_account_refresh_due( return false; } let Some(entry) = state.provider(provider) else { - return true; + return false; }; + let candidate_ids = eligible_refresh_account_ids(config, provider, state, false); + let flagged_ids = eligible_flagged_refresh_account_ids(config, provider, state, false); + let singleton_adoption = + config.selected_account_ids(provider).is_empty() && candidate_ids.len() == 1; if entry.is_refreshing - || entry.account_status != crate::model::AccountSelectionStatus::Ready - || entry.selected_account_ids.is_empty() + || candidate_ids.is_empty() + || (entry.account_status != crate::model::AccountSelectionStatus::Ready + && flagged_ids.is_empty() + && !singleton_adoption) { return false; } @@ -146,7 +161,7 @@ pub(super) fn selected_account_refresh_due( .cast_signed(), ); let now = Utc::now(); - entry.selected_account_ids.iter().any(|account_id| { + candidate_ids.iter().any(|account_id| { let Some(account) = state .provider_accounts .iter() @@ -154,9 +169,6 @@ pub(super) fn selected_account_refresh_due( else { return true; }; - if account.is_backing_off() { - return false; - } account .last_success_at .is_none_or(|last_success_at| now - last_success_at >= interval) @@ -169,7 +181,8 @@ pub fn refresh_provider_task( state: &mut AppState, provider: ProviderId, ) -> Task { - refresh_provider_task_for_process(config, state, provider, None, false) + let account_ids = eligible_refresh_account_ids(config, provider, state, false); + refresh_provider_task_for_process(config, state, provider, None, false, 0, account_ids) } pub(super) fn refresh_provider_task_for_process( @@ -177,7 +190,9 @@ pub(super) fn refresh_provider_task_for_process( state: &mut AppState, provider: ProviderId, process: Option, - force: bool, + _force: bool, + batch_token: u64, + account_ids: Vec, ) -> Task { if demo_env::is_active() { return Task::none(); @@ -201,8 +216,7 @@ pub(super) fn refresh_provider_task_for_process( let ready = state .provider(provider) .is_some_and(|entry| entry.account_status == crate::model::AccountSelectionStatus::Ready); - if !enabled || !ready || already_refreshing { - state.mark_provider_refreshing(provider, enabled); + if !enabled || (batch_token == 0 && (!ready || already_refreshing)) { let diagnostics = RefreshSkipDiagnostics::for_provider(state, provider); if !enabled { tracing::info!( @@ -268,13 +282,6 @@ pub(super) fn refresh_provider_task_for_process( } } - let account_ids = account_ids_to_refresh( - &config, - provider, - previous.as_ref(), - &previous_accounts, - force, - ); if account_ids.is_empty() { tracing::info!( provider = provider.label(), @@ -297,8 +304,6 @@ pub(super) fn refresh_provider_task_for_process( return Task::none(); } - state.mark_provider_refreshing(provider, enabled); - let tasks: Vec> = account_ids .into_iter() .map(|account_id| { @@ -310,12 +315,15 @@ pub(super) fn refresh_provider_task_for_process( async move { runtime::refresh_account( config, - provider, - enabled, - account_id, - previous, - previous_accounts, - process, + runtime::RefreshAccountInput { + provider, + enabled, + account_id, + batch_token, + previous, + previous_accounts, + process, + }, ) .await }, @@ -370,37 +378,65 @@ pub fn refresh_provider_account_statuses_task( ) } -fn account_ids_to_refresh( +pub(super) fn eligible_refresh_account_ids( config: &Config, provider: ProviderId, - previous: Option<&crate::model::ProviderRuntimeState>, - previous_accounts: &[crate::model::ProviderAccountRuntimeState], + state: &AppState, force: bool, ) -> Vec { - let config_ids = config.selected_account_ids(provider); - let candidate_ids = if !config_ids.is_empty() { - config_ids.to_vec() - } else if let Some(prev_id) = previous.and_then(|p| p.selected_account_ids.first()) { - vec![prev_id.clone()] + let configured_ids = refresh_account_ids( + config.selected_account_ids(provider), + config.panel_account_ids(provider), + ); + let discovered = registry::discover_accounts(provider, config); + let candidate_ids = if !configured_ids.is_empty() { + configured_ids + } else if discovered.len() == 1 { + vec![discovered[0].account_id.clone()] } else { - registry::discover_accounts(provider, config) - .into_iter() - .next() - .map(|a| vec![a.account_id]) - .unwrap_or_default() + Vec::new() }; candidate_ids .into_iter() .filter(|id| { - !previous_accounts.iter().any(|a| { - &a.account_id == id - && ((!force && a.is_backing_off()) || a.auth_state == AuthState::ActionRequired) - }) + discovered.iter().any(|account| account.account_id == *id) + && !state.accounts_for(provider).iter().any(|a| { + &a.account_id == id + && ((!force && a.is_backing_off()) + || a.auth_state == AuthState::ActionRequired) + }) }) .collect() } +pub(super) fn eligible_flagged_refresh_account_ids( + config: &Config, + provider: ProviderId, + state: &AppState, + force: bool, +) -> Vec { + let eligible = eligible_refresh_account_ids(config, provider, state, force); + config + .panel_account_ids(provider) + .iter() + .filter(|id| eligible.contains(id)) + .cloned() + .collect() +} + +fn refresh_account_ids(selected_ids: &[String], panel_ids: &[String]) -> Vec { + selected_ids + .iter() + .chain(panel_ids) + .fold(Vec::new(), |mut ids, account_id| { + if !ids.contains(account_id) { + ids.push(account_id.clone()); + } + ids + }) +} + #[cfg(test)] mod tests { use super::*; @@ -475,7 +511,7 @@ mod tests { } #[test] - fn refresh_tasks_mark_enabled_providers_refreshing() { + fn refresh_tasks_skip_undiscoverable_default_ids() { let _env = test_env_without_demo(); let config = Config::default(); let mut state = AppState::empty(); @@ -486,7 +522,7 @@ mod tests { for provider in ProviderId::ALL { let entry = state.provider(provider).unwrap(); assert!(entry.enabled); - assert!(entry.is_refreshing); + assert!(!entry.is_refreshing); } } @@ -511,7 +547,7 @@ mod tests { } #[test] - fn automatic_refresh_tasks_refresh_missing_selected_account() { + fn automatic_refresh_tasks_skip_missing_selected_account() { let _env = test_env_without_demo(); let config = Config::default(); let mut state = AppState::empty(); @@ -519,7 +555,7 @@ mod tests { let _tasks = automatic_refresh_provider_tasks(&config, &mut state); - assert!(state.provider(ProviderId::Codex).unwrap().is_refreshing); + assert!(!state.provider(ProviderId::Codex).unwrap().is_refreshing); } #[test] @@ -548,7 +584,7 @@ mod tests { } #[test] - fn automatic_refresh_tasks_refresh_stale_selected_account() { + fn automatic_refresh_tasks_skip_undiscoverable_stale_selected_account() { let _env = test_env_without_demo(); let config = Config::default(); let mut state = AppState::empty(); @@ -569,7 +605,101 @@ mod tests { let _tasks = automatic_refresh_provider_tasks(&config, &mut state); - assert!(state.provider(ProviderId::Codex).unwrap().is_refreshing); + assert!(!state.provider(ProviderId::Codex).unwrap().is_refreshing); + } + + #[test] + fn automatic_refresh_tasks_skip_undiscoverable_stale_flagged_account() { + let _env = test_env_without_demo(); + let config = Config { + panel_codex_account_ids: vec!["flagged".to_string()], + ..Config::default() + }; + let mut state = AppState::empty(); + mark_all_ready(&mut state); + state.upsert_account(crate::model::ProviderAccountRuntimeState { + provider: ProviderId::Codex, + account_id: "default".to_string(), + label: "Codex".to_string(), + source_label: None, + last_success_at: Some(Utc::now()), + snapshot: None, + health: crate::model::ProviderHealth::Ok, + auth_state: AuthState::Ready, + error: None, + retry_after: None, + consecutive_failures: 0, + }); + state.upsert_account(crate::model::ProviderAccountRuntimeState { + provider: ProviderId::Codex, + account_id: "flagged".to_string(), + label: "Flagged Codex".to_string(), + source_label: None, + last_success_at: Some(Utc::now() - chrono::Duration::minutes(10)), + snapshot: None, + health: crate::model::ProviderHealth::Ok, + auth_state: AuthState::Ready, + error: None, + retry_after: None, + consecutive_failures: 0, + }); + + let _tasks = automatic_refresh_provider_tasks(&config, &mut state); + + assert!(!state.provider(ProviderId::Codex).unwrap().is_refreshing); + } + + #[test] + fn automatic_refresh_tasks_skip_when_selected_and_flagged_accounts_are_fresh() { + let _env = test_env_without_demo(); + let config = Config { + panel_codex_account_ids: vec!["flagged".to_string()], + ..Config::default() + }; + let mut state = AppState::empty(); + mark_all_ready(&mut state); + for account_id in ["default", "flagged"] { + state.upsert_account(crate::model::ProviderAccountRuntimeState { + provider: ProviderId::Codex, + account_id: account_id.to_string(), + label: account_id.to_string(), + source_label: None, + last_success_at: Some(Utc::now()), + snapshot: None, + health: crate::model::ProviderHealth::Ok, + auth_state: AuthState::Ready, + error: None, + retry_after: None, + consecutive_failures: 0, + }); + } + + let _tasks = automatic_refresh_provider_tasks(&config, &mut state); + + assert!(!state.provider(ProviderId::Codex).unwrap().is_refreshing); + } + + #[test] + fn account_ids_to_refresh_unions_selected_and_flagged_ids_in_order() { + let config = Config { + selected_codex_account_ids: vec!["selected".to_string(), "duplicate".to_string()], + panel_codex_account_ids: vec!["duplicate".to_string(), "flagged".to_string()], + ..Config::default() + }; + + let account_ids = refresh_account_ids( + config.selected_account_ids(ProviderId::Codex), + config.panel_account_ids(ProviderId::Codex), + ); + + assert_eq!( + account_ids, + vec![ + "selected".to_string(), + "duplicate".to_string(), + "flagged".to_string() + ] + ); } #[test] @@ -624,7 +754,7 @@ mod tests { } #[test] - fn forced_refresh_retries_backing_off_account() { + fn forced_refresh_skips_undiscoverable_account() { let _env = test_env_without_demo(); let config = Config::default(); let mut state = AppState::empty(); @@ -643,15 +773,22 @@ mod tests { consecutive_failures: 1, }); - let task = - refresh_provider_task_for_process(&config, &mut state, ProviderId::Codex, None, true); + let task = refresh_provider_task_for_process( + &config, + &mut state, + ProviderId::Codex, + None, + true, + 0, + Vec::new(), + ); - assert!(task.units() > 0); - assert!(state.provider(ProviderId::Codex).unwrap().is_refreshing); + assert_eq!(task.units(), 0); + assert!(!state.provider(ProviderId::Codex).unwrap().is_refreshing); } #[test] - fn forced_refresh_retries_opencode_go_entitlement_failure() { + fn forced_refresh_skips_undiscoverable_opencode_go_account() { let _env = test_env_without_demo(); let config = Config::default(); let mut state = AppState::empty(); @@ -676,11 +813,13 @@ mod tests { ProviderId::OpenCodeGo, None, true, + 0, + Vec::new(), ); - assert!(task.units() > 0); + assert_eq!(task.units(), 0); assert!( - state + !state .provider(ProviderId::OpenCodeGo) .unwrap() .is_refreshing @@ -707,8 +846,15 @@ mod tests { consecutive_failures: 1, }); - let task = - refresh_provider_task_for_process(&config, &mut state, ProviderId::Codex, None, true); + let task = refresh_provider_task_for_process( + &config, + &mut state, + ProviderId::Codex, + None, + true, + 0, + Vec::new(), + ); assert_eq!(task.units(), 0); assert!(!state.provider(ProviderId::Codex).unwrap().is_refreshing); @@ -720,7 +866,7 @@ mod tests { let config = Config::default(); let mut state = AppState::empty(); mark_all_ready(&mut state); - state.mark_provider_refreshing(ProviderId::Codex, true); + state.provider_mut(ProviderId::Codex).unwrap().is_refreshing = true; let _tasks = refresh_provider_tasks(&config, &mut state); diff --git a/src/app/session.rs b/src/app/session.rs index 44cbf04..29cc30e 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -13,6 +13,7 @@ pub(super) fn delete_account( deleted = registry::delete_account(provider, account_id, new_config); if deleted { registry::sync_selected_ids_with_discoveries(new_config, provider); + registry::sync_panel_ids_with_discoveries(new_config, provider); } }); if !deleted { @@ -225,6 +226,22 @@ mod tests { } } + #[test] + fn delete_account_prunes_selected_and_panel_ids() { + let _env = crate::test_support::test_env(); + let mut app = test_app(); + app.config.codex_managed_accounts.push(codex_account()); + app.config.selected_codex_account_ids = vec!["codex-1".to_string()]; + app.config.panel_codex_account_ids = vec!["codex-1".to_string()]; + + let task = delete_account(&mut app, ProviderId::Codex, "codex-1"); + + assert_eq!(task.units(), 0); + assert!(app.config.codex_managed_accounts.is_empty()); + assert!(app.config.selected_codex_account_ids.is_empty()); + assert!(app.config.panel_codex_account_ids.is_empty()); + } + #[test] fn sync_metadata_after_refresh_clears_codex_legacy_snapshot_once_healthy() { let _env = crate::test_support::test_env(); diff --git a/src/app/state.rs b/src/app/state.rs index 26ff4e3..86afb63 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,8 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 -use crate::model::{ - AccountSelectionStatus, AppState, ProviderAccountRuntimeState, ProviderId, ProviderRuntimeState, -}; +use crate::model::{AppState, ProviderAccountRuntimeState, ProviderId, ProviderRuntimeState}; use chrono::Utc; impl AppState { @@ -34,9 +32,18 @@ impl AppState { #[must_use] pub fn active_account(&self, provider: ProviderId) -> Option<&ProviderAccountRuntimeState> { let first_id = self.provider(provider)?.selected_account_ids.first()?; + self.account(provider, first_id) + } + + #[must_use] + pub(super) fn account( + &self, + provider: ProviderId, + account_id: &str, + ) -> Option<&ProviderAccountRuntimeState> { self.provider_accounts .iter() - .find(|entry| entry.provider == provider && &entry.account_id == first_id) + .find(|entry| entry.provider == provider && entry.account_id == account_id) } #[must_use] @@ -77,20 +84,24 @@ impl AppState { self.updated_at = Utc::now(); } - pub fn mark_provider_refreshing(&mut self, provider: ProviderId, enabled: bool) { - let mut state = self - .provider(provider) - .cloned() - .unwrap_or_else(|| ProviderRuntimeState::empty(provider)); - if enabled { - state.provider = provider; - state.enabled = true; - state.is_refreshing = state.account_status == AccountSelectionStatus::Ready; - state.refresh_started_at = state.is_refreshing.then(Utc::now); - } else { - state = ProviderRuntimeState::disabled(provider); + pub fn begin_provider_refresh( + &mut self, + provider: ProviderId, + ) -> Option> { + let state = self.provider_mut(provider)?; + if !state.enabled || state.is_refreshing { + return None; + } + state.is_refreshing = true; + state.refresh_started_at = Some(Utc::now()); + state.refresh_started_at + } + + pub fn finish_provider_refresh(&mut self, provider: ProviderId) { + if let Some(state) = self.provider_mut(provider) { + state.is_refreshing = false; + state.refresh_started_at = None; } - self.upsert_provider(state); } pub fn upsert_account(&mut self, account_state: ProviderAccountRuntimeState) { @@ -111,20 +122,6 @@ impl AppState { #[cfg(test)] mod tests { use super::*; - use crate::model::{ProviderIdentity, UsageHeadline, UsageSnapshot}; - - fn snapshot(provider: ProviderId) -> UsageSnapshot { - UsageSnapshot { - provider, - source: "test".to_string(), - updated_at: Utc::now(), - headline: UsageHeadline(0), - windows: Vec::new(), - provider_cost: None, - extra_usage: None, - identity: ProviderIdentity::default(), - } - } #[test] fn upsert_provider_replaces_only_matching_provider() { @@ -170,31 +167,4 @@ mod tests { assert_eq!(state.updated_at, updated_at); } - - #[test] - fn mark_provider_refreshing_preserves_previous_snapshot() { - let mut state = AppState::empty(); - let mut codex = ProviderRuntimeState::empty(ProviderId::Codex); - codex.legacy_display_snapshot = Some(snapshot(ProviderId::Codex)); - codex.account_status = AccountSelectionStatus::Ready; - state.upsert_provider(codex); - - state.mark_provider_refreshing(ProviderId::Codex, true); - - let codex = state.provider(ProviderId::Codex).unwrap(); - assert!(codex.is_refreshing); - assert!(codex.legacy_display_snapshot.is_some()); - } - - #[test] - fn mark_provider_refreshing_marks_disabled_provider() { - let mut state = AppState::empty(); - - state.mark_provider_refreshing(ProviderId::Cursor, false); - - let cursor = state.provider(ProviderId::Cursor).unwrap(); - assert!(!cursor.enabled); - assert!(!cursor.is_refreshing); - assert_eq!(cursor.error.as_deref(), Some("Disabled in config")); - } } diff --git a/src/app/tests.rs b/src/app/tests.rs index c2a5dc3..6a13523 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -1,8 +1,7 @@ use super::applet::{ AppletBarLayout, applet_bar_layout, applet_bar_width, applet_button_size, applet_fallback_button_size, applet_percent_cell_alignment, applet_percent_cell_width, - applet_percent_text, panel_button_size, panel_fallback_active, select_provider, - selected_provider_bar_layout, + applet_percent_text, panel_button_size, panel_cells, select_provider, }; use super::popup_view::{ account_page_next, account_page_previous, clamp_account_page, pager_account_label, @@ -63,11 +62,16 @@ fn popup_size_limits_allow_tall_account_details() { fn owner_tick_runs_automatic_refresh() { let owner = refresh_owner("owner-tick"); let mut app = test_app(Some(owner)); - ready_selected_provider(&mut app.state, ProviderId::Codex); + ready_configured_copilot_provider(&mut app); let _task = app.handle_message(Message::Tick); - assert!(app.state.provider(ProviderId::Codex).unwrap().is_refreshing); + assert!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .is_refreshing + ); } #[test] @@ -186,15 +190,20 @@ fn refresh_now_excludes_disabled_providers_from_requests() { fn owner_observing_shared_control_runs_requested_refresh() { let owner = refresh_owner("owner-request"); let mut app = test_app(Some(owner)); - ready_selected_provider(&mut app.state, ProviderId::Codex); + ready_configured_copilot_provider(&mut app); let task = app.handle_message(Message::UpdateSharedControl( - Box::new(control_request(ProviderId::Codex)), + Box::new(control_request(ProviderId::Copilot)), vec!["requests"], )); assert!(task.units() > 0); - assert!(app.state.provider(ProviderId::Codex).unwrap().is_refreshing); + assert!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .is_refreshing + ); } #[test] @@ -349,14 +358,14 @@ fn owner_refresh_now_processes_new_shared_control_snapshot() { let _guard = crate::test_support::env_lock(); let owner = refresh_owner("owner-refresh-now-new-control"); let mut app = test_app(Some(owner)); - ready_selected_provider(&mut app.state, ProviderId::Cursor); + ready_configured_copilot_provider(&mut app); let task = app.handle_message(Message::RefreshNow); assert!(task.units() > 0); assert!( app.state - .provider(ProviderId::Cursor) + .provider(ProviderId::Copilot) .unwrap() .is_refreshing ); @@ -366,7 +375,16 @@ fn owner_refresh_now_processes_new_shared_control_snapshot() { fn provider_refresh_completion_consumes_shared_control_request() { let owner = refresh_owner("owner-consume-request"); let mut app = test_app(Some(owner)); + ready_configured_copilot_provider(&mut app); app.shared_control = control_request(ProviderId::Codex); + app.refresh_batches.push(super::ProviderRefreshBatch { + provider: ProviderId::Codex, + token: 1, + started_at: Utc::now(), + pending_account_ids: vec!["default".to_string()], + request: app.shared_control.requests.first().cloned(), + }); + app.state.begin_provider_refresh(ProviderId::Codex); let provider = ProviderRuntimeState { provider: ProviderId::Codex, enabled: true, @@ -382,6 +400,8 @@ fn provider_refresh_completion_consumes_shared_control_request() { let _task = app.handle_message(Message::ProviderRefreshed(Box::new( crate::runtime::ProviderRefreshResult { + batch_token: 1, + account_id: "default".to_string(), provider, accounts: Vec::new(), }, @@ -390,6 +410,307 @@ fn provider_refresh_completion_consumes_shared_control_request() { assert!(app.shared_control.requests.is_empty()); } +#[test] +fn refresh_outcomes_merge_without_reverting_newer_radio_selection() { + let mut app = test_app(None); + ready_two_copilot_accounts(&mut app); + begin_test_batch(&mut app, 1, &["a", "b"]); + + let _ = app.handle_message(Message::ToggleAccountSelection( + ProviderId::Copilot, + "b".to_string(), + )); + let _ = app.handle_message(Message::ProviderRefreshed(Box::new(refresh_result( + &app, + 1, + "a", + "refreshed-a", + )))); + assert_eq!(app.config.selected_copilot_account_ids, ["b"]); + assert_eq!( + app.state + .active_account(ProviderId::Copilot) + .unwrap() + .account_id, + "b" + ); + assert!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .is_refreshing + ); + + let _ = app.handle_message(Message::ProviderRefreshed(Box::new(refresh_result( + &app, + 1, + "b", + "refreshed-b", + )))); + assert_eq!(app.config.selected_copilot_account_ids, ["b"]); + assert_eq!( + app.state.account(ProviderId::Copilot, "a").unwrap().label, + "refreshed-a" + ); + assert_eq!( + app.state.account(ProviderId::Copilot, "b").unwrap().label, + "refreshed-b" + ); + assert!( + !app.state + .provider(ProviderId::Copilot) + .unwrap() + .is_refreshing + ); +} + +#[test] +fn reverse_refresh_outcomes_preserve_newer_radio_selection() { + let mut app = test_app(None); + ready_two_copilot_accounts(&mut app); + begin_test_batch(&mut app, 1, &["a", "b"]); + + let _ = app.handle_message(Message::ToggleAccountSelection( + ProviderId::Copilot, + "b".to_string(), + )); + let _ = app.handle_message(Message::ProviderRefreshed(Box::new(refresh_result( + &app, + 1, + "b", + "refreshed-b", + )))); + let _ = app.handle_message(Message::ProviderRefreshed(Box::new(refresh_result( + &app, + 1, + "a", + "refreshed-a", + )))); + + assert_eq!(app.config.selected_copilot_account_ids, ["b"]); + assert_eq!( + app.state + .active_account(ProviderId::Copilot) + .unwrap() + .account_id, + "b" + ); + assert_eq!( + app.state.account(ProviderId::Copilot, "a").unwrap().label, + "refreshed-a" + ); + assert_eq!( + app.state.account(ProviderId::Copilot, "b").unwrap().label, + "refreshed-b" + ); +} + +#[test] +fn refresh_batch_ignores_duplicate_and_stale_outcomes() { + let mut app = test_app(None); + ready_two_copilot_accounts(&mut app); + begin_test_batch(&mut app, 1, &["a", "b"]); + let started_at = app + .state + .provider(ProviderId::Copilot) + .unwrap() + .refresh_started_at; + + let _ = app.handle_message(Message::ProviderRefreshed(Box::new(refresh_result( + &app, 1, "a", "first", + )))); + let _ = app.handle_message(Message::ProviderRefreshed(Box::new(refresh_result( + &app, + 1, + "a", + "duplicate", + )))); + assert_eq!( + app.state.account(ProviderId::Copilot, "a").unwrap().label, + "first" + ); + assert_eq!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .refresh_started_at, + started_at + ); + assert!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .is_refreshing + ); + + app.state.finish_provider_refresh(ProviderId::Copilot); + let _ = app.terminate_refresh_batch(ProviderId::Copilot); + begin_test_batch(&mut app, 2, &["b"]); + let _ = app.handle_message(Message::ProviderRefreshed(Box::new(refresh_result( + &app, 1, "b", "late", + )))); + assert_eq!( + app.state.account(ProviderId::Copilot, "b").unwrap().label, + "b" + ); + assert!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .is_refreshing + ); +} + +#[test] +fn owner_shared_runtime_update_restores_local_batch_marker_and_blocks_rescheduling() { + let owner = refresh_owner("owner-local-batch-marker"); + let mut app = test_app(Some(owner)); + ready_two_copilot_accounts(&mut app); + begin_test_batch(&mut app, 1, &["a", "b"]); + let started_at = app + .state + .provider(ProviderId::Copilot) + .unwrap() + .refresh_started_at; + let mut incoming = app.state.clone(); + incoming + .provider_mut(ProviderId::Copilot) + .unwrap() + .is_refreshing = false; + incoming + .provider_mut(ProviderId::Copilot) + .unwrap() + .refresh_started_at = None; + + let _ = app.handle_message(Message::UpdateSharedRuntime( + Box::new(SharedRuntimeState::new(incoming, 1)), + vec!["app_state"], + )); + let task = app.handle_message(Message::UpdateSharedControl( + Box::new(control_request(ProviderId::Copilot)), + vec!["requests"], + )); + + assert_eq!(task.units(), 0); + assert!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .is_refreshing + ); + assert_eq!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .refresh_started_at, + started_at + ); + assert_eq!(app.refresh_batches.len(), 1); + assert_eq!(app.shared_control.requests.len(), 1); +} + +#[test] +fn flagged_candidate_refreshes_when_radio_state_is_login_required() { + let owner = refresh_owner("flagged-login-required-refresh"); + let mut app = test_app(Some(owner)); + ready_two_copilot_accounts(&mut app); + app.config.panel_copilot_account_ids = vec!["b".to_string()]; + app.state + .provider_mut(ProviderId::Copilot) + .unwrap() + .account_status = AccountSelectionStatus::LoginRequired; + + let task = app.automatic_refresh_task(); + + assert_eq!(task.units(), 2); + assert_eq!(app.refresh_batches[0].pending_account_ids, ["a", "b"]); + assert!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .is_refreshing + ); +} + +#[test] +fn empty_selection_is_adopted_before_refresh_outcomes() { + let _env = crate::test_support::test_env(); + let owner = refresh_owner("empty-selection-refresh"); + let mut app = test_app(Some(owner)); + app.config.copilot_enablement = crate::config::ProviderEnablement::Enabled; + app.config.copilot_managed_accounts = + vec![copilot_account("a", "a"), copilot_account("b", "b")]; + app.config.panel_copilot_account_ids = vec!["a".to_string(), "b".to_string()]; + runtime_reconcile_provider(&app.config, &mut app.state, ProviderId::Copilot); + assert_eq!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .account_status, + AccountSelectionStatus::SelectionRequired + ); + + let task = app.automatic_refresh_task(); + + assert!(task.units() > 0); + assert_eq!(app.config.selected_copilot_account_ids, ["a"]); + assert_eq!(app.refresh_batches[0].pending_account_ids, ["a", "b"]); +} + +#[test] +fn singleton_account_is_adopted_before_dispatch() { + let _env = crate::test_support::test_env(); + let owner = refresh_owner("singleton-selection-refresh"); + let mut app = test_app(Some(owner)); + app.config.copilot_enablement = crate::config::ProviderEnablement::Enabled; + app.config.copilot_managed_accounts = vec![copilot_account("only", "only")]; + runtime_reconcile_provider(&app.config, &mut app.state, ProviderId::Copilot); + app.config.selected_copilot_account_ids.clear(); + app.state + .provider_mut(ProviderId::Copilot) + .unwrap() + .selected_account_ids + .clear(); + app.state + .provider_mut(ProviderId::Copilot) + .unwrap() + .account_status = AccountSelectionStatus::SelectionRequired; + + let task = app.automatic_refresh_task(); + + assert!(task.units() > 0); + assert_eq!(app.config.selected_copilot_account_ids, ["only"]); + assert_eq!(app.refresh_batches[0].pending_account_ids, ["only"]); +} + +#[test] +fn action_required_flag_does_not_make_a_fresh_selected_account_due() { + let mut app = test_app(None); + ready_two_copilot_accounts(&mut app); + app.config.panel_copilot_account_ids = vec!["b".to_string()]; + let selected = app + .state + .provider_accounts + .iter_mut() + .find(|account| account.provider == ProviderId::Copilot && account.account_id == "a") + .unwrap(); + selected.last_success_at = Some(Utc::now()); + let flagged = app + .state + .provider_accounts + .iter_mut() + .find(|account| account.provider == ProviderId::Copilot && account.account_id == "b") + .unwrap(); + flagged.auth_state = crate::model::AuthState::ActionRequired; + flagged.last_success_at = None; + + assert!(!super::provider_refresh_due( + &app.config, + &app.state, + ProviderId::Copilot + )); +} + #[test] fn config_update_applies_selected_provider_without_changing_popup_route() { let mut app = test_app(None); @@ -512,13 +833,13 @@ fn partial_config_update_preserves_locally_written_account() { ..Config::default() }; - app.on_config_update(partial_watcher_config, &["selected_codex_account_ids"]); + let _ = app.on_config_update(partial_watcher_config, &["selected_codex_account_ids"]); assert_eq!(app.config.codex_managed_accounts.len(), 1); } #[test] -fn selecting_stale_enabled_provider_writes_provider_selected_request() { +fn selecting_stale_enabled_provider_without_configured_account_consumes_request() { let _env = crate::test_support::test_env(); let mut app = test_app(None); ready_selected_provider(&mut app.state, ProviderId::Claude); @@ -529,10 +850,7 @@ fn selecting_stale_enabled_provider_writes_provider_selected_request() { assert_eq!(task.units(), 0); assert_eq!(app.config.selected_provider, ProviderId::Claude); assert_eq!(app.selected_provider, ProviderId::Claude); - assert_eq!(app.shared_control.requests.len(), 1); - let request = &app.shared_control.requests[0]; - assert_eq!(request.provider, ProviderId::Claude); - assert_eq!(request.reason, RefreshRequestReason::ProviderSelected); + assert!(app.shared_control.requests.is_empty()); } #[test] @@ -633,6 +951,181 @@ fn cursor_reauthentication_starts_local_rescan() { assert!(matches!(app.cursor_scan, CursorScanState::Scanning)); } +#[test] +fn panel_flag_message_changes_only_flags_and_resyncs_bounds() { + let _env = crate::test_support::test_env(); + let mut app = test_app(None); + ready_two_copilot_accounts(&mut app); + let original_config = app.config.clone(); + let original_state = app.state.clone(); + + let task = app.handle_message(Message::ToggleAccountPanelFlag( + ProviderId::Copilot, + "b".to_string(), + )); + + let mut flagged_config = original_config.clone(); + flagged_config.panel_copilot_account_ids = vec!["b".to_string()]; + assert_eq!(app.config, flagged_config); + assert_eq!(app.state, original_state); + assert_eq!(task.units(), 0); + let (width, height) = panel_button_size(&app.core, &app.state, &app.config); + assert_eq!( + app.core.applet.suggested_bounds, + Some(cosmic::iced::Size::new(width, height)) + ); + assert_eq!(app.shared_control.requests.len(), 1); + assert_eq!(app.shared_control.requests[0].provider, ProviderId::Copilot); + assert_eq!( + app.shared_control.requests[0].reason, + RefreshRequestReason::AccountAction + ); + let requested = app.shared_control.clone(); + app.core.applet.suggested_bounds = None; + + let task = app.handle_message(Message::ToggleAccountPanelFlag( + ProviderId::Copilot, + "b".to_string(), + )); + + assert_eq!(app.config, original_config); + assert_eq!(app.state, original_state); + assert_eq!(task.units(), 0); + assert_eq!(app.shared_control, requested); + let (width, height) = applet_fallback_button_size(&app.core); + assert_eq!( + app.core.applet.suggested_bounds, + Some(cosmic::iced::Size::new(width, height)) + ); +} + +#[test] +fn panel_flag_message_requests_batch_safe_owner_refresh() { + let _env = crate::test_support::test_env(); + let owner = refresh_owner("panel-flag-owner"); + let mut app = test_app(Some(owner)); + ready_two_copilot_accounts(&mut app); + + let task = app.handle_message(Message::ToggleAccountPanelFlag( + ProviderId::Copilot, + "b".to_string(), + )); + + assert!(task.units() > 0); + assert_eq!(app.config.panel_copilot_account_ids, ["b"]); + assert_eq!(app.config.selected_copilot_account_ids, ["a"]); + assert_eq!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .selected_account_ids, + ["a"] + ); + assert!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .is_refreshing + ); + assert_eq!(app.refresh_batches.len(), 1); + assert_eq!(app.refresh_batches[0].pending_account_ids, ["a", "b"]); + let requested = app.shared_control.clone(); + + let task = app.handle_message(Message::ToggleAccountPanelFlag( + ProviderId::Copilot, + "b".to_string(), + )); + + assert_eq!(task.units(), 0); + assert!(app.config.panel_copilot_account_ids.is_empty()); + assert_eq!(app.shared_control, requested); + assert_eq!(app.refresh_batches[0].pending_account_ids, ["a", "b"]); +} + +#[test] +fn panel_flag_message_preserves_demo_radio_selection() { + let mut env = crate::test_support::test_env(); + env.set("YAPCAP_DEMO", "1"); + let mut app = test_app(None); + crate::demo_env::apply_config(&mut app.config); + crate::demo_env::apply(&app.config, &mut app.state); + let selected_ids = app.config.selected_codex_account_ids.clone(); + let state = app.state.clone(); + let mut expected_flags = app.config.panel_codex_account_ids.clone(); + expected_flags.push("yapcap-demo:codex-free".to_string()); + + let _ = app.handle_message(Message::ToggleAccountPanelFlag( + ProviderId::Codex, + "yapcap-demo:codex-free".to_string(), + )); + + assert_eq!(app.config.panel_codex_account_ids, expected_flags); + assert_eq!(app.config.selected_codex_account_ids, selected_ids); + assert_eq!(app.state, state); +} + +#[test] +fn panel_flag_message_does_not_select_when_non_owner_selection_is_empty() { + let _env = crate::test_support::test_env(); + let mut app = test_app(None); + ready_two_copilot_accounts(&mut app); + app.config.selected_copilot_account_ids.clear(); + app.state + .provider_mut(ProviderId::Copilot) + .unwrap() + .selected_account_ids + .clear(); + + let _ = app.handle_message(Message::ToggleAccountPanelFlag( + ProviderId::Copilot, + "b".to_string(), + )); + + assert_eq!(app.config.panel_copilot_account_ids, ["b"]); + assert!(app.config.selected_copilot_account_ids.is_empty()); + assert!( + app.state + .provider(ProviderId::Copilot) + .unwrap() + .selected_account_ids + .is_empty() + ); + assert_eq!( + app.shared_control.requests[0].reason, + RefreshRequestReason::AccountAction + ); +} + +#[test] +fn panel_flag_disable_clears_flags_and_reenable_does_not_restore_them() { + let _env = crate::test_support::test_env(); + let mut app = test_app(None); + ready_two_copilot_accounts(&mut app); + app.config.panel_copilot_account_ids = vec!["a".to_string(), "b".to_string()]; + app.config.panel_codex_account_ids = vec!["codex-1".to_string()]; + + let _ = app.handle_message(Message::SetProviderEnabled(ProviderId::Copilot, false)); + + assert!(app.config.panel_copilot_account_ids.is_empty()); + assert_eq!(app.config.panel_codex_account_ids, ["codex-1"]); + assert!(!app.state.provider(ProviderId::Copilot).unwrap().enabled); + let disabled_config = app.config.clone(); + let task = app.handle_message(Message::ToggleAccountPanelFlag( + ProviderId::Copilot, + "b".to_string(), + )); + assert_eq!(task.units(), 0); + assert_eq!(app.config, disabled_config); + assert!(app.shared_control.requests.is_empty()); + + let _ = app.handle_message(Message::SetProviderEnabled(ProviderId::Copilot, true)); + + assert!(app.state.provider(ProviderId::Copilot).unwrap().enabled); + assert!(app.config.panel_copilot_account_ids.is_empty()); + assert_eq!(app.config.panel_codex_account_ids, ["codex-1"]); + assert_eq!(app.config.selected_copilot_account_ids, ["a"]); +} + #[test] fn demo_codex_account_selection_keeps_account_rows() { let mut env = crate::test_support::test_env(); @@ -760,6 +1253,10 @@ fn applet_button_size_uses_panel_icon_style() { #[test] fn panel_button_size_stays_fixed_with_multiple_stored_accounts() { let core = cosmic::Core::default(); + let mut config = Config { + panel_codex_account_ids: vec!["codex-1".to_string()], + ..Config::default() + }; let mut one_account = AppState::empty(); one_account.upsert_account(ProviderAccountRuntimeState::empty( ProviderId::Codex, @@ -779,22 +1276,36 @@ fn panel_button_size_stays_fixed_with_multiple_stored_accounts() { PanelIconStyle::LogoAndPercent, PanelIconStyle::PercentOnly, ] { + config.panel_icon_style = style; assert_eq!( - panel_button_size(&core, &one_account, style), - panel_button_size(&core, &two_accounts, style) + panel_button_size(&core, &one_account, &config), + panel_button_size(&core, &two_accounts, &config) + ); + assert_eq!( + panel_button_size(&core, &one_account, &config), + applet_button_size(&core, style) ); } } #[test] -fn panel_fallback_is_active_when_no_provider_has_accounts() { +fn panel_cells_skip_unknown_flagged_accounts() { let state = AppState::empty(); + let config = Config { + panel_codex_account_ids: vec!["unknown".to_string()], + ..Config::default() + }; - assert!(panel_fallback_active(&state)); + assert!(panel_cells(&state, &config).is_empty()); + let core = cosmic::Core::default(); + assert_eq!( + panel_button_size(&core, &state, &config), + applet_fallback_button_size(&core) + ); } #[test] -fn panel_fallback_clears_when_an_enabled_provider_has_an_account() { +fn panel_cells_require_flags_even_when_accounts_are_selected() { let mut state = AppState::empty(); state.upsert_account(ProviderAccountRuntimeState::empty( ProviderId::Codex, @@ -802,11 +1313,26 @@ fn panel_fallback_clears_when_an_enabled_provider_has_an_account() { "Codex", )); - assert!(!panel_fallback_active(&state)); + state + .provider_mut(ProviderId::Codex) + .unwrap() + .selected_account_ids = vec!["codex-1".to_string()]; + let mut config = Config::default(); + assert!(panel_cells(&state, &config).is_empty()); + let core = cosmic::Core::default(); + assert_eq!( + panel_button_size(&core, &state, &config), + applet_fallback_button_size(&core) + ); + + config.panel_codex_account_ids = vec!["codex-1".to_string()]; + let cells = panel_cells(&state, &config); + assert_eq!(cells.len(), 1); + assert_eq!(cells[0].layout, AppletBarLayout::two_bar(0.0, 0.0)); } #[test] -fn panel_fallback_stays_active_when_only_disabled_providers_have_accounts() { +fn panel_cells_skip_disabled_providers() { let mut state = AppState::empty(); state.upsert_provider(ProviderRuntimeState::disabled(ProviderId::Codex)); state.upsert_account(ProviderAccountRuntimeState::empty( @@ -815,17 +1341,96 @@ fn panel_fallback_stays_active_when_only_disabled_providers_have_accounts() { "Codex", )); - assert!(panel_fallback_active(&state)); + let config = Config { + panel_codex_account_ids: vec!["codex-1".to_string()], + ..Config::default() + }; + assert!(panel_cells(&state, &config).is_empty()); } #[test] -fn panel_fallback_stays_active_when_all_providers_are_disabled() { +fn panel_cells_follow_provider_and_account_order_not_flag_order() { let mut state = AppState::empty(); - for provider in &mut state.providers { - provider.enabled = false; + let mut config = Config::default(); + for provider in ProviderId::ALL.into_iter().rev() { + for id in ["second", "first"] { + state.upsert_account(ProviderAccountRuntimeState::empty(provider, id, id)); + } + *config.panel_account_ids_mut(provider) = vec![ + "first".to_string(), + "unknown".to_string(), + "second".to_string(), + "first".to_string(), + ]; + } + + let cells = panel_cells(&state, &config); + let identities = cells + .iter() + .map(|cell| (cell.account.provider, cell.account.account_id.as_str())) + .collect::>(); + let expected = ProviderId::ALL + .into_iter() + .flat_map(|provider| [(provider, "second"), (provider, "first")]) + .collect::>(); + assert_eq!(identities, expected); +} + +#[test] +fn panel_button_size_grows_with_flagged_accounts() { + let core = cosmic::Core::default(); + let state = state_with_account_percents(&[30.0, 60.0, 90.0]); + let (major, minor) = core.applet.suggested_padding(false); + let padding = f32::from( + 2 * if core.applet.is_horizontal() { + major + } else { + minor + }, + ); + for style in [ + PanelIconStyle::LogoAndBars, + PanelIconStyle::BarsOnly, + PanelIconStyle::LogoAndPercent, + PanelIconStyle::PercentOnly, + ] { + let mut config = Config { + panel_icon_style: style, + ..Config::default() + }; + let (single_width, height) = applet_button_size(&core, style); + let step = single_width - padding + super::APPLET_CELL_SPACING; + let mut expected_width = single_width; + for id in ["codex-0", "codex-1", "codex-2"] { + config.panel_codex_account_ids.push(id.to_string()); + assert_eq!( + panel_button_size(&core, &state, &config), + (expected_width, height) + ); + expected_width += step; + } } +} + +#[test] +fn panel_suggested_bounds_track_flagged_cells_and_empty_fallback() { + let mut app = test_app(None); + app.state = state_with_account_percents(&[30.0, 60.0]); + app.config.panel_codex_account_ids = vec!["codex-0".to_string(), "codex-1".to_string()]; + app.sync_panel_suggested_bounds(); + let (width, height) = panel_button_size(&app.core, &app.state, &app.config); + assert_eq!( + app.core.applet.suggested_bounds, + Some(cosmic::iced::Size::new(width, height)) + ); - assert!(panel_fallback_active(&state)); + app.config.panel_codex_account_ids.clear(); + app.sync_panel_suggested_bounds(); + let (width, height) = applet_fallback_button_size(&app.core); + assert_eq!( + app.core.applet.suggested_bounds, + Some(cosmic::iced::Size::new(width, height)) + ); } #[test] @@ -983,7 +1588,7 @@ fn applet_percent_text_uses_one_decimal_through_100_percent() { } #[test] -fn selected_provider_bar_layout_uses_first_panel_window() { +fn panel_cells_use_first_panel_window_and_global_amount_format() { let mut state = AppState::empty(); let mut account = ProviderAccountRuntimeState::empty(ProviderId::Codex, "codex-1", "Codex"); account.snapshot = Some(UsageSnapshot { @@ -1020,13 +1625,17 @@ fn selected_provider_bar_layout_uses_first_panel_window() { .selected_account_ids = vec!["codex-1".to_string()]; state.upsert_account(account); - let percents_used = - selected_provider_bar_layout(&state, ProviderId::Codex, UsageAmountFormat::Used); + let mut config = Config { + panel_codex_account_ids: vec!["codex-1".to_string()], + usage_amount_format: UsageAmountFormat::Used, + ..Config::default() + }; + let percents_used = panel_cells(&state, &config)[0].layout; assert_eq!(percents_used.primary, 86.5); assert_eq!(percents_used.secondary, Some(42.0)); - let percents_left = - selected_provider_bar_layout(&state, ProviderId::Codex, UsageAmountFormat::Left); + config.usage_amount_format = UsageAmountFormat::Left; + let percents_left = panel_cells(&state, &config)[0].layout; assert_eq!(percents_left.primary, 13.5); } @@ -1060,12 +1669,60 @@ fn applet_bar_layout_preserves_single_bar_shape() { } #[test] -fn selected_provider_bar_layout_uses_only_the_active_account() { +fn panel_cells_use_each_flagged_account_without_changing_popup_selection() { let state = state_with_account_percents(&[30.0, 90.0]); + let config = Config { + panel_codex_account_ids: vec!["codex-1".to_string(), "codex-0".to_string()], + usage_amount_format: UsageAmountFormat::Used, + ..Config::default() + }; + + let cells = panel_cells(&state, &config); + + assert_eq!(cells[0].layout.primary, 30.0); + assert_eq!(cells[1].layout.primary, 90.0); + assert_eq!( + state.active_account(ProviderId::Codex).unwrap().account_id, + "codex-0" + ); +} + +#[test] +fn panel_cells_fall_back_to_each_providers_legacy_snapshot() { + let mut state = state_with_account_percents(&[30.0, 90.0]); + let legacy = state.provider_accounts[1].snapshot.take(); + state + .provider_mut(ProviderId::Codex) + .unwrap() + .legacy_display_snapshot = legacy; + let mut other_legacy = state.provider_accounts[0].snapshot.clone().unwrap(); + other_legacy.provider = ProviderId::Minimax; + other_legacy.windows[0].used_percent = 60.0; + state + .provider_mut(ProviderId::Minimax) + .unwrap() + .legacy_display_snapshot = Some(other_legacy); + state.upsert_account(ProviderAccountRuntimeState::empty( + ProviderId::Minimax, + "minimax-0", + "Minimax", + )); + let config = Config { + panel_codex_account_ids: vec!["codex-0".to_string(), "codex-1".to_string()], + panel_minimax_account_ids: vec!["minimax-0".to_string()], + usage_amount_format: UsageAmountFormat::Used, + ..Config::default() + }; - let layout = selected_provider_bar_layout(&state, ProviderId::Codex, UsageAmountFormat::Used); + let cells = panel_cells(&state, &config); - assert_eq!(layout.primary, 30.0); + assert_eq!( + cells + .iter() + .map(|cell| cell.layout.primary) + .collect::>(), + [30.0, 90.0, 60.0] + ); } fn state_with_account_percents(percents: &[f32]) -> AppState { @@ -1130,6 +1787,8 @@ pub(super) fn test_app(refresh_owner: Option) -> AppModel { lock_path, }, refresh_owner, + refresh_batches: Vec::new(), + next_refresh_batch_token: 1, codex_login: None, codex_login_handle: None, claude_login: None, @@ -1169,6 +1828,55 @@ fn ready_selected_provider(state: &mut AppState, provider: ProviderId) { entry.selected_account_ids = vec!["default".to_string()]; } +fn ready_configured_copilot_provider(app: &mut AppModel) { + app.config.copilot_enablement = crate::config::ProviderEnablement::Enabled; + app.config.copilot_managed_accounts = vec![copilot_account("default", "octocat")]; + app.config.selected_copilot_account_ids = vec!["default".to_string()]; + runtime_reconcile_provider(&app.config, &mut app.state, ProviderId::Copilot); +} + +fn ready_two_copilot_accounts(app: &mut AppModel) { + app.config.copilot_enablement = crate::config::ProviderEnablement::Enabled; + app.config.copilot_managed_accounts = + vec![copilot_account("a", "a"), copilot_account("b", "b")]; + app.config.selected_copilot_account_ids = vec!["a".to_string()]; + runtime_reconcile_provider(&app.config, &mut app.state, ProviderId::Copilot); +} + +fn begin_test_batch(app: &mut AppModel, token: u64, account_ids: &[&str]) { + let started_at = app + .state + .begin_provider_refresh(ProviderId::Copilot) + .unwrap(); + app.refresh_batches.push(super::ProviderRefreshBatch { + provider: ProviderId::Copilot, + token, + started_at, + pending_account_ids: account_ids.iter().map(|id| (*id).to_string()).collect(), + request: None, + }); +} + +fn refresh_result( + app: &AppModel, + token: u64, + account_id: &str, + label: &str, +) -> crate::runtime::ProviderRefreshResult { + let mut account = app + .state + .account(ProviderId::Copilot, account_id) + .unwrap() + .clone(); + account.label = label.to_string(); + crate::runtime::ProviderRefreshResult { + batch_token: token, + account_id: account_id.to_string(), + provider: app.state.provider(ProviderId::Copilot).unwrap().clone(), + accounts: vec![account], + } +} + fn selected_account_without_usage(state: &mut AppState, provider: ProviderId) { let mut account = ProviderAccountRuntimeState::empty(provider, "default", provider.label()); account.auth_state = crate::model::AuthState::Ready; diff --git a/src/config.rs b/src/config.rs index 7c77e5b..63d3f82 100644 --- a/src/config.rs +++ b/src/config.rs @@ -47,42 +47,64 @@ pub struct Config { #[serde(default)] pub grok_enablement: ProviderEnablement, pub selected_codex_account_ids: Vec, + #[serde(default)] + pub panel_codex_account_ids: Vec, pub codex_managed_accounts: Vec, pub selected_claude_account_ids: Vec, + #[serde(default)] + pub panel_claude_account_ids: Vec, pub claude_managed_accounts: Vec, pub selected_cursor_account_ids: Vec, + #[serde(default)] + pub panel_cursor_account_ids: Vec, pub cursor_managed_accounts: Vec, #[serde(default)] pub selected_gemini_account_ids: Vec, #[serde(default)] + pub panel_gemini_account_ids: Vec, + #[serde(default)] pub gemini_managed_accounts: Vec, #[serde(default)] pub selected_copilot_account_ids: Vec, #[serde(default)] + pub panel_copilot_account_ids: Vec, + #[serde(default)] pub copilot_managed_accounts: Vec, #[serde(default)] pub selected_minimax_account_ids: Vec, #[serde(default)] + pub panel_minimax_account_ids: Vec, + #[serde(default)] pub minimax_managed_accounts: Vec, #[serde(default)] pub selected_zai_account_ids: Vec, #[serde(default)] + pub panel_zai_account_ids: Vec, + #[serde(default)] pub zai_managed_accounts: Vec, #[serde(default)] pub selected_kimi_account_ids: Vec, #[serde(default)] + pub panel_kimi_account_ids: Vec, + #[serde(default)] pub kimi_managed_accounts: Vec, #[serde(default)] pub selected_antigravity_account_ids: Vec, #[serde(default)] + pub panel_antigravity_account_ids: Vec, + #[serde(default)] pub antigravity_managed_accounts: Vec, #[serde(default)] pub selected_opencode_go_account_ids: Vec, #[serde(default)] + pub panel_opencode_go_account_ids: Vec, + #[serde(default)] pub opencode_go_managed_accounts: Vec, #[serde(default)] pub selected_grok_account_ids: Vec, #[serde(default)] + pub panel_grok_account_ids: Vec, + #[serde(default)] pub grok_managed_accounts: Vec, pub log_level: String, } @@ -108,26 +130,37 @@ impl Default for Config { opencode_go_enablement: ProviderEnablement::Auto, grok_enablement: ProviderEnablement::Auto, selected_codex_account_ids: Vec::new(), + panel_codex_account_ids: Vec::new(), codex_managed_accounts: Vec::new(), selected_claude_account_ids: Vec::new(), + panel_claude_account_ids: Vec::new(), claude_managed_accounts: Vec::new(), selected_cursor_account_ids: Vec::new(), + panel_cursor_account_ids: Vec::new(), cursor_managed_accounts: Vec::new(), selected_gemini_account_ids: Vec::new(), + panel_gemini_account_ids: Vec::new(), gemini_managed_accounts: Vec::new(), selected_copilot_account_ids: Vec::new(), + panel_copilot_account_ids: Vec::new(), copilot_managed_accounts: Vec::new(), selected_minimax_account_ids: Vec::new(), + panel_minimax_account_ids: Vec::new(), minimax_managed_accounts: Vec::new(), selected_zai_account_ids: Vec::new(), + panel_zai_account_ids: Vec::new(), zai_managed_accounts: Vec::new(), selected_kimi_account_ids: Vec::new(), + panel_kimi_account_ids: Vec::new(), kimi_managed_accounts: Vec::new(), selected_antigravity_account_ids: Vec::new(), + panel_antigravity_account_ids: Vec::new(), antigravity_managed_accounts: Vec::new(), selected_opencode_go_account_ids: Vec::new(), + panel_opencode_go_account_ids: Vec::new(), opencode_go_managed_accounts: Vec::new(), selected_grok_account_ids: Vec::new(), + panel_grok_account_ids: Vec::new(), grok_managed_accounts: Vec::new(), log_level: "info".to_string(), } @@ -197,6 +230,39 @@ impl Config { } } + #[must_use] + pub fn panel_account_ids(&self, provider: ProviderId) -> &[String] { + match provider { + ProviderId::Codex => &self.panel_codex_account_ids, + ProviderId::Claude => &self.panel_claude_account_ids, + ProviderId::Cursor => &self.panel_cursor_account_ids, + ProviderId::Gemini => &self.panel_gemini_account_ids, + ProviderId::Copilot => &self.panel_copilot_account_ids, + ProviderId::Minimax => &self.panel_minimax_account_ids, + ProviderId::Zai => &self.panel_zai_account_ids, + ProviderId::Kimi => &self.panel_kimi_account_ids, + ProviderId::Antigravity => &self.panel_antigravity_account_ids, + ProviderId::OpenCodeGo => &self.panel_opencode_go_account_ids, + ProviderId::Grok => &self.panel_grok_account_ids, + } + } + + pub fn panel_account_ids_mut(&mut self, provider: ProviderId) -> &mut Vec { + match provider { + ProviderId::Codex => &mut self.panel_codex_account_ids, + ProviderId::Claude => &mut self.panel_claude_account_ids, + ProviderId::Cursor => &mut self.panel_cursor_account_ids, + ProviderId::Gemini => &mut self.panel_gemini_account_ids, + ProviderId::Copilot => &mut self.panel_copilot_account_ids, + ProviderId::Minimax => &mut self.panel_minimax_account_ids, + ProviderId::Zai => &mut self.panel_zai_account_ids, + ProviderId::Kimi => &mut self.panel_kimi_account_ids, + ProviderId::Antigravity => &mut self.panel_antigravity_account_ids, + ProviderId::OpenCodeGo => &mut self.panel_opencode_go_account_ids, + ProviderId::Grok => &mut self.panel_grok_account_ids, + } + } + pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) -> bool { let enablement = provider_enablement_mut(self, provider); let explicit = if enabled { @@ -236,6 +302,25 @@ pub fn migrate_provider_enablement(context: &cosmic_config::Config, config: &mut migrated } +pub fn migrate_panel_account_flags(context: &cosmic_config::Config, config: &mut Config) -> bool { + let mut migrated = false; + for provider in ProviderId::ALL { + let key = panel_account_ids_key(provider); + if !matches!( + context.get::>(key), + Err(cosmic_config::Error::NotFound | cosmic_config::Error::NoConfigDirectory) + ) { + continue; + } + let account_ids = config.selected_account_ids(provider).to_vec(); + if context.set(key, account_ids.clone()).is_ok() { + *config.panel_account_ids_mut(provider) = account_ids; + migrated = true; + } + } + migrated +} + fn provider_enabled_key(provider: ProviderId) -> &'static str { match provider { ProviderId::Codex => "codex_enabled", @@ -268,6 +353,22 @@ fn provider_enablement_key(provider: ProviderId) -> &'static str { } } +fn panel_account_ids_key(provider: ProviderId) -> &'static str { + match provider { + ProviderId::Codex => "panel_codex_account_ids", + ProviderId::Claude => "panel_claude_account_ids", + ProviderId::Cursor => "panel_cursor_account_ids", + ProviderId::Gemini => "panel_gemini_account_ids", + ProviderId::Copilot => "panel_copilot_account_ids", + ProviderId::Minimax => "panel_minimax_account_ids", + ProviderId::Zai => "panel_zai_account_ids", + ProviderId::Kimi => "panel_kimi_account_ids", + ProviderId::Antigravity => "panel_antigravity_account_ids", + ProviderId::OpenCodeGo => "panel_opencode_go_account_ids", + ProviderId::Grok => "panel_grok_account_ids", + } +} + fn provider_enablement_mut(config: &mut Config, provider: ProviderId) -> &mut ProviderEnablement { match provider { ProviderId::Codex => &mut config.codex_enablement, @@ -540,27 +641,38 @@ pub fn write_changed_config_entries( antigravity_enablement, opencode_go_enablement, selected_codex_account_ids, + panel_codex_account_ids, codex_managed_accounts, selected_claude_account_ids, + panel_claude_account_ids, claude_managed_accounts, selected_cursor_account_ids, + panel_cursor_account_ids, cursor_managed_accounts, selected_gemini_account_ids, + panel_gemini_account_ids, gemini_managed_accounts, selected_copilot_account_ids, + panel_copilot_account_ids, copilot_managed_accounts, selected_minimax_account_ids, + panel_minimax_account_ids, minimax_managed_accounts, selected_zai_account_ids, + panel_zai_account_ids, zai_managed_accounts, selected_kimi_account_ids, + panel_kimi_account_ids, kimi_managed_accounts, selected_antigravity_account_ids, + panel_antigravity_account_ids, antigravity_managed_accounts, selected_opencode_go_account_ids, + panel_opencode_go_account_ids, opencode_go_managed_accounts, grok_enablement, selected_grok_account_ids, + panel_grok_account_ids, grok_managed_accounts, log_level, } = new; @@ -591,26 +703,37 @@ pub fn write_changed_config_entries( set_changed!(opencode_go_enablement); set_changed!(grok_enablement); set_changed!(selected_codex_account_ids); + set_changed!(panel_codex_account_ids); set_changed!(codex_managed_accounts); set_changed!(selected_claude_account_ids); + set_changed!(panel_claude_account_ids); set_changed!(claude_managed_accounts); set_changed!(selected_cursor_account_ids); + set_changed!(panel_cursor_account_ids); set_changed!(cursor_managed_accounts); set_changed!(selected_gemini_account_ids); + set_changed!(panel_gemini_account_ids); set_changed!(gemini_managed_accounts); set_changed!(selected_copilot_account_ids); + set_changed!(panel_copilot_account_ids); set_changed!(copilot_managed_accounts); set_changed!(selected_minimax_account_ids); + set_changed!(panel_minimax_account_ids); set_changed!(minimax_managed_accounts); set_changed!(selected_zai_account_ids); + set_changed!(panel_zai_account_ids); set_changed!(zai_managed_accounts); set_changed!(selected_kimi_account_ids); + set_changed!(panel_kimi_account_ids); set_changed!(kimi_managed_accounts); set_changed!(selected_antigravity_account_ids); + set_changed!(panel_antigravity_account_ids); set_changed!(antigravity_managed_accounts); set_changed!(selected_opencode_go_account_ids); + set_changed!(panel_opencode_go_account_ids); set_changed!(opencode_go_managed_accounts); set_changed!(selected_grok_account_ids); + set_changed!(panel_grok_account_ids); set_changed!(grok_managed_accounts); set_changed!(log_level); @@ -829,6 +952,47 @@ mod tests { assert_eq!(config.cursor_enablement, ProviderEnablement::Auto); } + #[test] + fn panel_account_flags_migrate_selected_ids_when_keys_are_absent() { + let ctx = cosmic_config_context(APP_ID, Config::VERSION).unwrap(); + let mut config = Config { + selected_codex_account_ids: vec!["codex-selected".to_string()], + ..Config::default() + }; + + assert!(migrate_panel_account_flags(&ctx, &mut config)); + assert_eq!(config.panel_codex_account_ids, ["codex-selected"]); + assert_eq!( + ctx.get::>("panel_codex_account_ids").unwrap(), + ["codex-selected"] + ); + for provider in ProviderId::ALL { + assert_eq!( + ctx.get::>(panel_account_ids_key(provider)) + .unwrap(), + config.panel_account_ids(provider) + ); + } + } + + #[test] + fn panel_account_flags_migration_preserves_present_empty_lists() { + let ctx = cosmic_config_context(APP_ID, Config::VERSION).unwrap(); + let mut config = Config::default(); + assert!(migrate_panel_account_flags(&ctx, &mut config)); + + config.selected_codex_account_ids = vec!["codex-selected".to_string()]; + config.panel_codex_account_ids.clear(); + + assert!(!migrate_panel_account_flags(&ctx, &mut config)); + assert!(config.panel_codex_account_ids.is_empty()); + assert!( + ctx.get::>("panel_codex_account_ids") + .unwrap() + .is_empty() + ); + } + #[test] fn write_changed_config_entries_persists_only_changed_fields() { let ctx = cosmic_config_context(APP_ID, Config::VERSION).unwrap(); @@ -843,6 +1007,7 @@ mod tests { new.refresh_interval_seconds = old.refresh_interval_seconds + 60; new.kimi_enablement = ProviderEnablement::Enabled; new.selected_kimi_account_ids = vec!["kimi-test".to_string()]; + new.panel_kimi_account_ids = vec!["kimi-panel-test".to_string()]; new.zai_enablement = ProviderEnablement::Enabled; new.selected_zai_account_ids = vec!["zai-test".to_string()]; let now = Utc::now(); @@ -929,6 +1094,21 @@ mod tests { assert!(config.zai_managed_accounts.is_empty()); } + #[test] + fn missing_panel_account_ids_default_for_existing_config() { + let mut value = serde_json::to_value(Config::default()).unwrap(); + let object = value.as_object_mut().unwrap(); + for provider in ProviderId::ALL { + object.remove(panel_account_ids_key(provider)); + } + + let config: Config = serde_json::from_value(value).unwrap(); + + for provider in ProviderId::ALL { + assert!(config.panel_account_ids(provider).is_empty()); + } + } + #[test] fn config_schema_version_marks_fresh_patch_boundary() { let config = Config::default(); diff --git a/src/config/watch_update.rs b/src/config/watch_update.rs index cd01929..5d0b347 100644 --- a/src/config/watch_update.rs +++ b/src/config/watch_update.rs @@ -55,48 +55,72 @@ impl Config { "selected_codex_account_ids" => { self.selected_codex_account_ids = update.selected_codex_account_ids.clone(); } + "panel_codex_account_ids" => { + self.panel_codex_account_ids = update.panel_codex_account_ids.clone(); + } "codex_managed_accounts" => { self.codex_managed_accounts = update.codex_managed_accounts.clone(); } "selected_claude_account_ids" => { self.selected_claude_account_ids = update.selected_claude_account_ids.clone(); } + "panel_claude_account_ids" => { + self.panel_claude_account_ids = update.panel_claude_account_ids.clone(); + } "claude_managed_accounts" => { self.claude_managed_accounts = update.claude_managed_accounts.clone(); } "selected_cursor_account_ids" => { self.selected_cursor_account_ids = update.selected_cursor_account_ids.clone(); } + "panel_cursor_account_ids" => { + self.panel_cursor_account_ids = update.panel_cursor_account_ids.clone(); + } "cursor_managed_accounts" => { self.cursor_managed_accounts = update.cursor_managed_accounts.clone(); } "selected_gemini_account_ids" => { self.selected_gemini_account_ids = update.selected_gemini_account_ids.clone(); } + "panel_gemini_account_ids" => { + self.panel_gemini_account_ids = update.panel_gemini_account_ids.clone(); + } "gemini_managed_accounts" => { self.gemini_managed_accounts = update.gemini_managed_accounts.clone(); } "selected_copilot_account_ids" => { self.selected_copilot_account_ids = update.selected_copilot_account_ids.clone(); } + "panel_copilot_account_ids" => { + self.panel_copilot_account_ids = update.panel_copilot_account_ids.clone(); + } "copilot_managed_accounts" => { self.copilot_managed_accounts = update.copilot_managed_accounts.clone(); } "selected_minimax_account_ids" => { self.selected_minimax_account_ids = update.selected_minimax_account_ids.clone(); } + "panel_minimax_account_ids" => { + self.panel_minimax_account_ids = update.panel_minimax_account_ids.clone(); + } "minimax_managed_accounts" => { self.minimax_managed_accounts = update.minimax_managed_accounts.clone(); } "selected_zai_account_ids" => { self.selected_zai_account_ids = update.selected_zai_account_ids.clone(); } + "panel_zai_account_ids" => { + self.panel_zai_account_ids = update.panel_zai_account_ids.clone(); + } "zai_managed_accounts" => { self.zai_managed_accounts = update.zai_managed_accounts.clone(); } "selected_kimi_account_ids" => { self.selected_kimi_account_ids = update.selected_kimi_account_ids.clone(); } + "panel_kimi_account_ids" => { + self.panel_kimi_account_ids = update.panel_kimi_account_ids.clone(); + } "kimi_managed_accounts" => { self.kimi_managed_accounts = update.kimi_managed_accounts.clone(); } @@ -104,6 +128,9 @@ impl Config { self.selected_antigravity_account_ids = update.selected_antigravity_account_ids.clone(); } + "panel_antigravity_account_ids" => { + self.panel_antigravity_account_ids = update.panel_antigravity_account_ids.clone(); + } "antigravity_managed_accounts" => { self.antigravity_managed_accounts = update.antigravity_managed_accounts.clone(); } @@ -111,12 +138,18 @@ impl Config { self.selected_opencode_go_account_ids = update.selected_opencode_go_account_ids.clone(); } + "panel_opencode_go_account_ids" => { + self.panel_opencode_go_account_ids = update.panel_opencode_go_account_ids.clone(); + } "opencode_go_managed_accounts" => { self.opencode_go_managed_accounts = update.opencode_go_managed_accounts.clone(); } "selected_grok_account_ids" => { self.selected_grok_account_ids = update.selected_grok_account_ids.clone(); } + "panel_grok_account_ids" => { + self.panel_grok_account_ids = update.panel_grok_account_ids.clone(); + } "grok_managed_accounts" => { self.grok_managed_accounts = update.grok_managed_accounts.clone(); } @@ -208,6 +241,7 @@ mod tests { codex_enablement: ProviderEnablement::Disabled, zai_enablement: ProviderEnablement::Enabled, selected_zai_account_ids: vec!["zai-1".to_string()], + panel_zai_account_ids: vec!["zai-panel-1".to_string()], zai_managed_accounts: vec![ManagedZaiAccountConfig { id: "zai-1".to_string(), label: "Z.AI Coding Plan".to_string(), @@ -224,12 +258,14 @@ mod tests { &[ "zai_enablement", "selected_zai_account_ids", + "panel_zai_account_ids", "zai_managed_accounts", ], ); assert_eq!(config.zai_enablement, ProviderEnablement::Enabled); assert_eq!(config.selected_zai_account_ids, ["zai-1"]); + assert_eq!(config.panel_zai_account_ids, ["zai-panel-1"]); assert_eq!(config.zai_managed_accounts[0].id, "zai-1"); assert_eq!(config.codex_enablement, ProviderEnablement::Auto); } diff --git a/src/demo_env.rs b/src/demo_env.rs index 2efeaf1..4f532a2 100644 --- a/src/demo_env.rs +++ b/src/demo_env.rs @@ -97,6 +97,11 @@ pub fn apply_config(config: &mut Config) { config.selected_opencode_go_account_ids = vec![OPENCODE_GO_ID.to_string()]; config.selected_grok_account_ids = vec![GROK_PRIMARY_ID.to_string()]; config.selected_zai_account_ids = vec![ZAI_PRIMARY_ID.to_string()]; + + for provider in ProviderId::ALL { + let selected_ids = config.selected_account_ids(provider).to_vec(); + *config.panel_account_ids_mut(provider) = selected_ids; + } } pub fn strip_leaked_state(config: &mut Config) -> bool { @@ -126,6 +131,9 @@ pub fn strip_leaked_state(config: &mut Config) -> bool { changed |= retain_len_changed(&mut config.grok_managed_accounts, |account| &account.id); changed |= strip_ids(&mut config.selected_zai_account_ids); changed |= retain_len_changed(&mut config.zai_managed_accounts, |account| &account.id); + for provider in ProviderId::ALL { + changed |= strip_ids(config.panel_account_ids_mut(provider)); + } changed } @@ -1375,6 +1383,12 @@ mod tests { assert_eq!(config.selected_antigravity_account_ids.len(), 1); assert_eq!(config.selected_opencode_go_account_ids.len(), 1); assert_eq!(config.selected_zai_account_ids.len(), 1); + for provider in ProviderId::ALL { + assert_eq!( + config.panel_account_ids(provider), + config.selected_account_ids(provider) + ); + } for provider in ProviderId::ALL { assert_eq!( config.provider_enablement(provider), @@ -1418,6 +1432,7 @@ mod tests { "{} should have demo accounts selected", provider.label() ); + assert_eq!(config.panel_account_ids(provider), selected); let managed_ids: Vec<&String> = match provider { ProviderId::Codex => config .codex_managed_accounts @@ -1820,6 +1835,7 @@ mod tests { fn strip_leaked_state_removes_zai_demo_ids() { let mut config = Config { selected_zai_account_ids: vec![ZAI_PRIMARY_ID.to_string(), "real-zai".to_string()], + panel_zai_account_ids: vec![ZAI_PRIMARY_ID.to_string(), "real-zai".to_string()], zai_managed_accounts: demo_zai_accounts(), ..Config::default() }; @@ -1829,6 +1845,7 @@ mod tests { config.selected_zai_account_ids, vec!["real-zai".to_string()] ); + assert_eq!(config.panel_zai_account_ids, vec!["real-zai".to_string()]); assert!(config.zai_managed_accounts.is_empty()); } diff --git a/src/providers/registry.rs b/src/providers/registry.rs index 6dda34d..8b89930 100644 --- a/src/providers/registry.rs +++ b/src/providers/registry.rs @@ -82,6 +82,15 @@ pub fn toggle_account_selection(provider: ProviderId, config: &mut Config, accou } } +pub fn toggle_account_panel_flag(provider: ProviderId, config: &mut Config, account_id: &str) { + let ids = config.panel_account_ids_mut(provider); + if ids.iter().any(|id| id == account_id) { + ids.retain(|id| id != account_id); + } else { + ids.push(account_id.to_string()); + } +} + pub fn sync_selected_ids_with_discoveries(config: &mut Config, provider: ProviderId) { let valid: Vec = discover_accounts(provider, config) .into_iter() @@ -94,6 +103,15 @@ pub fn sync_selected_ids_with_discoveries(config: &mut Config, provider: Provide } } +pub fn sync_panel_ids_with_discoveries(config: &mut Config, provider: ProviderId) { + let valid: Vec = discover_accounts(provider, config) + .into_iter() + .map(|a| a.account_id) + .collect(); + let ids = config.panel_account_ids_mut(provider); + ids.retain(|id| valid.contains(id)); +} + pub async fn fetch_account( account: &ProviderAccountDescriptor, client: &reqwest::Client, diff --git a/src/providers/registry/tests.rs b/src/providers/registry/tests.rs index 487556e..5815752 100644 --- a/src/providers/registry/tests.rs +++ b/src/providers/registry/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::config::Config; +use crate::config::{Config, ManagedGrokAccountConfig}; use crate::providers::interface::ProviderAccountAction; #[test] @@ -124,6 +124,49 @@ fn each_provider_resolves_accounts() { } } +#[test] +fn panel_account_flag_toggle_is_independent_of_selection() { + let mut config = Config { + selected_grok_account_ids: vec!["selected".to_string()], + ..Config::default() + }; + + toggle_account_panel_flag(ProviderId::Grok, &mut config, "panel-account"); + assert_eq!(config.panel_grok_account_ids, ["panel-account"]); + assert_eq!(config.selected_grok_account_ids, ["selected"]); + + toggle_account_panel_flag(ProviderId::Grok, &mut config, "panel-account"); + assert!(config.panel_grok_account_ids.is_empty()); + assert_eq!(config.selected_grok_account_ids, ["selected"]); +} + +#[test] +fn panel_account_flags_prune_unknown_discoveries_without_selecting() { + let now = chrono::Utc::now(); + let mut config = Config { + selected_grok_account_ids: vec!["selected".to_string()], + panel_grok_account_ids: vec!["grok-1".to_string(), "missing".to_string()], + grok_managed_accounts: vec![ManagedGrokAccountConfig { + id: "grok-1".to_string(), + label: "Grok User".to_string(), + config_dir: std::path::PathBuf::from("/tmp/grok-1"), + email: Some("grok@example.com".to_string()), + provider_account_id: Some("user-1".to_string()), + team_id: None, + plan: None, + created_at: now, + updated_at: now, + last_authenticated_at: None, + }], + ..Config::default() + }; + + sync_panel_ids_with_discoveries(&mut config, ProviderId::Grok); + + assert_eq!(config.panel_grok_account_ids, ["grok-1"]); + assert_eq!(config.selected_grok_account_ids, ["selected"]); +} + #[test] fn host_aware_providers_resolve_system_active_account_id() { use crate::account_storage::{ diff --git a/src/runtime.rs b/src/runtime.rs index d0d80bc..2c047c6 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -22,6 +22,8 @@ const DEBUG_OFFLINE_PROXY: &str = "http://127.0.0.1:9"; #[derive(Debug, Clone)] pub struct ProviderRefreshResult { + pub batch_token: u64, + pub account_id: String, pub provider: ProviderRuntimeState, pub accounts: Vec, } @@ -44,6 +46,10 @@ pub fn load_initial_state( AppState::empty() } }; + for provider in &mut state.providers { + provider.is_refreshing = false; + provider.refresh_started_at = None; + } reconcile_state(config, detection, &mut state); state } @@ -142,7 +148,7 @@ pub(crate) const REFRESH_STALE_AFTER_SECS: i64 = 60; const STALE_REFRESH_MESSAGE: &str = "Refresh timed out"; -pub fn resolve_stale_refreshes(state: &mut AppState) -> bool { +pub fn resolve_stale_refreshes(state: &mut AppState) -> Vec { let now = Utc::now(); let stale = chrono::Duration::seconds(REFRESH_STALE_AFTER_SECS); let mut stale_providers = Vec::new(); @@ -177,7 +183,10 @@ pub fn resolve_stale_refreshes(state: &mut AppState) -> bool { account.retry_after = Some(now + chrono::Duration::seconds(backoff.cast_signed())); } } - !stale_providers.is_empty() + stale_providers + .into_iter() + .map(|(provider, _)| provider) + .collect() } pub(crate) fn classify_auth_state(error: &AppError) -> AuthState { @@ -196,15 +205,26 @@ pub async fn refresh_provider_account_statuses( providers::registry::refresh_account_statuses(provider, config, previous_accounts).await } -pub async fn refresh_account( - config: Config, - provider: ProviderId, - enabled: bool, - account_id: String, - previous: Option, - previous_accounts: Vec, - process: Option, -) -> ProviderRefreshResult { +pub struct RefreshAccountInput { + pub provider: ProviderId, + pub enabled: bool, + pub account_id: String, + pub batch_token: u64, + pub previous: Option, + pub previous_accounts: Vec, + pub process: Option, +} + +pub async fn refresh_account(config: Config, input: RefreshAccountInput) -> ProviderRefreshResult { + let RefreshAccountInput { + provider, + enabled, + account_id, + batch_token, + previous, + previous_accounts, + process, + } = input; tracing::info!( process_id = process .as_ref() @@ -221,12 +241,14 @@ pub async fn refresh_account( let client = http_client(); let accounts = providers::registry::discover_accounts(provider, &config); - let Some(account) = accounts - .iter() - .find(|a| a.account_id == account_id) - .or_else(|| accounts.first()) - else { - return no_provider_accounts(provider, enabled, previous.as_ref()); + let Some(account) = accounts.iter().find(|a| a.account_id == account_id) else { + return no_provider_accounts( + provider, + account_id, + batch_token, + enabled, + previous.as_ref(), + ); }; let account = account.clone(); @@ -250,19 +272,31 @@ pub async fn refresh_account( }, ) .await + .with_batch(batch_token) } fn no_provider_accounts( provider: ProviderId, + account_id: String, + batch_token: u64, enabled: bool, previous: Option<&ProviderRuntimeState>, ) -> ProviderRefreshResult { ProviderRefreshResult { + batch_token, + account_id, provider: not_ready_provider(provider, enabled, previous), accounts: Vec::new(), } } +impl ProviderRefreshResult { + fn with_batch(mut self, batch_token: u64) -> Self { + self.batch_token = batch_token; + self + } +} + fn not_ready_provider( provider: ProviderId, enabled: bool, @@ -302,6 +336,8 @@ where "provider refresh skipped because provider is disabled" ); return ProviderRefreshResult { + batch_token: 0, + account_id, provider: ProviderRuntimeState::disabled(provider), accounts: Vec::new(), }; @@ -312,10 +348,8 @@ where .unwrap_or_else(|| ProviderRuntimeState::empty(provider)); state.provider = provider; state.enabled = true; - state.is_refreshing = true; - if !state.selected_account_ids.contains(&account_id) { - state.selected_account_ids.push(account_id.clone()); - } + state.is_refreshing = false; + state.refresh_started_at = None; state.account_status = AccountSelectionStatus::Ready; state.error = None; @@ -331,8 +365,6 @@ where source = source_label.as_str(), "provider refresh succeeded" ); - state.is_refreshing = false; - state.refresh_started_at = None; state.error = None; account.health = ProviderHealth::Ok; account.auth_state = AuthState::Ready; @@ -369,8 +401,6 @@ where ); } let user_message = error.user_message(); - state.is_refreshing = false; - state.refresh_started_at = None; if providers::registry::auth_error_requires_reauth_prompt(provider) && error.requires_user_action() { @@ -392,6 +422,8 @@ where } ProviderRefreshResult { + batch_token: 0, + account_id: account.account_id.clone(), provider: state, accounts: vec![account], } @@ -402,7 +434,7 @@ pub fn reconcile_state( detection: &crate::detection::DetectionSnapshot, state: &mut AppState, ) { - reconcile_state_with_refresh(config, detection, state, false); + reconcile_state_with_refresh(config, detection, state, true); } pub fn reconcile_shared_state( @@ -449,8 +481,6 @@ pub fn reconcile_provider( providers::registry::reconcile_provider_accounts(provider, config, state); if let Some(entry) = state.provider_mut(provider) { entry.enabled = crate::provider_enablement::provider_enabled(config, detection, provider); - entry.is_refreshing = false; - entry.refresh_started_at = None; if !entry.enabled { entry.account_status = AccountSelectionStatus::Unavailable; entry.selected_account_ids = Vec::new(); @@ -755,7 +785,7 @@ mod tests { fn resolve_stale_refreshes_resolves_orphaned_refresh() { let mut state = state_with_refreshing_provider(None); - assert!(resolve_stale_refreshes(&mut state)); + assert_eq!(resolve_stale_refreshes(&mut state), vec![ProviderId::Codex]); let provider = state.provider(ProviderId::Codex).unwrap(); assert!(!provider.is_refreshing); @@ -773,7 +803,7 @@ mod tests { fn resolve_stale_refreshes_ignores_fresh_refresh() { let mut state = state_with_refreshing_provider(Some(Utc::now())); - assert!(!resolve_stale_refreshes(&mut state)); + assert!(resolve_stale_refreshes(&mut state).is_empty()); assert!(state.provider(ProviderId::Codex).unwrap().is_refreshing); } @@ -800,6 +830,46 @@ mod tests { assert!(result.provider.error.is_none()); } + #[tokio::test] + async fn refresh_provider_does_not_adopt_non_selected_account() { + let mut previous = ProviderRuntimeState::empty(ProviderId::Codex); + previous.selected_account_ids = vec!["codex-1".to_string()]; + + let result = refresh_provider_account( + ProviderId::Codex, + true, + Some(&previous), + None, + "codex-2".to_string(), + "Codex 2".to_string(), + async { Ok(("OAuth".to_string(), snapshot())) }, + ) + .await; + + assert_eq!( + result.provider.selected_account_ids, + vec!["codex-1".to_string()] + ); + } + + #[tokio::test] + async fn refresh_provider_does_not_adopt_account_when_selection_is_empty() { + let previous = ProviderRuntimeState::empty(ProviderId::Codex); + + let result = refresh_provider_account( + ProviderId::Codex, + true, + Some(&previous), + None, + "codex-2".to_string(), + "Codex 2".to_string(), + async { Ok(("OAuth".to_string(), snapshot())) }, + ) + .await; + + assert!(result.provider.selected_account_ids.is_empty()); + } + #[tokio::test] async fn refresh_provider_error_keeps_previous_snapshot() { let previous = ProviderRuntimeState::empty(ProviderId::Codex); @@ -1059,10 +1129,7 @@ mod tests { result.provider.account_status, AccountSelectionStatus::LoginRequired ); - assert_eq!( - result.provider.selected_account_ids.as_slice(), - ["cursor-managed:user@example.com"] - ); + assert!(result.provider.selected_account_ids.is_empty()); assert_eq!(result.provider.error.as_deref(), Some("Login required")); assert_eq!(account.auth_state, AuthState::ActionRequired); assert_eq!(account.health, ProviderHealth::Error);