diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 3cbf305ef5..96e3e4ace5 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -845,6 +845,7 @@ pub struct SettingsSnapshot { menu_bar_shows_highest_usage: bool, menu_bar_shows_percent: bool, menu_bar_color_pace: bool, + providers_sorted_alphabetically: bool, show_as_used: bool, show_all_token_accounts_in_menu: bool, enable_animations: bool, @@ -982,6 +983,7 @@ impl From for SettingsSnapshot { menu_bar_shows_highest_usage: settings.menu_bar_shows_highest_usage, menu_bar_shows_percent: settings.menu_bar_shows_percent, menu_bar_color_pace: settings.menu_bar_color_pace, + providers_sorted_alphabetically: settings.providers_sorted_alphabetically, show_as_used: settings.show_as_used, show_all_token_accounts_in_menu: settings.show_all_token_accounts_in_menu, enable_animations: settings.enable_animations, diff --git a/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs b/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs index e29e5f56df..cd668fc83b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs @@ -103,7 +103,7 @@ mod locale_tests { .entries .get("ProviderSidebarSearch") .map(String::as_str), - Some("Search"), + Some("Search providers"), "ProviderSidebarSearch should resolve instead of leaking the key" ); assert_eq!(bundle.entries.len(), locale::LocaleKey::ALL.len()); diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index b8dcf2603e..88f256381f 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -35,6 +35,7 @@ pub struct SettingsUpdate { pub menu_bar_shows_highest_usage: Option, pub menu_bar_shows_percent: Option, pub menu_bar_color_pace: Option, + pub providers_sorted_alphabetically: Option, pub show_as_used: Option, pub show_all_token_accounts_in_menu: Option, pub enable_animations: Option, @@ -298,6 +299,9 @@ impl SettingsUpdate { if let Some(v) = self.menu_bar_color_pace { settings.menu_bar_color_pace = v; } + if let Some(v) = self.providers_sorted_alphabetically { + settings.providers_sorted_alphabetically = v; + } if let Some(v) = self.show_all_token_accounts_in_menu { settings.show_all_token_accounts_in_menu = v; } @@ -855,6 +859,18 @@ mod tests { assert!(!settings.menu_bar_color_pace); } + #[test] + fn providers_sidebar_sort_applies_without_touching_order_or_tray() { + let mut settings = Settings::default(); + let order = settings.provider_order.clone(); + let update: SettingsUpdate = + serde_json::from_str(r#"{"providersSortedAlphabetically":true}"#).unwrap(); + assert!(!update.refreshes_tray_presentation()); + update.apply_display_settings(&mut settings); + assert!(settings.providers_sorted_alphabetically); + assert_eq!(settings.provider_order, order); + } + #[test] fn stacked_tray_update_accepts_mode_and_clears_automatic_provider() { let mut settings = Settings { diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 69e34b7ccf..11e391f8a5 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -12,6 +12,7 @@ //! - `settings:menuBar` — show settings on the Menu Bar tab //! - `settings:usageSpend` — show settings on the Usage & Spend tab //! - `settings:about` — show settings on the About tab +//! - `settings:providers:codex` — show settings on the Codex provider pane //! //! In proof mode the shell immediately transitions to the requested surface //! and suppresses blur-dismiss so the window stays visible for automated @@ -523,6 +524,23 @@ mod tests { }); } + #[test] + fn parse_settings_provider_pane_proof_target() { + with_proof_mode_env(Some("settings:providers:codex"), || { + let cfg = ProofConfig::from_env().unwrap(); + assert_eq!(cfg.settings_tab.as_deref(), Some("providers:codex")); + assert_eq!( + cfg.surface_target(), + SurfaceTarget::Settings { + tab: "providers:codex".into() + } + ); + }); + with_proof_mode_env(Some("settings:providers:nope"), || { + assert!(ProofConfig::from_env().is_none()); + }); + } + #[test] fn retired_popout_provider_proof_targets_are_rejected() { // The legacy PopOut layout (with provider deep links) is retired; diff --git a/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs b/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs index 9518ba3657..83a9a7b6f1 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs @@ -6,8 +6,10 @@ use tauri::{Emitter, Manager, PhysicalPosition, WebviewUrl}; use super::activation::{self, Activation}; const SETTINGS_LABEL: &str = "settings"; -const SETTINGS_WIDTH: f64 = 720.0; -const SETTINGS_HEIGHT: f64 = 580.0; +use crate::surface::{ + SETTINGS_WINDOW_HEIGHT, SETTINGS_WINDOW_MIN_HEIGHT, SETTINGS_WINDOW_MIN_WIDTH, + SETTINGS_WINDOW_WIDTH, +}; /// Open the detached Settings window, or focus it if already open. /// @@ -32,7 +34,8 @@ pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { // `activation::apply` below decides whether it takes focus. let win = tauri::WebviewWindowBuilder::new(app, SETTINGS_LABEL, url) .title("CodexBar Settings") - .inner_size(SETTINGS_WIDTH, SETTINGS_HEIGHT) + .inner_size(SETTINGS_WINDOW_WIDTH, SETTINGS_WINDOW_HEIGHT) + .min_inner_size(SETTINGS_WINDOW_MIN_WIDTH, SETTINGS_WINDOW_MIN_HEIGHT) .decorations(false) .shadow(false) .theme(Some(tauri::Theme::Dark)) @@ -50,8 +53,8 @@ pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { let pos = monitor.position(); let size = monitor.size(); let scale = win.scale_factor().unwrap_or(1.0); - let win_w = (SETTINGS_WIDTH * scale) as i32; - let win_h = (SETTINGS_HEIGHT * scale) as i32; + let win_w = (SETTINGS_WINDOW_WIDTH * scale) as i32; + let win_h = (SETTINGS_WINDOW_HEIGHT * scale) as i32; let x = pos.x + (size.width as i32 - win_w) / 2; let y = pos.y + (size.height as i32 - win_h) / 2; let _ = win.set_position(PhysicalPosition::new(x, y)); diff --git a/apps/desktop-tauri/src-tauri/src/shell/tests.rs b/apps/desktop-tauri/src-tauri/src/shell/tests.rs index c7651ce69b..8a386d7235 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/tests.rs @@ -335,7 +335,7 @@ fn larger_visible_destination_reclamps_preserved_top_left() { let reclamped = reclamp_preserved_visible_position(current_top_left, &monitor, SurfaceMode::Settings, 1.0); - assert_eq!(reclamped, (1416, 492)); + assert_eq!(reclamped, (1032, 452)); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/surface.rs b/apps/desktop-tauri/src-tauri/src/surface.rs index f7cf5318b7..5f2725fdc4 100644 --- a/apps/desktop-tauri/src-tauri/src/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/surface.rs @@ -84,10 +84,10 @@ impl SurfaceMode { visible: true, decorations: true, resizable: true, - width: 496.0, - height: 580.0, - min_width: None, - min_height: None, + width: SETTINGS_WINDOW_WIDTH, + height: SETTINGS_WINDOW_HEIGHT, + min_width: Some(SETTINGS_WINDOW_MIN_WIDTH), + min_height: Some(SETTINGS_WINDOW_MIN_HEIGHT), always_on_top: false, blur_dismiss: false, skip_taskbar: false, @@ -96,6 +96,15 @@ impl SurfaceMode { } } +/// Settings window size (logical px): sidebar plus detail column, as the +/// macOS 0.70 Settings window. Shared by the main-window Settings surface and +/// the detached Settings window; the frontend `applySettingsWindowSize` +/// mirrors these numbers. +pub const SETTINGS_WINDOW_WIDTH: f64 = 880.0; +pub const SETTINGS_WINDOW_HEIGHT: f64 = 620.0; +pub const SETTINGS_WINDOW_MIN_WIDTH: f64 = 800.0; +pub const SETTINGS_WINDOW_MIN_HEIGHT: f64 = 540.0; + /// Describes what the window should look like in a given surface mode. #[derive(Debug, Clone)] pub struct WindowProperties { @@ -328,8 +337,10 @@ mod tests { #[test] fn settings_properties() { let props = SurfaceMode::Settings.window_properties(); - assert_eq!(props.width, 496.0); - assert_eq!(props.height, 580.0); + assert_eq!(props.width, 880.0); + assert_eq!(props.height, 620.0); + assert_eq!(props.min_width, Some(800.0)); + assert_eq!(props.min_height, Some(540.0)); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/surface_target.rs b/apps/desktop-tauri/src-tauri/src/surface_target.rs index b80325bd3a..2397f91a1f 100644 --- a/apps/desktop-tauri/src-tauri/src/surface_target.rs +++ b/apps/desktop-tauri/src-tauri/src/surface_target.rs @@ -4,7 +4,7 @@ use crate::surface::SurfaceMode; // Must mirror the frontend `SettingsTabId` union // (apps/desktop-tauri/src/types/bridge.ts) and `TAB_META` in -// apps/desktop-tauri/src/surfaces/Settings.tsx. +// apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts. const SETTINGS_TAB_IDS: &[&str] = &[ "general", "providers", @@ -81,7 +81,14 @@ impl SurfaceTarget { } } +/// A settings tab id, or `providers:` for one provider's pane in +/// the Settings sidebar (frontend `parseSettingsTabRequest`). Only the exact +/// canonical `cli_name` is accepted, not CLI aliases such as `openai`. pub fn is_supported_settings_tab(tab: &str) -> bool { + if let Some(provider) = tab.strip_prefix("providers:") { + return codexbar::core::ProviderId::from_cli_name(provider) + .is_some_and(|id| id.cli_name() == provider); + } SETTINGS_TAB_IDS.contains(&tab) } @@ -168,4 +175,24 @@ mod tests { assert!(!is_supported_settings_tab("apiKeys")); assert!(!is_supported_settings_tab("security")); } + + #[test] + fn provider_pane_tabs_need_a_known_provider() { + assert!(is_supported_settings_tab("providers:codex")); + assert!(is_supported_settings_tab("providers:claude")); + assert!(!is_supported_settings_tab("providers:nope")); + assert!(!is_supported_settings_tab("providers:")); + assert!(!is_supported_settings_tab("providers:openai")); + assert!(!is_supported_settings_tab("apiKeys")); + } + + #[test] + fn every_provider_catalog_id_is_a_valid_provider_pane_tab() { + // The Settings catalog sends `cli_name` as the provider id, and the + // sidebar builds `providers:` from it. + for id in codexbar::core::ProviderId::all() { + let tab = format!("providers:{}", id.cli_name()); + assert!(is_supported_settings_tab(&tab), "{tab} rejected"); + } + } } diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 05c3355627..656cb68fc8 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -91,6 +91,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { menuBarShowsHighestUsage: false, menuBarShowsPercent: false, menuBarColorPace: false, + providersSortedAlphabetically: false, showAsUsed: true, showAllTokenAccountsInMenu: false, enableAnimations: true, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index ea6e6b4d52..1e3c6d5b74 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -158,6 +158,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { menuBarShowsHighestUsage: false, menuBarShowsPercent: false, menuBarColorPace: false, + providersSortedAlphabetically: false, showAsUsed: true, showAllTokenAccountsInMenu: false, enableAnimations: true, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index b14466b465..91291b5bd3 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -870,6 +870,13 @@ export const ALL_LOCALE_KEYS = [ "ProviderSidebarReorderHint", "ProviderSidebarMoveUp", "ProviderSidebarMoveDown", + "SettingsSidebarSortProviders", + "SettingsSidebarSortOffHint", + "SettingsSidebarSortOnHint", + "SettingsSidebarProvidersOnCount", + "ProviderContextEnable", + "ProviderContextDisable", + "SettingsSidebarAriaLabel", "ProviderStatusOk", "ProviderStatusStale", "ProviderStatusError", diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 7f271e583f..d6a478c5fb 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -382,53 +382,6 @@ html { } /* tab bar */ -.settings-tabs { - display: flex; - gap: 1px; - padding: 2px; - border-radius: 7px; - background: rgba(255, 255, 255, 0.06); - margin-bottom: 16px; - overflow-x: auto; - scrollbar-width: none; -} - -.settings-tabs::-webkit-scrollbar { - display: none; -} - -.settings-tab { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - padding: 5px 12px; - border: none; - border-radius: 5px; - background: transparent; - color: var(--text-secondary); - font-size: 0.78rem; - font-weight: 500; - white-space: nowrap; - cursor: pointer; - transition: background 0.15s, color 0.15s; -} - -.settings-tab:hover { - color: var(--text-primary); -} - -.settings-tab--active { - background: rgba(255, 255, 255, 0.1); - color: var(--text-primary); - font-weight: 600; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); -} - -.settings-tab__icon { - font-size: 1rem; -} /* status strip */ .settings-status { @@ -1945,152 +1898,7 @@ body:has(.tray-panel-reveal), text-transform: none; } -/* ── Providers sidebar (Phase 6a) ─────────────────────────────────── */ - -.providers-sidebar-shell { - width: 244px; - flex-shrink: 0; - background: rgba(255, 255, 255, 0.03); - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.04); - height: 100%; - min-height: 0; - box-sizing: border-box; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.providers-sidebar-search { - position: relative; - padding: 8px 10px 6px; -} - -.providers-sidebar-search__input { - width: 100%; - height: 30px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - background: rgba(0, 0, 0, 0.20); - color: var(--text); - font-size: var(--font-body); - outline: none; - padding: 0 30px 0 10px; -} - -.providers-sidebar-search__input:focus-visible { - border-color: var(--provider-row-border-selected); - box-shadow: 0 0 0 2px rgba(10, 132, 255, 0.22); -} - -.providers-sidebar-search__clear { - position: absolute; - right: 16px; - top: 13px; - width: 20px; - height: 20px; - border: 0; - border-radius: 50%; - background: transparent; - color: var(--muted); - cursor: pointer; - font-size: 1rem; - line-height: 1; -} - -.providers-sidebar-search__clear:hover { - background: rgba(255, 255, 255, 0.08); - color: var(--text); -} - -.providers-sidebar { - flex: 1; - min-height: 0; - box-sizing: border-box; - overflow-y: auto; - overscroll-behavior-y: contain; - scrollbar-width: auto; - scrollbar-color: rgba(255, 255, 255, 0.45) rgba(255, 255, 255, 0.08); - list-style: none; - margin: 0; - padding: 4px 10px 20px 4px; - scroll-padding-bottom: 20px; -} - -.providers-sidebar__empty { - padding: 16px 12px; - color: var(--muted); - font-size: var(--font-body); - text-align: center; -} - -.providers-sidebar__row { - position: relative; - display: grid; - grid-template-columns: 8px 24px 1fr auto auto auto; - align-items: center; - gap: 8px; - padding: 8px 16px 8px 10px; - border-radius: 8px; - cursor: pointer; - user-select: none; - border: 1px solid transparent; - transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; - outline: none; -} - -.providers-sidebar__row:hover { - background: var(--provider-row-bg-hover); -} - -.providers-sidebar__row:focus-visible { - border-color: var(--provider-row-border-selected); - box-shadow: 0 0 0 2px rgba(10, 132, 255, 0.25); -} - -.providers-sidebar__row--selected { - background: var(--provider-row-bg-selected); - border-color: var(--provider-row-border-selected); -} - -.providers-sidebar__row--selected::before { - content: ""; - position: absolute; - left: 2px; - top: 10px; - bottom: 10px; - width: 2px; - border-radius: 2px; - background: var(--provider-row-accent-selected); -} - -.providers-sidebar__row--disabled .providers-sidebar__name, -.providers-sidebar__row--disabled .providers-sidebar__subtitle { - color: var(--provider-row-text-disabled); -} - -.providers-sidebar__row--dragging { - opacity: 0.55; -} - -.providers-sidebar__row--drop { - box-shadow: inset 0 2px 0 0 var(--provider-row-drop-outline); -} - -.providers-sidebar__row--reveal { - animation: provider-sidebar-reveal 220ms ease-out both; -} - -@keyframes provider-sidebar-reveal { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} +/* ── Providers sidebar status dot ──────────────────────────────────── */ .providers-sidebar__status { width: 6px; @@ -2126,109 +1934,6 @@ body:has(.tray-panel-reveal), 50% { opacity: 1; } } -.providers-sidebar__text { - display: flex; - flex-direction: column; - min-width: 0; - gap: 1px; -} - -/* Long names and translated status lines ("Отключено — авто") wrap to a - second line instead of ellipsizing in the narrow sidebar; the row's - title attribute carries the full text if even two lines are not enough. */ -.providers-sidebar__name { - font-size: 0.88rem; - font-weight: 600; - color: var(--provider-row-text-primary); - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; - overflow: hidden; - overflow-wrap: anywhere; -} - -.providers-sidebar__subtitle-primary { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; - overflow: hidden; - overflow-wrap: anywhere; -} - -.providers-sidebar__subtitle { - display: flex; - flex-direction: column; - gap: 0; - font-size: 0.72rem; - color: var(--provider-row-text-secondary); - line-height: 1.25; -} - -.providers-sidebar__subtitle-secondary { - color: var(--provider-row-text-secondary); - opacity: 0.8; - font-variant-numeric: tabular-nums; -} - -.providers-sidebar__handle { - opacity: 0; - font-size: 0.7rem; - color: var(--provider-row-text-secondary); - cursor: grab; - letter-spacing: -2px; - transition: opacity 120ms ease; -} - -.providers-sidebar__row:hover .providers-sidebar__handle, -.providers-sidebar__row--selected .providers-sidebar__handle, -.providers-sidebar__row--dragging .providers-sidebar__handle { - opacity: 0.7; -} - -.providers-sidebar__reorder-controls { - display: inline-flex; - flex-direction: column; - gap: 2px; -} - -.providers-sidebar__reorder-button { - width: 18px; - height: 16px; - display: inline-grid; - place-items: center; - padding: 0; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: var(--provider-row-text-secondary); - cursor: pointer; - font-size: 0.62rem; - line-height: 1; - opacity: 0.72; - transition: background 120ms ease, border-color 120ms ease, color 120ms ease, opacity 120ms ease; -} - -.providers-sidebar__reorder-button:hover:not(:disabled), -.providers-sidebar__reorder-button:focus-visible { - background: var(--surface-card-hover); - border-color: var(--provider-row-border-selected); - color: var(--provider-row-text-primary); - opacity: 1; - outline: none; -} - -.providers-sidebar__reorder-button:disabled { - cursor: default; - opacity: 0.2; -} - -.providers-sidebar__checkbox { - margin: 0; - cursor: pointer; -} - /* ── Provider detail pane (Phase 6b) ──────────────────────────────── */ .provider-detail-header-block { @@ -4401,85 +4106,6 @@ html:has(.menu-surface--tray) { background: #e81123; color: #fff; } -.settings-tabs { - display: flex; - gap: 2px; - padding: 6px 12px; - margin: 0 0 4px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 0; - /* `safe`: when long translations overflow, start-align instead of pushing the - first tabs past the left edge where scrolling can't reach them. */ - justify-content: safe center; - overflow-x: auto; - /* Only shows when the tabs still overflow (very narrow window). */ - scrollbar-width: thin; -} -[data-theme="light"] .settings-tabs { - background: linear-gradient(180deg, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0)); -} -.settings-tab { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 2px; - /* Shrink before overflowing: multi-word labels wrap to two lines. */ - flex: 0 1 auto; - min-width: 56px; - padding: 6px 12px; - border-radius: 6px; - white-space: normal; - background: transparent; - color: var(--text-secondary); - font-size: 0.65rem; - font-weight: 500; - line-height: 1; - letter-spacing: 0; - text-transform: none; - transition: background 0.12s ease, color 0.12s ease; - border: none; - cursor: pointer; -} -.settings-tab__icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - font-size: 16px; - line-height: 1; - color: inherit; -} -.settings-tab__icon > svg { - width: 16px; - height: 16px; - display: block; -} - -.settings-tab__label { - display: block; - line-height: 1.1; - text-align: center; -} -.settings-tab:hover { - background: rgba(255, 255, 255, 0.05); - color: var(--text-primary); -} -[data-theme="light"] .settings-tab:hover { - background: rgba(0, 0, 0, 0.05); -} -.settings-tab--active { - background: rgba(10, 132, 255, 0.12); - color: var(--accent); - font-weight: 500; - box-shadow: none; -} -[data-theme="light"] .settings-tab--active { - background: rgba(10, 132, 255, 0.12); - color: var(--accent); -} /* ── Settings body container: padded, centered, capped at 560px ─── */ .settings-body { @@ -4491,10 +4117,6 @@ html:has(.menu-surface--tray) { overflow-y: auto; min-height: 0; } -.settings-body--providers { - max-width: 100%; - overflow: clip; -} .settings-status { max-width: 560px; margin: 6px auto 0; @@ -4696,40 +4318,6 @@ html:has(.menu-surface--tray) { border-color: var(--accent); } -/* ── Provider sidebar (Preferences › Providers) ───────────────────── */ -/* provider-split consolidated above (line ~1765) */ -/* providers-sidebar overrides consolidated above (line ~1981) */ -.providers-sidebar::-webkit-scrollbar { - width: 10px; -} -.providers-sidebar::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.08); - border-radius: 5px; - margin: 4px 0; -} -.providers-sidebar::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.45); - border-radius: 5px; -} -.providers-sidebar::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.65); -} -[data-theme="light"] .providers-sidebar-shell { - background: rgba(0, 0, 0, 0.025); - border: 1px solid rgba(0, 0, 0, 0.10); -} -.providers-sidebar__row { - grid-template-columns: 12px var(--icon-sidebar) 1fr auto auto auto; - padding: 6px 8px; -} -.providers-sidebar__name { - font-size: var(--font-body); - font-weight: 500; -} -.providers-sidebar__subtitle { - font-size: var(--font-caption); -} - /* ── Provider detail pane ─────────────────────────────────────────── */ .provider-detail { background: var(--surface-elevated); @@ -5071,35 +4659,6 @@ html:has(.menu-surface--tray) { background: rgba(var(--surface-tint-rgb), 0.16); } -.settings-tabs { - gap: 4px; - padding: 5px 12px; - background: rgba(255, 255, 255, 0.035); -} - -.settings-tab { - border: 1px solid transparent; - min-height: 42px; - padding: 5px 12px 6px; -} - -.settings-tab:hover:not(.settings-tab--active) { - background: rgba(255, 255, 255, 0.055); -} - -.settings-tab--active, -.settings-tab[aria-selected="true"] { - border-color: rgba(10, 132, 255, 0.48); - background: rgba(10, 132, 255, 0.18); - color: var(--text-primary); - box-shadow: inset 0 0 0 1px rgba(10, 132, 255, 0.12); -} - -.settings-tab--active .settings-tab__icon, -.settings-tab[aria-selected="true"] .settings-tab__icon { - color: var(--accent); -} - .provider-detail-error { display: flex; flex-direction: column; diff --git a/apps/desktop-tauri/src/surfaces/Settings.test.tsx b/apps/desktop-tauri/src/surfaces/Settings.test.tsx index 399d5c3f95..b0b84ba3d0 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.test.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.test.tsx @@ -1,6 +1,14 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { BootstrapState } from "../types/bridge"; +import type { BootstrapState, SurfaceTarget } from "../types/bridge"; + +const mocks = vi.hoisted(() => ({ + update: vi.fn(), + reorderProviders: vi.fn(), + setSurfaceMode: vi.fn(), + windowLabel: "settings", + shellTarget: null as SurfaceTarget | null, +})); vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ @@ -12,22 +20,24 @@ vi.mock("@tauri-apps/api/window", () => ({ LogicalSize: class {}, })); vi.mock("@tauri-apps/api/webviewWindow", () => ({ - getCurrentWebviewWindow: () => ({ label: "settings" }), + getCurrentWebviewWindow: () => ({ label: mocks.windowLabel }), })); vi.mock("../lib/tauri", () => ({ closeSettingsWindow: vi.fn(), getWorkAreaRect: () => Promise.resolve(null), - setSurfaceMode: vi.fn(), + setSurfaceMode: mocks.setSurfaceMode, + reorderProviders: mocks.reorderProviders, })); vi.mock("../hooks/useSettings", () => ({ useSettings: (settings: unknown) => ({ settings, saving: false, error: null, - update: vi.fn(), + update: mocks.update, }), })); -vi.mock("../hooks/useSurfaceMode", () => ({ useSurfaceTarget: () => null })); +vi.mock("../hooks/useProviders", () => ({ useProviders: () => ({ providers: [] }) })); +vi.mock("../hooks/useSurfaceMode", () => ({ useSurfaceTarget: () => mocks.shellTarget })); vi.mock("../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => key }) })); vi.mock("./settings/tabs/GeneralTab", () => ({ default: ({ mode }: { mode: string }) =>
general:{mode}
, @@ -45,52 +55,229 @@ vi.mock("./settings/tabs/UsageSpendTab", () => ({ default: () =>
usageSpend
, })); vi.mock("./settings/tabs/ProvidersTab", () => ({ - default: ({ - focusProviderId, - focusRequest, - }: { - focusProviderId: string | null; - focusRequest: number; - }) => ( -
- providers:{String(focusProviderId)}:{focusRequest} -
+ default: ({ providerId }: { providerId: string | null }) => ( +
providers:{String(providerId)}
), })); import Settings from "./Settings"; -const state = { settings: {}, providers: [] } as unknown as BootstrapState; +const providers = [ + { id: "codex", displayName: "Codex", cookieDomain: null }, + { id: "zai", displayName: "z.ai", cookieDomain: null }, + { id: "claude", displayName: "Claude", cookieDomain: null }, + { id: "cline", displayName: "Cline", cookieDomain: null }, + { id: "grok", displayName: "Grok", cookieDomain: null }, +]; + +function makeState(settings: Record = {}): BootstrapState { + return { + settings: { + enabledProviders: ["codex", "grok", "zai"], + providersSortedAlphabetically: false, + ...settings, + }, + providers, + } as unknown as BootstrapState; +} + +const state = makeState(); +const title = () => screen.getByTestId("settings-title").textContent; +const providerNames = () => + within(screen.getByRole("listbox", { name: "ProvidersAriaLabel" })) + .queryAllByRole("option") + .map((row) => row.getAttribute("data-provider-id")); +const row = (id: string) => + screen.getByRole("listbox").querySelector(`[data-provider-id="${id}"]`)!; + +describe("Settings sidebar", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.windowLabel = "settings"; + mocks.shellTarget = null; + mocks.reorderProviders.mockResolvedValue(undefined); + }); + + it("lists the app panes in the macOS order, then the providers with an on count", () => { + const { container } = render(); + const panes = Array.from( + container.querySelectorAll(".settings-sidebar__pane"), + (pane) => pane.dataset.paneId, + ); + expect(panes).toEqual([ + "general", + "usageSpend", + "notifications", + "menuBar", + "menu", + "advanced", + "about", + ]); + expect(container.querySelectorAll(".settings-sidebar__chip")).toHaveLength(6); + expect(container.querySelector(".settings-sidebar__app-icon")).not.toBeNull(); + expect(providerNames()).toEqual(["codex", "zai", "claude", "cline", "grok"]); + expect(screen.getByText("SettingsSidebarProvidersOnCount")).toBeInTheDocument(); + }); + + it("titles the window after the pane, or the provider on a provider pane", () => { + render(); + expect(title()).toBe("TabGeneral"); + fireEvent.click(screen.getByRole("button", { name: "TabMenuBar" })); + expect(title()).toBe("TabMenuBar"); + expect(screen.getByTestId("tab").textContent).toBe("display:menuBar"); + fireEvent.click(row("claude")); + expect(title()).toBe("Claude"); + expect(screen.getByTestId("tab").textContent).toBe("providers:claude"); + expect(row("claude")).toHaveAttribute("aria-selected", "true"); + }); + + it("filters providers by name or id without changing the selection", () => { + render(); + const search = screen.getByRole("searchbox", { name: "ProviderSidebarSearch" }); + fireEvent.change(search, { target: { value: "cl" } }); + expect(providerNames()).toEqual(["claude", "cline"]); + expect(screen.getByTestId("tab").textContent).toBe("providers:codex"); + + fireEvent.change(search, { target: { value: "ZA" } }); + expect(providerNames()).toEqual(["zai"]); + + fireEvent.change(search, { target: { value: "nothing" } }); + expect(providerNames()).toEqual([]); + expect(screen.getByText("ProviderSidebarNoMatches")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "ProviderSidebarClearSearch" })); + expect(providerNames()).toHaveLength(5); + }); + + it("sorts enabled providers first, alphabetically, without saving an order", () => { + render(); + expect(providerNames()).toEqual(["codex", "grok", "zai", "claude", "cline"]); + const sort = screen.getByRole("button", { name: "SettingsSidebarSortProviders" }); + expect(sort).toHaveAttribute("aria-pressed", "true"); + expect(sort).toHaveAttribute("title", "SettingsSidebarSortOnHint"); + + fireEvent.keyDown(row("codex"), { key: "ArrowDown", altKey: true }); + expect(mocks.reorderProviders).not.toHaveBeenCalled(); + + fireEvent.click(sort); + expect(mocks.update).toHaveBeenCalledWith({ providersSortedAlphabetically: false }); + }); + + it("turns sorting on from the toggle", () => { + render(); + const sort = screen.getByRole("button", { name: "SettingsSidebarSortProviders" }); + expect(sort).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(sort); + expect(mocks.update).toHaveBeenCalledWith({ providersSortedAlphabetically: true }); + }); + + it("enables and disables providers from the context menu in saved order", () => { + render(); + fireEvent.contextMenu(row("claude")); + fireEvent.click(screen.getByRole("menuitem", { name: "ProviderContextEnable" })); + expect(mocks.update).toHaveBeenLastCalledWith({ + enabledProviders: ["codex", "zai", "claude", "grok"], + }); + + fireEvent.contextMenu(row("zai")); + fireEvent.click(screen.getByRole("menuitem", { name: "ProviderContextDisable" })); + expect(mocks.update).toHaveBeenLastCalledWith({ enabledProviders: ["codex", "grok"] }); + }); + + it("reorders with Alt+Arrow and saves the new order", () => { + render(); + fireEvent.keyDown(row("codex"), { key: "ArrowDown", altKey: true }); + expect(mocks.reorderProviders).toHaveBeenCalledWith(["zai", "codex", "claude", "cline", "grok"]); + expect(providerNames()).toEqual(["zai", "codex", "claude", "cline", "grok"]); + }); + + it("does not reorder while a search filters the list", () => { + render(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "c" } }); + fireEvent.keyDown(row("codex"), { key: "ArrowDown", altKey: true }); + expect(mocks.reorderProviders).not.toHaveBeenCalled(); + }); + + it("drives the main window surface only outside the detached window", () => { + mocks.windowLabel = "main"; + render(); + fireEvent.click(row("grok")); + expect(mocks.setSurfaceMode).toHaveBeenCalledWith("settings", { + kind: "settings", + tab: "providers:grok", + }); + }); +}); describe("Settings deep links", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.windowLabel = "settings"; + mocks.shellTarget = null; }); - it("opens cold on Providers with the requested provider focused", () => { + it("opens cold on the requested provider row", () => { render(); - expect(screen.getByTestId("tab").textContent).toBe("providers:claude:1"); + expect(screen.getByTestId("tab").textContent).toBe("providers:claude"); + expect(row("claude")).toHaveAttribute("aria-selected", "true"); + expect(title()).toBe("Claude"); + }); + + it("selects a provider from the proof-mode shell target", () => { + mocks.shellTarget = { kind: "settings", tab: "providers:codex" }; + render(); + expect(screen.getByTestId("tab").textContent).toBe("providers:codex"); + expect(row("codex")).toHaveAttribute("aria-selected", "true"); + expect(title()).toBe("Codex"); }); - it("switches from another tab to Providers and refocuses on a repeated request", () => { + it("clears a search that would hide the deep-linked provider", () => { const { rerender } = render(); - expect(screen.getByTestId("tab").textContent).toBe("general:general"); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "zai" } }); + rerender(); + expect(screen.getByRole("searchbox")).toHaveValue(""); + expect(row("cline")).toHaveAttribute("aria-selected", "true"); + }); - rerender(); - expect(screen.getByTestId("tab").textContent).toBe("providers:codex:1"); + it("clears a search that would hide a provider named by the shell target", () => { + mocks.windowLabel = "main"; + mocks.shellTarget = { kind: "settings", tab: "general" }; + const { rerender } = render(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "zai" } }); + mocks.shellTarget = { kind: "settings", tab: "providers:cline" }; + rerender(); + expect(screen.getByRole("searchbox")).toHaveValue(""); + expect(row("cline")).toHaveAttribute("aria-selected", "true"); + expect(title()).toBe("Cline"); + }); - rerender(); - expect(screen.getByTestId("tab").textContent).toBe("providers:codex:2"); + it("keeps the search when the shell target echoes a visible row click", () => { + mocks.windowLabel = "main"; + mocks.shellTarget = { kind: "settings", tab: "general" }; + const { rerender } = render(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "c" } }); + fireEvent.click(row("claude")); + mocks.shellTarget = { kind: "settings", tab: "providers:claude" }; + rerender(); + expect(screen.getByRole("searchbox")).toHaveValue("c"); + expect(providerNames()).toEqual(["codex", "claude", "cline"]); + expect(row("claude")).toHaveAttribute("aria-selected", "true"); }); - it("keeps the focused provider when a plain tab request arrives", () => { + it("keeps the last provider when a plain providers request arrives", () => { const { rerender } = render( , ); rerender(); expect(screen.getByTestId("tab").textContent).toBe("about"); + expect(title()).toBe("TabAbout"); rerender(); - expect(screen.getByTestId("tab").textContent).toBe("providers:grok:1"); + expect(screen.getByTestId("tab").textContent).toBe("providers:grok"); + }); + + it("falls back to the first provider for a plain or unknown provider request", () => { + render(); + expect(screen.getByTestId("tab").textContent).toBe("providers:codex"); }); }); diff --git a/apps/desktop-tauri/src/surfaces/Settings.tsx b/apps/desktop-tauri/src/surfaces/Settings.tsx index fd02202f54..a7e07474d9 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.tsx @@ -1,16 +1,25 @@ -import { useCallback, useEffect, useState, type ReactElement, type ReactNode } from "react"; +import { useCallback, useEffect, useState } from "react"; import { getCurrentWindow, LogicalPosition, LogicalSize } from "@tauri-apps/api/window"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import type { BootstrapState, + SettingsSurfaceTarget, SettingsTabId, SettingsUpdate, } from "../types/bridge"; import { useSettings } from "../hooks/useSettings"; import { useSurfaceTarget } from "../hooks/useSurfaceMode"; import { useLocale } from "../hooks/useLocale"; +import { useProviders } from "../hooks/useProviders"; import { closeSettingsWindow, getWorkAreaRect, setSurfaceMode } from "../lib/tauri"; -import { TAB_META, isSettingsTab, parseSettingsTabRequest } from "./settings/settingsTabs"; +import { + TAB_META, + filterProvidersByQuery, + parseSettingsTabRequest, + providersTabRequest, + type SettingsTabRequest, +} from "./settings/settingsTabs"; +import { SettingsSidebar } from "./settings/SettingsSidebar"; import GeneralTab from "./settings/tabs/GeneralTab"; import DisplayTab from "./settings/tabs/DisplayTab"; import AdvancedTab from "./settings/tabs/AdvancedTab"; @@ -18,91 +27,10 @@ import AboutTab from "./settings/tabs/AboutTab"; import ProvidersTab from "./settings/tabs/ProvidersTab"; import UsageSpendTab from "./settings/tabs/UsageSpendTab"; -// Inline monochrome SVG icons stand in for the upstream macOS SF Symbols -// (gearshape / square.grid.2x2 / eye / slider.horizontal.3 / info.circle). -// They render in `currentColor` so they pick up the same secondary/accent -// text color as the tab label. -const ICON_SIZE = 16; - -function Svg({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - -const TabIcons: Record = { - general: ( - - - - - ), - providers: ( - - - - - - - ), - notifications: ( - - - - - ), - menuBar: ( - - - - - ), - menu: ( - - - - - - - ), - usageSpend: ( - - - - - ), - advanced: ( - - - - - - - ), - about: ( - - - - - - ), -}; - - -const SETTINGS_WINDOW_HEIGHT = 580; -const SETTINGS_WINDOW_WIDTH = 600; +// Mirrors `SETTINGS_WINDOW_WIDTH` / `SETTINGS_WINDOW_HEIGHT` in src-tauri `surface.rs` +// (macOS `PreferencesView` 880x620). +const SETTINGS_WINDOW_HEIGHT = 620; +const SETTINGS_WINDOW_WIDTH = 880; async function applySettingsWindowSize() { const workArea = await getWorkAreaRect().catch(() => null); @@ -148,41 +76,47 @@ export default function Settings({ tabRequestId?: number; }) { const { settings, saving, error, update } = useSettings(state.settings); + const { providers: snapshots } = useProviders(); const { t } = useLocale(); const shellTarget = useSurfaceTarget("settings"); const propRequest = parseSettingsTabRequest(propTab); - const initialTab: SettingsTabId = - propRequest - ? propRequest.tab - : shellTarget?.kind === "settings" && isSettingsTab(shellTarget.tab) - ? shellTarget.tab - : "general"; - const [activeTab, setActiveTab] = useState(initialTab); - const [providerFocus, setProviderFocus] = useState<{ id: string | null; request: number }>({ - id: propRequest?.providerId ?? null, - request: tabRequestId, - }); - const shellTab: SettingsTabId | null = - shellTarget?.kind === "settings" && isSettingsTab(shellTarget.tab) - ? shellTarget.tab - : null; + // Proof mode and the main-window surface carry the same raw tab string. + const rawShellTab = shellTarget?.kind === "settings" ? shellTarget.tab : null; + const shellRequest = parseSettingsTabRequest(rawShellTab); + const firstRequest: SettingsTabRequest | null = propRequest ?? shellRequest; + const [activeTab, setActiveTab] = useState(firstRequest?.tab ?? "general"); + // Last provider picked; a bare `providers` request keeps it. + const [selectedProviderId, setSelectedProviderId] = useState( + firstRequest?.providerId ?? null, + ); + const [searchText, setSearchText] = useState(""); const [prevPropRequest, setPrevPropRequest] = useState({ tab: propTab, id: tabRequestId }); - const [prevShellTab, setPrevShellTab] = useState(shellTab); + const [prevShellTab, setPrevShellTab] = useState(rawShellTab); - // Adjust local tab during render when external drivers change (no effect sync). + // Adjust local state during render when external drivers change (no effect sync). if (propTab !== prevPropRequest.tab || tabRequestId !== prevPropRequest.id) { setPrevPropRequest({ tab: propTab, id: tabRequestId }); if (propRequest) { setActiveTab(propRequest.tab); if (propRequest.providerId) { - setProviderFocus((focus) => ({ id: propRequest.providerId, request: focus.request + 1 })); + setSelectedProviderId(propRequest.providerId); + // A deep link must not land on a provider the search hides. + setSearchText(""); } } } - if (shellTab !== prevShellTab) { - setPrevShellTab(shellTab); - if (shellTab) { - setActiveTab(shellTab); + if (rawShellTab !== prevShellTab) { + setPrevShellTab(rawShellTab); + if (shellRequest) { + setActiveTab(shellRequest.tab); + const id = shellRequest.providerId; + if (id) { + setSelectedProviderId(id); + // Clear a search that hides the target. The main window's echo of the + // user's own row click names a visible row, so that search is kept. + const target = state.providers.filter((p) => p.id === id); + if (filterProvidersByQuery(target, searchText).length === 0) setSearchText(""); + } } } @@ -190,22 +124,49 @@ export default function Settings({ void applySettingsWindowSize(); }, []); + const catalog = state.providers; + const providerId = + selectedProviderId && catalog.some((p) => p.id === selectedProviderId) + ? selectedProviderId + : (catalog[0]?.id ?? null); + const providerName = catalog.find((p) => p.id === providerId)?.displayName ?? null; + const paneLabelKey = TAB_META.find((tab) => tab.id === activeTab)?.labelKey ?? "TabGeneral"; + const title = activeTab === "providers" && providerName ? providerName : t(paneLabelKey); + const set = (patch: SettingsUpdate) => void update(patch); - const handleTabClick = useCallback((tab: SettingsTabId) => { - setActiveTab(tab); - // Only transition the main window if we're NOT in the detached settings window + const showInMainWindow = useCallback((tab: SettingsSurfaceTarget["tab"]) => { + // Only drive the main window's surface when this is not the detached window. if (getCurrentWebviewWindow().label !== "settings") { void setSurfaceMode("settings", { kind: "settings", tab }); } }, []); + const handleSelectPane = useCallback( + (tab: SettingsTabId) => { + setActiveTab(tab); + showInMainWindow(tab); + }, + [showInMainWindow], + ); + const handleSelectProvider = useCallback( + (id: string) => { + setActiveTab("providers"); + setSelectedProviderId(id); + showInMainWindow(providersTabRequest(id)); + }, + [showInMainWindow], + ); return ( -
+
{/* custom title bar (decorations disabled for guaranteed dark theme) */}
- {t("SettingsWindowTitle")} + + {title} +
- {/* tab bar */} - +
+ - {/* status bar – the transient "Saving…" note overlays the tab body so it - never shifts the layout; an error stays in flow so it hides nothing */} -
- {(saving || error) && ( +
- {saving ? t("SettingsStatusSaving") : error} + {activeTab === "general" && ( + + )} + {activeTab === "providers" && ( + + )} + {activeTab === "notifications" && ( + + )} + {activeTab === "menuBar" && ( + + )} + {activeTab === "menu" && ( + + )} + {activeTab === "usageSpend" && ( + + )} + {activeTab === "advanced" && ( + + )} + {activeTab === "about" && ( + + )}
- )} -
- {/* tab panels */} -
- {activeTab === "general" && ( - - )} - {activeTab === "providers" && ( - - )} - {activeTab === "notifications" && ( - - )} - {activeTab === "menuBar" && ( - - )} - {activeTab === "menu" && ( - - )} - {activeTab === "usageSpend" && ( - - )} - {activeTab === "advanced" && ( - - )} - {activeTab === "about" && ( - - )} + {/* Save status at the foot of the detail column. "Saving" floats over + the pane so it never shifts the layout; an error stays in flow. */} +
+ {(saving || error) && ( +
+ {saving ? t("SettingsStatusSaving") : error} +
+ )} +
+
); } - diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index e1fb3ad751..11a77c9a91 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -156,6 +156,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { menuBarShowsHighestUsage: false, menuBarShowsPercent: false, menuBarColorPace: false, + providersSortedAlphabetically: false, showAsUsed: true, showAllTokenAccountsInMenu: false, enableAnimations: true, diff --git a/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx b/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx new file mode 100644 index 0000000000..bf032502dd --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx @@ -0,0 +1,287 @@ +import { useMemo, useState, type ReactElement, type ReactNode } from "react"; +import type { + BootstrapState, + ProviderCatalogEntry, + ProviderUsageSnapshot, + SettingsTabId, + SettingsUpdate, +} from "../../types/bridge"; +import { useLocale } from "../../hooks/useLocale"; +import { reorderProviders } from "../../lib/tauri"; +import codexbarIcon from "../../assets/codexbar-icon.png"; +import { + SIDEBAR_PANES, + canReorderProviders, + filterProvidersByQuery, + sortProvidersAlphabetically, + type SidebarPane, +} from "./settingsTabs"; +import { + ProvidersSidebar, + type ProviderSidebarRow, + type ProviderSidebarStatus, +} from "./providers/ProvidersSidebar"; + +// White glyphs on the pane chips, standing in for the macOS SF Symbols. +function Glyph({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +const PANE_GLYPHS: Record, ReactElement> = { + general: ( + + + + + ), + usageSpend: ( + + + + + ), + notifications: ( + + + + + ), + menuBar: ( + + + + + ), + menu: ( + + + + + + + ), + advanced: ( + + + + + + + ), +}; + +function PaneChip({ pane }: { pane: SidebarPane }) { + if (pane.chip === null || pane.id === "about") { + return ; + } + return ( + + {PANE_GLYPHS[pane.id]} + + ); +} + +/** Fetch state of one provider for its sidebar dot. */ +function deriveProviderStatus( + isEnabled: boolean, + snap: ProviderUsageSnapshot | null, +): ProviderSidebarStatus { + if (!isEnabled) return "disabled"; + if (!snap) return "loading"; + if (snap.error) return "error"; + const updatedMs = new Date(snap.updatedAt).getTime(); + if (Number.isFinite(updatedMs) && (Date.now() - updatedMs) / 60_000 > 10) return "stale"; + return "ok"; +} + +interface SettingsSidebarProps { + settings: BootstrapState["settings"]; + /** Provider catalog in the saved provider order. */ + providers: ProviderCatalogEntry[]; + snapshots: ProviderUsageSnapshot[]; + set: (patch: SettingsUpdate) => void; + saving: boolean; + activeTab: SettingsTabId; + /** Provider shown while `activeTab` is `providers`. */ + selectedProviderId: string | null; + searchText: string; + onSearchTextChange: (text: string) => void; + onSelectPane: (tab: SettingsTabId) => void; + onSelectProvider: (providerId: string) => void; +} + +/** + * The Settings sidebar (macOS `PreferencesSidebar`): search and the sort + * toggle, the app panes with their colored chips, then the providers with an + * "N on" count. Sorting only changes what the list shows; the saved provider + * order changes through drag, Alt+Arrow or the row context menu. + */ +export function SettingsSidebar({ + settings, + providers, + snapshots, + set, + saving, + activeTab, + selectedProviderId, + searchText, + onSearchTextChange, + onSelectPane, + onSelectProvider, +}: SettingsSidebarProps) { + const { t } = useLocale(); + // Local order so a reorder shows at once, before `reorder_providers` settles. + const [ordered, setOrdered] = useState(providers); + const [prevProviders, setPrevProviders] = useState(providers); + if (providers !== prevProviders) { + setPrevProviders(providers); + setOrdered(providers); + } + + const sorted = settings.providersSortedAlphabetically; + const enabled = useMemo(() => new Set(settings.enabledProviders), [settings.enabledProviders]); + const enabledCount = ordered.filter((p) => enabled.has(p.id)).length; + + const rows: ProviderSidebarRow[] = useMemo(() => { + const snapshotMap = new Map(snapshots.map((s) => [s.providerId, s])); + const shown = filterProvidersByQuery( + sorted ? sortProvidersAlphabetically(ordered, enabled) : ordered, + searchText, + ); + return shown.map((p) => { + const isOn = enabled.has(p.id); + return { + id: p.id, + displayName: p.displayName, + enabled: isOn, + status: deriveProviderStatus(isOn, snapshotMap.get(p.id) ?? null), + }; + }); + }, [enabled, ordered, searchText, snapshots, sorted]); + + const toggleEnabled = (id: string, on: boolean) => { + const next = new Set(enabled); + if (on) next.add(id); + else next.delete(id); + set({ enabledProviders: ordered.filter((p) => next.has(p.id)).map((p) => p.id) }); + }; + + const handleReorder = (ids: string[]) => { + const byId = new Map(ordered.map((p) => [p.id, p])); + setOrdered(ids.map((id) => byId.get(id)).filter((p): p is ProviderCatalogEntry => Boolean(p))); + void reorderProviders(ids).catch(() => setOrdered(providers)); + }; + + const sortHint = t(sorted ? "SettingsSidebarSortOnHint" : "SettingsSidebarSortOffHint"); + + return ( + + ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.test.tsx index ef10af1adb..13c43f538d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.test.tsx @@ -1,203 +1,148 @@ -import { useState } from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { LocaleProvider } from "../../../i18n/LocaleProvider"; -import { buildBundle } from "../../../test/localeHarness"; -import { TEST_PROVIDER_CATALOG } from "../../../test/providerCatalog"; -import { loadStyles, ruleBlock } from "../../../test/styles"; -import { - ProvidersSidebar, - type ProviderSidebarRow, -} from "./ProvidersSidebar"; - -const tauriMocks = vi.hoisted(() => ({ - getLocaleStrings: vi.fn(), - setUiLanguage: vi.fn(), -})); - -const eventMocks = vi.hoisted(() => ({ - listen: vi.fn(), -})); +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ProvidersSidebar, type ProviderSidebarRow } from "./ProvidersSidebar"; -vi.mock("../../../lib/tauri", async (importOriginal) => ({ - ...(await importOriginal()), - ...tauriMocks, +vi.mock("../../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key }), })); -vi.mock("@tauri-apps/api/event", () => eventMocks); function rows(): ProviderSidebarRow[] { - return TEST_PROVIDER_CATALOG.map(([id, displayName], index) => ({ - id, - displayName, - enabled: index < 2, - status: index < 2 ? "ok" : "disabled", - subtitlePrimary: index < 2 ? "auto" : "Disabled - auto", - subtitleSecondary: index < 2 ? `${index + 1}%` : undefined, - })); + return [ + { id: "codex", displayName: "Codex", enabled: true, status: "ok" }, + { id: "claude", displayName: "Claude", enabled: true, status: "error" }, + { id: "cursor", displayName: "Cursor", enabled: false, status: "disabled" }, + ]; +} + +function renderSidebar(overrides: Partial[0]> = {}) { + const props = { + providers: rows(), + selectedId: "codex", + canReorder: true, + onSelect: vi.fn(), + onReorder: vi.fn(), + onToggleEnabled: vi.fn(), + ...overrides, + }; + const view = render(); + return { ...props, ...view }; } +const option = (name: string) => screen.getByRole("option", { name: new RegExp(`^${name} `) }); + describe("ProvidersSidebar", () => { - beforeEach(() => { - vi.clearAllMocks(); - tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle({ - ProviderSidebarSearch: "Search", - ProviderSidebarNoMatches: "No matching providers", - ProviderSidebarMoveUp: "Move up", - ProviderSidebarMoveDown: "Move down", - })); - eventMocks.listen.mockResolvedValue(() => {}); + it("shows a status dot on enabled rows only and dims disabled rows", () => { + renderSidebar(); + expect(screen.getAllByRole("option")).toHaveLength(3); + expect(option("Codex").querySelector(".providers-sidebar__status--ok")).not.toBeNull(); + expect(option("Claude").querySelector(".providers-sidebar__status--error")).not.toBeNull(); + const cursor = option("Cursor"); + expect(cursor.querySelector(".providers-sidebar__status")).toBeNull(); + expect(cursor).toHaveClass("providers-sidebar__row--disabled"); + expect(option("Codex")).toHaveAttribute("aria-selected", "true"); }); - it("renders the full provider catalog without dropping rows", async () => { - const { container } = render( - - - , - ); + it("says when no provider matches", () => { + renderSidebar({ providers: [], selectedId: null }); + expect(screen.getByText("ProviderSidebarNoMatches")).toBeInTheDocument(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + }); - expect(await screen.findByRole("listbox", { name: "ProvidersAriaLabel" })).toBeInTheDocument(); - expect(screen.getAllByRole("option")).toHaveLength(TEST_PROVIDER_CATALOG.length); - const names = Array.from( - container.querySelectorAll(".providers-sidebar__name"), - (node) => node.textContent, - ); - expect(names).toEqual(TEST_PROVIDER_CATALOG.map(([, displayName]) => displayName)); + it("selects with the arrow keys and moves with Alt+Arrow", () => { + const { onSelect, onReorder } = renderSidebar(); + fireEvent.keyDown(option("Codex"), { key: "ArrowDown" }); + expect(onSelect).toHaveBeenCalledWith("claude"); + expect(onReorder).not.toHaveBeenCalled(); + + fireEvent.keyDown(option("Codex"), { key: "ArrowDown", altKey: true }); + expect(onReorder).toHaveBeenCalledWith(["claude", "codex", "cursor"]); }); - it("renders provider search and empty matches state", async () => { - render( - - - , - ); + it("keeps Alt+Arrow and drag inert while the list cannot be reordered", () => { + const { onReorder } = renderSidebar({ canReorder: false }); + fireEvent.keyDown(option("Codex"), { key: "ArrowDown", altKey: true }); + expect(onReorder).not.toHaveBeenCalled(); + expect(option("Codex")).toHaveAttribute("draggable", "false"); + }); - expect(await screen.findByRole("searchbox", { name: "Search" })).toBeInTheDocument(); - expect(screen.getByText("No matching providers")).toBeInTheDocument(); + it("toggles a provider from the row context menu", () => { + const { onToggleEnabled } = renderSidebar(); + fireEvent.contextMenu(option("Claude"), { clientX: 40, clientY: 60 }); + const menu = screen.getByRole("menu"); + fireEvent.click(within(menu).getByRole("menuitem", { name: "ProviderContextDisable" })); + expect(onToggleEnabled).toHaveBeenCalledWith("claude", false); + expect(screen.queryByRole("menu")).toBeNull(); + + fireEvent.contextMenu(option("Cursor")); + fireEvent.click(screen.getByRole("menuitem", { name: "ProviderContextEnable" })); + expect(onToggleEnabled).toHaveBeenCalledWith("cursor", true); }); - it("reorders providers through explicit move buttons", async () => { - const onReorder = vi.fn(); - const initial = rows(); - function Harness() { - const [providers, setProviders] = useState(initial); - return ( - - { - onReorder(orderedIds); - setProviders((prev) => { - const byId = new Map(prev.map((p) => [p.id, p])); - return orderedIds - .map((id) => byId.get(id)) - .filter((p): p is ProviderSidebarRow => Boolean(p)); - }); - }} - onToggleEnabled={vi.fn()} - /> - - ); - } - const { container } = render(); - - fireEvent.click(await screen.findByRole("button", { name: "Move down Codex" })); - - await waitFor(() => { - expect(onReorder).toHaveBeenCalledWith([ - "claude", - "codex", - ...TEST_PROVIDER_CATALOG.slice(2).map(([id]) => id), - ]); - }); - await waitFor(() => { - const names = Array.from( - container.querySelectorAll(".providers-sidebar__name"), - (node) => node.textContent, - ); - expect(names.slice(0, 3)).toEqual(["Claude", "Codex", "Pi"]); - }); + it("moves a provider from the context menu and disables impossible moves", () => { + const { onReorder } = renderSidebar(); + fireEvent.contextMenu(option("Codex")); + expect(screen.getByRole("menuitem", { name: "ProviderSidebarMoveUp" })).toBeDisabled(); + fireEvent.click(screen.getByRole("menuitem", { name: "ProviderSidebarMoveDown" })); + expect(onReorder).toHaveBeenCalledWith(["claude", "codex", "cursor"]); }); - it("does not let the first provider move up", async () => { - render( - - - , - ); + it("disables Move Up and Move Down while sorted or searching", () => { + renderSidebar({ canReorder: false }); + fireEvent.contextMenu(option("Claude")); + expect(screen.getByRole("menuitem", { name: "ProviderSidebarMoveUp" })).toBeDisabled(); + expect(screen.getByRole("menuitem", { name: "ProviderSidebarMoveDown" })).toBeDisabled(); + expect(screen.getByRole("menuitem", { name: "ProviderContextDisable" })).toBeEnabled(); + }); - expect(await screen.findByRole("button", { name: "Move up Codex" })).toBeDisabled(); + it("opens the menu from the keyboard and closes it with Escape", () => { + renderSidebar(); + const claude = option("Claude"); + fireEvent.keyDown(claude, { key: "F10", shiftKey: true }); + const menu = screen.getByRole("menu"); + expect(menu).toBeInTheDocument(); + fireEvent.keyDown(menu, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + expect(claude).toHaveFocus(); + + fireEvent.keyDown(claude, { key: "ContextMenu" }); + expect(screen.getByRole("menu")).toBeInTheDocument(); + fireEvent.mouseDown(document.body); + expect(screen.queryByRole("menu")).toBeNull(); }); -}); -// jsdom runs with `css: false` and no layout, so assert the stylesheet rules -// directly: translated status lines ("Отключено — авто", ~97px) must wrap -// inside the ~89px text column at the 600px settings window, not ellipsize. -describe("Providers tab text fitting", () => { - it("wraps names and status lines to two lines instead of one ellipsized line", () => { - const stylesSource = loadStyles(); - for (const selector of [ - ".providers-sidebar__name", - ".providers-sidebar__subtitle-primary", - ]) { - const rule = ruleBlock(stylesSource, selector); - expect(rule).toMatch(/-webkit-line-clamp:\s*2;/); - expect(rule).not.toContain("nowrap"); - } + it("reorders by drag and drop", () => { + const { onReorder } = renderSidebar(); + const store = new Map(); + const dataTransfer = { + setData: (k: string, v: string) => store.set(k, v), + getData: (k: string) => store.get(k) ?? "", + effectAllowed: "", + dropEffect: "", + }; + fireEvent.dragStart(option("Cursor"), { dataTransfer }); + fireEvent.dragOver(option("Codex"), { dataTransfer }); + fireEvent.drop(option("Codex"), { dataTransfer }); + expect(onReorder).toHaveBeenCalledWith(["cursor", "codex", "claude"]); }); - it("keeps the full name and status line available as a tooltip", async () => { - const providers: ProviderSidebarRow[] = [{ - id: "alibaba", - displayName: "Alibaba Token Plan", - enabled: false, - status: "disabled", - subtitlePrimary: "Отключено — авто", - }]; - const { container } = render( - - - , + it("shows no drop target when reordering stops during a drag", () => { + const view = renderSidebar(); + const dataTransfer = { setData: () => {}, getData: () => "", effectAllowed: "", dropEffect: "" }; + fireEvent.dragStart(option("Cursor"), { dataTransfer }); + view.rerender( + , ); - await screen.findByRole("listbox", { name: "ProvidersAriaLabel" }); - expect(container.querySelector(".providers-sidebar__name")?.getAttribute("title")) - .toBe("Alibaba Token Plan"); - expect(container.querySelector(".providers-sidebar__subtitle-primary")?.getAttribute("title")) - .toBe("Отключено — авто"); + // fireEvent returns false only when the handler called preventDefault (accepted the drop). + expect(fireEvent.dragOver(option("Codex"), { dataTransfer })).toBe(true); + expect(option("Codex")).not.toHaveClass("providers-sidebar__row--drop"); + fireEvent.drop(option("Codex"), { dataTransfer }); + expect(view.onReorder).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.tsx index 025ac1090d..01170503a0 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProvidersSidebar.tsx @@ -1,15 +1,16 @@ import { - type CSSProperties, - type KeyboardEvent as ReactKeyboardEvent, type DragEvent as ReactDragEvent, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + useLayoutEffect, useEffect, useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { useLocale } from "../../../hooks/useLocale"; import type { LocaleKey } from "../../../i18n/keys"; import { ProviderIcon } from "../../../components/providers/ProviderIcon"; -import { getProviderIcon } from "../../../components/providers/providerIcons"; /** Last-fetch state mapped from a ProviderUsageSnapshot / settings pair. */ export type ProviderSidebarStatus = @@ -24,18 +25,17 @@ export interface ProviderSidebarRow { displayName: string; enabled: boolean; status: ProviderSidebarStatus; - /** Primary subtitle text, e.g. source hint or disabled reason. */ - subtitlePrimary: string; - /** Optional secondary metric line (e.g. "Session 42%"). */ - subtitleSecondary?: string; } interface Props { + /** Rows to show, already filtered by the search and in display order. */ providers: ProviderSidebarRow[]; + /** Selected provider, or null while an app pane is selected. */ selectedId: string | null; - searchText: string; - onSearchTextChange: (value: string) => void; + /** False while sorted alphabetically or filtered: drag, Alt+Arrow and Move are off. */ + canReorder: boolean; onSelect: (id: string) => void; + /** Full provider order after a move; only called while `canReorder`. */ onReorder: (orderedIds: string[]) => void; onToggleEnabled: (id: string, enabled: boolean) => void; disabled?: boolean; @@ -49,99 +49,63 @@ const STATUS_TO_KEY: Record = { loading: "ProviderStatusLoading", }; +interface MenuState { + id: string; + x: number; + y: number; +} + /** - * Providers sidebar — parity port of egui `render_providers_sidebar` - * (rust/src/native_ui/preferences.rs:3370). - * - * Responsibilities (Phase 6a only): - * - render rows with brand icon + status dot + two-line subtitle + toggle - * - drag-and-drop reorder (HTML5 native; emits `onReorder`) - * - button reorder for WebView2/automation paths where HTML5 drag can be brittle - * - keyboard reorder: Alt+ArrowUp / Alt+ArrowDown on the selected row - * - mount-in reveal animation keyed on row id + * Provider rows of the Settings sidebar (macOS `SettingsSidebarProviderRow`): + * a 16px icon, the name and a fetch-status dot on enabled rows; disabled rows + * are dimmed. Enable/Disable, Move Up and Move Down live in the row context + * menu (right-click, Shift+F10 or the Menu key). Reorder also works by HTML5 + * drag and Alt+ArrowUp / Alt+ArrowDown, only while `canReorder`. */ export function ProvidersSidebar({ providers, selectedId, - searchText, - onSearchTextChange, + canReorder, onSelect, onReorder, onToggleEnabled, disabled, }: Props) { const { t } = useLocale(); - - // Optimistic local order so drag-drop feels instant while the backend - // round-trips `reorder_providers`. - const [localOrder, setLocalOrder] = useState(() => - providers.map((p) => p.id), - ); - useEffect(() => { - setLocalOrder(providers.map((p) => p.id)); - }, [providers]); - - const byId = new Map(providers.map((p) => [p.id, p])); - const ordered = localOrder - .map((id) => byId.get(id)) - .filter((p): p is ProviderSidebarRow => Boolean(p)); - - // Track previously-mounted ids to trigger a reveal animation for new rows. - const seenRef = useRef | null>(null); - if (seenRef.current === null) { - seenRef.current = new Set(ordered.map((p) => p.id)); - } - const [justMounted, setJustMounted] = useState>(new Set()); - useEffect(() => { - const seen = seenRef.current; - if (!seen) return; - const newly = new Set(); - for (const p of ordered) { - if (!seen.has(p.id)) { - newly.add(p.id); - seen.add(p.id); - } - } - if (newly.size) { - setJustMounted(newly); - const h = window.setTimeout(() => setJustMounted(new Set()), 260); - return () => window.clearTimeout(h); - } - return undefined; - }, [ordered]); - const [dragId, setDragId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); + const [menu, setMenu] = useState(null); + const rowRefs = useRef(new Map()); + const reorderable = canReorder && !disabled; + const ids = providers.map((p) => p.id); - const commitReorder = (nextIds: string[]) => { - setLocalOrder(nextIds); - onReorder(nextIds); - }; + const focusRow = (id: string) => rowRefs.current.get(id)?.focus(); const moveId = (id: string, delta: number) => { - const idx = localOrder.indexOf(id); - if (idx < 0) return; + if (!reorderable) return; + const idx = ids.indexOf(id); const target = idx + delta; - if (target < 0 || target >= localOrder.length) return; - const next = [...localOrder]; + if (idx < 0 || target < 0 || target >= ids.length) return; + const next = [...ids]; next.splice(idx, 1); next.splice(target, 0, id); - commitReorder(next); + onReorder(next); }; const handleDragStart = (id: string) => (e: ReactDragEvent) => { - if (disabled) return; + if (!reorderable) return; setDragId(id); e.dataTransfer.effectAllowed = "move"; try { e.dataTransfer.setData("text/plain", id); } catch { - /* Firefox sometimes throws; ignore. */ + /* Some engines throw on setData during dragstart; the id is kept in state. */ } }; const handleDragOver = (overId: string) => (e: ReactDragEvent) => { - if (!dragId || dragId === overId) return; + // No drop target once reordering stops mid-drag (sort, search or a save). + if (!reorderable || !dragId || dragId === overId) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (dropTargetId !== overId) setDropTargetId(overId); @@ -150,15 +114,15 @@ export function ProvidersSidebar({ const handleDrop = (overId: string) => (e: ReactDragEvent) => { if (!dragId || dragId === overId) return; e.preventDefault(); - const from = localOrder.indexOf(dragId); - const to = localOrder.indexOf(overId); - if (from < 0 || to < 0) return; - const next = [...localOrder]; - next.splice(from, 1); - next.splice(to, 0, dragId); - commitReorder(next); + const from = ids.indexOf(dragId); + const to = ids.indexOf(overId); setDragId(null); setDropTargetId(null); + if (from < 0 || to < 0 || !reorderable) return; + const next = [...ids]; + next.splice(from, 1); + next.splice(to, 0, dragId); + onReorder(next); }; const handleDragEnd = () => { @@ -166,178 +130,238 @@ export function ProvidersSidebar({ setDropTargetId(null); }; - const handleKey = (row: ProviderSidebarRow) => (e: ReactKeyboardEvent) => { - if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) { + const openMenuAtRow = (id: string) => { + const rect = rowRefs.current.get(id)?.getBoundingClientRect(); + setMenu({ id, x: (rect?.left ?? 0) + 24, y: rect?.bottom ?? 0 }); + }; + + const handleKey = (row: ProviderSidebarRow, index: number) => ( + e: ReactKeyboardEvent, + ) => { + if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); - moveId(row.id, e.key === "ArrowUp" ? -1 : 1); + const delta = e.key === "ArrowUp" ? -1 : 1; + if (e.altKey) { + moveId(row.id, delta); + return; + } + const next = providers[index + delta]; + if (next) { + onSelect(next.id); + focusRow(next.id); + } return; } if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(row.id); + return; + } + if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) { + e.preventDefault(); + openMenuAtRow(row.id); } }; - const sidebarRef = useRef(null); - - // Explicit wheel handler — WebView2 on Windows can swallow wheel events - // when parent containers have overflow:hidden/clip. This ensures the - // sidebar always scrolls in response to wheel input. - const handleWheel = (e: React.WheelEvent) => { - const el = sidebarRef.current; - if (!el) return; - el.scrollTop += e.deltaY; - e.stopPropagation(); + const handleContextMenu = (id: string) => (e: ReactMouseEvent) => { + e.preventDefault(); + setMenu({ id, x: e.clientX, y: e.clientY }); }; + const menuRow = menu ? providers.find((p) => p.id === menu.id) ?? null : null; + const menuIndex = menuRow ? ids.indexOf(menuRow.id) : -1; + const focusId = selectedId && ids.includes(selectedId) ? selectedId : ids[0]; + return ( -
-
- onSearchTextChange(e.target.value)} - placeholder={t("ProviderSidebarSearch")} - aria-label={t("ProviderSidebarSearch")} - spellCheck={false} - /> - {searchText.trim() && ( - - )} -
+ <>
    - {ordered.length === 0 && ( -
  • + {providers.length === 0 && ( +
  • {t("ProviderSidebarNoMatches")}
  • )} - {ordered.map((p, index) => { + {providers.map((p, index) => { const isSelected = p.id === selectedId; - const isDrop = dropTargetId === p.id; - const isDragging = dragId === p.id; - const reveal = justMounted.has(p.id); - const brand = getProviderIcon(p.id).brandColor; - const canMoveUp = index > 0 && !disabled; - const canMoveDown = index < ordered.length - 1 && !disabled; + const statusLabel = t(STATUS_TO_KEY[p.status]); const cls = [ "providers-sidebar__row", isSelected && "providers-sidebar__row--selected", !p.enabled && "providers-sidebar__row--disabled", - isDragging && "providers-sidebar__row--dragging", - isDrop && "providers-sidebar__row--drop", - reveal && "providers-sidebar__row--reveal", + dragId === p.id && "providers-sidebar__row--dragging", + dropTargetId === p.id && "providers-sidebar__row--drop", ] .filter(Boolean) .join(" "); - const rowStyle: CSSProperties = { - ["--provider-brand" as string]: brand, - }; - return (
  • { + if (node) rowRefs.current.set(p.id, node); + else rowRefs.current.delete(p.id); + }} className={cls} role="option" - tabIndex={isSelected ? 0 : -1} + tabIndex={p.id === focusId ? 0 : -1} aria-selected={isSelected} - draggable={!disabled} - style={rowStyle} + aria-label={`${p.displayName} — ${statusLabel}`} + data-provider-id={p.id} + draggable={reorderable} onClick={() => onSelect(p.id)} - onKeyDown={handleKey(p)} + onKeyDown={handleKey(p, index)} + onContextMenu={handleContextMenu(p.id)} onDragStart={handleDragStart(p.id)} onDragOver={handleDragOver(p.id)} onDrop={handleDrop(p.id)} onDragEnd={handleDragEnd} > - - -
    - - {p.displayName} - - - - {p.subtitlePrimary} - - {p.subtitleSecondary && ( - - {p.subtitleSecondary} - - )} - -
    -
  • ); - })} + })}
-
+ {menu && menuRow && ( + { + setMenu(null); + if (refocus) focusRow(menu.id); + }} + items={[ + { + label: t(menuRow.enabled ? "ProviderContextDisable" : "ProviderContextEnable"), + disabled: Boolean(disabled), + run: () => onToggleEnabled(menuRow.id, !menuRow.enabled), + }, + { + label: t("ProviderSidebarMoveUp"), + disabled: !reorderable || menuIndex <= 0, + run: () => moveId(menuRow.id, -1), + }, + { + label: t("ProviderSidebarMoveDown"), + disabled: !reorderable || menuIndex >= ids.length - 1, + run: () => moveId(menuRow.id, 1), + }, + ]} + /> + )} + + ); +} + +interface MenuItem { + label: string; + disabled: boolean; + run: () => void; +} + +const MENU_EDGE = 4; + +/** A small floating menu at a point, kept inside the window. */ +function ProviderRowMenu({ + x, + y, + items, + onClose, +}: { + x: number; + y: number; + items: MenuItem[]; + /** `refocus`: return focus to the row (keyboard close or after a command). */ + onClose: (refocus: boolean) => void; +}) { + const ref = useRef(null); + const [pos, setPos] = useState({ left: x, top: y }); + const onCloseRef = useRef(onClose); + useLayoutEffect(() => { + onCloseRef.current = onClose; + }); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const left = Math.max(MENU_EDGE, Math.min(x, window.innerWidth - rect.width - MENU_EDGE)); + const top = Math.max(MENU_EDGE, Math.min(y, window.innerHeight - rect.height - MENU_EDGE)); + setPos({ left, top }); + el.querySelector("button:not(:disabled)")?.focus(); + }, [x, y]); + + useEffect(() => { + const onPointer = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) onCloseRef.current(false); + }; + const onDismiss = () => onCloseRef.current(false); + document.addEventListener("mousedown", onPointer, true); + window.addEventListener("blur", onDismiss); + window.addEventListener("resize", onDismiss); + return () => { + document.removeEventListener("mousedown", onPointer, true); + window.removeEventListener("blur", onDismiss); + window.removeEventListener("resize", onDismiss); + }; + }, []); + + const handleKey = (e: ReactKeyboardEvent) => { + if (e.key === "Escape" || e.key === "Tab") { + e.preventDefault(); + onClose(true); + return; + } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const buttons = Array.from( + ref.current?.querySelectorAll("button:not(:disabled)") ?? [], + ); + if (buttons.length === 0) return; + const at = buttons.indexOf(document.activeElement as HTMLButtonElement); + const step = e.key === "ArrowDown" ? 1 : -1; + buttons[(at + step + buttons.length) % buttons.length].focus(); + } + }; + + return createPortal( +
e.preventDefault()} + > + {items.map((item) => ( + + ))} +
, + document.body, ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css index 2b25c654df..04f2dd9196 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css +++ b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css @@ -481,3 +481,359 @@ .provider-usage-details__pricing small { color: var(--text-secondary); } + +/* ── Sidebar window shell (macOS PreferencesView + PreferencesSidebar) ── + 880x620 window: a lighter sidebar with search, sort, app panes and the + provider list on the left; the selected pane fills the detail column. */ +.settings--sidebar .settings-titlebar { + background: var(--app-bg); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +/* Centered like the macOS title; the left padding balances the controls. */ +.settings--sidebar .settings-titlebar__title { + padding-left: 92px; + text-align: center; + font-size: 13px; + font-weight: 650; + letter-spacing: 0; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.settings-shell { + flex: 1; + min-height: 0; + display: flex; +} + +.settings-sidebar { + width: 240px; + min-width: 200px; + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 0; + background: rgba(255, 255, 255, 0.045); + border-right: 1px solid rgba(255, 255, 255, 0.06); +} + +.settings-sidebar__toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 8px 4px; +} + +.settings-sidebar__search { + position: relative; + flex: 1; + min-width: 0; +} + +.settings-sidebar__search-icon { + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + fill: none; + stroke: var(--text-secondary); + stroke-width: 1.6; + stroke-linecap: round; + pointer-events: none; +} + +.settings-sidebar__search-input { + width: 100%; + height: 28px; + padding: 0 24px 0 26px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + background: rgba(0, 0, 0, 0.2); + color: var(--text-primary); + font: inherit; + font-size: 13px; + outline: none; +} + +.settings-sidebar__search-input::-webkit-search-cancel-button { + appearance: none; +} + +.settings-sidebar__search-input:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(10, 132, 255, 0.3); +} + +.settings-sidebar__search-clear { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + width: 18px; + height: 18px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.settings-sidebar__search-clear svg, +.settings-sidebar__sort svg { + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.settings-sidebar__sort { + width: 28px; + height: 28px; + flex-shrink: 0; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.settings-sidebar__search-clear:hover, +.settings-sidebar__sort:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); +} + +.settings-sidebar__sort--on { + color: var(--accent); +} + +.settings-sidebar__sort:disabled { + cursor: default; + opacity: 0.5; +} + +.settings-sidebar__search-clear:focus-visible, +.settings-sidebar__sort:focus-visible, +.settings-sidebar__pane:focus-visible, +.providers-sidebar__row:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.settings-sidebar__scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + padding: 4px 8px 12px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.3) transparent; +} + +.settings-sidebar__panes { + list-style: none; + margin: 0 0 8px; + padding: 0; +} + +.settings-sidebar__pane, +.providers-sidebar__row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 28px; + padding: 4px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; + user-select: none; +} + +.settings-sidebar__pane:hover, +.providers-sidebar__row:hover { + background: rgba(255, 255, 255, 0.05); +} + +.settings-sidebar__pane--active, +.settings-sidebar__pane--active:hover, +.providers-sidebar__row--selected, +.providers-sidebar__row--selected:hover { + background: rgba(255, 255, 255, 0.12); +} + +.settings-sidebar__label, +.providers-sidebar__name { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 20px rounded-square chip with a white glyph (macOS SettingsSidebarIcon). */ +.settings-sidebar__chip { + width: 20px; + height: 20px; + flex-shrink: 0; + display: grid; + place-items: center; + border-radius: 4px; + color: #fff; +} + +.settings-sidebar__chip--gray { background: #8e8e93; } +.settings-sidebar__chip--green { background: #30d158; } +.settings-sidebar__chip--red { background: #ff453a; } +.settings-sidebar__chip--blue { background: #0a84ff; } +.settings-sidebar__chip--teal { background: #40c8e0; } +.settings-sidebar__chip--purple { background: #bf5af2; } + +.settings-sidebar__app-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + border-radius: 4px; +} + +.settings-sidebar__section-header { + display: flex; + align-items: baseline; + gap: 8px; + padding: 8px 8px 4px; + font-size: 12px; + font-weight: 650; + color: var(--text-secondary); +} + +.settings-sidebar__count { + font-weight: 400; + font-variant-numeric: tabular-nums; +} + +.providers-sidebar { + list-style: none; + margin: 0; + padding: 0; +} + +.providers-sidebar__empty { + padding: 8px; + font-size: 12px; + color: var(--text-secondary); +} + +/* 16px template icon, white like the macOS sidebar glyphs. */ +.providers-sidebar__row .provider-icon { + width: 16px; + height: 16px; + background: none; + box-shadow: none; + border-radius: 0; + filter: brightness(0) invert(1); + opacity: 0.85; +} + +.providers-sidebar__row--disabled { + opacity: 0.62; +} + +.providers-sidebar__row .providers-sidebar__status { + flex-shrink: 0; + box-shadow: none; +} + +.providers-sidebar__row--dragging { + opacity: 0.45; +} + +.providers-sidebar__row--drop { + box-shadow: inset 0 2px 0 0 var(--accent); +} + +/* Row context menu; a floating layer, so it carries the one shadow. */ +.providers-sidebar-menu { + position: fixed; + z-index: 1000; + min-width: 140px; + display: flex; + flex-direction: column; + padding: 4px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + background: #2c2c2e; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} + +.providers-sidebar-menu__item { + padding: 4px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.providers-sidebar-menu__item:hover:not(:disabled), +.providers-sidebar-menu__item:focus-visible { + background: var(--accent); + color: #fff; + outline: none; +} + +.providers-sidebar-menu__item:disabled { + color: var(--text-secondary); + cursor: default; +} + +.settings-detail { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.settings--sidebar .settings-body { + max-width: 600px; + padding: 16px 24px 24px; +} + +.settings--sidebar .settings-body--provider { + max-width: none; +} + +/* The save-status line sits at the foot of the detail column. */ +.settings--sidebar .settings-status-slot { + flex-shrink: 0; +} + +.settings--sidebar .settings-status { + margin: 0 24px 12px; + max-width: none; +} + +.settings--sidebar .settings-status--floating { + top: auto; + bottom: 12px; + margin: 0; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts index c226aa6594..f0a4b189ab 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts +++ b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts @@ -2,7 +2,16 @@ import { describe, expect, it } from "vitest"; import type { SettingsTabId } from "../../types/bridge"; -import { isSettingsTab, parseSettingsTabRequest, providersTabRequest, TAB_META } from "./settingsTabs"; +import { + SIDEBAR_PANES, + TAB_META, + canReorderProviders, + filterProvidersByQuery, + isSettingsTab, + parseSettingsTabRequest, + providersTabRequest, + sortProvidersAlphabetically, +} from "./settingsTabs"; describe("isSettingsTab", () => { it.each(["general", "providers", "notifications", "menuBar", "menu", "usageSpend", "advanced", "about"])( @@ -99,3 +108,77 @@ describe("providersTabRequest", () => { }); }); }); + +describe("SIDEBAR_PANES", () => { + it("lists the app panes in the macOS order with their chip colors", () => { + expect(SIDEBAR_PANES.map((pane) => [pane.id, pane.chip])).toEqual([ + ["general", "gray"], + ["usageSpend", "green"], + ["notifications", "red"], + ["menuBar", "blue"], + ["menu", "teal"], + ["advanced", "purple"], + ["about", null], + ]); + }); + + it("covers every settings tab except the provider panes", () => { + const paneIds = new Set(SIDEBAR_PANES.map((pane) => pane.id)); + for (const tab of TAB_META) { + expect(paneIds.has(tab.id)).toBe(tab.id !== "providers"); + } + }); +}); + +const catalog = [ + { id: "codex", displayName: "Codex" }, + { id: "zai", displayName: "z.ai" }, + { id: "claude", displayName: "Claude" }, + { id: "amp", displayName: "Amp" }, + { id: "clawrouter", displayName: "ClawRouter" }, +]; + +describe("filterProvidersByQuery", () => { + it("matches name or id, ignoring case and surrounding spaces", () => { + expect(filterProvidersByQuery(catalog, " CL ").map((p) => p.id)).toEqual([ + "claude", + "clawrouter", + ]); + expect(filterProvidersByQuery(catalog, "zai").map((p) => p.id)).toEqual(["zai"]); + expect(filterProvidersByQuery(catalog, "Z.A").map((p) => p.id)).toEqual(["zai"]); + }); + + it("keeps every provider for a blank query", () => { + expect(filterProvidersByQuery(catalog, " ")).toBe(catalog); + }); +}); + +describe("sortProvidersAlphabetically", () => { + it("puts enabled providers first, each group by name ignoring case", () => { + const sorted = sortProvidersAlphabetically(catalog, new Set(["zai", "codex"])); + expect(sorted.map((p) => p.id)).toEqual(["codex", "zai", "amp", "claude", "clawrouter"]); + }); + + it("does not reorder its input", () => { + const input = [...catalog]; + sortProvidersAlphabetically(input, new Set()); + expect(input).toEqual(catalog); + }); + + it("breaks name ties by id", () => { + const twins = [ + { id: "b", displayName: "Same" }, + { id: "a", displayName: "same" }, + ]; + expect(sortProvidersAlphabetically(twins, new Set()).map((p) => p.id)).toEqual(["a", "b"]); + }); +}); + +describe("canReorderProviders", () => { + it("allows reordering only in the saved order with no search", () => { + expect(canReorderProviders(false, "")).toBe(true); + expect(canReorderProviders(false, " ")).toBe(true); + expect(canReorderProviders(true, "")).toBe(false); + expect(canReorderProviders(false, "cl")).toBe(false); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts index a63ac0f03c..65e80808f5 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts +++ b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts @@ -43,6 +43,72 @@ export function parseSettingsTabRequest(raw: string | null | undefined): Setting } /** The `open_settings_window` argument that opens Settings > Providers on one provider. */ -export function providersTabRequest(providerId: string): string { +export function providersTabRequest(providerId: string): `providers:${string}` { return `providers:${providerId}`; } + +/** macOS system colors behind the white glyph of a sidebar pane chip. */ +export type SidebarChipColor = "gray" | "green" | "red" | "blue" | "teal" | "purple"; + +export interface SidebarPane { + id: Exclude; + labelKey: LocaleKey; + /** `null` shows the app icon instead of a chip (About). */ + chip: SidebarChipColor | null; +} + +/** + * App panes in the Settings sidebar, in the macOS 0.70 order + * (`PreferencesSidebar.swift` appPanesSection). iCloud Sync, Plugins and Debug + * have no Windows pane; Hooks arrives with its own pane later. + */ +export const SIDEBAR_PANES: SidebarPane[] = [ + { id: "general", labelKey: "TabGeneral", chip: "gray" }, + { id: "usageSpend", labelKey: "TabUsageSpend", chip: "green" }, + { id: "notifications", labelKey: "TabNotifications", chip: "red" }, + { id: "menuBar", labelKey: "TabMenuBar", chip: "blue" }, + { id: "menu", labelKey: "TabMenu", chip: "teal" }, + { id: "advanced", labelKey: "TabAdvanced", chip: "purple" }, + { id: "about", labelKey: "TabAbout", chip: null }, +]; + +interface NamedProvider { + id: string; + displayName: string; +} + +/** Case-insensitive match on display name or id; a blank query keeps every provider. */ +export function filterProvidersByQuery(providers: T[], query: string): T[] { + const needle = query.trim().toLocaleLowerCase(); + if (!needle) return providers; + return providers.filter( + (provider) => + provider.displayName.toLocaleLowerCase().includes(needle) || + provider.id.toLocaleLowerCase().includes(needle), + ); +} + +/** + * Enabled providers first, then by display name ignoring case, then by id + * (macOS `CodexBarConfig.alphabeticalProviderOrder`). Display only: the saved + * provider order is never rewritten. + */ +export function sortProvidersAlphabetically( + providers: T[], + enabled: ReadonlySet, +): T[] { + return [...providers].sort((lhs, rhs) => { + const lhsOn = enabled.has(lhs.id); + if (lhsOn !== enabled.has(rhs.id)) return lhsOn ? -1 : 1; + const byName = lhs.displayName.localeCompare(rhs.displayName, undefined, { + sensitivity: "base", + }); + if (byName !== 0) return byName; + return lhs.id < rhs.id ? -1 : lhs.id > rhs.id ? 1 : 0; + }); +} + +/** Reordering is only meaningful in the saved order with nothing filtered out (macOS). */ +export function canReorderProviders(sortedAlphabetically: boolean, query: string): boolean { + return !sortedAlphabetically && query.trim() === ""; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 8e1e518d53..15ff6d9533 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -67,6 +67,7 @@ const settings: SettingsSnapshot = { menuBarShowsHighestUsage: true, menuBarShowsPercent: true, menuBarColorPace: false, + providersSortedAlphabetically: false, showAsUsed: false, showAllTokenAccountsInMenu: true, enableAnimations: true, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx index a59cc74341..03f8868229 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx @@ -35,6 +35,7 @@ const settings: SettingsSnapshot = { menuBarShowsHighestUsage: true, menuBarShowsPercent: true, menuBarColorPace: false, + providersSortedAlphabetically: false, showAsUsed: false, showAllTokenAccountsInMenu: true, enableAnimations: true, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx index 33f6ef1b4f..bc118b31ed 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx @@ -23,6 +23,7 @@ const baseSettings = { menuBarShowsHighestUsage: false, menuBarShowsPercent: false, menuBarColorPace: false, + providersSortedAlphabetically: false, menuBarDisplayMode: "detailed", overviewLayout: "detailed", windowScalePercent: 100, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index e028f488ac..80aa9cc2b9 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -70,6 +70,7 @@ const settings: SettingsSnapshot = { menuBarShowsHighestUsage: true, menuBarShowsPercent: true, menuBarColorPace: false, + providersSortedAlphabetically: false, showAsUsed: false, showAllTokenAccountsInMenu: true, enableAnimations: true, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx index 0995c0aa23..640cb84306 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.test.tsx @@ -3,30 +3,24 @@ import { describe, expect, it, vi } from "vitest"; import type { ProviderCatalogEntry, ProviderUsageSnapshot, - RateWindowSnapshot, SettingsSnapshot, } from "../../../types/bridge"; -const hookMocks = vi.hoisted(() => ({ - useProviders: vi.fn(), -})); - -vi.mock("../../../hooks/useProviders", () => hookMocks); -vi.mock("../../../hooks/useLocale", () => ({ - useLocale: () => ({ t: (key: string) => key }), -})); -vi.mock("../../../lib/tauri", () => ({ - reorderProviders: vi.fn().mockResolvedValue(undefined), -})); vi.mock("../providers/ProviderDetailPane", () => ({ ProviderDetailPane: ({ providerId, liveSnapshot, + cookieDomain, }: { providerId: string | null; liveSnapshot?: { providerId: string } | null; + cookieDomain?: string | null; }) => ( -
+
{providerId}
), @@ -34,145 +28,51 @@ vi.mock("../providers/ProviderDetailPane", () => ({ import ProvidersTab from "./ProvidersTab"; -function rateWindow( - usedPercent: number, - isInformational?: boolean, -): RateWindowSnapshot { - return { - usedPercent, - remainingPercent: 100 - usedPercent, - windowMinutes: null, - resetsAt: null, - resetDescription: null, - isExhausted: false, - isInformational, - reservePercent: null, - reserveDescription: null, - }; -} - -const provider: ProviderCatalogEntry = { - id: "codex", - displayName: "Codex", - cookieDomain: null, +const codex: ProviderCatalogEntry = { id: "codex", displayName: "Codex", cookieDomain: null }; +const claude: ProviderCatalogEntry = { + id: "claude", + displayName: "Claude", + cookieDomain: "claude.ai", }; - const settings = { - enabledProviders: [provider.id], + enabledProviders: ["codex", "claude"], resetTimeRelative: true, providerMetrics: {}, -} as SettingsSnapshot; +} as unknown as SettingsSnapshot; -describe("ProvidersTab", () => { - it("shows the real secondary percentage when the primary is informational", () => { - const snapshot: ProviderUsageSnapshot = { - providerId: provider.id, - displayName: provider.displayName, - primary: rateWindow(0, true), - selectedMetric: rateWindow(42), - secondary: rateWindow(42), - modelSpecific: null, - tertiary: null, - extraRateWindows: [], - cost: null, - planName: null, - accountEmail: null, - sourceLabel: "auto", - updatedAt: new Date().toISOString(), - error: null, - errorState: "ready", - pace: null, - accountOrganization: null, - trayStatusLabel: null, - }; - hookMocks.useProviders.mockReturnValue({ providers: [snapshot] }); +const live = (providerId: string) => ({ providerId }) as unknown as ProviderUsageSnapshot; +describe("ProvidersTab", () => { + it("shows the sidebar's provider with its own snapshot and cookie domain only", () => { render( , - ); - - expect(screen.getByText("42%")).toBeInTheDocument(); - }); - - const claude: ProviderCatalogEntry = { id: "claude", displayName: "Claude", cookieDomain: null }; - const twoProviderSettings = { - ...settings, - enabledProviders: [provider.id, claude.id], - } as SettingsSnapshot; - - it("selects the deep-linked provider and follows a later request", () => { - hookMocks.useProviders.mockReturnValue({ providers: [] }); - const { rerender } = render( - , - ); - expect(screen.getByTestId("detail-pane")).toHaveTextContent("claude"); - - rerender( - , - ); - expect(screen.getByTestId("detail-pane")).toHaveTextContent("codex"); - }); - - it("falls back to the first provider for an unknown deep link", () => { - hookMocks.useProviders.mockReturnValue({ providers: [] }); - render( - , ); - expect(screen.getByTestId("detail-pane")).toHaveTextContent("codex"); + const pane = screen.getByTestId("detail-pane"); + expect(pane).toHaveTextContent("claude"); + expect(pane).toHaveAttribute("data-live", "claude"); + expect(pane).toHaveAttribute("data-cookie", "claude.ai"); }); - it("hands the pane the selected provider's live snapshot only", () => { - const live = (providerId: string) => - ({ - providerId, - displayName: providerId, - primary: rateWindow(10), - selectedMetric: rateWindow(10), - secondary: null, - modelSpecific: null, - tertiary: null, - extraRateWindows: [], - error: null, - }) as unknown as ProviderUsageSnapshot; - const codexSnapshot = live("codex"); - const claudeSnapshot = live("claude"); - hookMocks.useProviders.mockReturnValue({ providers: [codexSnapshot, claudeSnapshot] }); + it("shows no provider for an id outside the catalog", () => { render( , ); - expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-live", "claude"); + const pane = screen.getByTestId("detail-pane"); + expect(pane).toHaveTextContent(""); + expect(pane).toHaveAttribute("data-live", ""); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index ad4c6ba35d..855f4bca9a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -1,298 +1,52 @@ -import { useMemo, useState } from "react"; import type { + BootstrapState, ProviderCatalogEntry, ProviderUsageSnapshot, SettingsUpdate, } from "../../../types/bridge"; -import type { BootstrapState } from "../../../types/bridge"; -import { useLocale } from "../../../hooks/useLocale"; -import type { LocaleKey } from "../../../i18n/keys"; -import { - ProvidersSidebar, - type ProviderSidebarRow, - type ProviderSidebarStatus, -} from "../providers/ProvidersSidebar"; import { ProviderDetailPane } from "../providers/ProviderDetailPane"; -import { reorderProviders } from "../../../lib/tauri"; -import { selectSingleMetricUsageWindow } from "../../../lib/usageWindows"; -import { useProviders } from "../../../hooks/useProviders"; interface ProvidersTabProps { settings: BootstrapState["settings"]; providers: ProviderCatalogEntry[]; set: (patch: SettingsUpdate) => void; saving: boolean; - /** Provider to select, from a `providers:` Settings request (tray "Switch Account..."). */ - focusProviderId?: string | null; - /** Changes on every Settings request, so repeating the same deep link reselects it. */ - focusRequest?: number; + /** Provider selected in the Settings sidebar. */ + providerId: string | null; + /** Live usage snapshots, shared with the sidebar's status dots. */ + snapshots: ProviderUsageSnapshot[]; } +/** + * Detail pane of one provider. The provider list itself lives in the + * Settings sidebar (`SettingsSidebar`), as on macOS. + */ export default function ProvidersTab({ settings, providers, set, saving, - focusProviderId = null, - focusRequest = 0, + providerId, + snapshots, }: ProvidersTabProps) { - const { t } = useLocale(); - const { providers: snapshots } = useProviders(); - const [selectedId, setSelectedId] = useState( - focusProviderId ?? providers[0]?.id ?? null, - ); - const [prevFocusRequest, setPrevFocusRequest] = useState(focusRequest); - // Locally-owned catalog order so drag-reorder feels instant before the - // backend `reorder_providers` round-trip settles. - const [orderedProviders, setOrderedProviders] = - useState(providers); - const [prevProviders, setPrevProviders] = useState(providers); - const [searchText, setSearchText] = useState(""); - // A new deep link reselects its provider and clears a search that would hide it. - if (focusRequest !== prevFocusRequest) { - setPrevFocusRequest(focusRequest); - if (focusProviderId) { - setSelectedId(focusProviderId); - setSearchText(""); - } - } - if (providers !== prevProviders) { - setPrevProviders(providers); - setOrderedProviders(providers); - } - - const enabled = useMemo( - () => new Set(settings.enabledProviders), - [settings.enabledProviders], - ); - - const toggle = (id: string, on: boolean) => { - const next = new Set(enabled); - if (on) next.add(id); - else next.delete(id); - set({ - enabledProviders: orderedProviders.reduce((ids, provider) => { - if (next.has(provider.id)) ids.push(provider.id); - return ids; - }, []), - }); - }; - - const rows: ProviderSidebarRow[] = useMemo(() => { - const snapshotMap = new Map(snapshots.map((s) => [s.providerId, s])); - return orderedProviders.map((p) => { - const isOn = enabled.has(p.id); - const snap = snapshotMap.get(p.id) ?? null; - return { - id: p.id, - displayName: p.displayName, - enabled: isOn, - status: deriveProviderStatus(isOn, snap), - subtitlePrimary: providerSidebarSubtitle(p.id, isOn, snap, t), - subtitleSecondary: providerSidebarMetric(snap), - }; - }); - }, [enabled, orderedProviders, snapshots, t]); - - const normalizedSearch = searchText.trim().toLowerCase(); - const visibleRows = useMemo( - () => - normalizedSearch - ? rows.filter((row) => { - const name = row.displayName.toLowerCase(); - const id = row.id.toLowerCase(); - return name.includes(normalizedSearch) || id.includes(normalizedSearch); - }) - : rows, - [normalizedSearch, rows], - ); - - // Derive selection from visible rows — no effect to mirror/adjust state. - const resolvedSelectedId = - visibleRows.length === 0 - ? null - : selectedId && visibleRows.some((row) => row.id === selectedId) - ? selectedId - : visibleRows[0].id; - - const handleReorder = (ids: string[]) => { - const byId = new Map(orderedProviders.map((p) => [p.id, p])); - const nextIds = normalizedSearch - ? mergeFilteredOrder( - orderedProviders.map((p) => p.id), - new Set(visibleRows.map((row) => row.id)), - ids, - ) - : ids; - const next = nextIds - .map((id) => byId.get(id)) - .filter((p): p is ProviderCatalogEntry => Boolean(p)); - setOrderedProviders(next); - void reorderProviders(nextIds).catch(() => { - setOrderedProviders(providers); - }); - }; - - const selectedEntry = - orderedProviders.find((p) => p.id === resolvedSelectedId) ?? null; + const entry = providers.find((p) => p.id === providerId) ?? null; const liveSnapshot = - snapshots.find((snapshot) => snapshot.providerId === resolvedSelectedId) ?? null; + snapshots.find((snapshot) => snapshot.providerId === providerId) ?? null; return ( -
- - -
- ); -} - -function mergeFilteredOrder( - fullOrder: string[], - visibleIds: Set, - reorderedVisibleIds: string[], -): string[] { - const nextVisible = [...reorderedVisibleIds]; - return fullOrder.map((id) => - visibleIds.has(id) ? (nextVisible.shift() ?? id) : id, + ); } - -// ── Provider sidebar subtitle helpers (port of -// rust/src/native_ui/preferences.rs::provider_sidebar_subtitle). ───── - -function deriveProviderStatus( - isEnabled: boolean, - snap: ProviderUsageSnapshot | null, -): ProviderSidebarStatus { - if (!isEnabled) return "disabled"; - if (!snap) return "loading"; - if (snap.error) return "error"; - const updatedMs = new Date(snap.updatedAt).getTime(); - if (Number.isFinite(updatedMs)) { - const ageMins = (Date.now() - updatedMs) / 60_000; - if (ageMins > 10) return "stale"; - } - return "ok"; -} - -/** - * Minimal port of `provider_sidebar_source_hint` - * (rust/src/native_ui/preferences.rs:3753). When we have a live snapshot the - * backend-supplied `sourceLabel` wins; otherwise we fall back to the neutral - * "Not detected" / "Disabled" copy. - */ -function providerSidebarSubtitle( - providerId: string, - isEnabled: boolean, - snap: ProviderUsageSnapshot | null, - t: (key: LocaleKey) => string, -): string { - if (!isEnabled) { - return `${t("ProviderDisabled")} — ${providerSourceHintShort(providerId, t)}`; - } - if (!snap) { - return t("WaitingForUsage"); - } - const source = snap.sourceLabel || providerSourceHintShort(providerId, t); - return source; -} - -function providerSourceHintShort( - providerId: string, - t: (key: LocaleKey) => string, -): string { - const id = providerId.toLowerCase(); - switch (id) { - case "cursor": - case "factory": - case "droid": - case "kimi": - case "kimik2": - case "augment": - case "opencode": - case "amp": - case "ollama": - case "alibaba": - case "infini": - case "manus": - case "mimo": - case "zoommate": - case "notion": - case "t3chat": - case "commandcode": - case "raycast": - return t("ProviderSourceWebShort"); - case "gemini": - case "antigravity": - case "jetbrains": - return t("ProviderSourceCliShort"); - case "copilot": - return t("ProviderSourceOauthShort"); - case "zai": - case "vertexai": - case "openrouter": - case "bedrock": - case "nanogpt": - case "warp": - case "deepinfra": - case "aiand": - case "zenmux": - case "clinepass": - case "neuralwatt": - case "devpass": - case "xkiro": - case "doubao": - case "stepfun": - case "venice": - case "openaiapi": - case "elevenlabs": - case "deepgram": - case "groq": - case "llmproxy": - case "xai": - case "fireworks": - case "meta": - case "vercel": - return t("ProviderSourceApiShort"); - case "kiro": - return t("ProviderSourceKiroEnvShort"); - case "claude": - case "codex": - case "minimax": - default: - return t("ProviderSourceAutoShort"); - } -} - -function providerSidebarMetric( - snap: ProviderUsageSnapshot | null, -): string | undefined { - if (!snap) return undefined; - const rate = selectSingleMetricUsageWindow(snap); - if (Number.isFinite(rate.usedPercent)) { - return `${Math.round(Math.max(0, rate.usedPercent))}%`; - } - return undefined; -} diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index 389c25f4b0..6aaf48b765 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -96,6 +96,7 @@ describe("Language type", () => { menuBarShowsHighestUsage: true, menuBarShowsPercent: true, menuBarColorPace: false, + providersSortedAlphabetically: false, showAsUsed: false, showAllTokenAccountsInMenu: true, enableAnimations: true, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 1b1c3e24ff..2ca2a31c51 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -99,7 +99,11 @@ export type TrayPanelSurfaceTarget = { kind: "summary" }; export type PopOutSurfaceTarget = | { kind: "dashboard" } | { kind: "provider"; providerId: string }; -export type SettingsSurfaceTarget = { kind: "settings"; tab: SettingsTabId }; +/** `tab` is a settings tab id, or `providers:` for one provider's pane. */ +export type SettingsSurfaceTarget = { + kind: "settings"; + tab: SettingsTabId | `providers:${string}`; +}; export type SurfaceTarget = | TrayPanelSurfaceTarget @@ -202,6 +206,8 @@ export interface SettingsSnapshot { menuBarShowsHighestUsage: boolean; menuBarShowsPercent: boolean; menuBarColorPace: boolean; + /** Settings sidebar lists providers alphabetically, enabled first. Display only. */ + providersSortedAlphabetically: boolean; showAsUsed: boolean; showAllTokenAccountsInMenu: boolean; enableAnimations: boolean; @@ -334,6 +340,7 @@ export interface SettingsUpdate { menuBarShowsHighestUsage?: boolean; menuBarShowsPercent?: boolean; menuBarColorPace?: boolean; + providersSortedAlphabetically?: boolean; showAsUsed?: boolean; showAllTokenAccountsInMenu?: boolean; enableAnimations?: boolean; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index f4a9a50ed6..cc27f5a176 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1152,6 +1152,13 @@ locale_keys! { ProviderSidebarReorderHint, ProviderSidebarMoveUp, ProviderSidebarMoveDown, + SettingsSidebarSortProviders, + SettingsSidebarSortOffHint, + SettingsSidebarSortOnHint, + SettingsSidebarProvidersOnCount, + ProviderContextEnable, + ProviderContextDisable, + SettingsSidebarAriaLabel, ProviderStatusOk, ProviderStatusStale, ProviderStatusError, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index e2ed6da805..d547eef392 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -728,12 +728,19 @@ BannerReadyToInstallSuffix = ready to install BannerInstallRestart = Install & Restart BannerUpdateFailedPrefix = Update failed BannerRetry = Retry -ProviderSidebarSearch = Search +ProviderSidebarSearch = Search providers ProviderSidebarClearSearch = Clear provider search ProviderSidebarNoMatches = No matching providers ProviderSidebarReorderHint = Drag to reorder ProviderSidebarMoveUp = Move up ProviderSidebarMoveDown = Move down +SettingsSidebarSortProviders = Sort providers alphabetically +SettingsSidebarSortOffHint = Sort providers alphabetically (enabled first) +SettingsSidebarSortOnHint = Sorted alphabetically (enabled first) — click to use your custom order +SettingsSidebarProvidersOnCount = { "{}" } on +ProviderContextEnable = Enable +ProviderContextDisable = Disable +SettingsSidebarAriaLabel = Settings sections ProviderStatusOk = Up to date ProviderStatusStale = Stale ProviderStatusError = Error diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 6e35219ae0..d20f236b6c 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -746,12 +746,19 @@ BannerReadyToInstallSuffix = lista para instalar BannerInstallRestart = Instalar y reiniciar BannerUpdateFailedPrefix = Actualización fallida BannerRetry = Reintentar -ProviderSidebarSearch = Buscar +ProviderSidebarSearch = Buscar proveedores ProviderSidebarClearSearch = Limpiar búsqueda de proveedores ProviderSidebarNoMatches = Sin proveedores coincidentes ProviderSidebarReorderHint = Arrastrar para reordenar ProviderSidebarMoveUp = Subir ProviderSidebarMoveDown = Bajar +SettingsSidebarSortProviders = Ordenar proveedores alfabéticamente +SettingsSidebarSortOffHint = Ordenar proveedores alfabéticamente (activados primero) +SettingsSidebarSortOnHint = Orden alfabético (activados primero): haz clic para usar tu orden personalizado +SettingsSidebarProvidersOnCount = { "{}" } activos +ProviderContextEnable = Activar +ProviderContextDisable = Desactivar +SettingsSidebarAriaLabel = Secciones de configuración ProviderStatusOk = Actualizado ProviderStatusStale = Desactualizado ProviderStatusError = Error diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 432efc223c..22e0ca97ba 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -701,12 +701,19 @@ BannerReadyToInstallSuffix = インストール準備完了 BannerInstallRestart = インストールして再起動 BannerUpdateFailedPrefix = 更新に失敗 BannerRetry = 再試行 -ProviderSidebarSearch = 検索 +ProviderSidebarSearch = プロバイダーを検索 ProviderSidebarClearSearch = プロバイダー検索をクリア ProviderSidebarNoMatches = 一致するプロバイダーなし ProviderSidebarReorderHint = ドラッグで並べ替え ProviderSidebarMoveUp = 上へ ProviderSidebarMoveDown = 下へ +SettingsSidebarSortProviders = プロバイダーをアルファベット順に並べ替え +SettingsSidebarSortOffHint = プロバイダーをアルファベット順に並べ替え(有効なものを先頭) +SettingsSidebarSortOnHint = アルファベット順(有効なものを先頭)— クリックするとカスタム順に戻ります +SettingsSidebarProvidersOnCount = { "{}" } 件オン +ProviderContextEnable = 有効にする +ProviderContextDisable = 無効にする +SettingsSidebarAriaLabel = 設定セクション ProviderStatusOk = 最新 ProviderStatusStale = 古い ProviderStatusError = エラー diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 3741416876..067a7e634e 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -746,12 +746,19 @@ BannerReadyToInstallSuffix = 설치 준비 완료 BannerInstallRestart = 설치 및 재시작 BannerUpdateFailedPrefix = 업데이트 실패 BannerRetry = 재시도 -ProviderSidebarSearch = 검색 +ProviderSidebarSearch = 제공업체 검색 ProviderSidebarClearSearch = 제공업체 검색 지우기 ProviderSidebarNoMatches = 일치하는 제공업체 없음 ProviderSidebarReorderHint = 끌어서 순서 변경 ProviderSidebarMoveUp = 위로 이동 ProviderSidebarMoveDown = 아래로 이동 +SettingsSidebarSortProviders = 제공업체를 알파벳순으로 정렬 +SettingsSidebarSortOffHint = 제공업체를 알파벳순으로 정렬(활성 항목 먼저) +SettingsSidebarSortOnHint = 알파벳순 정렬됨(활성 항목 먼저) — 클릭하면 사용자 지정 순서로 돌아갑니다 +SettingsSidebarProvidersOnCount = { "{}" }개 켜짐 +ProviderContextEnable = 활성화 +ProviderContextDisable = 비활성화 +SettingsSidebarAriaLabel = 설정 섹션 ProviderStatusOk = 최신 상태 ProviderStatusStale = 이전 데이터 ProviderStatusError = 오류 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 2f641ad1a6..a1f4692d1d 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -696,12 +696,19 @@ BannerReadyToInstallSuffix = pronto para instalar BannerInstallRestart = Instalar e reiniciar BannerUpdateFailedPrefix = Falha na atualização BannerRetry = Tentar novamente -ProviderSidebarSearch = Pesquisar +ProviderSidebarSearch = Pesquisar provedores ProviderSidebarClearSearch = Limpar pesquisa de provedor ProviderSidebarNoMatches = Nenhum provedor correspondente ProviderSidebarReorderHint = Arraste para reordenar ProviderSidebarMoveUp = Subir ProviderSidebarMoveDown = Mover para baixo +SettingsSidebarSortProviders = Ordenar provedores em ordem alfabética +SettingsSidebarSortOffHint = Ordenar provedores em ordem alfabética (ativados primeiro) +SettingsSidebarSortOnHint = Em ordem alfabética (ativados primeiro) — clique para usar sua ordem personalizada +SettingsSidebarProvidersOnCount = { "{}" } ativos +ProviderContextEnable = Ativar +ProviderContextDisable = Desativar +SettingsSidebarAriaLabel = Seções de configurações ProviderStatusOk = Atualizado ProviderStatusStale = Desatualizado ProviderStatusError = Erro diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 377f7e636c..20a0e5c0e5 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -728,12 +728,19 @@ BannerReadyToInstallSuffix = готов к установке BannerInstallRestart = Установить и перезапустить BannerUpdateFailedPrefix = Обновление не выполнено BannerRetry = Повторить попытку -ProviderSidebarSearch = Поиск +ProviderSidebarSearch = Поиск провайдеров ProviderSidebarClearSearch = Очистить поиск провайдера ProviderSidebarNoMatches = Нет подходящих провайдеров ProviderSidebarReorderHint = Перетащите, чтобы изменить порядок ProviderSidebarMoveUp = Вверх ProviderSidebarMoveDown = Двигаться вниз +SettingsSidebarSortProviders = Сортировать провайдеров по алфавиту +SettingsSidebarSortOffHint = Сортировать провайдеров по алфавиту (сначала включённые) +SettingsSidebarSortOnHint = Отсортировано по алфавиту (сначала включённые) — нажмите, чтобы вернуть свой порядок +SettingsSidebarProvidersOnCount = Включено: { "{}" } +ProviderContextEnable = Включить +ProviderContextDisable = Отключить +SettingsSidebarAriaLabel = Разделы настроек ProviderStatusOk = Актуально ProviderStatusStale = Несвежий ProviderStatusError = Ошибка diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 2c91234048..3265ff65d2 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -728,12 +728,19 @@ BannerReadyToInstallSuffix = kurulmaya hazır BannerInstallRestart = Kur ve Yeniden Başlat BannerUpdateFailedPrefix = Güncelleme başarısız BannerRetry = Yeniden Dene -ProviderSidebarSearch = Ara +ProviderSidebarSearch = Sağlayıcı ara ProviderSidebarClearSearch = Sağlayıcı aramasını temizle ProviderSidebarNoMatches = Eşleşen sağlayıcı yok ProviderSidebarReorderHint = Yeniden sıralamak için sürükleyin ProviderSidebarMoveUp = Yukarı taşı ProviderSidebarMoveDown = Aşağı taşı +SettingsSidebarSortProviders = Sağlayıcıları alfabetik sırala +SettingsSidebarSortOffHint = Sağlayıcıları alfabetik sırala (etkin olanlar önce) +SettingsSidebarSortOnHint = Alfabetik sıralı (etkin olanlar önce) — özel sıranıza dönmek için tıklayın +SettingsSidebarProvidersOnCount = { "{}" } açık +ProviderContextEnable = Etkinleştir +ProviderContextDisable = Devre dışı bırak +SettingsSidebarAriaLabel = Ayar bölümleri ProviderStatusOk = Güncel ProviderStatusStale = Eski ProviderStatusError = Hata diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 35df104664..416eecbbe9 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -728,12 +728,19 @@ BannerReadyToInstallSuffix = готове до встановлення BannerInstallRestart = Встановити й перезапустити BannerUpdateFailedPrefix = Не вдалося оновити BannerRetry = Повторити -ProviderSidebarSearch = Пошук +ProviderSidebarSearch = Пошук провайдерів ProviderSidebarClearSearch = Очистити пошук провайдерів ProviderSidebarNoMatches = Немає відповідних провайдерів ProviderSidebarReorderHint = Перетягніть, щоб змінити порядок ProviderSidebarMoveUp = Вище ProviderSidebarMoveDown = Нижче +SettingsSidebarSortProviders = Сортувати провайдерів за абеткою +SettingsSidebarSortOffHint = Сортувати провайдерів за абеткою (спершу увімкнені) +SettingsSidebarSortOnHint = Відсортовано за абеткою (спершу увімкнені) — натисніть, щоб повернути свій порядок +SettingsSidebarProvidersOnCount = Увімкнено: { "{}" } +ProviderContextEnable = Увімкнути +ProviderContextDisable = Вимкнути +SettingsSidebarAriaLabel = Розділи налаштувань ProviderStatusOk = Актуально ProviderStatusStale = Застаріло ProviderStatusError = Помилка diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index fcfac1fd84..4c07dc3116 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -701,12 +701,19 @@ BannerReadyToInstallSuffix = 已准备好安装 BannerInstallRestart = 安装并重启 BannerUpdateFailedPrefix = 更新失败 BannerRetry = 重试 -ProviderSidebarSearch = 搜索 +ProviderSidebarSearch = 搜索服务商 ProviderSidebarClearSearch = 清除提供商搜索 ProviderSidebarNoMatches = 没有匹配的提供商 ProviderSidebarReorderHint = 拖动以重新排序 ProviderSidebarMoveUp = 上移 ProviderSidebarMoveDown = 下移 +SettingsSidebarSortProviders = 按字母顺序排列服务商 +SettingsSidebarSortOffHint = 按字母顺序排列服务商(已启用的在前) +SettingsSidebarSortOnHint = 已按字母顺序排列(已启用的在前)— 点击恢复自定义顺序 +SettingsSidebarProvidersOnCount = 已开启 { "{}" } 个 +ProviderContextEnable = 启用 +ProviderContextDisable = 停用 +SettingsSidebarAriaLabel = 设置分区 ProviderStatusOk = 已更新 ProviderStatusStale = 已过期 ProviderStatusError = 错误 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 25ab4ec7fe..dc08ec73c4 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -701,12 +701,19 @@ BannerReadyToInstallSuffix = 已準備好安裝 BannerInstallRestart = 安裝並重啟 BannerUpdateFailedPrefix = 更新失敗 BannerRetry = 重試 -ProviderSidebarSearch = 搜尋 +ProviderSidebarSearch = 搜尋提供者 ProviderSidebarClearSearch = 清除提供商搜尋 ProviderSidebarNoMatches = 沒有匹配的提供商 ProviderSidebarReorderHint = 拖動以重新排序 ProviderSidebarMoveUp = 上移 ProviderSidebarMoveDown = 下移 +SettingsSidebarSortProviders = 依字母順序排列提供者 +SettingsSidebarSortOffHint = 依字母順序排列提供者(已啟用的在前) +SettingsSidebarSortOnHint = 已依字母順序排列(已啟用的在前)— 點擊以恢復自訂順序 +SettingsSidebarProvidersOnCount = 已開啟 { "{}" } 個 +ProviderContextEnable = 啟用 +ProviderContextDisable = 停用 +SettingsSidebarAriaLabel = 設定分區 ProviderStatusOk = 已更新 ProviderStatusStale = 已過期 ProviderStatusError = 錯誤 diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 1139085e7f..2ff86ca3e1 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -180,6 +180,11 @@ pub struct Settings { #[serde(default)] pub menu_bar_color_pace: bool, + /// Settings sidebar lists providers alphabetically (enabled first) + /// instead of in `provider_order`. Display only; never rewrites the order. + #[serde(default)] + pub providers_sorted_alphabetically: bool, + /// Show usage bars as "used" (true) or "remaining" (false) pub show_as_used: bool, @@ -617,6 +622,7 @@ impl Default for Settings { menu_bar_shows_highest_usage: false, menu_bar_shows_percent: false, menu_bar_color_pace: false, + providers_sorted_alphabetically: false, show_as_used: false, enable_animations: true, // Animations enabled by default reset_time_relative: true, // Show relative times by default diff --git a/rust/src/settings/preferences_document.rs b/rust/src/settings/preferences_document.rs index 753dc1ca07..5656914039 100644 --- a/rust/src/settings/preferences_document.rs +++ b/rust/src/settings/preferences_document.rs @@ -74,6 +74,7 @@ const ALLOWED_PREFERENCES: &[(&str, Validator)] = &[ ("menu_bar_shows_percent", typed::), ("tray_icon_mode", typed::), ("switcher_shows_icons", typed::), + ("providers_sorted_alphabetically", typed::), ("overview_layout", |v| one_of(v, &["compact", "detailed"])), ("menu_bar_display_mode", |v| { one_of(v, &["minimal", "compact", "detailed"]) diff --git a/rust/src/settings/preferences_document/tests.rs b/rust/src/settings/preferences_document/tests.rs index 96af12d377..7d0a80eef4 100644 --- a/rust/src/settings/preferences_document/tests.rs +++ b/rust/src/settings/preferences_document/tests.rs @@ -47,6 +47,7 @@ fn customized_settings() -> Settings { menu_bar_shows_percent: true, tray_icon_mode: TrayIconMode::PerProvider, switcher_shows_icons: false, + providers_sorted_alphabetically: true, overview_layout: "detailed".to_string(), menu_bar_display_mode: "minimal".to_string(), // Loading normalizes the order to the full canonical list. diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index b93a016836..1cceb24ae8 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -127,6 +127,8 @@ pub(super) struct RawSettings { menu_bar_shows_percent: bool, #[serde(default)] menu_bar_color_pace: bool, + #[serde(default)] + providers_sorted_alphabetically: bool, show_as_used: bool, enable_animations: bool, reset_time_relative: bool, @@ -313,6 +315,7 @@ impl Default for RawSettings { menu_bar_shows_highest_usage: s.menu_bar_shows_highest_usage, menu_bar_shows_percent: s.menu_bar_shows_percent, menu_bar_color_pace: s.menu_bar_color_pace, + providers_sorted_alphabetically: s.providers_sorted_alphabetically, show_as_used: s.show_as_used, enable_animations: s.enable_animations, reset_time_relative: s.reset_time_relative, @@ -660,6 +663,7 @@ impl From for Settings { menu_bar_shows_highest_usage: raw.menu_bar_shows_highest_usage, menu_bar_shows_percent: raw.menu_bar_shows_percent, menu_bar_color_pace: raw.menu_bar_color_pace, + providers_sorted_alphabetically: raw.providers_sorted_alphabetically, show_as_used: raw.show_as_used, enable_animations: raw.enable_animations, reset_time_relative: raw.reset_time_relative, diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 33ca17aa0e..cc773d7a2c 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -180,6 +180,25 @@ fn tray_pace_color_defaults_off_and_round_trips() { assert!(loaded.menu_bar_color_pace); } +#[test] +fn providers_sorted_alphabetically_defaults_off_and_round_trips() { + assert!(!Settings::default().providers_sorted_alphabetically); + + let legacy: Settings = serde_json::from_str(r#"{ "enabled_providers": [] }"#) + .expect("legacy file without the sidebar sort key"); + assert!(!legacy.providers_sorted_alphabetically); + + let sorted = Settings { + providers_sorted_alphabetically: true, + ..Settings::default() + }; + let json = serde_json::to_string(&sorted).expect("serialize sidebar sort"); + assert!(json.contains(r#""providers_sorted_alphabetically":true"#)); + + let loaded: Settings = serde_json::from_str(&json).expect("deserialize sidebar sort"); + assert!(loaded.providers_sorted_alphabetically); +} + #[test] fn cost_reporting_period_defaults_to_thirty_days_and_round_trips() { let defaulted: Settings = serde_json::from_str(r#"{ "enabled_providers": [] }"#)