diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 4e7e2c9..4f1a50a 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -470,14 +470,6 @@ pub async fn check_for_browser_updates() -> Result, Stri Ok(grouped) } -#[tauri::command] -pub async fn is_browser_disabled_for_update(browser: String) -> Result { - let updater = AutoUpdater::instance(); - updater - .is_browser_disabled(&browser) - .map_err(|e| format!("Failed to check browser status: {e}")) -} - #[tauri::command] pub async fn dismiss_update_notification(notification_id: String) -> Result<(), String> { let updater = AutoUpdater::instance(); diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index f2bc706..854efcb 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -2,6 +2,7 @@ use crate::platform_browser; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; use directories::BaseDirs; +use serde::Serialize; use std::collections::HashSet; use std::fs::create_dir_all; use std::path::{Path, PathBuf}; @@ -151,11 +152,6 @@ impl BrowserRunner { pub fn list_profiles(&self) -> Result, Box> { let profile_manager = ProfileManager::instance(); let profiles = profile_manager.list_profiles(); - if let Ok(ref ps) = profiles { - let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { - let _ = tm.rebuild_from_profiles(ps); - }); - } profiles } @@ -271,11 +267,35 @@ impl BrowserRunner { updated_profile.name ); + println!( + "Emitting profile events for successful Camoufox launch: {}", + updated_profile.name + ); + // Emit profile update event to frontend if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { println!("Warning: Failed to emit profile update event: {e}"); + } + + // Emit minimal running changed event to frontend with a small delay + #[derive(Serialize)] + struct RunningChangedPayload { + id: String, + is_running: bool, + } + + let payload = RunningChangedPayload { + id: updated_profile.id.to_string(), + is_running: updated_profile.process_id.is_some(), + }; + + if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + println!("Warning: Failed to emit profile running changed event: {e}"); } else { - println!("Emitted profile update event for: {}", updated_profile.name); + println!( + "Successfully emitted profile-running-changed event for Camoufox {}: running={}", + updated_profile.name, payload.is_running + ); } return Ok(updated_profile); @@ -484,11 +504,36 @@ impl BrowserRunner { // which is already handled in the profile creation process } + println!( + "Emitting profile events for successful launch: {}", + updated_profile.name + ); + // Emit profile update event to frontend if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { println!("Warning: Failed to emit profile update event: {e}"); } + // Emit minimal running changed event to frontend with a small delay to ensure UI consistency + #[derive(Serialize)] + struct RunningChangedPayload { + id: String, + is_running: bool, + } + let payload = RunningChangedPayload { + id: updated_profile.id.to_string(), + is_running: updated_profile.process_id.is_some(), + }; + + if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + println!("Warning: Failed to emit profile running changed event: {e}"); + } else { + println!( + "Successfully emitted profile-running-changed event for {}: running={}", + updated_profile.name, payload.is_running + ); + } + Ok(updated_profile) } @@ -705,20 +750,27 @@ impl BrowserRunner { url: Option, internal_proxy_settings: Option<&ProxySettings>, ) -> Result> { + println!("launch_or_open_url called for profile: {}", profile.name); + // Get the most up-to-date profile data - let profiles = self.list_profiles().expect("Failed to list profiles"); + let profiles = self.list_profiles() + .map_err(|e| format!("Failed to list profiles in launch_or_open_url: {}", e))?; let updated_profile = profiles .into_iter() .find(|p| p.name == profile.name) .unwrap_or_else(|| profile.clone()); + println!("Checking browser status for profile: {}", updated_profile.name); + // Check if browser is already running let is_running = self .check_browser_status(app_handle.clone(), &updated_profile) - .await?; + .await + .map_err(|e| format!("Failed to check browser status: {}", e))?; // Get the updated profile again after status check (PID might have been updated) - let profiles = self.list_profiles().expect("Failed to list profiles"); + let profiles = self.list_profiles() + .map_err(|e| format!("Failed to list profiles after status check: {}", e))?; let final_profile = profiles .into_iter() .find(|p| p.name == profile.name) @@ -927,11 +979,36 @@ impl BrowserRunner { .save_process_info(&updated_profile) .map_err(|e| format!("Failed to update profile: {e}"))?; + println!( + "Emitting profile events for successful Camoufox kill: {}", + updated_profile.name + ); + // Emit profile update event to frontend if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { println!("Warning: Failed to emit profile update event: {e}"); } + // Emit minimal running changed event to frontend immediately + #[derive(Serialize)] + struct RunningChangedPayload { + id: String, + is_running: bool, + } + let payload = RunningChangedPayload { + id: updated_profile.id.to_string(), + is_running: false, // Explicitly set to false since we just killed it + }; + + if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + println!("Warning: Failed to emit profile running changed event: {e}"); + } else { + println!( + "Successfully emitted profile-running-changed event for Camoufox {}: running={}", + updated_profile.name, payload.is_running + ); + } + println!( "Camoufox process cleanup completed for profile: {}", profile.name @@ -1036,6 +1113,36 @@ impl BrowserRunner { .save_process_info(&updated_profile) .map_err(|e| format!("Failed to update profile: {e}"))?; + println!( + "Emitting profile events for successful kill: {}", + updated_profile.name + ); + + // Emit profile update event to frontend + if let Err(e) = app_handle.emit("profile-updated", &updated_profile) { + println!("Warning: Failed to emit profile update event: {e}"); + } + + // Emit minimal running changed event to frontend immediately + #[derive(Serialize)] + struct RunningChangedPayload { + id: String, + is_running: bool, + } + let payload = RunningChangedPayload { + id: updated_profile.id.to_string(), + is_running: false, // Explicitly set to false since we just killed it + }; + + if let Err(e) = app_handle.emit("profile-running-changed", &payload) { + println!("Warning: Failed to emit profile running changed event: {e}"); + } else { + println!( + "Successfully emitted profile-running-changed event for {}: running={}", + updated_profile.name, payload.is_running + ); + } + Ok(()) } @@ -1515,18 +1622,28 @@ pub async fn launch_browser_profile( profile: BrowserProfile, url: Option, ) -> Result { + println!("Launch request received for profile: {}", profile.name); + let browser_runner = BrowserRunner::instance(); // Store the internal proxy settings for passing to launch_browser let mut internal_proxy_settings: Option = None; // Resolve the most up-to-date profile from disk by name to avoid using stale proxy_id/browser state - let profile_for_launch = browser_runner + let profile_for_launch = match browser_runner .list_profiles() - .map_err(|e| format!("Failed to list profiles: {e}"))? - .into_iter() - .find(|p| p.name == profile.name) - .unwrap_or_else(|| profile.clone()); + .map_err(|e| format!("Failed to list profiles: {e}")) + { + Ok(profiles) => profiles + .into_iter() + .find(|p| p.name == profile.name) + .unwrap_or_else(|| profile.clone()), + Err(e) => { + return Err(e); + } + }; + + println!("Resolved profile for launch: {}", profile_for_launch.name); // Always start a local proxy before launching (non-Camoufox handled here; Camoufox has its own flow) if profile.browser != "camoufox" { @@ -1585,9 +1702,6 @@ pub async fn launch_browser_profile( .map(|p| format!("{}:{}", p.host, p.port)) .unwrap_or_else(|| "DIRECT".to_string()) ); - - // Give the proxy a moment to fully start up - tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; } Err(e) => { eprintln!("Failed to start local proxy (will launch without it): {e}"); @@ -1595,11 +1709,27 @@ pub async fn launch_browser_profile( } } + println!("Starting browser launch for profile: {}", profile_for_launch.name); + // Launch browser or open URL in existing instance let updated_profile = browser_runner .launch_or_open_url(app_handle.clone(), &profile_for_launch, url, internal_proxy_settings.as_ref()) .await .map_err(|e| { + println!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e); + + // Emit a failure event to clear loading states in the frontend + #[derive(serde::Serialize)] + struct RunningChangedPayload { + id: String, + is_running: bool, + } + let payload = RunningChangedPayload { + id: profile_for_launch.id.to_string(), + is_running: false, + }; + let _ = app_handle.emit("profile-running-changed", &payload); + // Check if this is an architecture compatibility issue if let Some(io_error) = e.downcast_ref::() { if io_error.kind() == std::io::ErrorKind::Other @@ -1610,6 +1740,8 @@ pub async fn launch_browser_profile( format!("Failed to launch browser or open URL: {e}") })?; + println!("Browser launch completed for profile: {}", updated_profile.name); + // Now update the proxy with the correct PID if we have one if let Some(actual_pid) = updated_profile.process_id { // Update the proxy manager with the correct PID (we always started with temp pid 1 for non-Camoufox) @@ -1797,11 +1929,37 @@ pub async fn kill_browser_profile( app_handle: tauri::AppHandle, profile: BrowserProfile, ) -> Result<(), String> { + println!("Kill request received for profile: {}", profile.name); + let browser_runner = BrowserRunner::instance(); - browser_runner - .kill_browser_process(app_handle, &profile) + + match browser_runner + .kill_browser_process(app_handle.clone(), &profile) .await - .map_err(|e| format!("Failed to kill browser: {e}")) + { + Ok(()) => { + println!("Successfully killed browser profile: {}", profile.name); + Ok(()) + } + Err(e) => { + println!("Failed to kill browser profile {}: {}", profile.name, e); + + // Emit a failure event to clear loading states in the frontend + #[derive(serde::Serialize)] + struct RunningChangedPayload { + id: String, + is_running: bool, + } + // On kill failure, we assume the process is still running + let payload = RunningChangedPayload { + id: profile.id.to_string(), + is_running: true, + }; + let _ = app_handle.emit("profile-running-changed", &payload); + + Err(format!("Failed to kill browser: {e}")) + } + } } #[allow(clippy::too_many_arguments)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6081d16..2f84cee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -53,7 +53,6 @@ use version_updater::{ use auto_updater::{ check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification, - is_browser_disabled_for_update, }; use app_auto_updater::{ @@ -489,6 +488,84 @@ pub fn run() { } }); + // Periodically broadcast browser running status to the frontend + let app_handle_status = app.handle().clone(); + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(500)); + let mut last_running_states: std::collections::HashMap = + std::collections::HashMap::new(); + + loop { + interval.tick().await; + + let runner = crate::browser_runner::BrowserRunner::instance(); + // If listing profiles fails, skip this tick + let profiles = match runner.list_profiles() { + Ok(p) => p, + Err(e) => { + println!("Warning: Failed to list profiles in status checker: {}", e); + continue; + } + }; + + for profile in profiles { + // Check browser status and track changes + match runner + .check_browser_status(app_handle_status.clone(), &profile) + .await + { + Ok(is_running) => { + let profile_id = profile.id.to_string(); + let last_state = last_running_states + .get(&profile_id) + .copied() + .unwrap_or(false); + + // Only emit event if state actually changed + if last_state != is_running { + println!( + "Status checker detected change for profile {}: {} -> {}", + profile.name, last_state, is_running + ); + + #[derive(serde::Serialize)] + struct RunningChangedPayload { + id: String, + is_running: bool, + } + + let payload = RunningChangedPayload { + id: profile_id.clone(), + is_running, + }; + + if let Err(e) = app_handle_status.emit("profile-running-changed", &payload) { + println!("Warning: Failed to emit profile running changed event: {e}"); + } else { + println!( + "Status checker emitted profile-running-changed event for {}: running={}", + profile.name, is_running + ); + } + + last_running_states.insert(profile_id, is_running); + } else { + // Update the state even if unchanged to ensure we have it tracked + last_running_states.insert(profile_id, is_running); + } + } + Err(e) => { + println!( + "Warning: Status check failed for profile {}: {}", + profile.name, e + ); + continue; + } + } + } + } + }); + // Nodecar warm-up is now triggered from the frontend to allow UI blocking overlay // Start API server if enabled in settings @@ -574,7 +651,6 @@ pub fn run() { trigger_manual_version_update, get_version_update_status, check_for_browser_updates, - is_browser_disabled_for_update, dismiss_update_notification, complete_browser_update_with_auto_update, check_for_app_updates, @@ -603,7 +679,7 @@ pub fn run() { warm_up_nodecar, start_api_server, stop_api_server, - get_api_server_status, + get_api_server_status ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/app/page.tsx b/src/app/page.tsx index 721ae98..7907e7f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,7 @@ 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, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; @@ -76,7 +76,9 @@ export default function Home() { const [isBulkDeleting, setIsBulkDeleting] = useState(false); const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } = usePermissions(); - + const [runningProfiles, setRunningProfiles] = useState>( + new Set(), + ); const handleSelectGroup = useCallback((groupId: string) => { setSelectedGroupId(groupId); setSelectedProfiles([]); @@ -423,20 +425,17 @@ export default function Home() { setError(null); try { - const _profile = await invoke( - "create_browser_profile_new", - { - name: profileData.name, - browserStr: profileData.browserStr, - version: profileData.version, - releaseType: profileData.releaseType, - proxyId: profileData.proxyId, - camoufoxConfig: profileData.camoufoxConfig, - groupId: - profileData.groupId || - (selectedGroupId !== "default" ? selectedGroupId : undefined), - }, - ); + await invoke("create_browser_profile_new", { + name: profileData.name, + browserStr: profileData.browserStr, + version: profileData.version, + releaseType: profileData.releaseType, + proxyId: profileData.proxyId, + camoufoxConfig: profileData.camoufoxConfig, + groupId: + profileData.groupId || + (selectedGroupId !== "default" ? selectedGroupId : undefined), + }); await loadProfiles(); await loadGroups(); @@ -453,77 +452,48 @@ export default function Home() { [loadProfiles, loadGroups, selectedGroupId], ); - const [runningProfiles, setRunningProfiles] = useState>( - new Set(), - ); - - const runningProfilesRef = useRef>(new Set()); - - const checkBrowserStatus = useCallback(async (profile: BrowserProfile) => { - try { - const isRunning = await invoke("check_browser_status", { - profile, - }); - - const currentRunning = runningProfilesRef.current.has(profile.name); - - if (isRunning !== currentRunning) { - console.log( - `Profile ${profile.name} (${profile.browser}) status changed: ${currentRunning} -> ${isRunning}`, - ); - setRunningProfiles((prev) => { - const next = new Set(prev); - if (isRunning) { - next.add(profile.name); - } else { - next.delete(profile.name); - } - runningProfilesRef.current = next; - return next; - }); - } - } catch (err) { - console.error("Failed to check browser status:", err); - } - }, []); - - const launchProfile = useCallback( - async (profile: BrowserProfile) => { - setError(null); - - // Check if browser is disabled due to ongoing update + useEffect(() => { + let unlisten: (() => void) | undefined; + (async () => { try { - const isDisabled = await invoke( - "is_browser_disabled_for_update", - { - browser: profile.browser, + unlisten = await listen<{ id: string; is_running: boolean }>( + "profile-running-changed", + (event) => { + const { id, is_running } = event.payload; + setRunningProfiles((prev) => { + const next = new Set(prev); + if (is_running) next.add(id); + else next.delete(id); + return next; + }); }, ); - - if (isDisabled || isUpdating(profile.browser)) { - setError( - `${profile.browser} is currently being updated. Please wait for the update to complete.`, - ); - return; - } - } catch (err) { - console.error("Failed to check browser update status:", err); + } catch { + // best-effort listener } + })(); + return () => { + if (unlisten) unlisten(); + }; + }, []); - try { - const updatedProfile = await invoke( - "launch_browser_profile", - { profile }, - ); - await loadProfiles(); - await checkBrowserStatus(updatedProfile); - } catch (err: unknown) { - console.error("Failed to launch browser:", err); - setError(`Failed to launch browser: ${JSON.stringify(err)}`); - } - }, - [loadProfiles, checkBrowserStatus, isUpdating], - ); + const launchProfile = useCallback(async (profile: BrowserProfile) => { + setError(null); + console.log("Starting launch for profile:", profile.name); + + try { + const result = await invoke("launch_browser_profile", { + profile, + }); + console.log("Successfully launched profile:", result.name); + } catch (err: unknown) { + console.error("Failed to launch browser:", err); + const errorMessage = err instanceof Error ? err.message : String(err); + setError(`Failed to launch browser: ${errorMessage}`); + // Re-throw the error so the table component can handle loading state cleanup + throw err; + } + }, []); const handleDeleteProfile = useCallback( async (profile: BrowserProfile) => { @@ -582,12 +552,19 @@ export default function Home() { const handleKillProfile = useCallback( async (profile: BrowserProfile) => { setError(null); + console.log("Starting kill for profile:", profile.name); + try { await invoke("kill_browser_profile", { profile }); await loadProfiles(); + console.log("Successfully killed profile:", profile.name); + // Don't reload profiles here - let the backend events handle UI updates } catch (err: unknown) { console.error("Failed to kill browser:", err); - setError(`Failed to kill browser: ${JSON.stringify(err)}`); + const errorMessage = err instanceof Error ? err.message : String(err); + setError(`Failed to kill browser: ${errorMessage}`); + // Re-throw the error so the table component can handle loading state cleanup + throw err; } }, [loadProfiles], @@ -732,24 +709,6 @@ export default function Home() { } }, [profiles]); - useEffect(() => { - if (profiles.length === 0) return; - - const interval = setInterval(() => { - for (const profile of profiles) { - void checkBrowserStatus(profile); - } - }, 500); - - return () => { - clearInterval(interval); - }; - }, [profiles, checkBrowserStatus]); - - useEffect(() => { - runningProfilesRef.current = runningProfiles; - }, [runningProfiles]); - useEffect(() => { if (error) { showErrorToast(error); diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 16741b3..c17549a 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -10,6 +10,7 @@ import { } from "@tanstack/react-table"; import { invoke } from "@tauri-apps/api/core"; import { emit, listen } from "@tauri-apps/api/event"; +import type { Dispatch, SetStateAction } from "react"; import * as React from "react"; import { CiCircleCheck } from "react-icons/ci"; import { IoEllipsisHorizontal } from "react-icons/io5"; @@ -66,6 +67,68 @@ import MultipleSelector, { type Option } from "./multiple-selector"; import { Input } from "./ui/input"; import { RippleButton } from "./ui/ripple"; +// Stable table meta type to pass volatile state/handlers into TanStack Table without +// causing column definitions to be recreated on every render. +type TableMeta = { + selectedProfiles: string[]; + selectableCount: number; + showCheckboxes: boolean; + isClient: boolean; + runningProfiles: Set; + launchingProfiles: Set; + stoppingProfiles: Set; + isUpdating: (browser: string) => boolean; + browserState: ReturnType; + + // Tags editor state + tagsOverrides: Record; + allTags: string[]; + openTagsEditorFor: string | null; + setAllTags: React.Dispatch>; + setOpenTagsEditorFor: React.Dispatch>; + setTagsOverrides: React.Dispatch< + React.SetStateAction> + >; + + // Proxy selector state + openProxySelectorFor: string | null; + setOpenProxySelectorFor: React.Dispatch>; + proxyOverrides: Record; + storedProxies: StoredProxy[]; + handleProxySelection: ( + profileName: string, + proxyId: string | null, + ) => void | Promise; + + // Selection helpers + isProfileSelected: (name: string) => boolean; + handleToggleAll: (checked: boolean) => void; + handleCheckboxChange: (name: string, checked: boolean) => void; + handleIconClick: (name: string) => void; + + // Rename helpers + handleRename: () => void | Promise; + setProfileToRename: React.Dispatch< + React.SetStateAction + >; + setNewProfileName: React.Dispatch>; + setRenameError: React.Dispatch>; + profileToRename: BrowserProfile | null; + newProfileName: string; + isRenamingSaving: boolean; + renameError: string | null; + + // Launch/stop helpers + setLaunchingProfiles: React.Dispatch>>; + setStoppingProfiles: React.Dispatch>>; + onKillProfile: (profile: BrowserProfile) => void | Promise; + onLaunchProfile: (profile: BrowserProfile) => void | Promise; + + // Overflow actions + onAssignProfilesToGroup?: (profileNames: string[]) => void; + onConfigureCamoufox?: (profile: BrowserProfile) => void; +}; + const TagsCell = React.memo<{ profile: BrowserProfile; isDisabled: boolean; @@ -303,39 +366,6 @@ const TagsCell = React.memo<{ ); TagsCell.displayName = "TagsCell"; -interface TableMeta { - tagsOverrides: Record; - allTags: string[]; - setAllTags: React.Dispatch>; - openTagsEditorFor: string | null; - setOpenTagsEditorFor: React.Dispatch>; - setTagsOverrides: React.Dispatch< - React.SetStateAction> - >; - selectedProfiles: Set; - showCheckboxes: boolean; - browserState: { - isClient: boolean; - canLaunchProfile: (profile: BrowserProfile) => boolean; - canSelectProfile: (profile: BrowserProfile) => boolean; - getLaunchTooltipContent: (profile: BrowserProfile) => string | null; - }; - runningProfiles: Set; - launchingProfiles: Set; - stoppingProfiles: Set; - isUpdating: (browser: string) => boolean; - proxyOverrides: Record; - storedProxies: StoredProxy[]; - openProxySelectorFor: string | null; - profileToRename: BrowserProfile | null; - newProfileName: string; - renameError: string | null; - isRenamingSaving: boolean; - onLaunchProfile: (profile: BrowserProfile) => void | Promise; - onKillProfile: (profile: BrowserProfile) => void | Promise; - onConfigureCamoufox?: (profile: BrowserProfile) => void; - onAssignProfilesToGroup?: (profileNames: string[]) => void; -} interface ProfilesDataTableProps { profiles: BrowserProfile[]; @@ -343,14 +373,14 @@ interface ProfilesDataTableProps { onKillProfile: (profile: BrowserProfile) => void | Promise; onDeleteProfile: (profile: BrowserProfile) => void | Promise; onRenameProfile: (oldName: string, newName: string) => Promise; - onConfigureCamoufox?: (profile: BrowserProfile) => void; + onConfigureCamoufox: (profile: BrowserProfile) => void; runningProfiles: Set; isUpdating: (browser: string) => boolean; - onDeleteSelectedProfiles?: (profileNames: string[]) => Promise; - onAssignProfilesToGroup?: (profileNames: string[]) => void; - selectedGroupId?: string | null; - selectedProfiles?: string[]; - onSelectedProfilesChange?: (profiles: string[]) => void; + onDeleteSelectedProfiles: (profileNames: string[]) => Promise; + onAssignProfilesToGroup: (profileNames: string[]) => void; + selectedGroupId: string | null; + selectedProfiles: string[]; + onSelectedProfilesChange: Dispatch>; } export function ProfilesDataTable({ @@ -363,7 +393,7 @@ export function ProfilesDataTable({ runningProfiles, isUpdating, onAssignProfilesToGroup, - selectedProfiles: externalSelectedProfiles = [], + selectedProfiles, onSelectedProfilesChange, }: ProfilesDataTableProps) { const { getTableSorting, updateSorting, isLoaded } = useTableSorting(); @@ -391,9 +421,6 @@ export function ProfilesDataTable({ const [proxyOverrides, setProxyOverrides] = React.useState< Record >({}); - const [selectedProfiles, setSelectedProfiles] = React.useState>( - new Set(externalSelectedProfiles), - ); const [showCheckboxes, setShowCheckboxes] = React.useState(false); const [tagsOverrides, setTagsOverrides] = React.useState< Record @@ -450,6 +477,41 @@ export function ProfilesDataTable({ } }, []); + // Clear launching/stopping spinners when backend reports running status changes + React.useEffect(() => { + if (!browserState.isClient) return; + let unlisten: (() => void) | undefined; + (async () => { + try { + unlisten = await listen<{ id: string; is_running: boolean }>( + "profile-running-changed", + (event) => { + const { id } = event.payload; + // Clear launching state for this profile if present + setLaunchingProfiles((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + // Clear stopping state for this profile if present + setStoppingProfiles((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + }, + ); + } catch (error) { + console.error("Failed to listen for profile running changes:", error); + } + })(); + return () => { + if (unlisten) unlisten(); + }; + }, [browserState.isClient]); + React.useEffect(() => { if (browserState.isClient) { void loadStoredProxies(); @@ -480,33 +542,28 @@ export function ProfilesDataTable({ // Automatically deselect profiles that become running, updating, launching, or stopping React.useEffect(() => { - setSelectedProfiles((prev) => { - const newSet = new Set(prev); - let hasChanges = false; + const newSet = new Set(selectedProfiles); + let hasChanges = false; - for (const profileName of prev) { - const profile = profiles.find((p) => p.name === profileName); - if (profile) { - const isRunning = - browserState.isClient && runningProfiles.has(profile.name); - const isLaunching = launchingProfiles.has(profile.name); - const isStopping = stoppingProfiles.has(profile.name); - const isBrowserUpdating = isUpdating(profile.browser); + for (const profileName of selectedProfiles) { + const profile = profiles.find((p) => p.name === profileName); + if (profile) { + const isRunning = + browserState.isClient && runningProfiles.has(profile.id); + const isLaunching = launchingProfiles.has(profile.id); + const isStopping = stoppingProfiles.has(profile.id); + const isBrowserUpdating = isUpdating(profile.browser); - if (isRunning || isLaunching || isStopping || isBrowserUpdating) { - newSet.delete(profileName); - hasChanges = true; - } + if (isRunning || isLaunching || isStopping || isBrowserUpdating) { + newSet.delete(profileName); + hasChanges = true; } } + } - if (hasChanges) { - onSelectedProfilesChange?.(Array.from(newSet)); - return newSet; - } - - return prev; - }); + if (hasChanges) { + onSelectedProfilesChange(Array.from(newSet)); + } }, [ profiles, runningProfiles, @@ -515,15 +572,9 @@ export function ProfilesDataTable({ isUpdating, browserState.isClient, onSelectedProfilesChange, + selectedProfiles, ]); - // Sync external selected profiles with internal state - React.useEffect(() => { - const newSet = new Set(externalSelectedProfiles); - setSelectedProfiles(newSet); - setShowCheckboxes(newSet.size > 0); - }, [externalSelectedProfiles]); - // Update local sorting state when settings are loaded React.useEffect(() => { if (isLoaded && browserState.isClient) { @@ -608,28 +659,26 @@ export function ProfilesDataTable({ } setShowCheckboxes(true); - setSelectedProfiles((prev) => { - const newSet = new Set(prev); - if (newSet.has(profileName)) { - newSet.delete(profileName); - } else { - newSet.add(profileName); - } + const newSet = new Set(selectedProfiles); + if (newSet.has(profileName)) { + newSet.delete(profileName); + } else { + newSet.add(profileName); + } - // Hide checkboxes if no profiles are selected - if (newSet.size === 0) { - setShowCheckboxes(false); - } + // 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(Array.from(newSet)); }, - [profiles, browserState.canSelectProfile, onSelectedProfilesChange], + [ + profiles, + browserState.canSelectProfile, + onSelectedProfilesChange, + selectedProfiles, + ], ); React.useEffect(() => { @@ -641,28 +690,21 @@ export function ProfilesDataTable({ // 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); - } + const newSet = new Set(selectedProfiles); + if (checked) { + newSet.add(profileName); + } else { + newSet.delete(profileName); + } - // Hide checkboxes if no profiles are selected - if (newSet.size === 0) { - setShowCheckboxes(false); - } + // 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(Array.from(newSet)); }, - [onSelectedProfilesChange], + [onSelectedProfilesChange, selectedProfiles], ); // Handle select all checkbox @@ -673,9 +715,9 @@ export function ProfilesDataTable({ profiles .filter((profile) => { const isRunning = - browserState.isClient && runningProfiles.has(profile.name); - const isLaunching = launchingProfiles.has(profile.name); - const isStopping = stoppingProfiles.has(profile.name); + browserState.isClient && runningProfiles.has(profile.id); + const isLaunching = launchingProfiles.has(profile.id); + const isStopping = stoppingProfiles.has(profile.id); const isBrowserUpdating = isUpdating(profile.browser); return ( !isRunning && @@ -688,13 +730,8 @@ export function ProfilesDataTable({ ) : new Set(); - setSelectedProfiles(newSet); setShowCheckboxes(checked); - - // Notify parent component - if (onSelectedProfilesChange) { - onSelectedProfilesChange(Array.from(newSet)); - } + onSelectedProfilesChange(Array.from(newSet)); }, [ profiles, @@ -711,9 +748,9 @@ export function ProfilesDataTable({ const selectableProfiles = React.useMemo(() => { return profiles.filter((profile) => { const isRunning = - browserState.isClient && runningProfiles.has(profile.name); - const isLaunching = launchingProfiles.has(profile.name); - const isStopping = stoppingProfiles.has(profile.name); + browserState.isClient && runningProfiles.has(profile.id); + const isLaunching = launchingProfiles.has(profile.id); + const isStopping = stoppingProfiles.has(profile.id); const isBrowserUpdating = isUpdating(profile.browser); return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating; }); @@ -726,82 +763,89 @@ export function ProfilesDataTable({ isUpdating, ]); - // Stable handlers that don't change on every render - const stableHandlers = React.useMemo( + // Build table meta from volatile state so columns can stay stable + const tableMeta = React.useMemo( () => ({ + selectedProfiles, + selectableCount: selectableProfiles.length, + showCheckboxes, + isClient: browserState.isClient, + runningProfiles, + launchingProfiles, + stoppingProfiles, + isUpdating, + browserState, + + // Tags editor state + tagsOverrides, + allTags, + openTagsEditorFor, + setAllTags, + setOpenTagsEditorFor, + setTagsOverrides, + + // Proxy selector state + openProxySelectorFor, + setOpenProxySelectorFor, + proxyOverrides, + storedProxies, + handleProxySelection, + + // Selection helpers + isProfileSelected: (name: string) => selectedProfiles.includes(name), handleToggleAll, handleCheckboxChange, handleIconClick, - handleProxySelection, + + // Rename helpers handleRename, - setStoppingProfiles, - setLaunchingProfiles, setProfileToRename, setNewProfileName, setRenameError, - setProfileToDelete, - setOpenProxySelectorFor, + profileToRename, + newProfileName, + isRenamingSaving, + renameError, + + // Launch/stop helpers + setLaunchingProfiles, + setStoppingProfiles, + onKillProfile, + onLaunchProfile, + + // Overflow actions + onAssignProfilesToGroup, + onConfigureCamoufox, }), [ + selectedProfiles, + selectableProfiles.length, + showCheckboxes, + browserState.isClient, + runningProfiles, + launchingProfiles, + stoppingProfiles, + isUpdating, + browserState, + tagsOverrides, + allTags, + openTagsEditorFor, + openProxySelectorFor, + proxyOverrides, + storedProxies, + handleProxySelection, handleToggleAll, handleCheckboxChange, handleIconClick, - handleProxySelection, handleRename, - ], - ); - - // Memoize table meta to prevent unnecessary re-renders - const tableMeta = React.useMemo( - () => ({ - tagsOverrides, - allTags, - setAllTags, - openTagsEditorFor, - setOpenTagsEditorFor, - setTagsOverrides, - // Include all the state needed by columns - selectedProfiles, - showCheckboxes, - browserState, - runningProfiles, - launchingProfiles, - stoppingProfiles, - isUpdating, - proxyOverrides, - storedProxies, - openProxySelectorFor, profileToRename, newProfileName, - renameError, isRenamingSaving, - onLaunchProfile, - onKillProfile, - onConfigureCamoufox, - onAssignProfilesToGroup, - }), - [ - tagsOverrides, - allTags, - openTagsEditorFor, - selectedProfiles, - showCheckboxes, - browserState, - runningProfiles, - launchingProfiles, - stoppingProfiles, - isUpdating, - proxyOverrides, - storedProxies, - openProxySelectorFor, - profileToRename, - newProfileName, renameError, - isRenamingSaving, - onLaunchProfile, onKillProfile, - onConfigureCamoufox, + onLaunchProfile, onAssignProfilesToGroup, + onConfigureCamoufox, ], ); @@ -809,17 +853,16 @@ export function ProfilesDataTable({ () => [ { id: "select", - header: () => { + header: ({ table }) => { + const meta = table.options.meta as TableMeta; return ( - stableHandlers.handleToggleAll(!!value) + meta.selectedProfiles.length === meta.selectableCount && + meta.selectableCount !== 0 } + onCheckedChange={(value) => meta.handleToggleAll(!!value)} aria-label="Select all" className="cursor-pointer" /> @@ -827,27 +870,20 @@ export function ProfilesDataTable({ ); }, cell: ({ row, table }) => { + const meta = table.options.meta as TableMeta; const profile = row.original; const browser = profile.browser; const IconComponent = getBrowserIcon(browser); - // Get dynamic state from table meta - const tableMeta = table.options.meta as TableMeta; - const isSelected = - tableMeta?.selectedProfiles?.has(profile.name) || false; + const isSelected = meta.isProfileSelected(profile.name); const isRunning = - tableMeta?.browserState?.isClient && - tableMeta?.runningProfiles?.has(profile.name); - const isLaunching = - tableMeta?.launchingProfiles?.has(profile.name) || false; - const isStopping = - tableMeta?.stoppingProfiles?.has(profile.name) || false; - const isBrowserUpdating = tableMeta?.isUpdating?.(browser) || false; + meta.isClient && meta.runningProfiles.has(profile.id); + const isLaunching = meta.launchingProfiles.has(profile.id); + const isStopping = meta.stoppingProfiles.has(profile.id); + const isBrowserUpdating = meta.isUpdating(browser); const isDisabled = isRunning || isLaunching || isStopping || isBrowserUpdating; - const showCheckboxes = tableMeta?.showCheckboxes || false; - // Show tooltip for disabled profiles if (isDisabled) { const tooltipMessage = isRunning ? "Can't modify running profile" @@ -873,13 +909,13 @@ export function ProfilesDataTable({ ); } - if (showCheckboxes || isSelected) { + if (meta.showCheckboxes || isSelected) { return ( - stableHandlers.handleCheckboxChange(profile.name, !!value) + meta.handleCheckboxChange(profile.name, !!value) } aria-label="Select row" className="w-4 h-4" @@ -893,7 +929,7 @@ export function ProfilesDataTable({