Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,7 @@ pub struct SettingsSnapshot {
http_proxy_username: String,
http_proxy_password: String,
ui_language: &'static str,
ui_language_follows_system: bool,
theme: &'static str,
window_scale_percent: u16,
tray_scale_percent: u16,
Expand Down Expand Up @@ -1140,6 +1141,7 @@ impl From<Settings> for SettingsSnapshot {
http_proxy_username: settings.http_proxy_username,
http_proxy_password: settings.http_proxy_password,
ui_language: language_label(settings.ui_language),
ui_language_follows_system: settings.ui_language_follows_system,
theme: theme_label(settings.theme),
window_scale_percent: settings.window_scale_percent,
tray_scale_percent: settings.tray_scale_percent,
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@ pub fn set_ui_language(app: tauri::AppHandle, language: String) -> Result<(), St
let lang =
parse_locale_language(&language).ok_or_else(|| format!("unknown language: {language}"))?;
let mut settings = Settings::load();
if settings.ui_language == lang {
if settings.ui_language == lang && !settings.ui_language_follows_system {
return Ok(());
}
// Picking a language explicitly stops following the system language.
settings.ui_language_follows_system = false;
settings.ui_language = lang;
settings.save().map_err(|e| e.to_string())?;
let _ = app.emit(events::LOCALE_CHANGED, language_label(lang));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ pub async fn import_preferences(
let applied = document
.apply_to(&mut settings)
.map_err(|error| error.to_string())?;
// An imported "System" language resolves to this PC's display language.
settings.resolve_system_language();
if settings.cost_usage_enabled && !previous_cost_tracking {
// Turning "Track costs" on pins the bucket zone, as in Settings.
settings.pin_cost_usage_bucket_time_zone();
Expand Down
70 changes: 64 additions & 6 deletions apps/desktop-tauri/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ pub struct SettingsUpdate {
pub http_proxy_username: Option<String>,
pub http_proxy_password: Option<String>,
pub ui_language: Option<String>,
pub ui_language_follows_system: Option<bool>,
pub theme: Option<String>,
pub window_scale_percent: Option<u16>,
pub tray_scale_percent: Option<u16>,
Expand Down Expand Up @@ -147,7 +148,22 @@ impl SettingsUpdate {
}

fn rebuilds_tray_menu(&self) -> bool {
self.float_bar_enabled.is_some() || self.ui_language.is_some()
self.float_bar_enabled.is_some()
|| self.ui_language.is_some()
|| self.ui_language_follows_system.is_some()
}

/// Picking a language stops following the system; picking "System"
/// (`ui_language_follows_system: true`) resolves to `system`.
fn apply_language_with(&self, settings: &mut Settings, system: Language) {
if let Some(language) = self.ui_language.as_deref().and_then(parse_language) {
settings.ui_language = language;
settings.ui_language_follows_system = false;
}
if let Some(follow) = self.ui_language_follows_system {
settings.ui_language_follows_system = follow;
}
settings.resolve_system_language_with(system);
}

pub fn changes_tray_promotion(&self) -> bool {
Expand Down Expand Up @@ -176,6 +192,7 @@ impl SettingsUpdate {
|| self.cost_reporting_period.is_some()
|| self.enabled_providers.is_some()
|| self.ui_language.is_some()
|| self.ui_language_follows_system.is_some()
}

fn validate_shortcut_change(
Expand Down Expand Up @@ -289,11 +306,13 @@ impl SettingsUpdate {
if let Some(v) = self.global_shortcut.clone() {
settings.global_shortcut = v;
}
if let Some(v) = self.ui_language.as_deref().and_then(parse_language)
&& settings.ui_language != v
{
settings.ui_language = v;
}
// Read the registry only when "System" is being picked.
let system = if self.ui_language_follows_system == Some(true) {
Language::system()
} else {
settings.ui_language
};
self.apply_language_with(settings, system);
if let Some(v) = self.theme.as_deref().and_then(parse_theme) {
settings.theme = v;
}
Expand Down Expand Up @@ -1200,6 +1219,45 @@ mod tests {
);
}

#[test]
fn choosing_system_language_follows_the_system() {
let mut settings = Settings {
ui_language: Language::Spanish,
ui_language_follows_system: false,
..Settings::default()
};
let patch = SettingsUpdate {
ui_language_follows_system: Some(true),
..Default::default()
};
assert!(patch.rebuilds_tray_menu());
assert!(patch.refreshes_tray_presentation());
patch.apply_language_with(&mut settings, Language::Korean);
assert!(settings.ui_language_follows_system);
assert_eq!(settings.ui_language, Language::Korean);
}

#[test]
fn choosing_a_specific_language_stops_following_the_system() {
let mut settings = Settings {
ui_language: Language::Korean,
ui_language_follows_system: true,
..Settings::default()
};
SettingsUpdate {
ui_language: Some("japanese".to_string()),
..Default::default()
}
.apply_language_with(&mut settings, Language::Korean);
assert!(!settings.ui_language_follows_system);
assert_eq!(settings.ui_language, Language::Japanese);

// An unrelated patch leaves the choice alone.
SettingsUpdate::default().apply_language_with(&mut settings, Language::Korean);
assert!(!settings.ui_language_follows_system);
assert_eq!(settings.ui_language, Language::Japanese);
}

#[test]
fn ui_language_change_refreshes_tray_presentation() {
assert!(
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop-tauri/src-tauri/src/commands/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1373,9 +1373,9 @@ fn usage_item_descriptors_keep_raw_ids_and_redact_titles() {
let items = super::usage_item_descriptors(Some(&snapshot), &settings, ProviderId::Codex);

assert_eq!(items[0].id, "metric:primary");
assert_eq!(items[0].title, "Account Hidden");
assert_eq!(items[0].title, "Account");
assert_eq!(items[1].id, "metric:extra-credits");
assert_eq!(items[1].title, "Credits Hidden");
assert_eq!(items[1].title, "Credits");
assert_eq!(items[2].id, "metric:extra-missing");
assert!(!items[2].available);
}
Expand Down Expand Up @@ -1434,7 +1434,7 @@ fn usage_item_descriptors_include_detail_sections() {
.find(|item| item.id.starts_with("detailSection:") && !item.available)
.expect("hidden detail placeholder");
assert!(hidden.id.contains("owner@example.com quota"));
assert_eq!(hidden.title, "Hidden quota");
assert_eq!(hidden.title, "quota");
}

#[test]
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop-tauri/src-tauri/src/commands/usage_items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub(crate) fn usage_item_id(raw_id: &str) -> String {
}

fn redacted_usage_item_title(title: &str, settings: &Settings) -> String {
PersonalInfoRedactor::redact_emails_in_text(Some(title), settings.hide_personal_info)
PersonalInfoRedactor::hide_emails_in_text(Some(title), settings.hide_personal_info)
.unwrap_or_default()
}

Expand Down Expand Up @@ -92,20 +92,22 @@ pub(crate) fn usage_item_descriptors(
let mut detail_titles = std::collections::HashSet::new();
for detail in &snapshot.display_details {
if detail_titles.insert(detail.title.clone()) {
let redacted = PersonalInfoRedactor::redact_emails_in_text(
// The ID keeps the "Hidden" marker it has always used, so
// sections hidden under Hide Personal Info stay hidden.
let id_title = PersonalInfoRedactor::redact_emails_in_text(
Some(&detail.title),
settings.hide_personal_info,
)
.unwrap_or_default();
let id = format!(
"{}{}",
codexbar::settings::USAGE_ITEM_DETAIL_SECTION_PREFIX,
redacted
id_title
);
if seen.insert(id.clone()) {
items.push(ProviderUsageItemSnapshot {
id,
title: redacted,
title: redacted_usage_item_title(&detail.title, settings),
available: true,
});
}
Expand Down
142 changes: 134 additions & 8 deletions apps/desktop-tauri/src-tauri/src/tray_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use codexbar::codex_accounts::{CodexAccount, ordinals_by_id};
use codexbar::locale::{self, LocaleKey};
use codexbar::settings::{Language, Settings};
use std::collections::HashMap;
use tauri::AppHandle;
#[cfg(test)]
use uuid::Uuid;
Expand Down Expand Up @@ -261,20 +262,40 @@ fn codex_account_menu_label(
)
}

/// Number accounts 1..n by their sorted stable id, not by list order, so an
/// account keeps its "Account N" when a switch reorders the list. Settings
/// uses the same rule (`buildAccountOrdinals`).
fn ordinals_by_sorted_id<'a>(ids: impl IntoIterator<Item = &'a str>) -> HashMap<&'a str, usize> {
let mut ids: Vec<&str> = ids.into_iter().collect();
ids.sort_unstable();
ids.dedup();
ids.into_iter()
.enumerate()
.map(|(index, id)| (id, index + 1))
.collect()
}

/// "Account N": Hide Personal Info shows no part of an email (Mac
/// `PersonalInfoRedactor.AccountOrdinal`).
fn hidden_account_label(lang: Language, ordinal: usize) -> String {
let word = locale::get_text(lang, LocaleKey::Account);
let word = word.trim();
let word = if word.is_empty() { "Account" } else { word };
format!("{word} {ordinal}")
}

fn claude_accounts_menu(
accounts: &[codexbar::providers::claude::accounts::ClaudeAccount],
lang: Language,
hide_personal_info: bool,
) -> TrayMenuEntry {
let text = |key| locale::get_text(lang, key);
let ordinals = ordinals_by_sorted_id(accounts.iter().map(|account| account.id.as_str()));
let mut children: Vec<_> = accounts
.iter()
.map(|account| {
let label = if hide_personal_info {
codexbar::core::PersonalInfoRedactor::partial_redact_email(
Some(&account.email),
true,
)
hidden_account_label(lang, ordinals[account.id.as_str()])
} else {
account.organization.as_ref().map_or_else(
|| account.email.clone(),
Expand Down Expand Up @@ -322,14 +343,12 @@ fn grok_accounts_menu(
hide_personal_info: bool,
) -> TrayMenuEntry {
let text = |key| locale::get_text(lang, key);
let ordinals = ordinals_by_sorted_id(accounts.iter().map(|account| account.id.as_str()));
let mut children: Vec<_> = accounts
.iter()
.map(|account| {
let label = if hide_personal_info {
codexbar::core::PersonalInfoRedactor::partial_redact_email(
Some(&account.email),
true,
)
hidden_account_label(lang, ordinals[account.id.as_str()])
} else {
account.email.clone()
};
Expand Down Expand Up @@ -605,4 +624,111 @@ mod tests {
"add_grok_account"
));
}

fn claude_menu_account(id: &str) -> codexbar::providers::claude::accounts::ClaudeAccount {
codexbar::providers::claude::accounts::ClaudeAccount {
id: id.into(),
email: format!("{id}@example.com"),
organization: None,
plan: None,
is_active: false,
is_saved: true,
}
}

#[test]
fn hidden_claude_and_grok_tray_labels_show_no_email() {
use codexbar::providers::claude::accounts::ClaudeAccount;
use codexbar::providers::grok::accounts::GrokAccount;
let claude = ClaudeAccount {
id: "a:org".into(),
email: "proof@example.com".into(),
organization: Some("proof@example.com's Organization".into()),
plan: None,
is_active: true,
is_saved: true,
};
let claude_other = ClaudeAccount {
id: "b:org".into(),
email: "second@example.com".into(),
is_active: false,
..claude.clone()
};
let grok = GrokAccount {
id: "user-a".into(),
email: "proof@example.com".into(),
organization: None,
plan: None,
is_active: true,
is_saved: true,
};
let claude_menu = claude_accounts_menu(&[claude, claude_other], Language::English, true);
let grok_menu = grok_accounts_menu(&[grok], Language::English, true);
let labels: Vec<&str> = claude_menu.children[..2]
.iter()
.chain(&grok_menu.children[..1])
.map(|entry| entry.label.as_str())
.collect();
assert_eq!(labels, ["Account 1", "Account 2", "Account 1"]);

// An unsaved live login is listed first; numbering still follows the
// sorted ids, so "b:org" stays "Account 2" whatever the list order.
let live = ClaudeAccount {
is_active: true,
is_saved: false,
..claude_menu_account("0:org")
};
let reordered = claude_accounts_menu(
&[
live,
claude_menu_account("b:org"),
claude_menu_account("a:org"),
],
Language::English,
true,
);
let by_id: Vec<(&str, &str)> = reordered.children[..3]
.iter()
.map(|entry| (entry.id.as_deref().unwrap_or(""), entry.label.as_str()))
.collect();
assert_eq!(
by_id,
[
("switch_claude_account:0:org", "Account 1"),
("switch_claude_account:b:org", "Account 3"),
("switch_claude_account:a:org", "Account 2"),
]
);
let grok_reordered = grok_accounts_menu(
&[
GrokAccount {
id: "user-z".into(),
email: "z@example.com".into(),
organization: None,
plan: None,
is_active: true,
is_saved: false,
},
GrokAccount {
id: "user-a".into(),
email: "a@example.com".into(),
organization: None,
plan: None,
is_active: false,
is_saved: true,
},
],
Language::English,
true,
);
let grok_labels: Vec<&str> = grok_reordered.children[..2]
.iter()
.map(|entry| entry.label.as_str())
.collect();
assert_eq!(grok_labels, ["Account 2", "Account 1"]);
for entry in claude_menu.children.iter().chain(&grok_menu.children) {
assert!(!entry.label.contains('@'), "{}", entry.label);
assert!(!entry.label.contains("example.com"), "{}", entry.label);
}
}
}
Loading