diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 10db798..32d9f69 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -517,6 +517,7 @@ mod tests { camoufox_config: None, group_id: None, tags: Vec::new(), + note: None, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 18b1be2..5e404c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,7 +40,8 @@ use browser_runner::{ use profile::manager::{ check_browser_status, create_browser_profile_new, delete_profile, list_browser_profiles, - rename_profile, update_camoufox_config, update_profile_proxy, update_profile_tags, + rename_profile, update_camoufox_config, update_profile_note, update_profile_proxy, + update_profile_tags, }; use browser_version_manager::{ @@ -710,6 +711,7 @@ pub fn run() { get_browser_release_types, update_profile_proxy, update_profile_tags, + update_profile_note, check_browser_status, kill_browser_profile, rename_profile, diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 6dc78d8..246b7fc 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -165,6 +165,7 @@ impl ProfileManager { camoufox_config: None, group_id: group_id.clone(), tags: Vec::new(), + note: None, }; match self @@ -207,6 +208,7 @@ impl ProfileManager { camoufox_config: final_camoufox_config, group_id: group_id.clone(), tags: Vec::new(), + note: None, }; // Save profile info @@ -522,6 +524,35 @@ impl ProfileManager { Ok(profile) } + pub fn update_profile_note( + &self, + app_handle: &tauri::AppHandle, + profile_id: &str, + note: Option, + ) -> Result> { + // Find the profile by ID + let profile_uuid = + uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; + let profiles = self.list_profiles()?; + let mut profile = profiles + .into_iter() + .find(|p| p.id == profile_uuid) + .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + + // Update note (trim whitespace, set to None if empty) + profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty()); + + // Save profile + self.save_profile(&profile)?; + + // Emit profile note update event + if let Err(e) = app_handle.emit("profiles-changed", ()) { + log::warn!("Warning: Failed to emit profiles-changed event: {e}"); + } + + Ok(profile) + } + pub fn delete_multiple_profiles( &self, app_handle: &tauri::AppHandle, @@ -1445,6 +1476,18 @@ pub fn update_profile_tags( .map_err(|e| format!("Failed to update profile tags: {e}")) } +#[tauri::command] +pub fn update_profile_note( + app_handle: tauri::AppHandle, + profile_id: String, + note: Option, +) -> Result { + let profile_manager = ProfileManager::instance(); + profile_manager + .update_profile_note(&app_handle, &profile_id, note) + .map_err(|e| format!("Failed to update profile note: {e}")) +} + #[tauri::command] pub async fn check_browser_status( app_handle: tauri::AppHandle, diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index c991d36..37e1dbc 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -22,6 +22,8 @@ pub struct BrowserProfile { pub group_id: Option, // Reference to profile group #[serde(default)] pub tags: Vec, // Free-form tags + #[serde(default)] + pub note: Option, // User note } pub fn default_release_type() -> String { diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 3c61b25..21024c9 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -561,6 +561,7 @@ impl ProfileImporter { camoufox_config: None, group_id: None, tags: Vec::new(), + note: None, }; // Save the profile metadata diff --git a/src/app/page.tsx b/src/app/page.tsx index 7962e73..ca5984d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -676,8 +676,8 @@ export default function Home() { // Search in profile name if (profile.name.toLowerCase().includes(query)) return true; - // Search in browser name - if (profile.browser.toLowerCase().includes(query)) return true; + // Search in note + if (profile.note?.toLowerCase().includes(query)) return true; // Search in tags if (profile.tags?.some((tag) => tag.toLowerCase().includes(query))) diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 48e5cbb..2d44b03 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -74,7 +74,6 @@ import { DataTableActionBarAction, DataTableActionBarSelection, } from "./data-table-action-bar"; -import { LoadingButton } from "./loading-button"; import MultipleSelector, { type Option } from "./multiple-selector"; import { ProxyCheckButton } from "./proxy-check-button"; import { Input } from "./ui/input"; @@ -103,6 +102,14 @@ type TableMeta = { React.SetStateAction> >; + // Note editor state + noteOverrides: Record; + openNoteEditorFor: string | null; + setOpenNoteEditorFor: React.Dispatch>; + setNoteOverrides: React.Dispatch< + React.SetStateAction> + >; + // Proxy selector state openProxySelectorFor: string | null; setOpenProxySelectorFor: React.Dispatch>; @@ -402,6 +409,243 @@ const TagsCell = React.memo<{ TagsCell.displayName = "TagsCell"; +const NonHoverableTooltip = React.memo<{ + children: React.ReactNode; + content: React.ReactNode; + sideOffset?: number; + alignOffset?: number; + horizontalOffset?: number; +}>( + ({ + children, + content, + sideOffset = 4, + alignOffset = 0, + horizontalOffset = 0, + }) => { + const [isOpen, setIsOpen] = React.useState(false); + + return ( + + setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + > + {children} + + e.preventDefault()} + onPointerLeave={() => setIsOpen(false)} + className="pointer-events-none" + style={ + horizontalOffset !== 0 + ? { transform: `translateX(${horizontalOffset}px)` } + : undefined + } + > + {content} + + + ); + }, +); + +NonHoverableTooltip.displayName = "NonHoverableTooltip"; + +const NoteCell = React.memo<{ + profile: BrowserProfile; + isDisabled: boolean; + noteOverrides: Record; + openNoteEditorFor: string | null; + setOpenNoteEditorFor: React.Dispatch>; + setNoteOverrides: React.Dispatch< + React.SetStateAction> + >; +}>( + ({ + profile, + isDisabled, + noteOverrides, + openNoteEditorFor, + setOpenNoteEditorFor, + setNoteOverrides, + }) => { + const effectiveNote: string | null = Object.hasOwn( + noteOverrides, + profile.id, + ) + ? noteOverrides[profile.id] + : (profile.note ?? null); + + const onNoteChange = React.useCallback( + async (newNote: string | null) => { + const trimmedNote = newNote?.trim() || null; + setNoteOverrides((prev) => ({ ...prev, [profile.id]: trimmedNote })); + try { + await invoke("update_profile_note", { + profileId: profile.id, + note: trimmedNote, + }); + } catch (error) { + console.error("Failed to update note:", error); + } + }, + [profile.id, setNoteOverrides], + ); + + const editorRef = React.useRef(null); + const textareaRef = React.useRef(null); + const [noteValue, setNoteValue] = React.useState(effectiveNote || ""); + + // Update local state when effective note changes (from outside) + React.useEffect(() => { + if (openNoteEditorFor !== profile.id) { + setNoteValue(effectiveNote || ""); + } + }, [effectiveNote, openNoteEditorFor, profile.id]); + + // Auto-resize textarea on open + React.useEffect(() => { + if (openNoteEditorFor === profile.id && textareaRef.current) { + const textarea = textareaRef.current; + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + } + }, [openNoteEditorFor, profile.id]); + + const handleTextareaChange = React.useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setNoteValue(newValue); + // Auto-resize + const textarea = e.target; + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + }, + [], + ); + + React.useEffect(() => { + if (openNoteEditorFor !== profile.id) return; + const handleClick = (e: MouseEvent) => { + const target = e.target as Node | null; + if ( + editorRef.current && + target && + !editorRef.current.contains(target) + ) { + const currentValue = textareaRef.current?.value || ""; + void onNoteChange(currentValue); + setOpenNoteEditorFor(null); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [openNoteEditorFor, profile.id, setOpenNoteEditorFor, onNoteChange]); + + React.useEffect(() => { + if (openNoteEditorFor === profile.id && textareaRef.current) { + textareaRef.current.focus(); + // Move cursor to end + const len = textareaRef.current.value.length; + textareaRef.current.setSelectionRange(len, len); + } + }, [openNoteEditorFor, profile.id]); + + const displayNote = effectiveNote || ""; + const trimmedNote = + displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote; + const showTooltip = displayNote.length > 12 || displayNote.length > 0; + + if (openNoteEditorFor !== profile.id) { + return ( +
+ + + + + {showTooltip && ( + +

+ {effectiveNote || "No Note"} +

+
+ )} +
+
+ ); + } + + return ( +
+
+