diff --git a/.vscode/settings.json b/.vscode/settings.json index 8c9736f..11dec8f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "adwaita", "ahooks", "akhilmhdh", "appimage", @@ -9,7 +10,9 @@ "autoconfig", "autologin", "biomejs", + "breezedark", "browserforge", + "busctl", "CAMOU", "camoufox", "cdylib", @@ -26,6 +29,7 @@ "dataclasses", "datareporting", "datas", + "dconf", "devedition", "doctest", "doesn", @@ -54,8 +58,10 @@ "idletime", "idna", "Inno", + "kdeglobals", "keras", "KHTML", + "kreadconfig", "launchservices", "letterboxing", "libatk", @@ -157,6 +163,8 @@ "winreg", "wiremock", "xattr", + "xfconf", + "xsettings", "zhom", "zoneinfo" ] diff --git a/package.json b/package.json index 7dc2df5..d2d240d 100644 --- a/package.json +++ b/package.json @@ -30,22 +30,23 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-table": "^8.21.3", - "@tauri-apps/api": "^2.6.0", - "@tauri-apps/plugin-deep-link": "^2.4.0", - "@tauri-apps/plugin-dialog": "^2.3.0", - "@tauri-apps/plugin-fs": "~2.4.0", + "@tauri-apps/api": "^2.7.0", + "@tauri-apps/plugin-deep-link": "^2.4.1", + "@tauri-apps/plugin-dialog": "^2.3.1", + "@tauri-apps/plugin-fs": "~2.4.1", "@tauri-apps/plugin-opener": "^2.4.0", "ahooks": "^3.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "next": "^15.3.5", + "next": "^15.4.4", "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -57,16 +58,16 @@ "devDependencies": { "@biomejs/biome": "2.1.1", "@tailwindcss/postcss": "^4.1.11", - "@tauri-apps/cli": "^2.6.2", - "@types/node": "^24.0.13", + "@tauri-apps/cli": "^2.7.1", + "@types/node": "^24.1.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", + "@vitejs/plugin-react": "^4.7.0", "husky": "^9.1.7", "lint-staged": "^16.1.2", "tailwindcss": "^4.1.11", "ts-unused-exports": "^11.0.1", - "tw-animate-css": "^1.3.5", + "tw-animate-css": "^1.3.6", "typescript": "~5.8.3" }, "packageManager": "pnpm@10.13.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99cca89..c66c06d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@radix-ui/react-progress': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-radio-group': + specifier: ^1.3.7 + version: 1.3.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-scroll-area': specifier: ^1.2.9 version: 1.2.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -45,16 +48,16 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tauri-apps/api': - specifier: ^2.6.0 + specifier: ^2.7.0 version: 2.7.0 '@tauri-apps/plugin-deep-link': - specifier: ^2.4.0 + specifier: ^2.4.1 version: 2.4.1 '@tauri-apps/plugin-dialog': - specifier: ^2.3.0 + specifier: ^2.3.1 version: 2.3.1 '@tauri-apps/plugin-fs': - specifier: ~2.4.0 + specifier: ~2.4.1 version: 2.4.1 '@tauri-apps/plugin-opener': specifier: ^2.4.0 @@ -72,7 +75,7 @@ importers: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: - specifier: ^15.3.5 + specifier: ^15.4.4 version: 15.4.4(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-themes: specifier: ^0.4.6 @@ -103,10 +106,10 @@ importers: specifier: ^4.1.11 version: 4.1.11 '@tauri-apps/cli': - specifier: ^2.6.2 + specifier: ^2.7.1 version: 2.7.1 '@types/node': - specifier: ^24.0.13 + specifier: ^24.1.0 version: 24.1.0 '@types/react': specifier: ^19.1.8 @@ -115,7 +118,7 @@ importers: specifier: ^19.1.6 version: 19.1.6(@types/react@19.1.8) '@vitejs/plugin-react': - specifier: ^4.6.0 + specifier: ^4.7.0 version: 4.7.0(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0)) husky: specifier: ^9.1.7 @@ -130,7 +133,7 @@ importers: specifier: ^11.0.1 version: 11.0.1(typescript@5.8.3) tw-animate-css: - specifier: ^1.3.5 + specifier: ^1.3.6 version: 1.3.6 typescript: specifier: ~5.8.3 @@ -954,6 +957,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.3.7': + resolution: {integrity: sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.10': resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==} peerDependencies: @@ -3618,6 +3634,24 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-radio-group@1.3.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 3617ee8..de47fe1 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -518,6 +518,7 @@ mod tests { last_launch: None, release_type: "stable".to_string(), camoufox_config: None, + group_id: None, } } diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 301f991..4d8a9fc 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -34,6 +34,8 @@ pub struct BrowserProfile { pub release_type: String, // "stable" or "nightly" #[serde(default)] pub camoufox_config: Option, // Camoufox configuration + #[serde(default)] + pub group_id: Option, // Reference to profile group } fn default_release_type() -> String { @@ -1351,6 +1353,28 @@ impl BrowserRunner { release_type: &str, proxy_id: Option, camoufox_config: Option, + ) -> Result> { + self.create_profile_with_group( + name, + browser, + version, + release_type, + proxy_id, + camoufox_config, + None, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn create_profile_with_group( + &self, + name: &str, + browser: &str, + version: &str, + release_type: &str, + proxy_id: Option, + camoufox_config: Option, + group_id: Option, ) -> Result> { println!("Attempting to create profile: {name}"); @@ -1384,6 +1408,7 @@ impl BrowserRunner { last_launch: None, release_type: release_type.to_string(), camoufox_config: camoufox_config.clone(), + group_id: group_id.clone(), }; // Save profile info @@ -1614,6 +1639,73 @@ impl BrowserRunner { Ok(cleaned_up) } + pub fn assign_profiles_to_group( + &self, + profile_names: Vec, + group_id: Option, + ) -> Result<(), Box> { + let profiles = self.list_profiles()?; + + for profile_name in profile_names { + let mut profile = profiles + .iter() + .find(|p| p.name == profile_name) + .ok_or_else(|| format!("Profile '{profile_name}' not found"))? + .clone(); + + // Check if browser is running + if profile.process_id.is_some() { + return Err(format!( + "Cannot modify group for profile '{profile_name}' while browser is running. Please stop the browser first." + ).into()); + } + + profile.group_id = group_id.clone(); + self.save_profile(&profile)?; + } + + Ok(()) + } + + pub fn delete_multiple_profiles( + &self, + profile_names: Vec, + ) -> Result<(), Box> { + let profiles = self.list_profiles()?; + + for profile_name in profile_names { + let profile = profiles + .iter() + .find(|p| p.name == profile_name) + .ok_or_else(|| format!("Profile '{profile_name}' not found"))?; + + // Check if browser is running + if profile.process_id.is_some() { + return Err( + format!( + "Cannot delete profile '{profile_name}' while browser is running. Please stop the browser first." + ) + .into(), + ); + } + + // Delete the profile + let profiles_dir = self.get_profiles_dir(); + let profile_uuid_dir = profiles_dir.join(profile.id.to_string()); + + if profile_uuid_dir.exists() { + std::fs::remove_dir_all(&profile_uuid_dir)?; + } + } + + // Always perform cleanup after profile deletion to remove unused binaries + if let Err(e) = self.cleanup_unused_binaries_internal() { + println!("Warning: Failed to cleanup unused binaries: {e}"); + } + + Ok(()) + } + fn get_common_firefox_preferences(&self) -> Vec { vec![ // Disable default browser check diff --git a/src-tauri/src/group_manager.rs b/src-tauri/src/group_manager.rs new file mode 100644 index 0000000..8e9b24e --- /dev/null +++ b/src-tauri/src/group_manager.rs @@ -0,0 +1,257 @@ +use directories::BaseDirs; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileGroup { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupWithCount { + pub id: String, + pub name: String, + pub count: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GroupsData { + groups: Vec, +} + +pub struct GroupManager { + base_dirs: BaseDirs, +} + +impl GroupManager { + pub fn new() -> Self { + Self { + base_dirs: BaseDirs::new().expect("Failed to get base directories"), + } + } + + fn get_groups_file_path(&self) -> PathBuf { + let mut path = self.base_dirs.data_local_dir().to_path_buf(); + path.push(if cfg!(debug_assertions) { + "DonutBrowserDev" + } else { + "DonutBrowser" + }); + path.push("data"); + path.push("groups.json"); + path + } + + fn load_groups_data(&self) -> Result> { + let groups_file = self.get_groups_file_path(); + + if !groups_file.exists() { + return Ok(GroupsData { groups: Vec::new() }); + } + + let content = fs::read_to_string(groups_file)?; + let groups_data: GroupsData = serde_json::from_str(&content)?; + Ok(groups_data) + } + + fn save_groups_data(&self, groups_data: &GroupsData) -> Result<(), Box> { + let groups_file = self.get_groups_file_path(); + + // Ensure the parent directory exists + if let Some(parent) = groups_file.parent() { + fs::create_dir_all(parent)?; + } + + let json = serde_json::to_string_pretty(groups_data)?; + fs::write(groups_file, json)?; + Ok(()) + } + + pub fn get_all_groups(&self) -> Result, Box> { + let groups_data = self.load_groups_data()?; + Ok(groups_data.groups) + } + + pub fn create_group(&self, name: String) -> Result> { + let mut groups_data = self.load_groups_data()?; + + // Check if group with this name already exists + if groups_data.groups.iter().any(|g| g.name == name) { + return Err(format!("Group with name '{name}' already exists").into()); + } + + let group = ProfileGroup { + id: uuid::Uuid::new_v4().to_string(), + name, + }; + + groups_data.groups.push(group.clone()); + self.save_groups_data(&groups_data)?; + + Ok(group) + } + + pub fn update_group( + &self, + id: String, + name: String, + ) -> Result> { + let mut groups_data = self.load_groups_data()?; + + // Check if another group with this name already exists + if groups_data + .groups + .iter() + .any(|g| g.name == name && g.id != id) + { + return Err(format!("Group with name '{name}' already exists").into()); + } + + let group = groups_data + .groups + .iter_mut() + .find(|g| g.id == id) + .ok_or_else(|| format!("Group with id '{id}' not found"))?; + + group.name = name; + let updated_group = group.clone(); + + self.save_groups_data(&groups_data)?; + Ok(updated_group) + } + + pub fn delete_group(&self, id: String) -> Result<(), Box> { + let mut groups_data = self.load_groups_data()?; + + let initial_len = groups_data.groups.len(); + groups_data.groups.retain(|g| g.id != id); + + if groups_data.groups.len() == initial_len { + return Err(format!("Group with id '{id}' not found").into()); + } + + self.save_groups_data(&groups_data)?; + Ok(()) + } + + pub fn get_groups_with_profile_counts( + &self, + profiles: &[crate::browser_runner::BrowserProfile], + ) -> Result, Box> { + let groups = self.get_all_groups()?; + let mut group_counts = HashMap::new(); + + // Count profiles in each group + for profile in profiles { + if let Some(group_id) = &profile.group_id { + *group_counts.entry(group_id.clone()).or_insert(0) += 1; + } + } + + // Create result with counts + let mut result = Vec::new(); + for group in groups { + let count = group_counts.get(&group.id).copied().unwrap_or(0); + if count > 0 { + result.push(GroupWithCount { + id: group.id, + name: group.name, + count, + }); + } + } + + // Add default group count (profiles without group_id) + let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count(); + if default_count > 0 { + let default_group = GroupWithCount { + id: "default".to_string(), + name: "Default".to_string(), + count: default_count, + }; + result.insert(0, default_group); + } + + Ok(result) + } +} + +// Global instance +lazy_static::lazy_static! { + pub static ref GROUP_MANAGER: Mutex = Mutex::new(GroupManager::new()); +} + +// Helper function to get groups with counts +pub fn get_groups_with_counts( + profiles: &[crate::browser_runner::BrowserProfile], +) -> Vec { + let group_manager = GROUP_MANAGER.lock().unwrap(); + group_manager + .get_groups_with_profile_counts(profiles) + .unwrap_or_default() +} + +// Tauri commands +#[tauri::command] +pub async fn get_profile_groups() -> Result, String> { + let group_manager = GROUP_MANAGER.lock().unwrap(); + group_manager + .get_all_groups() + .map_err(|e| format!("Failed to get profile groups: {e}")) +} + +#[tauri::command] +pub async fn get_groups_with_profile_counts() -> Result, String> { + let browser_runner = crate::browser_runner::BrowserRunner::new(); + let profiles = browser_runner + .list_profiles() + .map_err(|e| format!("Failed to list profiles: {e}"))?; + Ok(get_groups_with_counts(&profiles)) +} + +#[tauri::command] +pub async fn create_profile_group(name: String) -> Result { + let group_manager = GROUP_MANAGER.lock().unwrap(); + group_manager + .create_group(name) + .map_err(|e| format!("Failed to create group: {e}")) +} + +#[tauri::command] +pub async fn update_profile_group(group_id: String, name: String) -> Result { + let group_manager = GROUP_MANAGER.lock().unwrap(); + group_manager + .update_group(group_id, name) + .map_err(|e| format!("Failed to update group: {e}")) +} + +#[tauri::command] +pub async fn delete_profile_group(group_id: String) -> Result<(), String> { + let group_manager = GROUP_MANAGER.lock().unwrap(); + group_manager + .delete_group(group_id) + .map_err(|e| format!("Failed to delete group: {e}")) +} + +#[tauri::command] +pub async fn assign_profiles_to_group( + profile_names: Vec, + group_id: Option, +) -> Result<(), String> { + let browser_runner = crate::browser_runner::BrowserRunner::new(); + browser_runner + .assign_profiles_to_group(profile_names, group_id) + .map_err(|e| format!("Failed to assign profiles to group: {e}")) +} + +#[tauri::command] +pub async fn delete_selected_profiles(profile_names: Vec) -> Result<(), String> { + let browser_runner = crate::browser_runner::BrowserRunner::new(); + browser_runner + .delete_multiple_profiles(profile_names) + .map_err(|e| format!("Failed to delete profiles: {e}")) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b637590..d008ce9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ mod download; mod downloaded_browsers; mod extraction; mod geoip_downloader; +mod group_manager; mod profile_importer; mod proxy_manager; @@ -66,6 +67,11 @@ use theme_detector::get_system_theme; use system_utils::{get_system_locale, get_system_timezone}; +use group_manager::{ + assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles, + get_groups_with_profile_counts, get_profile_groups, update_profile_group, +}; + // Trait to extend WebviewWindow with transparent titlebar functionality pub trait WindowExt { #[cfg(target_os = "macos")] @@ -434,6 +440,13 @@ pub fn run() { update_camoufox_config, get_system_locale, get_system_timezone, + get_profile_groups, + get_groups_with_profile_counts, + create_profile_group, + update_profile_group, + delete_profile_group, + assign_profiles_to_group, + delete_selected_profiles, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 430d9a9..524cdea 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -690,6 +690,7 @@ impl ProfileImporter { last_launch: None, release_type: "stable".to_string(), camoufox_config: None, + group_id: None, }; // Save the profile metadata diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 6f1a851..570ec91 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -412,26 +412,6 @@ impl ProxyManager { Ok(()) } - // Get proxy settings for a browser process ID - #[allow(dead_code)] - pub fn get_proxy_settings(&self, browser_pid: u32) -> Option { - let proxies = self.active_proxies.lock().unwrap(); - proxies.get(&browser_pid).map(|proxy| ProxySettings { - proxy_type: "http".to_string(), - host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility - port: proxy.local_port, - username: None, - password: None, - }) - } - - // Get stored proxy info for a profile - #[allow(dead_code)] - pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option { - let profile_proxies = self.profile_proxies.lock().unwrap(); - profile_proxies.get(profile_name).cloned() - } - // Update the PID mapping for an existing proxy pub fn update_proxy_pid(&self, old_pid: u32, new_pid: u32) -> Result<(), String> { let mut proxies = self.active_proxies.lock().unwrap(); @@ -530,70 +510,6 @@ mod tests { Ok(nodecar_binary) } - #[tokio::test] - async fn test_proxy_manager_profile_persistence() { - let proxy_manager = ProxyManager::new(); - - let proxy_settings = ProxySettings { - proxy_type: "socks5".to_string(), - host: "127.0.0.1".to_string(), - port: 1080, - username: None, - password: None, - }; - - // Test profile proxy info storage - { - let mut profile_proxies = proxy_manager.profile_proxies.lock().unwrap(); - profile_proxies.insert("test_profile".to_string(), proxy_settings.clone()); - } - - // Test retrieval - let retrieved = proxy_manager.get_profile_proxy_info("test_profile"); - assert!(retrieved.is_some()); - let retrieved = retrieved.unwrap(); - assert_eq!(retrieved.proxy_type, "socks5"); - assert_eq!(retrieved.host, "127.0.0.1"); - assert_eq!(retrieved.port, 1080); - - // Test non-existent profile - let non_existent = proxy_manager.get_profile_proxy_info("non_existent"); - assert!(non_existent.is_none()); - } - - #[tokio::test] - async fn test_proxy_manager_active_proxy_tracking() { - let proxy_manager = ProxyManager::new(); - - let proxy_info = ProxyInfo { - id: "test_proxy_123".to_string(), - local_url: "http://localhost:8080".to_string(), - upstream_host: "proxy.example.com".to_string(), - upstream_port: 3128, - upstream_type: "http".to_string(), - local_port: 8080, - }; - - let browser_pid = 54321u32; - - // Add active proxy - { - let mut active_proxies = proxy_manager.active_proxies.lock().unwrap(); - active_proxies.insert(browser_pid, proxy_info.clone()); - } - - // Test retrieval of proxy settings - let proxy_settings = proxy_manager.get_proxy_settings(browser_pid); - assert!(proxy_settings.is_some()); - let settings = proxy_settings.unwrap(); - assert!(settings.host == "127.0.0.1"); - assert!(settings.port == 8080); - - // Test non-existent browser PID - let non_existent = proxy_manager.get_proxy_settings(99999); - assert!(non_existent.is_none()); - } - #[test] fn test_proxy_settings_validation() { // Test valid proxy settings @@ -647,10 +563,6 @@ mod tests { active_proxies.insert(browser_pid, proxy_info); } - // Read proxy - let settings = pm.get_proxy_settings(browser_pid); - assert!(settings.is_some()); - browser_pid }); handles.push(handle); diff --git a/src/app/page.tsx b/src/app/page.tsx index f5bde75..e815517 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,12 +4,13 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrent } from "@tauri-apps/plugin-deep-link"; import { useCallback, useEffect, useRef, useState } from "react"; -import { FaDownload } from "react-icons/fa"; -import { FiWifi } from "react-icons/fi"; -import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go"; import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog"; import { ChangeVersionDialog } from "@/components/change-version-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog"; +import { GroupAssignmentDialog } from "@/components/group-assignment-dialog"; +import { GroupBadges } from "@/components/group-badges"; +import { GroupManagementDialog } from "@/components/group-management-dialog"; +import HomeHeader from "@/components/home-header"; import { ImportProfileDialog } from "@/components/import-profile-dialog"; import { PermissionDialog } from "@/components/permission-dialog"; import { ProfilesDataTable } from "@/components/profile-data-table"; @@ -17,19 +18,7 @@ import { ProfileSelectorDialog } from "@/components/profile-selector-dialog"; import { ProxyManagementDialog } from "@/components/proxy-management-dialog"; import { ProxySettingsDialog } from "@/components/proxy-settings-dialog"; import { SettingsDialog } from "@/components/settings-dialog"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"; import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; @@ -37,7 +26,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications"; import { useVersionUpdater } from "@/hooks/use-version-updater"; import { showErrorToast } from "@/lib/toast-utils"; import { sleep } from "@/lib/utils"; -import type { BrowserProfile, CamoufoxConfig } from "@/types"; +import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types"; type BrowserTypeString = | "mullvad-browser" @@ -66,6 +55,15 @@ export default function Home() { useState(false); const [camoufoxConfigDialogOpen, setCamoufoxConfigDialogOpen] = useState(false); + const [groupManagementDialogOpen, setGroupManagementDialogOpen] = + useState(false); + const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] = + useState(false); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState< + string[] + >([]); + const [selectedProfiles, setSelectedProfiles] = useState([]); const [pendingUrls, setPendingUrls] = useState([]); const [currentProfileForProxy, setCurrentProfileForProxy] = useState(null); @@ -75,12 +73,18 @@ export default function Home() { useState(null); const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); + const [groups, setGroups] = useState([]); + const [areGroupsLoading, setGroupsLoading] = useState(true); const [currentPermissionType, setCurrentPermissionType] = useState("microphone"); - const [proxyDataReloadTrigger, setProxyDataReloadTrigger] = useState(0); const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } = usePermissions(); + const handleSelectGroup = useCallback((groupId: string | null) => { + setSelectedGroupId(groupId); + setSelectedProfiles([]); + }, []); + // Check for missing binaries and offer to download them const checkMissingBinaries = useCallback(async () => { try { @@ -151,11 +155,6 @@ export default function Home() { } }, [checkMissingBinaries]); - // Trigger proxy data reload in ProfilesDataTable - const triggerProxyDataReload = useCallback(() => { - setProxyDataReloadTrigger((prev) => prev + 1); - }, []); - const [processingUrls, setProcessingUrls] = useState>(new Set()); const handleUrlOpen = useCallback( @@ -379,13 +378,12 @@ export default function Home() { } await loadProfiles(); // Trigger proxy data reload in the table - triggerProxyDataReload(); } catch (err: unknown) { console.error("Failed to update proxy settings:", err); setError(`Failed to update proxy settings: ${JSON.stringify(err)}`); } }, - [currentProfileForProxy, loadProfiles, triggerProxyDataReload], + [currentProfileForProxy, loadProfiles], ); const handleCreateProfile = useCallback( @@ -414,7 +412,6 @@ export default function Home() { await loadProfiles(); // Trigger proxy data reload in the table - triggerProxyDataReload(); } catch (error) { setError( `Failed to create profile: ${ @@ -424,7 +421,7 @@ export default function Home() { throw error; } }, - [loadProfiles, triggerProxyDataReload], + [loadProfiles], ); const [runningProfiles, setRunningProfiles] = useState>( @@ -563,8 +560,71 @@ export default function Home() { [loadProfiles], ); + const loadGroups = useCallback(async () => { + setGroupsLoading(true); + try { + const groupsWithCounts = await invoke( + "get_groups_with_profile_counts", + ); + setGroups(groupsWithCounts); + } catch (err) { + console.error("Failed to load groups with counts:", err); + setGroups([]); + } finally { + setGroupsLoading(false); + } + }, []); + + const handleDeleteSelectedProfiles = useCallback( + async (profileNames: string[]) => { + setError(null); + try { + await invoke("delete_selected_profiles", { profileNames }); + await loadProfiles(); + await loadGroups(); + } catch (err: unknown) { + console.error("Failed to delete selected profiles:", err); + setError(`Failed to delete selected profiles: ${JSON.stringify(err)}`); + } + }, + [loadProfiles, loadGroups], + ); + + const handleAssignProfilesToGroup = useCallback((profileNames: string[]) => { + setSelectedProfilesForGroup(profileNames); + setGroupAssignmentDialogOpen(true); + }, []); + + const handleBulkDelete = useCallback(async () => { + if (selectedProfiles.length === 0) return; + + try { + await invoke("delete_selected_profiles", { + profileNames: selectedProfiles, + }); + await loadProfiles(); + setSelectedProfiles([]); + } catch (error) { + console.error("Failed to delete selected profiles:", error); + } + }, [selectedProfiles, loadProfiles]); + + const handleBulkGroupAssignment = useCallback(() => { + if (selectedProfiles.length === 0) return; + handleAssignProfilesToGroup(selectedProfiles); + setSelectedProfiles([]); + }, [selectedProfiles, handleAssignProfilesToGroup]); + + const handleGroupAssignmentComplete = useCallback(async () => { + await loadProfiles(); + await loadGroups(); + setGroupAssignmentDialogOpen(false); + setSelectedProfilesForGroup([]); + }, [loadProfiles, loadGroups]); + useEffect(() => { void loadProfilesWithUpdateCheck(); + void loadGroups(); // Check for startup default browser prompt void checkStartupPrompt(); @@ -592,6 +652,7 @@ export default function Home() { checkStartupPrompt, listenForUrlEvents, checkCurrentUrl, + loadGroups, ]); useEffect(() => { @@ -629,66 +690,26 @@ export default function Home() { return (
- + -
- Profiles -
- - - - - - { - setSettingsDialogOpen(true); - }} - > - - Settings - - { - setProxyManagementDialogOpen(true); - }} - > - - Proxies - - { - setImportProfileDialogOpen(true); - }} - > - - Import Profile - - - - - - - - Create a new profile - -
-
+
+ 0 ? triggerProxyDataReload : undefined - } + onDeleteSelectedProfiles={handleDeleteSelectedProfiles} + onAssignProfilesToGroup={handleAssignProfilesToGroup} + selectedGroupId={selectedGroupId} + selectedProfiles={selectedProfiles} + onSelectedProfilesChange={setSelectedProfiles} />
@@ -788,6 +811,22 @@ export default function Home() { profile={currentProfileForCamoufoxConfig} onSave={handleSaveCamoufoxConfig} /> + + { + setGroupManagementDialogOpen(false); + }} + /> + + { + setGroupAssignmentDialogOpen(false); + }} + selectedProfiles={selectedProfilesForGroup} + onAssignmentComplete={handleGroupAssignmentComplete} + />
); } diff --git a/src/components/create-group-dialog.tsx b/src/components/create-group-dialog.tsx new file mode 100644 index 0000000..5175a2a --- /dev/null +++ b/src/components/create-group-dialog.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { LoadingButton } from "@/components/loading-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { ProfileGroup } from "@/types"; + +interface CreateGroupDialogProps { + isOpen: boolean; + onClose: () => void; + onGroupCreated: (group: ProfileGroup) => void; +} + +export function CreateGroupDialog({ + isOpen, + onClose, + onGroupCreated, +}: CreateGroupDialogProps) { + const [groupName, setGroupName] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + const handleCreate = useCallback(async () => { + if (!groupName.trim()) return; + + setIsCreating(true); + setError(null); + try { + const newGroup = await invoke("create_profile_group", { + name: groupName.trim(), + }); + + toast.success("Group created successfully"); + onGroupCreated(newGroup); + setGroupName(""); + onClose(); + } catch (err) { + console.error("Failed to create group:", err); + const errorMessage = + err instanceof Error ? err.message : "Failed to create group"; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setIsCreating(false); + } + }, [groupName, onGroupCreated, onClose]); + + const handleClose = useCallback(() => { + setGroupName(""); + setError(null); + onClose(); + }, [onClose]); + + return ( + + + + Create New Group + + Create a new group to organize your browser profiles. + + + +
+
+ + setGroupName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && groupName.trim()) { + void handleCreate(); + } + }} + disabled={isCreating} + /> +
+ + {error && ( +
+ {error} +
+ )} +
+ + + + void handleCreate()} + disabled={!groupName.trim()} + > + Create Group + + +
+
+ ); +} diff --git a/src/components/delete-group-dialog.tsx b/src/components/delete-group-dialog.tsx new file mode 100644 index 0000000..d1c1a25 --- /dev/null +++ b/src/components/delete-group-dialog.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { LoadingButton } from "@/components/loading-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import type { BrowserProfile, ProfileGroup } from "@/types"; + +interface DeleteGroupDialogProps { + isOpen: boolean; + onClose: () => void; + group: ProfileGroup | null; + onGroupDeleted: () => void; +} + +export function DeleteGroupDialog({ + isOpen, + onClose, + group, + onGroupDeleted, +}: DeleteGroupDialogProps) { + const [associatedProfiles, setAssociatedProfiles] = useState< + BrowserProfile[] + >([]); + const [deleteAction, setDeleteAction] = useState<"move" | "delete">("move"); + const [isDeleting, setIsDeleting] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const loadAssociatedProfiles = useCallback(async () => { + if (!group) return; + + setIsLoading(true); + setError(null); + try { + const allProfiles = await invoke( + "list_browser_profiles", + ); + const groupProfiles = allProfiles.filter( + (profile) => profile.group_id === group.id, + ); + setAssociatedProfiles(groupProfiles); + } catch (err) { + console.error("Failed to load associated profiles:", err); + setError(err instanceof Error ? err.message : "Failed to load profiles"); + } finally { + setIsLoading(false); + } + }, [group]); + + useEffect(() => { + if (isOpen && group) { + void loadAssociatedProfiles(); + } + }, [isOpen, group, loadAssociatedProfiles]); + + const handleDelete = useCallback(async () => { + if (!group) return; + + setIsDeleting(true); + setError(null); + try { + if (deleteAction === "delete" && associatedProfiles.length > 0) { + // Delete all associated profiles first + const profileNames = associatedProfiles.map((p) => p.name); + await invoke("delete_selected_profiles", { profileNames }); + } else if (deleteAction === "move" && associatedProfiles.length > 0) { + // Move profiles to default group (null group_id) + const profileNames = associatedProfiles.map((p) => p.name); + await invoke("assign_profiles_to_group", { + profileNames, + groupId: null, + }); + } + + // Delete the group + await invoke("delete_profile_group", { groupId: group.id }); + + toast.success("Group deleted successfully"); + onGroupDeleted(); + onClose(); + } catch (err) { + console.error("Failed to delete group:", err); + const errorMessage = + err instanceof Error ? err.message : "Failed to delete group"; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setIsDeleting(false); + } + }, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose]); + + const handleClose = useCallback(() => { + setError(null); + setDeleteAction("move"); + setAssociatedProfiles([]); + onClose(); + }, [onClose]); + + return ( + + + + Delete Group + + This action cannot be undone. This will permanently delete the group + "{group?.name}". + + + +
+ {isLoading ? ( +
+ Loading associated profiles... +
+ ) : ( + <> + {associatedProfiles.length > 0 && ( +
+
+ + +
+ {associatedProfiles.map((profile) => ( +
+ • {profile.name} +
+ ))} +
+
+
+ +
+ + + setDeleteAction(value as "move" | "delete") + } + > +
+ + +
+
+ + +
+
+
+
+ )} + + {associatedProfiles.length === 0 && !isLoading && ( +
+ This group has no associated profiles. +
+ )} + + )} + + {error && ( +
+ {error} +
+ )} +
+ + + + void handleDelete()} + disabled={isLoading} + > + Delete Group + {deleteAction === "delete" && + associatedProfiles.length > 0 && + " & Profiles"} + + +
+
+ ); +} diff --git a/src/components/edit-group-dialog.tsx b/src/components/edit-group-dialog.tsx new file mode 100644 index 0000000..e44d66c --- /dev/null +++ b/src/components/edit-group-dialog.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { LoadingButton } from "@/components/loading-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { ProfileGroup } from "@/types"; + +interface EditGroupDialogProps { + isOpen: boolean; + onClose: () => void; + group: ProfileGroup | null; + onGroupUpdated: (group: ProfileGroup) => void; +} + +export function EditGroupDialog({ + isOpen, + onClose, + group, + onGroupUpdated, +}: EditGroupDialogProps) { + const [groupName, setGroupName] = useState(""); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (group) { + setGroupName(group.name); + } else { + setGroupName(""); + } + setError(null); + }, [group]); + + const handleUpdate = useCallback(async () => { + if (!group || !groupName.trim()) return; + + setIsUpdating(true); + setError(null); + try { + const updatedGroup = await invoke("update_profile_group", { + groupId: group.id, + name: groupName.trim(), + }); + + toast.success("Group updated successfully"); + onGroupUpdated(updatedGroup); + onClose(); + } catch (err) { + console.error("Failed to update group:", err); + const errorMessage = + err instanceof Error ? err.message : "Failed to update group"; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setIsUpdating(false); + } + }, [group, groupName, onGroupUpdated, onClose]); + + const handleClose = useCallback(() => { + setError(null); + onClose(); + }, [onClose]); + + return ( + + + + Edit Group + + Update the name of the group "{group?.name}". + + + +
+
+ + setGroupName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && groupName.trim()) { + void handleUpdate(); + } + }} + disabled={isUpdating} + /> +
+ + {error && ( +
+ {error} +
+ )} +
+ + + + void handleUpdate()} + disabled={!groupName.trim() || groupName === group?.name} + > + Update Group + + +
+
+ ); +} diff --git a/src/components/group-assignment-dialog.tsx b/src/components/group-assignment-dialog.tsx new file mode 100644 index 0000000..f4ec345 --- /dev/null +++ b/src/components/group-assignment-dialog.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { LoadingButton } from "@/components/loading-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ProfileGroup } from "@/types"; + +interface GroupAssignmentDialogProps { + isOpen: boolean; + onClose: () => void; + selectedProfiles: string[]; + onAssignmentComplete: () => void; +} + +export function GroupAssignmentDialog({ + isOpen, + onClose, + selectedProfiles, + onAssignmentComplete, +}: GroupAssignmentDialogProps) { + const [groups, setGroups] = useState([]); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isAssigning, setIsAssigning] = useState(false); + const [error, setError] = useState(null); + + const loadGroups = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const groupList = await invoke("get_profile_groups"); + setGroups(groupList); + } catch (err) { + console.error("Failed to load groups:", err); + setError(err instanceof Error ? err.message : "Failed to load groups"); + } finally { + setIsLoading(false); + } + }, []); + + const handleAssign = useCallback(async () => { + setIsAssigning(true); + setError(null); + try { + await invoke("assign_profiles_to_group", { + profileNames: selectedProfiles, + groupId: selectedGroupId, + }); + + const groupName = selectedGroupId + ? groups.find((g) => g.id === selectedGroupId)?.name || "Unknown Group" + : "Default"; + + toast.success( + `Successfully assigned ${selectedProfiles.length} profile(s) to ${groupName}`, + ); + onAssignmentComplete(); + onClose(); + } catch (err) { + console.error("Failed to assign profiles to group:", err); + const errorMessage = + err instanceof Error + ? err.message + : "Failed to assign profiles to group"; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setIsAssigning(false); + } + }, [ + selectedProfiles, + selectedGroupId, + groups, + onAssignmentComplete, + onClose, + ]); + + useEffect(() => { + if (isOpen) { + void loadGroups(); + setSelectedGroupId(null); + setError(null); + } + }, [isOpen, loadGroups]); + + return ( + + + + Assign to Group + + Assign {selectedProfiles.length} selected profile(s) to a group. + + + +
+
+ +
+
    + {selectedProfiles.map((profileName) => ( +
  • + • {profileName} +
  • + ))} +
+
+
+ +
+ + {isLoading ? ( +
+ Loading groups... +
+ ) : ( + + )} +
+ + {error && ( +
+ {error} +
+ )} +
+ + + + void handleAssign()} + disabled={isLoading} + > + Assign + + +
+
+ ); +} diff --git a/src/components/group-badges.tsx b/src/components/group-badges.tsx new file mode 100644 index 0000000..a8b46a1 --- /dev/null +++ b/src/components/group-badges.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import type { GroupWithCount } from "@/types"; + +interface GroupBadgesProps { + selectedGroupId: string | null; + onGroupSelect: (groupId: string | null) => void; + refreshTrigger?: number; + groups: GroupWithCount[]; + isLoading: boolean; +} + +export function GroupBadges({ + selectedGroupId, + onGroupSelect, + groups, + isLoading, +}: GroupBadgesProps) { + if (isLoading) { + return ( +
+
Loading groups...
+
+ ); + } + + if (groups.length === 0) { + return null; + } + + return ( +
+ {groups.map((group) => ( + { + onGroupSelect(selectedGroupId === group.id ? null : group.id); + }} + > + {group.name} + + {group.count} + + + ))} +
+ ); +} diff --git a/src/components/group-management-dialog.tsx b/src/components/group-management-dialog.tsx new file mode 100644 index 0000000..fff1b12 --- /dev/null +++ b/src/components/group-management-dialog.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; +import { GoPlus } from "react-icons/go"; +import { LuPencil, LuTrash2 } from "react-icons/lu"; +import { CreateGroupDialog } from "@/components/create-group-dialog"; +import { DeleteGroupDialog } from "@/components/delete-group-dialog"; +import { EditGroupDialog } from "@/components/edit-group-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { ProfileGroup } from "@/types"; + +interface GroupManagementDialogProps { + isOpen: boolean; + onClose: () => void; +} + +export function GroupManagementDialog({ + isOpen, + onClose, +}: GroupManagementDialogProps) { + const [groups, setGroups] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Dialog states + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + + const loadGroups = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const groupList = await invoke("get_profile_groups"); + setGroups(groupList); + } catch (err) { + console.error("Failed to load groups:", err); + setError(err instanceof Error ? err.message : "Failed to load groups"); + } finally { + setIsLoading(false); + } + }, []); + + const handleGroupCreated = useCallback((newGroup: ProfileGroup) => { + setGroups((prev) => [...prev, newGroup]); + }, []); + + const handleGroupUpdated = useCallback((updatedGroup: ProfileGroup) => { + setGroups((prev) => + prev.map((group) => + group.id === updatedGroup.id ? updatedGroup : group, + ), + ); + }, []); + + const handleGroupDeleted = useCallback(() => { + void loadGroups(); + }, [loadGroups]); + + const handleEditGroup = useCallback((group: ProfileGroup) => { + setSelectedGroup(group); + setEditDialogOpen(true); + }, []); + + const handleDeleteGroup = useCallback((group: ProfileGroup) => { + setSelectedGroup(group); + setDeleteDialogOpen(true); + }, []); + + useEffect(() => { + if (isOpen) { + void loadGroups(); + } + }, [isOpen, loadGroups]); + + return ( + <> + + + + Manage Profile Groups + + Create, edit, and delete profile groups. Profiles without a group + will appear in the "Default" group. + + + +
+ {/* Create new group button */} +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + {/* Groups list */} + {isLoading ? ( +
+ Loading groups... +
+ ) : groups.length === 0 ? ( +
+ No groups created yet. Create your first group using the button + above. +
+ ) : ( +
+ + + + Name + Actions + + + + {groups.map((group) => ( + + + {group.name} + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+ + + + +
+
+ + setCreateDialogOpen(false)} + onGroupCreated={handleGroupCreated} + /> + + setEditDialogOpen(false)} + group={selectedGroup} + onGroupUpdated={handleGroupUpdated} + /> + + setDeleteDialogOpen(false)} + group={selectedGroup} + onGroupDeleted={handleGroupDeleted} + /> + + ); +} diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx new file mode 100644 index 0000000..286f5e6 --- /dev/null +++ b/src/components/home-header.tsx @@ -0,0 +1,139 @@ +import { FaDownload } from "react-icons/fa"; +import { FiWifi } from "react-icons/fi"; +import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go"; +import { LuTrash2, LuUsers } from "react-icons/lu"; +import { Logo } from "./icons/logo"; +import { Button } from "./ui/button"; +import { CardTitle } from "./ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +type Props = { + selectedProfiles: string[]; + onBulkGroupAssignment: () => void; + onBulkDelete: () => Promise; + onSettingsDialogOpen: (open: boolean) => void; + onProxyManagementDialogOpen: (open: boolean) => void; + onGroupManagementDialogOpen: (open: boolean) => void; + onImportProfileDialogOpen: (open: boolean) => void; + onCreateProfileDialogOpen: (open: boolean) => void; +}; + +const HomeHeader = ({ + selectedProfiles, + onBulkGroupAssignment, + onBulkDelete, + onSettingsDialogOpen, + onProxyManagementDialogOpen, + onGroupManagementDialogOpen, + onImportProfileDialogOpen, + onCreateProfileDialogOpen, +}: Props) => { + return ( +
+
+ + {selectedProfiles.length > 0 ? ( +
+ + {selectedProfiles.length} profile + {selectedProfiles.length !== 1 ? "s" : ""} selected + +
+ + +
+
+ ) : ( + Donut + )} +
+
+ + + + + + { + onSettingsDialogOpen(true); + }} + > + + Settings + + { + onProxyManagementDialogOpen(true); + }} + > + + Proxies + + { + onGroupManagementDialogOpen(true); + }} + > + + Groups + + { + onImportProfileDialogOpen(true); + }} + > + + Import Profile + + + + + + + + + + Create a new profile + +
+
+ ); +}; + +export default HomeHeader; diff --git a/src/components/icons/logo.tsx b/src/components/icons/logo.tsx new file mode 100644 index 0000000..ac65c06 --- /dev/null +++ b/src/components/icons/logo.tsx @@ -0,0 +1,14 @@ +export const Logo = (props: React.SVGProps) => ( + + + + +); diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 153d018..c9212c5 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -10,9 +10,11 @@ import { } from "@tanstack/react-table"; import { invoke } from "@tauri-apps/api/core"; import * as React from "react"; +import { CiCircleCheck } from "react-icons/ci"; import { IoEllipsisHorizontal } from "react-icons/io5"; import { LuChevronDown, LuChevronUp } from "react-icons/lu"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -67,6 +69,11 @@ interface ProfilesDataTableProps { runningProfiles: Set; isUpdating?: (browser: string) => boolean; onReloadProxyData?: () => void | Promise; + onDeleteSelectedProfiles?: (profileNames: string[]) => Promise; + onAssignProfilesToGroup?: (profileNames: string[]) => void; + selectedGroupId?: string | null; + selectedProfiles?: string[]; + onSelectedProfilesChange?: (profiles: string[]) => void; } export function ProfilesDataTable({ @@ -80,7 +87,11 @@ export function ProfilesDataTable({ onConfigureCamoufox, runningProfiles, isUpdating = () => false, - onReloadProxyData, + onDeleteSelectedProfiles: _onDeleteSelectedProfiles, + onAssignProfilesToGroup, + selectedGroupId, + selectedProfiles: externalSelectedProfiles = [], + onSelectedProfilesChange, }: ProfilesDataTableProps) { const { getTableSorting, updateSorting, isLoaded } = useTableSorting(); const [sorting, setSorting] = React.useState([]); @@ -95,6 +106,10 @@ export function ProfilesDataTable({ const [deleteError, setDeleteError] = React.useState(null); const [storedProxies, setStoredProxies] = React.useState([]); + const [selectedProfiles, setSelectedProfiles] = React.useState>( + new Set(externalSelectedProfiles), + ); + const [showCheckboxes, setShowCheckboxes] = React.useState(false); // Helper function to check if a profile has a proxy const hasProxy = React.useCallback( @@ -125,8 +140,21 @@ export function ProfilesDataTable({ [storedProxies], ); + // Filter data by selected group + const filteredData = React.useMemo(() => { + if (!selectedGroupId) return data; + if (selectedGroupId === "default") { + return data.filter((profile) => !profile.group_id); + } + return data.filter((profile) => profile.group_id === selectedGroupId); + }, [data, selectedGroupId]); + // Use shared browser state hook - const browserState = useBrowserState(data, runningProfiles, isUpdating); + const browserState = useBrowserState( + filteredData, + runningProfiles, + isUpdating, + ); // Load stored proxies const loadStoredProxies = React.useCallback(async () => { @@ -144,12 +172,12 @@ export function ProfilesDataTable({ } }, [browserState.isClient, loadStoredProxies]); - // Reload proxy data when requested from parent + // Sync external selected profiles with internal state React.useEffect(() => { - if (onReloadProxyData) { - void loadStoredProxies(); - } - }, [onReloadProxyData, loadStoredProxies]); + const newSet = new Set(externalSelectedProfiles); + setSelectedProfiles(newSet); + setShowCheckboxes(newSet.size > 0); + }, [externalSelectedProfiles]); // Update local sorting state when settings are loaded React.useEffect(() => { @@ -203,8 +231,139 @@ export function ProfilesDataTable({ } }; + // Handle icon/checkbox click + const handleIconClick = React.useCallback( + (profileName: string) => { + setShowCheckboxes(true); + setSelectedProfiles((prev) => { + const newSet = new Set(prev); + if (newSet.has(profileName)) { + newSet.delete(profileName); + } else { + newSet.add(profileName); + } + + // Hide checkboxes if no profiles are selected + if (newSet.size === 0) { + setShowCheckboxes(false); + } + + // Notify parent component + if (onSelectedProfilesChange) { + onSelectedProfilesChange(Array.from(newSet)); + } + + return newSet; + }); + }, + [onSelectedProfilesChange], + ); + + // Handle checkbox change + const handleCheckboxChange = React.useCallback( + (profileName: string, checked: boolean) => { + setSelectedProfiles((prev) => { + const newSet = new Set(prev); + if (checked) { + newSet.add(profileName); + } else { + newSet.delete(profileName); + } + + // Hide checkboxes if no profiles are selected + if (newSet.size === 0) { + setShowCheckboxes(false); + } + + // Notify parent component + if (onSelectedProfilesChange) { + onSelectedProfilesChange(Array.from(newSet)); + } + + return newSet; + }); + }, + [onSelectedProfilesChange], + ); + + // Handle select all checkbox + const handleToggleAll = React.useCallback( + (checked: boolean) => { + const newSet = checked + ? new Set(filteredData.map((profile) => profile.name)) + : new Set(); + + setSelectedProfiles(newSet); + setShowCheckboxes(checked); + + // Notify parent component + if (onSelectedProfilesChange) { + onSelectedProfilesChange(Array.from(newSet)); + } + }, + [filteredData, onSelectedProfilesChange], + ); + const columns: ColumnDef[] = React.useMemo( () => [ + { + id: "select", + header: () => ( + + handleToggleAll(!!value)} + aria-label="Select all" + className="cursor-pointer" + /> + + ), + cell: ({ row }) => { + const profile = row.original; + const browser = profile.browser; + const IconComponent = getBrowserIcon(browser); + const isSelected = selectedProfiles.has(profile.name); + + if (showCheckboxes || isSelected) { + return ( + + + handleCheckboxChange(profile.name, !!value) + } + aria-label="Select row" + className="w-4 h-4" + /> + + ); + } + + return ( + + + + ); + }, + enableSorting: false, + enableHiding: false, + size: 40, + }, { id: "actions", cell: ({ row }) => { @@ -289,10 +448,8 @@ export function ProfilesDataTable({ }, cell: ({ row }) => { const browser: string = row.getValue("browser"); - const IconComponent = getBrowserIcon(browser); return ( -
- {IconComponent && } +
{getBrowserDisplayName(browser)}
); @@ -358,21 +515,21 @@ export function ProfilesDataTable({
- - + + {profileHasProxy && ( + + )} + {proxyDisplayName.length > 10 ? ( + + {proxyDisplayName.slice(0, 10)}... + + ) : ( + + {profile.browser === "tor-browser" + ? "Not supported" + : proxyDisplayName} + + )} {tooltipText} @@ -414,6 +571,16 @@ export function ProfilesDataTable({ > Configure Proxy + { + if (onAssignProfilesToGroup) { + onAssignProfilesToGroup([profile.name]); + } + }} + disabled={!browserState.isClient || isBrowserUpdating} + > + Assign to Group + {profile.browser === "camoufox" && onConfigureCamoufox && ( { @@ -470,6 +637,11 @@ export function ProfilesDataTable({ }, ], [ + showCheckboxes, + selectedProfiles, + handleToggleAll, + handleCheckboxChange, + handleIconClick, runningProfiles, browserState, hasProxy, @@ -480,12 +652,14 @@ export function ProfilesDataTable({ onKillProfile, onConfigureCamoufox, onChangeVersion, + onAssignProfilesToGroup, isUpdating, + filteredData.length, ], ); const table = useReactTable({ - data, + data: filteredData, columns, state: { sorting, @@ -502,7 +676,7 @@ export function ProfilesDataTable({ @@ -530,6 +704,7 @@ export function ProfilesDataTable({ {row.getVisibleCells().map((cell) => ( diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..e7f2fcb --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -0,0 +1,44 @@ +"use client"; + +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import * as React from "react"; +import { LuCircle } from "react-icons/lu"; + +import { cn } from "@/lib/utils"; + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index 90a6d67..7f184ed 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
[role=checkbox]]:translate-y-[2px]", + "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap", className, )} {...props} @@ -82,10 +82,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { return ( [role=checkbox]]:translate-y-[2px]", - className, - )} + className={cn("p-2 align-middle whitespace-nowrap", className)} {...props} /> ); diff --git a/src/types.ts b/src/types.ts index 955add0..b11c1d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,7 @@ export interface BrowserProfile { last_launch?: number; release_type: string; // "stable" or "nightly" camoufox_config?: CamoufoxConfig; // Camoufox configuration + group_id?: string; // Reference to profile group } export interface StoredProxy { @@ -29,6 +30,17 @@ export interface StoredProxy { proxy_settings: ProxySettings; } +export interface ProfileGroup { + id: string; + name: string; +} + +export interface GroupWithCount { + id: string; + name: string; + count: number; +} + export interface DetectedProfile { browser: string; name: string;