diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a80574fbd..032b81a29 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,5 +14,8 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Check translation consistency + run: bun run check:translations + - name: Run ESLint run: bun run lint diff --git a/bun.lock b/bun.lock index c0f3202d2..c45196b0f 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@tauri-apps/api": "^2.9.0", "@tauri-apps/plugin-autostart": "~2.5.1", "@tauri-apps/plugin-clipboard-manager": "~2.3.2", + "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-fs": "~2.4.4", "@tauri-apps/plugin-global-shortcut": "~2.3.1", "@tauri-apps/plugin-opener": "^2.5.2", @@ -313,6 +314,8 @@ "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ=="], + "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.4", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-MTorXxIRmOnOPT1jZ3w96vjSuScER38ryXY88vl5F0uiKdnvTKKTtaEjTEo8uPbl4e3gnUtfsDVwC7h77GQLvQ=="], "@tauri-apps/plugin-global-shortcut": ["@tauri-apps/plugin-global-shortcut@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g=="], diff --git a/package.json b/package.json index f039567ab..cb4ba5868 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,15 @@ "format:frontend": "prettier --write .", "format:backend": "cd src-tauri && cargo fmt", "test:playwright": "playwright test", - "test:playwright:ui": "playwright test --ui" + "test:playwright:ui": "playwright test --ui", + "check:translations": "bun scripts/check-translations.ts" }, "dependencies": { "@tailwindcss/vite": "^4.1.16", "@tauri-apps/api": "^2.9.0", "@tauri-apps/plugin-autostart": "~2.5.1", "@tauri-apps/plugin-clipboard-manager": "~2.3.2", + "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-fs": "~2.4.4", "@tauri-apps/plugin-global-shortcut": "~2.3.1", "@tauri-apps/plugin-opener": "^2.5.2", @@ -30,16 +32,16 @@ "@tauri-apps/plugin-sql": "~2.3.1", "@tauri-apps/plugin-store": "~2.4.1", "@tauri-apps/plugin-updater": "~2.9.0", - "react-select": "^5.8.0", - "tauri-plugin-macos-permissions-api": "2.3.0", "i18next": "^25.7.2", "immer": "^11.1.3", "lucide-react": "^0.542.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.4.1", + "react-select": "^5.8.0", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", + "tauri-plugin-macos-permissions-api": "2.3.0", "zod": "^3.25.76", "zustand": "^5.0.8" }, diff --git a/scripts/check-translations.ts b/scripts/check-translations.ts new file mode 100644 index 000000000..f19d8d3b9 --- /dev/null +++ b/scripts/check-translations.ts @@ -0,0 +1,224 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Configuration +const LOCALES_DIR = path.join(__dirname, "..", "src", "i18n", "locales"); +const REFERENCE_LANG = "en"; + +type TranslationData = Record; + +interface ValidationResult { + valid: boolean; + missing: string[][]; + extra: string[][]; +} + +function getLanguages(): string[] { + const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory() && entry.name !== REFERENCE_LANG) + .map((entry) => entry.name) + .sort(); +} + +const LANGUAGES = getLanguages(); + +// Colors for terminal output +const colors: Record = { + reset: "\x1b[0m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", +}; + +function colorize(text: string, color: string): string { + return `${colors[color]}${text}${colors.reset}`; +} + +function getAllKeyPaths( + obj: TranslationData, + prefix: string[] = [], +): string[][] { + let paths: string[][] = []; + for (const key in obj) { + if (!Object.hasOwn(obj, key)) continue; + + const currentPath = prefix.concat([key]); + const value = obj[key]; + + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + paths = paths.concat( + getAllKeyPaths(value as TranslationData, currentPath), + ); + } else { + paths.push(currentPath); + } + } + return paths; +} + +function hasKeyPath(obj: TranslationData, keyPath: string[]): boolean { + let current: unknown = obj; + for (const key of keyPath) { + if ( + typeof current !== "object" || + current === null || + (current as Record)[key] === undefined + ) { + return false; + } + current = (current as Record)[key]; + } + return true; +} + +function loadTranslationFile(lang: string): TranslationData | null { + const filePath = path.join(LOCALES_DIR, lang, "translation.json"); + + try { + const content = fs.readFileSync(filePath, "utf8"); + return JSON.parse(content) as TranslationData; + } catch (error) { + console.error(colorize(`✗ Error loading ${lang}/translation.json:`, "red")); + console.error(` ${(error as Error).message}`); + return null; + } +} + +function validateTranslations(): void { + console.log(colorize("\n🌍 Translation Consistency Check\n", "blue")); + + // Load reference file + console.log(`Loading reference language: ${REFERENCE_LANG}`); + const referenceData = loadTranslationFile(REFERENCE_LANG); + + if (!referenceData) { + console.error( + colorize(`\n✗ Failed to load reference file (${REFERENCE_LANG})`, "red"), + ); + process.exit(1); + } + + // Get all key paths from reference + const referenceKeyPaths = getAllKeyPaths(referenceData); + console.log(`Reference has ${referenceKeyPaths.length} keys\n`); + + // Track validation results + let hasErrors = false; + const results: Record = {}; + + // Validate each language + for (const lang of LANGUAGES) { + const langData = loadTranslationFile(lang); + + if (!langData) { + hasErrors = true; + results[lang] = { valid: false, missing: [], extra: [] }; + continue; + } + + // Find missing keys + const missing = referenceKeyPaths.filter( + (keyPath) => !hasKeyPath(langData, keyPath), + ); + + // Find extra keys (keys in language but not in reference) + const langKeyPaths = getAllKeyPaths(langData); + const extra = langKeyPaths.filter( + (keyPath) => !hasKeyPath(referenceData, keyPath), + ); + + results[lang] = { + valid: missing.length === 0 && extra.length === 0, + missing, + extra, + }; + + if (missing.length > 0 || extra.length > 0) { + hasErrors = true; + } + } + + // Print results + console.log(colorize("Results:", "blue")); + console.log("─".repeat(60)); + + for (const lang of LANGUAGES) { + const result = results[lang]; + + if (result.valid) { + console.log( + colorize(`✓ ${lang.toUpperCase()}: All keys present`, "green"), + ); + } else { + console.log(colorize(`✗ ${lang.toUpperCase()}: Issues found`, "red")); + + if (result.missing.length > 0) { + console.log( + colorize(` Missing ${result.missing.length} keys:`, "yellow"), + ); + result.missing.slice(0, 10).forEach((keyPath) => { + console.log(` - ${keyPath.join(".")}`); + }); + if (result.missing.length > 10) { + console.log( + colorize( + ` ... and ${result.missing.length - 10} more`, + "yellow", + ), + ); + } + } + + if (result.extra.length > 0) { + console.log( + colorize( + ` Extra ${result.extra.length} keys (not in reference):`, + "yellow", + ), + ); + result.extra.slice(0, 10).forEach((keyPath) => { + console.log(` - ${keyPath.join(".")}`); + }); + if (result.extra.length > 10) { + console.log( + colorize(` ... and ${result.extra.length - 10} more`, "yellow"), + ); + } + } + + console.log(""); + } + } + + console.log("─".repeat(60)); + + // Summary + const validCount = Object.values(results).filter((r) => r.valid).length; + const totalCount = LANGUAGES.length; + + if (hasErrors) { + console.log( + colorize( + `\n✗ Validation failed: ${validCount}/${totalCount} languages passed`, + "red", + ), + ); + process.exit(1); + } else { + console.log( + colorize( + `\n✓ All ${totalCount} languages have complete translations!`, + "green", + ), + ); + process.exit(0); + } +} + +// Run validation +validateTranslations(); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f372c45a..881ae30c8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -189,6 +189,27 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1401,6 +1422,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", + "block2 0.6.2", + "libc", "objc2 0.6.3", ] @@ -1415,6 +1438,15 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading 0.8.9", +] + [[package]] name = "dlopen2" version = "0.8.0" @@ -2446,6 +2478,7 @@ dependencies = [ "tauri-nspanel", "tauri-plugin-autostart", "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-global-shortcut", "tauri-plugin-log", @@ -4991,6 +5024,31 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -5299,6 +5357,12 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -6308,6 +6372,24 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.17", + "url", +] + [[package]] name = "tauri-plugin-fs" version = "2.4.4" @@ -6778,8 +6860,10 @@ dependencies = [ "libc", "mio 1.1.0", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -7467,6 +7551,7 @@ dependencies = [ "cc", "downcast-rs", "rustix 1.1.2", + "scoped-tls", "smallvec 1.15.1", "wayland-sys", ] @@ -7525,6 +7610,8 @@ version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ + "dlib", + "log", "pkg-config", ] @@ -8530,6 +8617,7 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", @@ -8683,6 +8771,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.13", "zvariant_derive", "zvariant_utils", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0b7bb0cd2..a638de72d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,6 +74,7 @@ ferrous-opencc = "0.2.3" specta = "=2.0.0-rc.22" specta-typescript = "0.0.9" tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } +tauri-plugin-dialog = "2" [target.'cfg(unix)'.dependencies] signal-hook = "0.3" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 84974d5c3..d051e5470 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,7 @@ "store:default", "updater:default", "process:default", + "dialog:default", "global-shortcut:allow-is-registered", "global-shortcut:allow-register", "global-shortcut:allow-unregister", diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index b2e30b658..cb4898468 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -36,9 +36,23 @@ pub async fn download_model( #[tauri::command] #[specta::specta] pub async fn delete_model( + app_handle: AppHandle, model_manager: State<'_, Arc>, + transcription_manager: State<'_, Arc>, model_id: String, ) -> Result<(), String> { + // If deleting the active model, unload it and clear the setting + let settings = get_settings(&app_handle); + if settings.selected_model == model_id { + transcription_manager + .unload_model() + .map_err(|e| format!("Failed to unload model: {}", e))?; + + let mut settings = get_settings(&app_handle); + settings.selected_model = String::new(); + write_settings(&app_handle, settings); + } + model_manager .delete_model(&model_id) .map_err(|e| e.to_string()) @@ -128,10 +142,3 @@ pub async fn cancel_download( .cancel_download(&model_id) .map_err(|e| e.to_string()) } - -#[tauri::command] -#[specta::specta] -pub async fn get_recommended_first_model() -> Result { - // Recommend Parakeet V3 model for first-time users - fastest and most accurate - Ok("parakeet-tdt-0.6b-v3".to_string()) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c80a8b98f..c45adf9fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -299,7 +299,6 @@ pub fn run() { commands::models::is_model_loading, commands::models::has_any_models_available, commands::models::has_any_models_or_downloads, - commands::models::get_recommended_first_model, commands::audio::update_microphone_mode, commands::audio::get_microphone_mode, commands::audio::get_available_microphones, @@ -333,29 +332,31 @@ pub fn run() { ) .expect("Failed to export typescript bindings"); - let mut builder = tauri::Builder::default().plugin( - LogBuilder::new() - .level(log::LevelFilter::Trace) // Set to most verbose level globally - .max_file_size(500_000) - .rotation_strategy(RotationStrategy::KeepOne) - .clear_targets() - .targets([ - // Console output respects RUST_LOG environment variable - Target::new(TargetKind::Stdout).filter({ - let console_filter = console_filter.clone(); - move |metadata| console_filter.enabled(metadata) - }), - // File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic) - Target::new(TargetKind::LogDir { - file_name: Some("handy".into()), - }) - .filter(|metadata| { - let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed); - metadata.level() <= level_filter_from_u8(file_level) - }), - ]) - .build(), - ); + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin( + LogBuilder::new() + .level(log::LevelFilter::Trace) // Set to most verbose level globally + .max_file_size(500_000) + .rotation_strategy(RotationStrategy::KeepOne) + .clear_targets() + .targets([ + // Console output respects RUST_LOG environment variable + Target::new(TargetKind::Stdout).filter({ + let console_filter = console_filter.clone(); + move |metadata| console_filter.enabled(metadata) + }), + // File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic) + Target::new(TargetKind::LogDir { + file_name: Some("handy".into()), + }) + .filter(|metadata| { + let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed); + metadata.level() <= level_filter_from_u8(file_level) + }), + ]) + .build(), + ); #[cfg(target_os = "macos")] { diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index d64dab519..0ac628d57 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -5,12 +5,14 @@ use futures_util::StreamExt; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use specta::Type; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::fs::File; use std::io::Write; use std::path::PathBuf; -use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use tar::Archive; use tauri::{AppHandle, Emitter, Manager}; @@ -34,8 +36,11 @@ pub struct ModelInfo { pub partial_size: u64, pub is_directory: bool, pub engine_type: EngineType, - pub accuracy_score: f32, // 0.0 to 1.0, higher is more accurate - pub speed_score: f32, // 0.0 to 1.0, higher is faster + pub accuracy_score: f32, // 0.0 to 1.0, higher is more accurate + pub speed_score: f32, // 0.0 to 1.0, higher is faster + pub supports_translation: bool, // Whether the model supports translating to English + pub is_recommended: bool, // Whether this is the recommended model for new users + pub supported_languages: Vec, // Languages this model can transcribe } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -50,6 +55,8 @@ pub struct ModelManager { app_handle: AppHandle, models_dir: PathBuf, available_models: Mutex>, + cancel_flags: Arc>>>, + extracting_models: Arc>>, } impl ModelManager { @@ -67,6 +74,22 @@ impl ModelManager { let mut available_models = HashMap::new(); + // Whisper supported languages (99 languages from tokenizer) + // Including zh-Hans and zh-Hant variants to match frontend language codes + let whisper_languages: Vec = vec![ + "en", "zh", "zh-Hans", "zh-Hant", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", + "ca", "nl", "ar", "sv", "it", "id", "hi", "fi", "vi", "he", "uk", "el", "ms", "cs", + "ro", "da", "hu", "ta", "no", "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy", + "sk", "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", "br", "eu", "is", + "hy", "ne", "mn", "bs", "kk", "sq", "sw", "gl", "mr", "pa", "si", "km", "sn", "yo", + "so", "af", "oc", "ka", "be", "tg", "sd", "gu", "am", "yi", "lo", "uz", "fo", "ht", + "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", "mg", "as", "tt", "haw", "ln", + "ha", "ba", "jw", "su", "yue", + ] + .into_iter() + .map(String::from) + .collect(); + // TODO this should be read from a JSON file or something.. available_models.insert( "small".to_string(), @@ -84,6 +107,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.60, speed_score: 0.85, + supports_translation: true, + is_recommended: false, + supported_languages: whisper_languages.clone(), }, ); @@ -104,6 +130,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.75, speed_score: 0.60, + supports_translation: true, + is_recommended: false, + supported_languages: whisper_languages.clone(), }, ); @@ -123,6 +152,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.80, speed_score: 0.40, + supports_translation: false, // Turbo doesn't support translation + is_recommended: false, + supported_languages: whisper_languages.clone(), }, ); @@ -142,6 +174,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.85, speed_score: 0.30, + supports_translation: true, + is_recommended: false, + supported_languages: whisper_languages, }, ); @@ -162,15 +197,28 @@ impl ModelManager { engine_type: EngineType::Parakeet, accuracy_score: 0.85, speed_score: 0.85, + supports_translation: false, + is_recommended: false, + supported_languages: vec!["en".to_string()], }, ); + // Parakeet V3 supported languages (25 EU languages + Russian/Ukrainian): + // bg, hr, cs, da, nl, en, et, fi, fr, de, el, hu, it, lv, lt, mt, pl, pt, ro, sk, sl, es, sv, ru, uk + let parakeet_v3_languages: Vec = vec![ + "bg", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el", "hu", "it", "lv", + "lt", "mt", "pl", "pt", "ro", "sk", "sl", "es", "sv", "ru", "uk", + ] + .into_iter() + .map(String::from) + .collect(); + available_models.insert( "parakeet-tdt-0.6b-v3".to_string(), ModelInfo { id: "parakeet-tdt-0.6b-v3".to_string(), name: "Parakeet V3".to_string(), - description: "Fast and accurate".to_string(), + description: "Fast and accurate. Supports 25 European languages.".to_string(), filename: "parakeet-tdt-0.6b-v3-int8".to_string(), // Directory name url: Some("https://blob.handy.computer/parakeet-v3-int8.tar.gz".to_string()), size_mb: 478, // Approximate size for int8 quantized model @@ -181,6 +229,9 @@ impl ModelManager { engine_type: EngineType::Parakeet, accuracy_score: 0.80, speed_score: 0.85, + supports_translation: false, + is_recommended: true, + supported_languages: parakeet_v3_languages, }, ); @@ -200,6 +251,9 @@ impl ModelManager { engine_type: EngineType::Moonshine, accuracy_score: 0.70, speed_score: 0.90, + supports_translation: false, + is_recommended: false, + supported_languages: vec!["en".to_string()], }, ); @@ -207,6 +261,8 @@ impl ModelManager { app_handle: app_handle.clone(), models_dir, available_models: Mutex::new(available_models), + cancel_flags: Arc::new(Mutex::new(HashMap::new())), + extracting_models: Arc::new(Mutex::new(HashSet::new())), }; // Migrate any bundled models to user directory @@ -271,7 +327,12 @@ impl ModelManager { .join(format!("{}.extracting", &model.filename)); // Clean up any leftover .extracting directories from interrupted extractions - if extracting_path.exists() { + // But only if this model is NOT currently being extracted + let is_currently_extracting = { + let extracting = self.extracting_models.lock().unwrap(); + extracting.contains(&model.id) + }; + if extracting_path.exists() && !is_currently_extracting { warn!("Cleaning up interrupted extraction for model: {}", model.id); let _ = fs::remove_dir_all(&extracting_path); } @@ -376,6 +437,13 @@ impl ModelManager { } } + // Create cancellation flag for this download + let cancel_flag = Arc::new(AtomicBool::new(false)); + { + let mut flags = self.cancel_flags.lock().unwrap(); + flags.insert(model_id.to_string(), cancel_flag.clone()); + } + // Create HTTP client with range request for resuming let client = reqwest::Client::new(); let mut request = client.get(&url); @@ -456,8 +524,36 @@ impl ModelManager { .app_handle .emit("model-download-progress", &initial_progress); + // Throttle progress events to max 10/sec (100ms intervals) + let mut last_emit = Instant::now(); + let throttle_duration = Duration::from_millis(100); + // Download with progress while let Some(chunk) = stream.next().await { + // Check if download was cancelled + if cancel_flag.load(Ordering::Relaxed) { + // Close the file before returning + drop(file); + info!("Download cancelled for: {}", model_id); + + // Update state to mark as not downloading + { + let mut models = self.available_models.lock().unwrap(); + if let Some(model) = models.get_mut(model_id) { + model.is_downloading = false; + } + } + + // Remove cancel flag + { + let mut flags = self.cancel_flags.lock().unwrap(); + flags.remove(model_id); + } + + // Keep partial file for resume functionality + return Ok(()); + } + let chunk = chunk.map_err(|e| { // Mark as not downloading on error { @@ -478,17 +574,34 @@ impl ModelManager { 0.0 }; - // Emit progress event - let progress = DownloadProgress { - model_id: model_id.to_string(), - downloaded, - total: total_size, - percentage, - }; - - let _ = self.app_handle.emit("model-download-progress", &progress); + // Emit progress event (throttled to avoid UI freeze) + if last_emit.elapsed() >= throttle_duration { + let progress = DownloadProgress { + model_id: model_id.to_string(), + downloaded, + total: total_size, + percentage, + }; + let _ = self.app_handle.emit("model-download-progress", &progress); + last_emit = Instant::now(); + } } + // Emit final progress to ensure 100% is shown + let final_progress = DownloadProgress { + model_id: model_id.to_string(), + downloaded, + total: total_size, + percentage: if total_size > 0 { + (downloaded as f64 / total_size as f64) * 100.0 + } else { + 100.0 + }, + }; + let _ = self + .app_handle + .emit("model-download-progress", &final_progress); + file.flush()?; drop(file); // Ensure file is closed before moving @@ -514,6 +627,12 @@ impl ModelManager { // Handle directory-based models (extract tar.gz) vs file-based models if model_info.is_directory { + // Track that this model is being extracted + { + let mut extracting = self.extracting_models.lock().unwrap(); + extracting.insert(model_id.to_string()); + } + // Emit extraction started event let _ = self.app_handle.emit("model-extraction-started", model_id); info!("Extracting archive for directory-based model: {}", model_id); @@ -542,6 +661,11 @@ impl ModelManager { let error_msg = format!("Failed to extract archive: {}", e); // Clean up failed extraction let _ = fs::remove_dir_all(&temp_extract_dir); + // Remove from extracting set + { + let mut extracting = self.extracting_models.lock().unwrap(); + extracting.remove(model_id); + } let _ = self.app_handle.emit( "model-extraction-failed", &serde_json::json!({ @@ -576,6 +700,11 @@ impl ModelManager { } info!("Successfully extracted archive for model: {}", model_id); + // Remove from extracting set + { + let mut extracting = self.extracting_models.lock().unwrap(); + extracting.remove(model_id); + } // Emit extraction completed event let _ = self.app_handle.emit("model-extraction-completed", model_id); @@ -596,6 +725,12 @@ impl ModelManager { } } + // Remove cancel flag on successful completion + { + let mut flags = self.cancel_flags.lock().unwrap(); + flags.remove(model_id); + } + // Emit completion event let _ = self.app_handle.emit("model-download-complete", model_id); @@ -663,6 +798,9 @@ impl ModelManager { self.update_download_status()?; debug!("ModelManager: download status updated"); + // Emit event to notify UI + let _ = self.app_handle.emit("model-deleted", model_id); + Ok(()) } @@ -714,15 +852,18 @@ impl ModelManager { pub fn cancel_download(&self, model_id: &str) -> Result<()> { debug!("ModelManager: cancel_download called for: {}", model_id); - let _model_info = { - let models = self.available_models.lock().unwrap(); - models.get(model_id).cloned() - }; - - let _model_info = - _model_info.ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?; + // Set the cancellation flag to stop the download loop + { + let flags = self.cancel_flags.lock().unwrap(); + if let Some(flag) = flags.get(model_id) { + flag.store(true, Ordering::Relaxed); + info!("Cancellation flag set for: {}", model_id); + } else { + warn!("No active download found for: {}", model_id); + } + } - // Mark as not downloading + // Update state immediately for UI responsiveness { let mut models = self.available_models.lock().unwrap(); if let Some(model) = models.get_mut(model_id) { @@ -730,14 +871,13 @@ impl ModelManager { } } - // Note: The actual download cancellation would need to be handled - // by the download task itself. This just updates the state. - // The partial file is kept so the download can be resumed later. - // Update download status to reflect current state self.update_download_status()?; - info!("Download cancelled for: {}", model_id); + // Emit cancellation event so all UI components can clear their state + let _ = self.app_handle.emit("model-download-cancelled", model_id); + + info!("Download cancellation initiated for: {}", model_id); Ok(()) } } diff --git a/src/bindings.ts b/src/bindings.ts index 325dc7f77..ff3846d32 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -513,14 +513,6 @@ async hasAnyModelsOrDownloads() : Promise> { else return { status: "error", error: e as any }; } }, -async getRecommendedFirstModel() : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("get_recommended_first_model") }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, async updateMicrophoneMode(alwaysOn: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("update_microphone_mode", { alwaysOn }) }; @@ -721,7 +713,7 @@ reset_bindings: string[] } export type KeyboardImplementation = "tauri" | "handy_keys" export type LLMPrompt = { id: string; name: string; prompt: string } export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" -export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number } +export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number; supports_translation: boolean; is_recommended: boolean; supported_languages: string[] } export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null } export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_5" export type OverlayPosition = "none" | "top" | "bottom" diff --git a/src/components/AccessibilityPermissions.tsx b/src/components/AccessibilityPermissions.tsx index 6454c8ccf..2826403fc 100644 --- a/src/components/AccessibilityPermissions.tsx +++ b/src/components/AccessibilityPermissions.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { type } from "@tauri-apps/plugin-os"; import { checkAccessibilityPermission, requestAccessibilityPermission, @@ -20,6 +21,9 @@ const AccessibilityPermissions: React.FC = () => { const [permissionState, setPermissionState] = useState("request"); + // Accessibility permissions are only required on macOS + const isMacOS = type() === "macos"; + // Check permissions without requesting const checkPermissions = async (): Promise => { const hasPermissions: boolean = await checkAccessibilityPermission(); @@ -45,8 +49,10 @@ const AccessibilityPermissions: React.FC = () => { } }; - // On app boot - check permissions + // On app boot - check permissions (only on macOS) useEffect(() => { + if (!isMacOS) return; + const initialSetup = async (): Promise => { const hasPermissions: boolean = await checkAccessibilityPermission(); setHasAccessibility(hasPermissions); @@ -54,9 +60,10 @@ const AccessibilityPermissions: React.FC = () => { }; initialSetup(); - }, []); + }, [isMacOS]); - if (hasAccessibility) { + // Skip rendering on non-macOS platforms or if permission is already granted + if (!isMacOS || hasAccessibility) { return null; } @@ -70,7 +77,7 @@ const AccessibilityPermissions: React.FC = () => { verify: { text: t("accessibility.openSettings"), className: - "bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-3 rounded text-sm flex items-center justify-center cursor-pointer", + "bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-3 rounded-md text-sm flex items-center justify-center cursor-pointer", }, granted: null, }; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2522c16af..0d7211da8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Cog, FlaskConical, History, Info, Sparkles } from "lucide-react"; +import { Cog, FlaskConical, History, Info, Sparkles, Cpu } from "lucide-react"; import HandyTextLogo from "./icons/HandyTextLogo"; import HandyHand from "./icons/HandyHand"; import { useSettings } from "../hooks/useSettings"; @@ -11,6 +11,7 @@ import { DebugSettings, AboutSettings, PostProcessingSettings, + ModelsSettings, } from "./settings"; export type SidebarSection = keyof typeof SECTIONS_CONFIG; @@ -37,6 +38,12 @@ export const SECTIONS_CONFIG = { component: GeneralSettings, enabled: () => true, }, + models: { + labelKey: "sidebar.models", + icon: Cpu, + component: ModelsSettings, + enabled: () => true, + }, advanced: { labelKey: "sidebar.advanced", icon: Cog, diff --git a/src/components/model-selector/ModelDropdown.tsx b/src/components/model-selector/ModelDropdown.tsx index 4a2003d63..f2d57017c 100644 --- a/src/components/model-selector/ModelDropdown.tsx +++ b/src/components/model-selector/ModelDropdown.tsx @@ -1,91 +1,34 @@ import React from "react"; import { useTranslation } from "react-i18next"; import type { ModelInfo } from "@/bindings"; -import { formatModelSize } from "../../lib/utils/format"; import { getTranslatedModelName, getTranslatedModelDescription, } from "../../lib/utils/modelTranslation"; -import { ProgressBar } from "../shared"; - -interface DownloadProgress { - model_id: string; - downloaded: number; - total: number; - percentage: number; -} interface ModelDropdownProps { models: ModelInfo[]; currentModelId: string; - downloadProgress: Record; onModelSelect: (modelId: string) => void; - onModelDownload: (modelId: string) => void; - onModelDelete: (modelId: string) => Promise; - onError?: (error: string) => void; } const ModelDropdown: React.FC = ({ models, currentModelId, - downloadProgress, onModelSelect, - onModelDownload, - onModelDelete, - onError, }) => { const { t } = useTranslation(); - const availableModels = models.filter((m) => m.is_downloaded); - const downloadableModels = models.filter((m) => !m.is_downloaded); - const isFirstRun = availableModels.length === 0 && models.length > 0; - - const handleDeleteClick = async (e: React.MouseEvent, modelId: string) => { - e.preventDefault(); - e.stopPropagation(); - - try { - await onModelDelete(modelId); - } catch (err) { - const errorMsg = `Failed to delete model: ${err}`; - onError?.(errorMsg); - } - }; + const downloadedModels = models.filter((m) => m.is_downloaded); const handleModelClick = (modelId: string) => { - if (modelId in downloadProgress) { - return; // Don't allow interaction while downloading - } onModelSelect(modelId); }; - const handleDownloadClick = (modelId: string) => { - if (modelId in downloadProgress) { - return; // Don't allow interaction while downloading - } - onModelDownload(modelId); - }; - return ( -
- {/* First Run Welcome */} - {isFirstRun && ( -
-
- {t("modelSelector.welcome")} -
-
- {t("modelSelector.downloadPrompt")} -
-
- )} - - {/* Available Models */} - {availableModels.length > 0 && ( +
+ {downloadedModels.length > 0 ? (
-
- {t("modelSelector.availableModels")} -
- {availableModels.map((model) => ( + {downloadedModels.map((model) => (
handleModelClick(model.id)} @@ -105,128 +48,23 @@ const ModelDropdown: React.FC = ({ >
-
+
{getTranslatedModelName(model, t)}
{getTranslatedModelDescription(model, t)}
-
- {currentModelId === model.id && ( -
- {t("modelSelector.active")} -
- )} - {currentModelId !== model.id && ( - - )} -
-
-
- ))} -
- )} - - {/* Downloadable Models */} - {downloadableModels.length > 0 && ( -
- {(availableModels.length > 0 || isFirstRun) && ( -
- )} -
- {isFirstRun - ? t("modelSelector.chooseModel") - : t("modelSelector.downloadModels")} -
- {downloadableModels.map((model) => { - const isDownloading = model.id in downloadProgress; - const progress = downloadProgress[model.id]; - - return ( -
handleDownloadClick(model.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleDownloadClick(model.id); - } - }} - tabIndex={0} - role="button" - aria-disabled={isDownloading} - className={`w-full px-3 py-2 text-start hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ - isDownloading - ? "opacity-50 cursor-not-allowed hover:bg-transparent" - : "" - }`} - > -
-
-
- {getTranslatedModelName(model, t)} - {model.id === "parakeet-tdt-0.6b-v3" && isFirstRun && ( - - {t("onboarding.recommended")} - - )} -
-
- {getTranslatedModelDescription(model, t)} -
-
- {t("modelSelector.downloadSize")} ·{" "} - {formatModelSize(Number(model.size_mb))} -
-
-
- {isDownloading && progress - ? `${Math.max(0, Math.min(100, Math.round(progress.percentage)))}%` - : t("modelSelector.download")} -
-
- - {isDownloading && progress && ( -
- + {currentModelId === model.id && ( +
+ {t("modelSelector.active")}
)}
- ); - })} +
+ ))}
- )} - - {/* No Models Available */} - {availableModels.length === 0 && downloadableModels.length === 0 && ( + ) : (
{t("modelSelector.noModelsAvailable")}
diff --git a/src/components/model-selector/ModelSelector.tsx b/src/components/model-selector/ModelSelector.tsx index f78134056..5045c6b90 100644 --- a/src/components/model-selector/ModelSelector.tsx +++ b/src/components/model-selector/ModelSelector.tsx @@ -1,9 +1,9 @@ import React, { useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { listen } from "@tauri-apps/api/event"; -import { produce } from "immer"; -import { commands, type ModelInfo } from "@/bindings"; +import { commands } from "@/bindings"; import { getTranslatedModelName } from "../../lib/utils/modelTranslation"; +import { useModelStore } from "../../stores/modelStore"; import ModelStatusButton from "./ModelStatusButton"; import ModelDropdown from "./ModelDropdown"; import DownloadProgressDisplay from "./DownloadProgressDisplay"; @@ -15,13 +15,6 @@ interface ModelStateEvent { error?: string; } -interface DownloadProgress { - model_id: string; - downloaded: number; - total: number; - percentage: number; -} - type ModelStatus = | "ready" | "loading" @@ -31,46 +24,59 @@ type ModelStatus = | "unloaded" | "none"; -interface DownloadStats { - startTime: number; - lastUpdate: number; - totalDownloaded: number; - speed: number; -} - interface ModelSelectorProps { onError?: (error: string) => void; } const ModelSelector: React.FC = ({ onError }) => { const { t } = useTranslation(); - const [models, setModels] = useState([]); - const [currentModelId, setCurrentModelId] = useState(""); + const { + models, + currentModel, + downloadProgress, + downloadStats, + extractingModels, + selectModel, + } = useModelStore(); + const [modelStatus, setModelStatus] = useState("unloaded"); const [modelError, setModelError] = useState(null); - const [modelDownloadProgress, setModelDownloadProgress] = useState< - Record - >({}); const [showModelDropdown, setShowModelDropdown] = useState(false); - const [downloadStats, setDownloadStats] = useState< - Record - >({}); - const [extractingModels, setExtractingModels] = useState< - Record - >({}); + // Track pending model switch for optimistic display + const [pendingModelId, setPendingModelId] = useState(null); const dropdownRef = useRef(null); + const displayModelId = pendingModelId || currentModel; + + // Check model status when currentModel changes useEffect(() => { - loadModels(); - loadCurrentModel(); + const checkStatus = async () => { + if (currentModel) { + try { + const statusResult = await commands.getTranscriptionModelStatus(); + if (statusResult.status === "ok") { + setModelStatus( + statusResult.data === currentModel ? "ready" : "unloaded", + ); + } + } catch { + setModelStatus("error"); + setModelError("Failed to check model status"); + } + } else { + setModelStatus("none"); + } + }; + checkStatus(); + }, [currentModel]); - // Listen for model state changes + useEffect(() => { + // Listen for model loading lifecycle events const modelStateUnlisten = listen( "model-state-changed", (event) => { - const { event_type, model_id, model_name, error } = event.payload; - + const { event_type, error } = event.payload; switch (event_type) { case "loading_started": setModelStatus("loading"); @@ -79,11 +85,12 @@ const ModelSelector: React.FC = ({ onError }) => { case "loading_completed": setModelStatus("ready"); setModelError(null); - if (model_id) setCurrentModelId(model_id); + setPendingModelId(null); break; case "loading_failed": setModelStatus("error"); setModelError(error || "Failed to load model"); + setPendingModelId(null); break; case "unloaded": setModelStatus("unloaded"); @@ -93,140 +100,30 @@ const ModelSelector: React.FC = ({ onError }) => { }, ); - // Listen for model download progress - const downloadProgressUnlisten = listen( - "model-download-progress", - (event) => { - const progress = event.payload; - setModelDownloadProgress( - produce((downloadProgress) => { - downloadProgress[progress.model_id] = progress; - }), - ); - setModelStatus("downloading"); - - // Update download stats for speed calculation - const now = Date.now(); - setDownloadStats( - produce((stats) => { - const current = stats[progress.model_id]; - - if (!current) { - // First progress update - initialize - stats[progress.model_id] = { - startTime: now, - lastUpdate: now, - totalDownloaded: progress.downloaded, - speed: 0, - }; - } else { - // Calculate speed over last few seconds - const timeDiff = (now - current.lastUpdate) / 1000; // seconds - const bytesDiff = progress.downloaded - current.totalDownloaded; - - if (timeDiff > 0.5) { - // Update speed every 500ms - const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; // MB/s - // Smooth the speed with exponential moving average, but ensure positive values - const validCurrentSpeed = Math.max(0, currentSpeed); - const smoothedSpeed = - current.speed > 0 - ? current.speed * 0.8 + validCurrentSpeed * 0.2 - : validCurrentSpeed; - - stats[progress.model_id] = { - startTime: current.startTime, - lastUpdate: now, - totalDownloaded: progress.downloaded, - speed: Math.max(0, smoothedSpeed), - }; - } - } - }), - ); - }, - ); - - // Listen for model download completion + // Auto-select model when download completes (fires after extraction too) const downloadCompleteUnlisten = listen( "model-download-complete", (event) => { const modelId = event.payload; - setModelDownloadProgress( - produce((progress) => { - delete progress[modelId]; - }), - ); - setDownloadStats( - produce((stats) => { - delete stats[modelId]; - }), - ); - loadModels(); // Refresh models list - - // Auto-select the newly downloaded model (skip if recording in progress) - setTimeout(async () => { - const isRecording = await commands.isRecording(); - if (isRecording) { - return; // Skip auto-switch if recording in progress - } - loadCurrentModel(); - handleModelSelect(modelId); - }, 500); - }, - ); - - // Listen for extraction events - const extractionStartedUnlisten = listen( - "model-extraction-started", - (event) => { - const modelId = event.payload; - setExtractingModels( - produce((extracting) => { - extracting[modelId] = true; - }), - ); - setModelStatus("extracting"); - }, - ); - - const extractionCompletedUnlisten = listen( - "model-extraction-completed", - (event) => { - const modelId = event.payload; - setExtractingModels( - produce((extracting) => { - delete extracting[modelId]; - }), - ); - loadModels(); // Refresh models list - - // Auto-select the newly extracted model (skip if recording in progress) setTimeout(async () => { - const isRecording = await commands.isRecording(); - if (isRecording) { - return; // Skip auto-switch if recording in progress + try { + const isRecording = await commands.isRecording(); + if (!isRecording) { + setPendingModelId(modelId); + setModelError(null); + setShowModelDropdown(false); + const success = await selectModel(modelId); + if (!success) { + setPendingModelId(null); + } + } + } catch { + // Ignore errors in auto-select } - loadCurrentModel(); - handleModelSelect(modelId); }, 500); }, ); - const extractionFailedUnlisten = listen<{ - model_id: string; - error: string; - }>("model-extraction-failed", (event) => { - const modelId = event.payload.model_id; - setExtractingModels( - produce((extracting) => { - delete extracting[modelId]; - }), - ); - setModelError(`Failed to extract model: ${event.payload.error}`); - setModelStatus("error"); - }); - // Click outside to close dropdown const handleClickOutside = (event: MouseEvent) => { if ( @@ -242,96 +139,23 @@ const ModelSelector: React.FC = ({ onError }) => { return () => { document.removeEventListener("mousedown", handleClickOutside); modelStateUnlisten.then((fn) => fn()); - downloadProgressUnlisten.then((fn) => fn()); downloadCompleteUnlisten.then((fn) => fn()); - extractionStartedUnlisten.then((fn) => fn()); - extractionCompletedUnlisten.then((fn) => fn()); - extractionFailedUnlisten.then((fn) => fn()); }; - }, []); - - const loadModels = async () => { - try { - const result = await commands.getAvailableModels(); - if (result.status === "ok") { - setModels(result.data); - } - } catch (err) { - console.error("Failed to load models:", err); - } - }; - - const loadCurrentModel = async () => { - try { - const result = await commands.getCurrentModel(); - if (result.status === "ok") { - const current = result.data; - setCurrentModelId(current); - - if (current) { - // Check if model is actually loaded - const statusResult = await commands.getTranscriptionModelStatus(); - if (statusResult.status === "ok") { - const transcriptionStatus = statusResult.data; - if (transcriptionStatus === current) { - setModelStatus("ready"); - } else { - setModelStatus("unloaded"); - } - } - } else { - setModelStatus("none"); - } - } - } catch (err) { - console.error("Failed to load current model:", err); - setModelStatus("error"); - setModelError("Failed to check model status"); - } - }; + }, [selectModel]); const handleModelSelect = async (modelId: string) => { - try { - setCurrentModelId(modelId); // Set optimistically so loading text shows correct model - setModelError(null); - setShowModelDropdown(false); - const result = await commands.setActiveModel(modelId); - if (result.status === "error") { - const errorMsg = result.error; - setModelError(errorMsg); - setModelStatus("error"); - onError?.(errorMsg); - } - } catch (err) { - const errorMsg = `${err}`; - setModelError(errorMsg); + setPendingModelId(modelId); + setModelError(null); + setShowModelDropdown(false); + const success = await selectModel(modelId); + if (!success) { + setPendingModelId(null); setModelStatus("error"); - onError?.(errorMsg); + setModelError("Failed to switch model"); + onError?.("Failed to switch model"); } }; - const handleModelDownload = async (modelId: string) => { - try { - setModelError(null); - const result = await commands.downloadModel(modelId); - if (result.status === "error") { - const errorMsg = result.error; - setModelError(errorMsg); - setModelStatus("error"); - onError?.(errorMsg); - } - } catch (err) { - const errorMsg = `${err}`; - setModelError(errorMsg); - setModelStatus("error"); - onError?.(errorMsg); - } - }; - - const getCurrentModel = () => { - return models.find((m) => m.id === currentModelId); - }; - const getModelDisplayText = (): string => { const extractingKeys = Object.keys(extractingModels); if (extractingKeys.length > 0) { @@ -349,7 +173,7 @@ const ModelSelector: React.FC = ({ onError }) => { } } - const progressValues = Object.values(modelDownloadProgress); + const progressValues = Object.values(downloadProgress); if (progressValues.length > 0) { if (progressValues.length === 1) { const progress = progressValues[0]; @@ -365,46 +189,45 @@ const ModelSelector: React.FC = ({ onError }) => { } } - const currentModel = getCurrentModel(); + const currentModelInfo = models.find((m) => m.id === displayModelId); switch (modelStatus) { case "ready": - return currentModel - ? getTranslatedModelName(currentModel, t) + return currentModelInfo + ? getTranslatedModelName(currentModelInfo, t) : t("modelSelector.modelReady"); case "loading": - return currentModel + return currentModelInfo ? t("modelSelector.loading", { - modelName: getTranslatedModelName(currentModel, t), + modelName: getTranslatedModelName(currentModelInfo, t), }) : t("modelSelector.loadingGeneric"); case "extracting": - return currentModel + return currentModelInfo ? t("modelSelector.extracting", { - modelName: getTranslatedModelName(currentModel, t), + modelName: getTranslatedModelName(currentModelInfo, t), }) : t("modelSelector.extractingGeneric"); case "error": return modelError || t("modelSelector.modelError"); case "unloaded": - return currentModel - ? getTranslatedModelName(currentModel, t) + return currentModelInfo + ? getTranslatedModelName(currentModelInfo, t) : t("modelSelector.modelUnloaded"); case "none": return t("modelSelector.noModelDownloadRequired"); default: - return currentModel - ? getTranslatedModelName(currentModel, t) + return currentModelInfo + ? getTranslatedModelName(currentModelInfo, t) : t("modelSelector.modelUnloaded"); } }; - const handleModelDelete = async (modelId: string) => { - const result = await commands.deleteModel(modelId); - if (result.status === "ok") { - await loadModels(); - setModelError(null); - } + // Derive display status from model status + store state + const getDisplayStatus = (): ModelStatus => { + if (Object.keys(extractingModels).length > 0) return "extracting"; + if (Object.keys(downloadProgress).length > 0) return "downloading"; + return modelStatus; }; return ( @@ -412,7 +235,7 @@ const ModelSelector: React.FC = ({ onError }) => { {/* Model Status and Switcher */}
setShowModelDropdown(!showModelDropdown)} @@ -422,19 +245,15 @@ const ModelSelector: React.FC = ({ onError }) => { {showModelDropdown && ( )}
{/* Download Progress Bar for Models */} diff --git a/src/components/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index 5994534ea..f119ebddc 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -1,109 +1,285 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Download } from "lucide-react"; +import { + Check, + Download, + Globe, + Languages, + Loader2, + Trash2, +} from "lucide-react"; import type { ModelInfo } from "@/bindings"; import { formatModelSize } from "../../lib/utils/format"; import { - getTranslatedModelName, getTranslatedModelDescription, + getTranslatedModelName, } from "../../lib/utils/modelTranslation"; +import { LANGUAGES } from "../../lib/constants/languages"; import Badge from "../ui/Badge"; +import { Button } from "../ui/Button"; + +// Get display text for model's language support +const getLanguageDisplayText = ( + supportedLanguages: string[], + t: (key: string, options?: Record) => string, +): string => { + if (supportedLanguages.length === 1) { + const langCode = supportedLanguages[0]; + const langName = + LANGUAGES.find((l) => l.value === langCode)?.label || langCode; + return t("modelSelector.capabilities.languageOnly", { language: langName }); + } + return t("modelSelector.capabilities.multiLanguage"); +}; + +export type ModelCardStatus = + | "downloadable" + | "downloading" + | "extracting" + | "switching" + | "active" + | "available"; interface ModelCardProps { model: ModelInfo; variant?: "default" | "featured"; + status?: ModelCardStatus; disabled?: boolean; className?: string; onSelect: (modelId: string) => void; + onDownload?: (modelId: string) => void; + onDelete?: (modelId: string) => void; + onCancel?: (modelId: string) => void; + downloadProgress?: number; + downloadSpeed?: number; // MB/s + showRecommended?: boolean; } const ModelCard: React.FC = ({ model, variant = "default", + status = "downloadable", disabled = false, className = "", onSelect, + onDownload, + onDelete, + onCancel, + downloadProgress, + downloadSpeed, + showRecommended = true, }) => { const { t } = useTranslation(); const isFeatured = variant === "featured"; + const isClickable = + status === "available" || status === "active" || status === "downloadable"; // Get translated model name and description const displayName = getTranslatedModelName(model, t); const displayDescription = getTranslatedModelDescription(model, t); - const baseButtonClasses = - "flex justify-between items-center rounded-xl p-3 px-4 text-start transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] cursor-pointer group"; + const baseClasses = + "flex flex-col rounded-xl px-4 py-3 gap-2 text-left transition-all duration-200"; + + const getVariantClasses = () => { + if (status === "active") { + return "border-2 border-logo-primary/50 bg-logo-primary/10"; + } + if (isFeatured) { + return "border-2 border-logo-primary/25 bg-logo-primary/5"; + } + return "border-2 border-mid-gray/20"; + }; + + const getInteractiveClasses = () => { + if (!isClickable) return ""; + if (disabled) return "opacity-50 cursor-not-allowed"; + return "cursor-pointer hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.01] active:scale-[0.99] group"; + }; - const variantClasses = isFeatured - ? "border-2 border-logo-primary/25 bg-logo-primary/5 hover:border-logo-primary/40 hover:bg-logo-primary/8 hover:shadow-lg hover:scale-[1.02] disabled:hover:border-logo-primary/25 disabled:hover:bg-logo-primary/5 disabled:hover:shadow-none disabled:hover:scale-100" - : "border-2 border-mid-gray/20 hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.02] disabled:hover:border-mid-gray/20 disabled:hover:bg-transparent disabled:hover:shadow-none disabled:hover:scale-100"; + const handleClick = () => { + if (!isClickable || disabled) return; + if (status === "downloadable" && onDownload) { + onDownload(model.id); + } else { + onSelect(model.id); + } + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete?.(model.id); + }; return ( - + )} +
+ + {/* Download/extract progress */} + {status === "downloading" && downloadProgress !== undefined && ( +
+
+
+ + {t("modelSelector.downloading", { + percentage: Math.round(downloadProgress), + })} + +
+ {downloadSpeed !== undefined && downloadSpeed > 0 && ( + + {t("modelSelector.downloadSpeed", { + speed: downloadSpeed.toFixed(1), + })} + + )} + {onCancel && ( + + )} +
+
-
-

- {t("onboarding.modelCard.speed")} -

-
-
+ )} + {status === "extracting" && ( +
+
+
+

+ {t("modelSelector.extractingGeneric")} +

-
- - ); -}; - -const DownloadSize = ({ sizeMb }: { sizeMb: number }) => { - const { t } = useTranslation(); - - return ( -
-
); }; diff --git a/src/components/onboarding/Onboarding.tsx b/src/components/onboarding/Onboarding.tsx index f88a9bd33..34fe373b6 100644 --- a/src/components/onboarding/Onboarding.tsx +++ b/src/components/onboarding/Onboarding.tsx @@ -1,8 +1,11 @@ -import React, { useState, useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { commands, type ModelInfo } from "@/bindings"; +import { toast } from "sonner"; +import type { ModelInfo } from "@/bindings"; +import type { ModelCardStatus } from "./ModelCard"; import ModelCard from "./ModelCard"; import HandyTextLogo from "../icons/HandyTextLogo"; +import { useModelStore } from "../../stores/modelStore"; interface OnboardingProps { onModelSelected: () => void; @@ -10,52 +13,69 @@ interface OnboardingProps { const Onboarding: React.FC = ({ onModelSelected }) => { const { t } = useTranslation(); - const [availableModels, setAvailableModels] = useState([]); - const [downloading, setDownloading] = useState(false); - const [error, setError] = useState(null); + const { + models, + downloadModel, + selectModel, + downloadingModels, + extractingModels, + downloadProgress, + downloadStats, + } = useModelStore(); + const [selectedModelId, setSelectedModelId] = useState(null); + const isDownloading = selectedModelId !== null; + + // Watch for the selected model to finish downloading + extracting useEffect(() => { - loadModels(); - }, []); + if (!selectedModelId) return; + + const model = models.find((m) => m.id === selectedModelId); + const stillDownloading = selectedModelId in downloadingModels; + const stillExtracting = selectedModelId in extractingModels; - const loadModels = async () => { - try { - const result = await commands.getAvailableModels(); - if (result.status === "ok") { - // Only show downloadable models for onboarding - setAvailableModels(result.data.filter((m) => !m.is_downloaded)); - } else { - setError(t("onboarding.errors.loadModels")); - } - } catch (err) { - console.error("Failed to load models:", err); - setError(t("onboarding.errors.loadModels")); + if (model?.is_downloaded && !stillDownloading && !stillExtracting) { + // Model is ready — select it and transition + selectModel(selectedModelId).then((success) => { + if (success) { + onModelSelected(); + } else { + toast.error(t("onboarding.errors.selectModel")); + setSelectedModelId(null); + } + }); } - }; + }, [ + selectedModelId, + models, + downloadingModels, + extractingModels, + selectModel, + onModelSelected, + ]); const handleDownloadModel = async (modelId: string) => { - setDownloading(true); - setError(null); + setSelectedModelId(modelId); - // Immediately transition to main app - download will continue in footer - onModelSelected(); - - try { - const result = await commands.downloadModel(modelId); - if (result.status === "error") { - console.error("Download failed:", result.error); - setError(t("onboarding.errors.downloadModel", { error: result.error })); - setDownloading(false); - } - } catch (err) { - console.error("Download failed:", err); - setError(t("onboarding.errors.downloadModel", { error: String(err) })); - setDownloading(false); + const success = await downloadModel(modelId); + if (!success) { + toast.error(t("onboarding.downloadFailed")); + setSelectedModelId(null); } }; - const getRecommendedBadge = (modelId: string): boolean => { - return modelId === "parakeet-tdt-0.6b-v3"; + const getModelStatus = (modelId: string): ModelCardStatus => { + if (modelId in extractingModels) return "extracting"; + if (modelId in downloadingModels) return "downloading"; + return "downloadable"; + }; + + const getModelDownloadProgress = (modelId: string): number | undefined => { + return downloadProgress[modelId]?.percentage; + }; + + const getModelDownloadSpeed = (modelId: string): number | undefined => { + return downloadStats[modelId]?.speed; }; return ( @@ -68,34 +88,41 @@ const Onboarding: React.FC = ({ onModelSelected }) => {
- {error && ( -
-

{error}

-
- )} -
- {availableModels - .filter((model) => getRecommendedBadge(model.id)) - .map((model) => ( + {models + .filter((m: ModelInfo) => !m.is_downloaded) + .filter((model: ModelInfo) => model.is_recommended) + .map((model: ModelInfo) => ( ))} - {availableModels - .filter((model) => !getRecommendedBadge(model.id)) - .sort((a, b) => Number(a.size_mb) - Number(b.size_mb)) - .map((model) => ( + {models + .filter((m: ModelInfo) => !m.is_downloaded) + .filter((model: ModelInfo) => !model.is_recommended) + .sort( + (a: ModelInfo, b: ModelInfo) => + Number(a.size_mb) - Number(b.size_mb), + ) + .map((model: ModelInfo) => ( ))}
diff --git a/src/components/onboarding/index.ts b/src/components/onboarding/index.ts index 739efa57b..988568407 100644 --- a/src/components/onboarding/index.ts +++ b/src/components/onboarding/index.ts @@ -1,2 +1,4 @@ export { default } from "./Onboarding"; export { default as AccessibilityOnboarding } from "./AccessibilityOnboarding"; +export { default as ModelCard } from "./ModelCard"; +export type { ModelCardStatus } from "./ModelCard"; diff --git a/src/components/settings/GlobalShortcutInput.tsx b/src/components/settings/GlobalShortcutInput.tsx index 0bf451764..66be894b3 100644 --- a/src/components/settings/GlobalShortcutInput.tsx +++ b/src/components/settings/GlobalShortcutInput.tsx @@ -293,13 +293,13 @@ export const GlobalShortcutInput: React.FC = ({ {editingShortcutId === shortcutId ? (
setShortcutRef(shortcutId, ref)} - className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded" + className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded-md" > {formatCurrentKeys()}
) : (
startRecording(shortcutId)} > {formatKeyCombination(binding.current_binding, osType)} diff --git a/src/components/settings/HandyKeysShortcutInput.tsx b/src/components/settings/HandyKeysShortcutInput.tsx index 2c936ab5e..ee1d5f532 100644 --- a/src/components/settings/HandyKeysShortcutInput.tsx +++ b/src/components/settings/HandyKeysShortcutInput.tsx @@ -278,13 +278,13 @@ export const HandyKeysShortcutInput: React.FC = ({ {isRecording ? (
{formatCurrentKeys()}
) : (
{formatKeyCombination(binding.current_binding, osType)} diff --git a/src/components/settings/advanced/AdvancedSettings.tsx b/src/components/settings/advanced/AdvancedSettings.tsx index a6104aa74..fa220ea67 100644 --- a/src/components/settings/advanced/AdvancedSettings.tsx +++ b/src/components/settings/advanced/AdvancedSettings.tsx @@ -1,7 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ShowOverlay } from "../ShowOverlay"; -import { TranslateToEnglish } from "../TranslateToEnglish"; import { ModelUnloadTimeoutSetting } from "../ModelUnloadTimeout"; import { CustomWords } from "../CustomWords"; import { SettingsGroup } from "../../ui/SettingsGroup"; @@ -9,7 +8,6 @@ import { StartHidden } from "../StartHidden"; import { AutostartToggle } from "../AutostartToggle"; import { PasteMethodSetting } from "../PasteMethod"; import { ClipboardHandlingSetting } from "../ClipboardHandling"; -import { useModelStore } from "../../../stores/modelStore"; import { PostProcessingToggle } from "../PostProcessingToggle"; import { AppendTrailingSpace } from "../AppendTrailingSpace"; import { HistoryLimit } from "../HistoryLimit"; @@ -20,11 +18,7 @@ import { KeyboardImplementationSelector } from "../debug/KeyboardImplementationS export const AdvancedSettings: React.FC = () => { const { t } = useTranslation(); - const { currentModel, getModelInfo } = useModelStore(); const { getSetting } = useSettings(); - const currentModelInfo = getModelInfo(currentModel); - const showTranslateToEnglish = - currentModelInfo?.engine_type === "Whisper" && currentModel !== "turbo"; const experimentalEnabled = getSetting("experimental_enabled") || false; return ( @@ -43,9 +37,6 @@ export const AdvancedSettings: React.FC = () => { - {showTranslateToEnglish && ( - - )} diff --git a/src/components/settings/general/GeneralSettings.tsx b/src/components/settings/general/GeneralSettings.tsx index ca0f56ebc..88bedf8c2 100644 --- a/src/components/settings/general/GeneralSettings.tsx +++ b/src/components/settings/general/GeneralSettings.tsx @@ -1,32 +1,26 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { MicrophoneSelector } from "../MicrophoneSelector"; -import { LanguageSelector } from "../LanguageSelector"; import { ShortcutInput } from "../ShortcutInput"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { OutputDeviceSelector } from "../OutputDeviceSelector"; import { PushToTalk } from "../PushToTalk"; import { AudioFeedback } from "../AudioFeedback"; import { useSettings } from "../../../hooks/useSettings"; -import { useModelStore } from "../../../stores/modelStore"; import { VolumeSlider } from "../VolumeSlider"; import { MuteWhileRecording } from "../MuteWhileRecording"; +import { ModelSettingsCard } from "./ModelSettingsCard"; export const GeneralSettings: React.FC = () => { const { t } = useTranslation(); const { audioFeedbackEnabled } = useSettings(); - const { currentModel, getModelInfo } = useModelStore(); - const currentModelInfo = getModelInfo(currentModel); - const showLanguageSelector = currentModelInfo?.engine_type === "Whisper"; return (
- {showLanguageSelector && ( - - )} + diff --git a/src/components/settings/general/ModelSettingsCard.tsx b/src/components/settings/general/ModelSettingsCard.tsx new file mode 100644 index 000000000..f91258c2e --- /dev/null +++ b/src/components/settings/general/ModelSettingsCard.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { SettingsGroup } from "../../ui/SettingsGroup"; +import { LanguageSelector } from "../LanguageSelector"; +import { TranslateToEnglish } from "../TranslateToEnglish"; +import { useModelStore } from "../../../stores/modelStore"; +import type { ModelInfo } from "@/bindings"; + +export const ModelSettingsCard: React.FC = () => { + const { t } = useTranslation(); + const { currentModel, models } = useModelStore(); + + const currentModelInfo = models.find((m: ModelInfo) => m.id === currentModel); + + // Only Whisper models support manual language selection + const supportsLanguageSelection = currentModelInfo?.engine_type === "Whisper"; + const supportsTranslation = currentModelInfo?.supports_translation ?? false; + const hasAnySettings = supportsLanguageSelection || supportsTranslation; + + // Don't render anything if no model is selected or no settings available + if (!currentModel || !currentModelInfo || !hasAnySettings) { + return null; + } + + return ( + + {supportsLanguageSelection && ( + + )} + {supportsTranslation && ( + + )} + + ); +}; diff --git a/src/components/settings/history/HistorySettings.tsx b/src/components/settings/history/HistorySettings.tsx index b8f7fd05e..eaa7acc21 100644 --- a/src/components/settings/history/HistorySettings.tsx +++ b/src/components/settings/history/HistorySettings.tsx @@ -273,7 +273,7 @@ const HistoryEntryComponent: React.FC = ({ + + {languageDropdownOpen && ( +
+
+ setLanguageSearch(e.target.value)} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + filteredLanguages.length > 0 + ) { + setLanguageFilter(filteredLanguages[0].value); + setLanguageDropdownOpen(false); + setLanguageSearch(""); + } else if (e.key === "Escape") { + setLanguageDropdownOpen(false); + setLanguageSearch(""); + } + }} + placeholder={t( + "settings.general.language.searchPlaceholder", + )} + className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded-md focus:outline-none focus:ring-1 focus:ring-logo-primary" + /> +
+
+ + {filteredLanguages.map((lang) => ( + + ))} + {filteredLanguages.length === 0 && ( +
+ {t("settings.general.language.noResults")} +
+ )} +
+
+ )} +
+
+ {downloadedModels.map((model: ModelInfo) => ( + + ))} +
+ )} + + {/* Available Models Section */} + {availableModels.length > 0 && ( +
+

+ {t("settings.models.availableModels")} +

+ {availableModels.map((model: ModelInfo) => ( + + ))} +
+ )} +
+ ) : ( +
+ {t("settings.models.noModelsMatch")} +
+ )} +
+ ); +}; diff --git a/src/components/settings/models/index.ts b/src/components/settings/models/index.ts new file mode 100644 index 000000000..1de9a5030 --- /dev/null +++ b/src/components/settings/models/index.ts @@ -0,0 +1 @@ +export { ModelsSettings } from "./ModelsSettings"; diff --git a/src/components/settings/post-processing/PostProcessingSettings.tsx b/src/components/settings/post-processing/PostProcessingSettings.tsx index 2dfbbf262..4750a0d4d 100644 --- a/src/components/settings/post-processing/PostProcessingSettings.tsx +++ b/src/components/settings/post-processing/PostProcessingSettings.tsx @@ -344,7 +344,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { )} {!isCreating && !selectedPrompt && ( -
+

{hasPrompts ? t("settings.postProcessing.prompts.selectToEdit") diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 18aa9134a..d7a36734b 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -2,7 +2,7 @@ import React from "react"; interface BadgeProps { children: React.ReactNode; - variant?: "primary"; + variant?: "primary" | "success" | "secondary"; className?: string; } @@ -13,6 +13,8 @@ const Badge: React.FC = ({ }) => { const variantClasses = { primary: "bg-logo-primary", + success: "bg-green-500/20 text-green-400", + secondary: "bg-mid-gray/20 text-text/70", }; return ( diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index adb389db0..df92fdc90 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1,7 +1,13 @@ import React from "react"; interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: "primary" | "secondary" | "danger" | "ghost"; + variant?: + | "primary" + | "primary-soft" + | "secondary" + | "danger" + | "danger-ghost" + | "ghost"; size?: "sm" | "md" | "lg"; } @@ -13,15 +19,19 @@ export const Button: React.FC = ({ ...props }) => { const baseClasses = - "font-medium rounded border focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"; + "font-medium rounded-lg border focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"; const variantClasses = { primary: "text-white bg-background-ui border-background-ui hover:bg-background-ui/80 hover:border-background-ui/80 focus:ring-1 focus:ring-background-ui", + "primary-soft": + "text-text bg-logo-primary/20 border-transparent hover:bg-logo-primary/30 focus:ring-1 focus:ring-logo-primary", secondary: "bg-mid-gray/10 border-mid-gray/20 hover:bg-background-ui/30 hover:border-logo-primary focus:outline-none", danger: "text-white bg-red-600 border-mid-gray/20 hover:bg-red-700 hover:border-red-700 focus:ring-1 focus:ring-red-500", + "danger-ghost": + "text-red-400 border-transparent hover:text-red-300 hover:bg-red-500/10 focus:bg-red-500/20", ghost: "text-current border-transparent hover:bg-mid-gray/10 hover:border-logo-primary focus:bg-mid-gray/20", }; diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index de3bf7a7c..792c676d4 100644 --- a/src/components/ui/Dropdown.tsx +++ b/src/components/ui/Dropdown.tsx @@ -62,7 +62,7 @@ export const Dropdown: React.FC = ({

{isOpen && !disabled && ( -
+
{options.length === 0 ? (
{t("common.noOptionsFound")} diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index 767643eac..95e03e266 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -11,7 +11,7 @@ export const Input: React.FC = ({ ...props }) => { const baseClasses = - "px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded text-start transition-all duration-150"; + "px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded-md text-start transition-all duration-150"; const interactiveClasses = disabled ? "opacity-60 cursor-not-allowed bg-mid-gray/10 border-mid-gray/40" diff --git a/src/components/ui/PathDisplay.tsx b/src/components/ui/PathDisplay.tsx index f30f17c3f..ecf03cb6b 100644 --- a/src/components/ui/PathDisplay.tsx +++ b/src/components/ui/PathDisplay.tsx @@ -17,7 +17,7 @@ export const PathDisplay: React.FC = ({ return (
-
+
{path}