diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3ddf213..da27db7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-theme", "core:window:allow-set-background-color", "core:webview:allow-set-webview-background-color", - "dialog:allow-open" + "dialog:allow-open", + "dialog:allow-save" ] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 63eeaa2..5114994 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2725,6 +2725,7 @@ fn main() { usage::get_usage_overview, usage::get_usage_analysis, usage::get_usage_events, + usage::save_usage_events_export, usage::get_usage_pricing, usage::get_usage_storage_settings, usage::repair_usage_cache_records, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index a0caf57..9f08070 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -21,7 +21,7 @@ use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, HashMap, HashSet}, fs, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, sync::{LazyLock, Mutex}, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -4232,6 +4232,113 @@ fn load_api_key_categories( Ok(categories) } + +#[tauri::command] +pub(crate) async fn save_usage_events_export(path: String, contents: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || write_usage_events_export(&path, &contents)) + .await + .map_err(|error| format!("Failed to save usage export: {error}"))? +} + +fn write_usage_events_export(path: &str, contents: &str) -> Result<(), String> { + let path = validated_usage_export_path(path)?; + fs::write(&path, contents.as_bytes()) + .map_err(|error| format!("Failed to write usage export {}: {error}", path.display())) +} + +fn validated_usage_export_path(path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() || trimmed.contains('\0') { + return Err("Usage export path is invalid".to_string()); + } + let path = PathBuf::from(trimmed); + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err("Usage export path must be an absolute file path".to_string()); + } + if path.file_name().is_none_or(|name| name.is_empty()) { + return Err("Usage export path must include a file name".to_string()); + } + if path.is_dir() { + return Err("Usage export path is a directory".to_string()); + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| "Usage export path has no directory".to_string())?; + if !parent.is_dir() { + return Err(format!( + "Usage export directory does not exist: {}", + parent.display() + )); + } + Ok(path) +} + +#[cfg(test)] +mod save_usage_events_export_tests { + use super::{validated_usage_export_path, write_usage_events_export}; + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TempDir(PathBuf); + + impl TempDir { + fn new(label: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "cpa-usage-export-{}-{}-{}", + std::process::id(), + label, + stamp + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn writes_the_selected_csv_and_replaces_an_existing_file() { + let directory = TempDir::new("write"); + let path = directory.path().join("usage.csv"); + fs::write(&path, "old").unwrap(); + let selected = path.to_string_lossy().into_owned(); + write_usage_events_export(&selected, "\u{feff}id\r\n1").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "\u{feff}id\r\n1"); + } + + #[test] + fn rejects_relative_paths_missing_directories_and_directories() { + let directory = TempDir::new("reject"); + assert!(validated_usage_export_path("usage.csv").is_err()); + assert!(validated_usage_export_path(&directory.path().to_string_lossy()).is_err()); + let missing = directory + .path() + .join("missing") + .join("usage.csv") + .to_string_lossy() + .into_owned(); + assert!(write_usage_events_export(&missing, "id").is_err()); + } +} + #[tauri::command] pub(crate) async fn get_usage_events( query: UsageQuery, diff --git a/src/components/AgentModelPicker.tsx b/src/components/AgentModelPicker.tsx new file mode 100644 index 0000000..3ff8af3 --- /dev/null +++ b/src/components/AgentModelPicker.tsx @@ -0,0 +1,341 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; +import { Check, ChevronDown, LoaderCircle, RefreshCw, Search, X } from 'lucide-react'; +import { + agentModelAlias, + filterAgentModels, + findAgentModel, +} from '../services/agentModelPicker'; +import type { ModelOption } from '../services/modelService'; +import { useI18n } from '../i18n'; + +export type AgentModelPickerProps = { + models: ModelOption[]; + value: string; + loading?: boolean; + error?: string; + disabled?: boolean; + onChange: (value: string) => void; + onRefresh?: () => void; + allowCustomValue?: boolean; + editable?: { label: string; placeholder: string; maxLength: number }; + menuClassName?: string; +}; + +type AgentModelDropdownLayout = { + top: number; + left: number; + width: number; + height: number; +}; + +export function AgentModelPicker({ + models, + value, + loading = false, + error = '', + disabled = false, + onChange, + onRefresh, + allowCustomValue = false, + editable, + menuClassName = '', +}: AgentModelPickerProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const [dropdownLayout, setDropdownLayout] = useState(null); + const [dialogOverlay, setDialogOverlay] = useState(false); + const rootRef = useRef(null); + const menuRef = useRef(null); + const searchRef = useRef(null); + const valueRef = useRef(null); + const listboxId = useId(); + const visibleModels = useMemo( + () => editable && !search.trim() ? models : filterAgentModels(models, search), + [models, search, editable], + ); + const choices = useMemo(() => { + const options = visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })); + if (allowCustomValue && search.trim() && !findAgentModel(models, search)) { + options.push({ name: search.trim(), alias: t('agents.model.useCustom') }); + } + return options; + }, [visibleModels, allowCustomValue, search, models, t]); + const selectedModel = findAgentModel(models, value); + const selectedName = selectedModel?.name ?? (allowCustomValue || editable ? value.trim() : ''); + const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : ''; + + const updateDropdownLayout = useCallback(() => { + const root = rootRef.current; + if (!root) return; + + const rect = root.getBoundingClientRect(); + const dialog = root.closest('dialog'); + const footer = dialog?.querySelector('.credential-settings-footer'); + const heading = dialog?.querySelector('.credential-settings-heading'); + const frame = dialog?.getBoundingClientRect(); + const footerTop = footer?.getBoundingClientRect().top; + const headingBottom = heading?.getBoundingClientRect().bottom; + const edgeGap = 12; + const triggerGap = 6; + const preferredHeight = 282; + const minimumHeight = 150; + const bounds = { + top: (headingBottom ?? frame?.top ?? 0) + edgeGap, + bottom: (footerTop ?? frame?.bottom ?? window.innerHeight) - edgeGap, + left: (frame?.left ?? 0) + edgeGap, + right: (frame?.right ?? window.innerWidth) - edgeGap, + }; + const scroller = dialog?.querySelector('.credential-settings-body'); + if (scroller) { + const view = scroller.getBoundingClientRect(); + if (rect.bottom < view.top + 4 || rect.top > view.bottom - 4) { + setOpen(false); + return; + } + } + const spaceBelow = Math.max(0, bounds.bottom - rect.bottom - triggerGap); + const spaceAbove = Math.max(0, rect.top - triggerGap - bounds.top); + const placeAbove = spaceBelow < minimumHeight && spaceAbove > spaceBelow; + const availableHeight = placeAbove ? spaceAbove : spaceBelow; + const limit = Math.max(0, bounds.bottom - bounds.top); + const height = Math.min(preferredHeight, limit, Math.max(Math.min(minimumHeight, limit), availableHeight)); + const width = Math.min(rect.width, Math.max(0, bounds.right - bounds.left)); + const left = Math.min(Math.max(bounds.left, rect.left), Math.max(bounds.left, bounds.right - width)); + const desiredTop = placeAbove ? rect.top - triggerGap - height : rect.bottom + triggerGap; + const top = Math.min(Math.max(bounds.top, desiredTop), Math.max(bounds.top, bounds.bottom - height)); + + setDropdownLayout({ top, left, width, height }); + }, []); + + useLayoutEffect(() => { + if (!open) { + setDropdownLayout(null); + setDialogOverlay(false); + return undefined; + } + const inDialog = Boolean(rootRef.current?.closest('dialog')); + if (inDialog !== dialogOverlay) { + setDialogOverlay(inDialog); + return undefined; + } + + updateDropdownLayout(); + const menu = menuRef.current; + if (dialogOverlay && menu && !menu.matches(':popover-open')) menu.showPopover(); + const onScroll = (event: Event) => { + if (menuRef.current?.contains(event.target as Node)) return; + updateDropdownLayout(); + }; + window.addEventListener('resize', updateDropdownLayout); + window.addEventListener('scroll', onScroll, true); + return () => { + window.removeEventListener('resize', updateDropdownLayout); + window.removeEventListener('scroll', onScroll, true); + }; + }, [open, dialogOverlay, updateDropdownLayout]); + + useEffect(() => { + if (!open) return undefined; + const close = (event: MouseEvent) => { + const target = event.target as Node; + if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + useEffect(() => { + if (!open) return; + if (!editable) setSearch(''); + const selectedIndex = (editable ? visibleModels : filterAgentModels(models, '')).findIndex( + (model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(), + ); + setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); + if (!editable) requestAnimationFrame(() => searchRef.current?.focus()); + }, [open]); + + useEffect(() => { + setActiveIndex((current) => Math.min(current, Math.max(choices.length - 1, 0))); + }, [choices.length]); + + const choose = (name: string) => { + onChange(name); + setOpen(false); + if (editable) valueRef.current?.focus(); + }; + + const moveActive = (offset: number) => { + if (choices.length === 0) return; + setActiveIndex((current) => (current + offset + choices.length) % choices.length); + }; + + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (!open) setOpen(true); + else moveActive(1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (!open) setOpen(true); + else moveActive(-1); + } else if (event.key === 'Enter' && open && choices[activeIndex]) { + event.preventDefault(); + choose(choices[activeIndex].name); + } else if (event.key === 'Escape') { + event.preventDefault(); + setOpen(false); + } + }; + + return ( +
{ + if (event.button === 0 && event.currentTarget.contains(document.activeElement) + && event.target instanceof Element && event.target.closest('button')) { + event.preventDefault(); + } + }} + onBlur={(event) => { + const next = event.relatedTarget as Node | null; + if (!event.currentTarget.contains(next) && !menuRef.current?.contains(next)) setOpen(false); + }}> + {editable ? ( +
+ { setSearch(''); setOpen(true); }} onKeyDown={handleSearchKeyDown} + onChange={(event) => { + onChange(event.currentTarget.value); + setSearch(event.currentTarget.value); + setActiveIndex(0); + setOpen(true); + }} /> + +
+ ) : } + + {open ? ( +
+ {!editable ?
+ + { + setSearch(event.currentTarget.value); + setActiveIndex(0); + }} + onKeyDown={handleSearchKeyDown} + placeholder={t('agents.model.search')} + role="combobox" + aria-controls={listboxId} + aria-expanded="true" + /> + {search ? ( + + ) : null} + {onRefresh ? : null} +
: null} + +
+ {loading && models.length === 0 && choices.length === 0 ? ( +
{t('agents.model.fetching')}
+ ) : error && models.length === 0 && choices.length === 0 ? ( +
{t('agents.model.loadFailed')}{error}
+ ) : choices.length === 0 ? ( +
+ {editable ? {t('agents.claudeDesktopMapping.customAliasHint')} : <> + {search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')} + {search.trim() ? t('agents.model.tryKeywords') : t('agents.model.connectFirst')} + } +
+ ) : choices.map((choice, index) => { + const selected = choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(); + return ( + + ); + })} +
+
+ {t(editable ? 'agents.claudeDesktopMapping.suggestionCount' : 'agents.model.count', { count: models.length })} + {error && models.length > 0 ? {t('agents.model.stale')} : null} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/AuthFileSettingsDialog.css b/src/components/AuthFileSettingsDialog.css index f93da8f..83859db 100644 --- a/src/components/AuthFileSettingsDialog.css +++ b/src/components/AuthFileSettingsDialog.css @@ -1,6 +1,8 @@ .credential-settings-dialog { - width: min(760px, calc(100vw - 32px)); + width: min(820px, calc(100vw - 32px)); max-height: min(900px, calc(100dvh - 40px)); + display: flex; + flex-direction: column; padding: 0; overflow: hidden; border: 1px solid var(--border-color); @@ -10,17 +12,17 @@ box-shadow: 0 24px 80px #0004; } .credential-settings-dialog::backdrop { background: #0b122080; backdrop-filter: blur(3px); } -.credential-settings-dialog form { display: flex; flex-direction: column; max-height: min(898px, calc(100dvh - 42px)); min-height: 0; } +.credential-settings-dialog form { display: flex; flex: 1 1 auto; flex-direction: column; width: 100%; min-height: 0; } .credential-settings-heading, .credential-settings-footer { flex-shrink: 0; } .credential-settings-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--border-color); } .credential-settings-heading > div { min-width: 0; } .credential-settings-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; font-size: var(--font-size-dialog-title); line-height: var(--line-height-tight); } .credential-settings-heading p { margin: 6px 0 0; color: var(--text-tertiary); font-size: var(--font-size-meta); overflow-wrap: anywhere; } -.credential-settings-body { overflow-y: auto; min-height: 0; padding: 22px 24px; overscroll-behavior: contain; } +.credential-settings-body { overflow: auto; flex: 1 1 auto; min-height: 0; padding: 22px 24px 28px; overscroll-behavior: contain; } .credential-settings-body fieldset { border: 0; padding: 0; margin: 0; min-width: 0; } -.credential-settings-section + .credential-settings-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border-color); } -.credential-settings-section h3 { margin: 0 0 15px; font-size: var(--font-size-section); line-height: var(--line-height-tight); } -.credential-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 20px; } +.credential-settings-section + .credential-settings-section { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border-color); } +.credential-settings-section h3 { margin: 0 0 8px; font-size: var(--font-size-section); line-height: var(--line-height-tight); } +.credential-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; } .credential-settings-field { display: grid; align-content: start; gap: 7px; min-width: 0; font-size: var(--font-size-label); } .credential-settings-field > span { font-weight: var(--font-weight-medium); } .credential-settings-field + .credential-settings-field { margin-top: 16px; } @@ -40,7 +42,7 @@ .credential-settings-models label { display: flex; align-items: flex-start; gap: 8px; overflow-wrap: anywhere; } .credential-settings-models input { accent-color: var(--primary-color); margin-top: 3px; } .credential-settings-models span { min-width: 0; } -.credential-settings-footer { padding: 16px 24px; border-top: 1px solid var(--border-color); } +.credential-settings-footer { position: relative; z-index: 1; flex: 0 0 auto; padding: 16px 24px; border-top: 1px solid var(--border-color); background: var(--bg-card); } .credential-settings-actions { display: flex; gap: 10px; justify-content: flex-end; } .credential-settings-error { color: var(--failure-badge-text); margin: 0 0 12px; font-size: var(--font-size-caption); overflow-wrap: anywhere; max-height: 90px; overflow-y: auto; } .credential-settings-warning { color: var(--amber-text); } @@ -51,3 +53,55 @@ .credential-settings-grid { grid-template-columns: minmax(0, 1fr); } .credential-settings-heading, .credential-settings-body, .credential-settings-footer { padding: 16px; } } + +.credential-settings-lead { margin: 0 0 10px; color: var(--text-tertiary); font-size: var(--font-size-meta); line-height: var(--line-height-body); } +.credential-settings-span { grid-column: 1 / -1; } +.credential-settings-inline-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.credential-alias-list, .credential-header-list { display: grid; gap: 12px; } +.credential-alias-card { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-primary); } +.credential-alias-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; } +.credential-alias-fields .credential-settings-field { margin: 0; } +.credential-alias-fields .credential-settings-field:last-child { grid-column: 1 / -1; } +.credential-settings-dialog .agent-model-trigger { box-sizing: border-box; min-height: 41px; padding: 0 4px 0 11px; border-color: var(--border-color); border-radius: 7px; background: var(--bg-primary); color: var(--text-primary); } +.credential-settings-dialog .agent-model-trigger strong { color: var(--text-primary); } +.credential-settings-dialog .agent-model-editable-trigger input { box-sizing: border-box; width: 100%; height: 39px; margin: 0; padding: 0; border: 0; border-radius: 0; outline: 0; background: transparent; box-shadow: none; color: inherit; font: inherit; font-size: inherit; line-height: 1.5; } +.credential-settings-dialog .credential-model-menu { + position: fixed; + z-index: 2; + margin: 0; + max-height: none; + overflow: hidden; + border: 1px solid var(--border-color); + background: var(--bg-card); + color: var(--text-primary); + box-shadow: 0 16px 40px #0003; +} +.credential-settings-dialog .credential-model-menu .agent-model-option { + background: var(--bg-card); + color: var(--text-primary); +} +.credential-settings-dialog .credential-model-menu .agent-model-option:hover, +.credential-settings-dialog .credential-model-menu .agent-model-option.active { + background: var(--bg-secondary); +} +.credential-settings-dialog .credential-model-menu .agent-model-option.selected { + background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-card)); +} +.credential-settings-dialog .credential-model-menu .agent-model-option small, +.credential-settings-dialog .credential-model-menu .agent-model-dropdown-footer { + color: var(--text-tertiary); +} +.credential-alias-card-heading, .credential-header-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.credential-alias-card-heading strong { font-size: var(--font-size-meta); } +.credential-alias-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; } +.credential-alias-checks label { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-meta); } +.credential-header-row { display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr) auto; } +.credential-header-row input { min-width: 0; } +@media (max-width: 560px) { + .credential-header-row { grid-template-columns: minmax(0, 1fr); } +} + +@media (max-width: 560px) { + .credential-alias-fields { grid-template-columns: minmax(0, 1fr); } +} + diff --git a/src/components/AuthFileSettingsDialog.tsx b/src/components/AuthFileSettingsDialog.tsx index 717063a..a77b62a 100644 --- a/src/components/AuthFileSettingsDialog.tsx +++ b/src/components/AuthFileSettingsDialog.tsx @@ -6,22 +6,23 @@ import { managementApi } from '../services/managementApi'; import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft, type BooleanOverride } from '../services/authFileSettings'; import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels'; import './AuthFileSettingsDialog.css'; -import { StructuredConfigEditor, useConfigText } from './StructuredConfigEditor'; -import { credentialAdvancedShape } from '../services/credentialAdvancedSettings'; +import { CredentialAdvancedFields, CredentialHeadersEditor, credentialProviderKey } from './CredentialAdvancedFields'; +import { modelSearchText, type ModelOption } from '../services/modelService'; -export function AuthFileSettingsDialog({ name, onClose, onSaved }: { +export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }: { name: string; + provider?: string; onClose: () => void; onSaved: () => void; }) { const { t } = useI18n(); - const tx = useConfigText(); const id = useId(); const dialogRef = useRef(null); const savingRef = useRef(false); const [original, setOriginal] = useState(null); const [draft, setDraft] = useState(null); const [models, setModels] = useState([]); + const [pickerModels, setPickerModels] = useState([]); const [catalogError, setCatalogError] = useState(''); const [catalogLoading, setCatalogLoading] = useState(true); const [search, setSearch] = useState(''); @@ -57,7 +58,10 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: { if (active) setError(reason instanceof Error ? reason.message : String(reason)); }).finally(() => { if (active) setLoading(false); }); void managementApi.get('/credentials/models', { name }).then((payload) => { - if (active) setModels(oauthModelsFromPayload(payload)); + const catalog = oauthModelsFromPayload(payload); + if (!active) return; + setModels(catalog); + setPickerModels(catalog.map((model) => ({ name: model.id, displayName: model.displayName }))); }).catch((reason: unknown) => { if (active) setCatalogError(reason instanceof Error ? reason.message : String(reason)); }).finally(() => { if (active) setCatalogLoading(false); }); @@ -92,8 +96,10 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: { } }; const rules = normalizeOAuthExcludedRules((draft?.excluded_models ?? '').split(/\r?\n/)); - const candidates = oauthModelCandidates(models, rules).filter((model) => - `${model.id} ${model.displayName ?? ''}`.toLowerCase().includes(search.trim().toLowerCase())); + const query = search.trim().toLowerCase(); + const candidates = oauthModelCandidates(models, rules).filter((model) => modelSearchText({ name: model.id, displayName: model.displayName }).includes(query)); + const resolvedProvider = credentialProviderKey(name, provider); + const showWebsockets = resolvedProvider === 'codex' || resolvedProvider === 'xai' || draft?.websockets === 'true' || draft?.websockets === 'false'; const textField = (key: 'prefix' | 'proxy_url' | 'priority' | 'weight') => (