From 9f68a218248ebe93cea87ec8ef8bceb875feef2d Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:37:22 +0400 Subject: [PATCH] refactor: make ui reactive for group changes --- src-tauri/src/group_manager.rs | 307 +++-------------------- src/app/page.tsx | 30 ++- src/components/import-profile-dialog.tsx | 17 +- src/hooks/use-group-events.ts | 90 +++++++ 4 files changed, 147 insertions(+), 297 deletions(-) create mode 100644 src/hooks/use-group-events.ts diff --git a/src-tauri/src/group_manager.rs b/src-tauri/src/group_manager.rs index e912062..9b4b9f5 100644 --- a/src-tauri/src/group_manager.rs +++ b/src-tauri/src/group_manager.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; +use tauri::Emitter; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProfileGroup { @@ -97,7 +98,11 @@ impl GroupManager { Ok(groups_data.groups) } - pub fn create_group(&self, name: String) -> Result> { + pub fn create_group( + &self, + app_handle: &tauri::AppHandle, + name: String, + ) -> Result> { let mut groups_data = self.load_groups_data()?; // Check if group with this name already exists @@ -113,11 +118,17 @@ impl GroupManager { groups_data.groups.push(group.clone()); self.save_groups_data(&groups_data)?; + // Emit event for reactive UI updates + if let Err(e) = app_handle.emit("groups-changed", ()) { + eprintln!("Failed to emit groups-changed event: {e}"); + } + Ok(group) } pub fn update_group( &self, + app_handle: &tauri::AppHandle, id: String, name: String, ) -> Result> { @@ -142,10 +153,20 @@ impl GroupManager { let updated_group = group.clone(); self.save_groups_data(&groups_data)?; + + // Emit event for reactive UI updates + if let Err(e) = app_handle.emit("groups-changed", ()) { + eprintln!("Failed to emit groups-changed event: {e}"); + } + Ok(updated_group) } - pub fn delete_group(&self, id: String) -> Result<(), Box> { + pub fn delete_group( + &self, + app_handle: &tauri::AppHandle, + id: String, + ) -> Result<(), Box> { let mut groups_data = self.load_groups_data()?; let initial_len = groups_data.groups.len(); @@ -156,6 +177,12 @@ impl GroupManager { } self.save_groups_data(&groups_data)?; + + // Emit event for reactive UI updates + if let Err(e) = app_handle.emit("groups-changed", ()) { + eprintln!("Failed to emit groups-changed event: {e}"); + } + Ok(()) } @@ -203,270 +230,6 @@ lazy_static::lazy_static! { pub static ref GROUP_MANAGER: Mutex = Mutex::new(GroupManager::new()); } -#[cfg(test)] -mod tests { - use super::*; - use std::env; - use tempfile::TempDir; - - fn create_test_group_manager() -> (GroupManager, TempDir) { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - - // Set up a temporary home directory for testing - env::set_var("HOME", temp_dir.path()); - - // Use per-test isolated data directory without relying on global env vars - let data_override = temp_dir.path().join("donutbrowser_test_data"); - let manager = GroupManager::with_data_dir_override(&data_override); - (manager, temp_dir) - } - - #[test] - fn test_group_manager_creation() { - let (_manager, _temp_dir) = create_test_group_manager(); - // Test passes if no panic occurs - } - - #[test] - fn test_create_and_get_groups() { - let (manager, _temp_dir) = create_test_group_manager(); - - // Initially should have no groups - let groups = manager - .get_all_groups() - .expect("Should be able to get groups"); - assert!(groups.is_empty(), "Should start with no groups"); - - // Create a group - let group_name = "Test Group".to_string(); - let created_group = manager - .create_group(group_name.clone()) - .expect("Should create group successfully"); - - assert_eq!( - created_group.name, group_name, - "Created group should have correct name" - ); - assert!( - !created_group.id.is_empty(), - "Created group should have an ID" - ); - - // Verify group was saved - let groups = manager - .get_all_groups() - .expect("Should be able to get groups"); - assert_eq!(groups.len(), 1, "Should have one group"); - assert_eq!( - groups[0].name, group_name, - "Retrieved group should have correct name" - ); - assert_eq!( - groups[0].id, created_group.id, - "Retrieved group should have correct ID" - ); - } - - #[test] - fn test_create_duplicate_group_fails() { - let (manager, _temp_dir) = create_test_group_manager(); - - let group_name = "Duplicate Group".to_string(); - - // Create first group - let _first_group = manager - .create_group(group_name.clone()) - .expect("Should create first group"); - - // Try to create duplicate group - let result = manager.create_group(group_name.clone()); - assert!(result.is_err(), "Should fail to create duplicate group"); - - let error_msg = result.unwrap_err().to_string(); - assert!( - error_msg.contains("already exists"), - "Error should mention group already exists" - ); - } - - #[test] - fn test_update_group() { - let (manager, _temp_dir) = create_test_group_manager(); - - // Create a group - let original_name = "Original Name".to_string(); - let created_group = manager - .create_group(original_name) - .expect("Should create group"); - - // Update the group - let new_name = "Updated Name".to_string(); - let updated_group = manager - .update_group(created_group.id.clone(), new_name.clone()) - .expect("Should update group successfully"); - - assert_eq!( - updated_group.name, new_name, - "Updated group should have new name" - ); - assert_eq!( - updated_group.id, created_group.id, - "Updated group should keep same ID" - ); - - // Verify update was persisted - let groups = manager.get_all_groups().expect("Should get groups"); - assert_eq!(groups.len(), 1, "Should still have one group"); - assert_eq!( - groups[0].name, new_name, - "Persisted group should have updated name" - ); - } - - #[test] - fn test_update_nonexistent_group_fails() { - let (manager, _temp_dir) = create_test_group_manager(); - - let result = manager.update_group("nonexistent-id".to_string(), "New Name".to_string()); - assert!(result.is_err(), "Should fail to update nonexistent group"); - - let error_msg = result.unwrap_err().to_string(); - assert!( - error_msg.contains("not found"), - "Error should mention group not found" - ); - } - - #[test] - fn test_delete_group() { - let (manager, _temp_dir) = create_test_group_manager(); - - // Create a group - let group_name = "To Delete".to_string(); - let created_group = manager - .create_group(group_name) - .expect("Should create group"); - - // Verify group exists - let groups = manager.get_all_groups().expect("Should get groups"); - assert_eq!(groups.len(), 1, "Should have one group"); - - // Delete the group - manager - .delete_group(created_group.id) - .expect("Should delete group successfully"); - - // Verify group was deleted - let groups = manager.get_all_groups().expect("Should get groups"); - assert!(groups.is_empty(), "Should have no groups after deletion"); - } - - #[test] - fn test_delete_nonexistent_group_fails() { - let (manager, _temp_dir) = create_test_group_manager(); - - let result = manager.delete_group("nonexistent-id".to_string()); - assert!(result.is_err(), "Should fail to delete nonexistent group"); - - let error_msg = result.unwrap_err().to_string(); - assert!( - error_msg.contains("not found"), - "Error should mention group not found" - ); - } - - #[test] - fn test_get_groups_with_profile_counts() { - let (manager, _temp_dir) = create_test_group_manager(); - - // Create test groups - let group1 = manager - .create_group("Group 1".to_string()) - .expect("Should create group 1"); - let _group2 = manager - .create_group("Group 2".to_string()) - .expect("Should create group 2"); - - // Create mock profiles - let profiles = vec![ - crate::profile::BrowserProfile { - id: uuid::Uuid::new_v4(), - name: "Profile 1".to_string(), - browser: "firefox".to_string(), - version: "1.0".to_string(), - proxy_id: None, - process_id: None, - last_launch: None, - release_type: "stable".to_string(), - camoufox_config: None, - group_id: Some(group1.id.clone()), - tags: Vec::new(), - }, - crate::profile::BrowserProfile { - id: uuid::Uuid::new_v4(), - name: "Profile 2".to_string(), - browser: "firefox".to_string(), - version: "1.0".to_string(), - proxy_id: None, - process_id: None, - last_launch: None, - release_type: "stable".to_string(), - camoufox_config: None, - group_id: Some(group1.id.clone()), - tags: Vec::new(), - }, - crate::profile::BrowserProfile { - id: uuid::Uuid::new_v4(), - name: "Profile 3".to_string(), - browser: "firefox".to_string(), - version: "1.0".to_string(), - proxy_id: None, - process_id: None, - last_launch: None, - release_type: "stable".to_string(), - camoufox_config: None, - group_id: None, // Default group - tags: Vec::new(), - }, - ]; - - let groups_with_counts = manager - .get_groups_with_profile_counts(&profiles) - .expect("Should get groups with counts"); - - // Should have default group + group1 + group2 (group2 has 0 profiles but should still appear) - assert_eq!( - groups_with_counts.len(), - 3, - "Should include all groups, even those with 0 profiles" - ); - - // Check default group - let default_group = groups_with_counts - .iter() - .find(|g| g.id == "default") - .expect("Should have default group"); - assert_eq!( - default_group.count, 1, - "Default group should have 1 profile" - ); - - // Check group1 - let group1_with_count = groups_with_counts - .iter() - .find(|g| g.id == group1.id) - .expect("Should have group1"); - assert_eq!(group1_with_count.count, 2, "Group1 should have 2 profiles"); - - // Check that group2 exists with 0 profiles - let group2_with_count = groups_with_counts - .iter() - .find(|g| g.name == "Group 2") - .expect("Should have group2 present even with 0 profiles"); - assert_eq!(group2_with_count.count, 0, "Group2 should have 0 profiles"); - } -} - // Helper function to get groups with counts pub fn get_groups_with_counts(profiles: &[crate::profile::BrowserProfile]) -> Vec { let group_manager = GROUP_MANAGER.lock().unwrap(); @@ -494,26 +257,26 @@ pub async fn get_groups_with_profile_counts() -> Result, Str } #[tauri::command] -pub async fn create_profile_group(name: String) -> Result { +pub async fn create_profile_group(app_handle: tauri::AppHandle, name: String) -> Result { let group_manager = GROUP_MANAGER.lock().unwrap(); group_manager - .create_group(name) + .create_group(&app_handle, name) .map_err(|e| format!("Failed to create group: {e}")) } #[tauri::command] -pub async fn update_profile_group(group_id: String, name: String) -> Result { +pub async fn update_profile_group(app_handle: tauri::AppHandle, group_id: String, name: String) -> Result { let group_manager = GROUP_MANAGER.lock().unwrap(); group_manager - .update_group(group_id, name) + .update_group(&app_handle, 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> { +pub async fn delete_profile_group(app_handle: tauri::AppHandle, group_id: String) -> Result<(), String> { let group_manager = GROUP_MANAGER.lock().unwrap(); group_manager - .delete_group(group_id) + .delete_group(&app_handle, group_id) .map_err(|e| format!("Failed to delete group: {e}")) } diff --git a/src/app/page.tsx b/src/app/page.tsx index fca9ac3..91a25eb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -18,9 +18,11 @@ import { ProfileSelectorDialog } from "@/components/profile-selector-dialog"; import { ProxyManagementDialog } from "@/components/proxy-management-dialog"; import { SettingsDialog } from "@/components/settings-dialog"; import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"; +import { useGroupEvents } from "@/hooks/use-group-events"; import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; import { useProfileEvents } from "@/hooks/use-profile-events"; +import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useUpdateNotifications } from "@/hooks/use-update-notifications"; import { useVersionUpdater } from "@/hooks/use-version-updater"; import { showErrorToast, showToast } from "@/lib/toast-utils"; @@ -49,14 +51,23 @@ export default function Home() { // Use the new profile events hook for centralized profile management const { profiles, - groups, runningProfiles, isLoading: profilesLoading, error: profilesError, - loadProfiles, - clearError: clearProfilesError, } = useProfileEvents(); + const { + groups: groupsData, + isLoading: groupsLoading, + error: groupsError, + } = useGroupEvents(); + + const { + storedProxies, + isLoading: proxiesLoading, + error: proxiesError, + } = useProxyEvents(); + const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false); @@ -194,7 +205,7 @@ export default function Home() { ); // Auto-update functionality - use the existing hook for compatibility - const updateNotifications = useUpdateNotifications(loadProfiles); + const updateNotifications = useUpdateNotifications(); const { checkForUpdates, isUpdating } = updateNotifications; useAppUpdateNotifications(); @@ -260,9 +271,8 @@ export default function Home() { useEffect(() => { if (profilesError) { showErrorToast(profilesError); - clearProfilesError(); } - }, [profilesError, clearProfilesError]); + }, [profilesError]); const checkAllPermissions = useCallback(async () => { try { @@ -644,6 +654,9 @@ export default function Home() { return profiles.filter((profile) => profile.group_id === selectedGroupId); }, [profiles, selectedGroupId]); + // Update loading states + const isLoading = profilesLoading || groupsLoading || proxiesLoading; + return (
@@ -663,8 +676,8 @@ export default function Home() { { setImportProfileDialogOpen(false); }} - onImportComplete={() => void loadProfiles()} /> void; - onImportComplete?: () => void; } export function ImportProfileDialog({ isOpen, onClose, - onImportComplete, }: ImportProfileDialogProps) { const [detectedProfiles, setDetectedProfiles] = useState( [], @@ -140,9 +138,6 @@ export function ImportProfileDialog({ toast.success( `Successfully imported profile "${autoDetectProfileName.trim()}"`, ); - if (onImportComplete) { - onImportComplete(); - } onClose(); } catch (error) { console.error("Failed to import profile:", error); @@ -168,7 +163,6 @@ export function ImportProfileDialog({ selectedDetectedProfile, autoDetectProfileName, detectedProfiles, - onImportComplete, onClose, ]); @@ -193,9 +187,6 @@ export function ImportProfileDialog({ toast.success( `Successfully imported profile "${manualProfileName.trim()}"`, ); - if (onImportComplete) { - onImportComplete(); - } onClose(); } catch (error) { console.error("Failed to import profile:", error); @@ -217,13 +208,7 @@ export function ImportProfileDialog({ } finally { setIsImporting(false); } - }, [ - manualBrowserType, - manualProfilePath, - manualProfileName, - onImportComplete, - onClose, - ]); + }, [manualBrowserType, manualProfilePath, manualProfileName, onClose]); const handleClose = () => { setSelectedDetectedProfile(null); diff --git a/src/hooks/use-group-events.ts b/src/hooks/use-group-events.ts new file mode 100644 index 0000000..63ce09a --- /dev/null +++ b/src/hooks/use-group-events.ts @@ -0,0 +1,90 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useState } from "react"; +import type { GroupWithCount } from "@/types"; + +/** + * Custom hook to manage group-related state and listen for backend events. + * This hook eliminates the need for manual UI refreshes by automatically + * updating state when the backend emits group change events. + */ +export function useGroupEvents() { + const [groups, setGroups] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Load groups from backend + const loadGroups = useCallback(async () => { + try { + const groupsWithCounts = await invoke( + "get_groups_with_profile_counts", + ); + setGroups(groupsWithCounts); + setError(null); + } catch (err: unknown) { + console.error("Failed to load groups:", err); + setError(`Failed to load groups: ${JSON.stringify(err)}`); + } + }, []); + + // Clear error state + const clearError = useCallback(() => { + setError(null); + }, []); + + // Initial load and event listeners setup + useEffect(() => { + let groupsUnlisten: (() => void) | undefined; + + const setupListeners = async () => { + try { + // Initial load + await loadGroups(); + + // Listen for group changes (create, delete, rename, update, etc.) + groupsUnlisten = await listen("groups-changed", () => { + console.log("Received groups-changed event, reloading groups"); + void loadGroups(); + }); + + // Also listen for profile changes since groups show profile counts + const profilesUnlisten = await listen("profiles-changed", () => { + console.log( + "Received profiles-changed event, reloading groups for updated counts", + ); + void loadGroups(); + }); + + // Store both listeners for cleanup + groupsUnlisten = () => { + groupsUnlisten?.(); + profilesUnlisten(); + }; + + console.log("Group event listeners set up successfully"); + } catch (err) { + console.error("Failed to setup group event listeners:", err); + setError( + `Failed to setup group event listeners: ${JSON.stringify(err)}`, + ); + } finally { + setIsLoading(false); + } + }; + + void setupListeners(); + + // Cleanup listeners on unmount + return () => { + if (groupsUnlisten) groupsUnlisten(); + }; + }, [loadGroups]); + + return { + groups, + isLoading, + error, + loadGroups, + clearError, + }; +}