From 7c2ed1e0fc438179fd900a135ce8a5f68e350d97 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 15 Aug 2025 00:04:10 +0400 Subject: [PATCH] refactor: tags --- src-tauri/src/browser_runner.rs | 47 ++-- src-tauri/src/profile/manager.rs | 32 ++- src-tauri/src/tag_manager.rs | 39 +--- src/components/profile-data-table.tsx | 295 +++++++++++++++++++------- 4 files changed, 256 insertions(+), 157 deletions(-) diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index dacebc8..42ba6a7 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -142,11 +142,9 @@ impl BrowserRunner { let profile_manager = ProfileManager::instance(); let result = profile_manager.save_profile(profile); // Update tag suggestions after any save - let _ = crate::tag_manager::TAG_MANAGER - .lock() - .map(|tm| { - let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); - }); + let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { + let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); + }); result } @@ -154,11 +152,9 @@ impl BrowserRunner { 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); - }); + let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { + let _ = tm.rebuild_from_profiles(ps); + }); } profiles } @@ -264,12 +260,10 @@ impl BrowserRunner { println!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}"); } - // Save the updated profile - self.save_process_info(&updated_profile)?; - // Ensure tag suggestions include any tags from this profile - let _ = crate::tag_manager::TAG_MANAGER - .lock() - .map(|tm| { + // Save the updated profile + self.save_process_info(&updated_profile)?; + // Ensure tag suggestions include any tags from this profile + let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); }); println!( @@ -471,11 +465,9 @@ impl BrowserRunner { updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()); self.save_process_info(&updated_profile)?; - let _ = crate::tag_manager::TAG_MANAGER - .lock() - .map(|tm| { - let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); - }); + let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { + let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); + }); // Apply proxy settings if needed (for Firefox-based browsers) if profile.proxy_id.is_some() @@ -842,11 +834,9 @@ impl BrowserRunner { } // Rebuild tags after deletion - let _ = crate::tag_manager::TAG_MANAGER - .lock() - .map(|tm| { - let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); - }); + let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { + let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); + }); Ok(()) } @@ -1642,7 +1632,10 @@ pub async fn update_profile_proxy( } #[tauri::command] -pub fn update_profile_tags(profile_name: String, tags: Vec) -> Result { +pub fn update_profile_tags( + profile_name: String, + tags: Vec, +) -> Result { let profile_manager = ProfileManager::instance(); profile_manager .update_profile_tags(&profile_name, tags) diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index e6cae51..1915b98 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -287,11 +287,9 @@ impl ProfileManager { self.save_profile(&profile)?; // Keep tag suggestions up to date after name change (rebuild from all profiles) - let _ = crate::tag_manager::TAG_MANAGER - .lock() - .map(|tm| { - let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); - }); + let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { + let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); + }); Ok(profile) } @@ -331,11 +329,9 @@ impl ProfileManager { println!("Profile '{profile_name}' deleted successfully"); // Rebuild tag suggestions after deletion - let _ = crate::tag_manager::TAG_MANAGER - .lock() - .map(|tm| { - let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); - }); + let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { + let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); + }); Ok(()) } @@ -412,11 +408,9 @@ impl ProfileManager { } // Rebuild tag suggestions after group changes just in case - let _ = crate::tag_manager::TAG_MANAGER - .lock() - .map(|tm| { - let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); - }); + let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { + let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); + }); Ok(()) } @@ -440,11 +434,9 @@ impl ProfileManager { self.save_profile(&profile)?; // Update global tag suggestions from all profiles - let _ = crate::tag_manager::TAG_MANAGER - .lock() - .map(|tm| { - let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); - }); + let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { + let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default()); + }); Ok(profile) } diff --git a/src-tauri/src/tag_manager.rs b/src-tauri/src/tag_manager.rs index 94f77d1..94393d0 100644 --- a/src-tauri/src/tag_manager.rs +++ b/src-tauri/src/tag_manager.rs @@ -19,7 +19,9 @@ impl TagManager { pub fn new() -> Self { Self { base_dirs: BaseDirs::new().expect("Failed to get base directories"), - data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR").ok().map(PathBuf::from), + data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR") + .ok() + .map(PathBuf::from), } } @@ -40,7 +42,11 @@ impl TagManager { } let mut path = self.base_dirs.data_local_dir().to_path_buf(); - path.push(if cfg!(debug_assertions) { "DonutBrowserDev" } else { "DonutBrowser" }); + path.push(if cfg!(debug_assertions) { + "DonutBrowserDev" + } else { + "DonutBrowser" + }); path.push("data"); path.push("tags.json"); path @@ -74,21 +80,6 @@ impl TagManager { Ok(all) } - pub fn suggest_tags(&self, query: Option<&str>) -> Result, Box> { - let all = self.get_all_tags()?; - if let Some(q) = query { - let q_lower = q.to_lowercase(); - Ok( - all - .into_iter() - .filter(|t| t.to_lowercase().contains(&q_lower)) - .collect(), - ) - } else { - Ok(all) - } - } - pub fn rebuild_from_profiles( &self, profiles: &[BrowserProfile], @@ -102,21 +93,13 @@ impl TagManager { } } let combined: Vec = set.into_iter().collect(); - self.save_tags_data(&TagsData { tags: combined.clone() })?; + self.save_tags_data(&TagsData { + tags: combined.clone(), + })?; Ok(combined) } - - pub fn add_tags(&self, tags: &[String]) -> Result<(), Box> { - let mut data = self.load_tags_data()?; - data.tags.extend(tags.iter().cloned()); - data.tags.sort(); - data.tags.dedup(); - self.save_tags_data(&data) - } } lazy_static::lazy_static! { pub static ref TAG_MANAGER: std::sync::Mutex = std::sync::Mutex::new(TagManager::new()); } - - diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 42499ba..f4ddcc5 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -15,6 +15,7 @@ import { CiCircleCheck } from "react-icons/ci"; import { IoEllipsisHorizontal } from "react-icons/io5"; import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -63,9 +64,183 @@ import type { BrowserProfile, StoredProxy } from "@/types"; import { LoadingButton } from "./loading-button"; import MultipleSelector, { type Option } from "./multiple-selector"; import { Input } from "./ui/input"; -// Label no longer needed after removing dialog-based renaming import { RippleButton } from "./ui/ripple"; +const TagsCell: React.FC<{ + profile: BrowserProfile; + isDisabled: boolean; + tagsOverrides: Record; + allTags: string[]; + setAllTags: React.Dispatch>; + openTagsEditorFor: string | null; + setOpenTagsEditorFor: React.Dispatch>; + setTagsOverrides: React.Dispatch< + React.SetStateAction> + >; +}> = ({ + profile, + isDisabled, + tagsOverrides, + allTags, + setAllTags, + openTagsEditorFor, + setOpenTagsEditorFor, + setTagsOverrides, +}) => { + const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.name) + ? tagsOverrides[profile.name] + : (profile.tags ?? []); + + const valueOptions: Option[] = React.useMemo( + () => effectiveTags.map((t) => ({ value: t, label: t })), + [effectiveTags], + ); + const allOptions: Option[] = React.useMemo( + () => allTags.map((t) => ({ value: t, label: t })), + [allTags], + ); + + const onSearch = React.useCallback( + async (q: string): Promise => { + const query = q.trim().toLowerCase(); + if (!query) return allOptions; + return allOptions.filter((o) => o.value.toLowerCase().includes(query)); + }, + [allOptions], + ); + + const handleChange = React.useCallback( + async (opts: Option[]) => { + const newTags = opts.map((o) => o.value); + setTagsOverrides((prev) => ({ ...prev, [profile.name]: newTags })); + try { + await invoke("update_profile_tags", { + profileName: profile.name, + tags: newTags, + }); + setAllTags((prev) => { + const next = new Set(prev); + for (const t of newTags) next.add(t); + return Array.from(next).sort(); + }); + } catch (error) { + console.error("Failed to update tags:", error); + } + }, + [profile.name, setAllTags, setTagsOverrides], + ); + + const containerRef = React.useRef(null); + const [visibleCount, setVisibleCount] = React.useState( + effectiveTags.length, + ); + + React.useLayoutEffect(() => { + if (openTagsEditorFor === profile.name) return; + const container = containerRef.current; + if (!container) return; + const compute = () => { + const available = container.clientWidth; + if (available <= 0) return; + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const style = window.getComputedStyle(container); + const font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + ctx.font = font; + const padding = 16; + const gap = 4; + let used = 0; + let count = 0; + for (let i = 0; i < effectiveTags.length; i++) { + const text = effectiveTags[i]; + const width = Math.ceil(ctx.measureText(text).width) + padding; + const remaining = effectiveTags.length - (i + 1); + let extra = 0; + if (remaining > 0) { + const plusText = `+${remaining}`; + extra = Math.ceil(ctx.measureText(plusText).width) + padding; + } + const nextUsed = + used + + (used > 0 ? gap : 0) + + width + + (remaining > 0 ? gap + extra : 0); + if (nextUsed <= available) { + used += (used > 0 ? gap : 0) + width; + count = i + 1; + } else { + break; + } + } + setVisibleCount(count); + }; + compute(); + const ro = new ResizeObserver(() => compute()); + ro.observe(container); + return () => ro.disconnect(); + }, [effectiveTags, openTagsEditorFor, profile.name]); + + if (openTagsEditorFor !== profile.name) { + const hiddenCount = Math.max(0, effectiveTags.length - visibleCount); + return ( +
+
{ + if (!isDisabled) setOpenTagsEditorFor(profile.name); + }} + onKeyDown={(e) => { + if (!isDisabled && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + setOpenTagsEditorFor(profile.name); + } + }} + onKeyUp={() => {}} + > + {effectiveTags.slice(0, visibleCount).map((t) => ( + + {t} + + ))} + {hiddenCount > 0 && ( + + +{hiddenCount} + + )} +
+
+ ); + } + + return ( +
+ void handleChange(opts)} + onSearch={onSearch} + creatable + placeholder={effectiveTags.length === 0 ? "Add tags" : ""} + className="bg-transparent" + badgeClassName="" + inputProps={{ + className: "py-1", + onKeyDown: (e) => { + if (e.key === "Escape") setOpenTagsEditorFor(null); + }, + }} + /> +
+ ); +}; + interface ProfilesDataTableProps { data: BrowserProfile[]; onLaunchProfile: (profile: BrowserProfile) => void | Promise; @@ -129,6 +304,9 @@ export function ProfilesDataTable({ Record >({}); const [allTags, setAllTags] = React.useState([]); + const [openTagsEditorFor, setOpenTagsEditorFor] = React.useState< + string | null + >(null); const loadAllTags = React.useCallback(async () => { try { @@ -368,6 +546,12 @@ export function ProfilesDataTable({ [filteredData, browserState.canSelectProfile, onSelectedProfilesChange], ); + React.useEffect(() => { + if (browserState.isClient) { + void loadAllTags(); + } + }, [browserState.isClient, loadAllTags]); + // Handle checkbox change const handleCheckboxChange = React.useCallback( (profileName: string, checked: boolean) => { @@ -760,6 +944,33 @@ export function ProfilesDataTable({ ); }, }, + { + id: "tags", + header: "Tags", + cell: ({ row }) => { + const profile = row.original; + 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); + const isDisabled = + isRunning || isLaunching || isStopping || isBrowserUpdating; + + return ( + + ); + }, + }, { accessorKey: "browser", header: ({ column }) => { @@ -946,87 +1157,6 @@ export function ProfilesDataTable({ ); }, }, - { - id: "tags", - header: "Tags", - cell: ({ row }) => { - const profile = row.original; - 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); - const isDisabled = - isRunning || isLaunching || isStopping || isBrowserUpdating; - - const effectiveTags: string[] = Object.hasOwn( - tagsOverrides, - profile.name, - ) - ? tagsOverrides[profile.name] - : (profile.tags ?? []); - - const valueOptions: Option[] = effectiveTags.map((t) => ({ - value: t, - label: t, - })); - const defaultOptions: Option[] = (profile.tags ?? []).map((t) => ({ - value: t, - label: t, - })); - const options: Option[] = allTags.map((t) => ({ - value: t, - label: t, - })); - - const onSearch = async (q: string) => { - const query = q.trim().toLowerCase(); - if (!query) return options; - return options.filter((o) => o.value.toLowerCase().includes(query)); - }; - - const handleChange = async (opts: Option[]) => { - const newTags = opts.map((o) => o.value); - setTagsOverrides((prev) => ({ ...prev, [profile.name]: newTags })); - try { - await invoke("update_profile_tags", { - profileName: profile.name, - tags: newTags, - }); - // Optimistically merge new tags into suggestions list - setAllTags((prev) => { - const next = new Set(prev); - for (const t of newTags) next.add(t); - return Array.from(next).sort(); - }); - } catch (error) { - console.error("Failed to update tags:", error); - } - }; - - return ( -
- void handleChange(opts)} - onSearch={onSearch} - creatable - placeholder={effectiveTags.length === 0 ? "Add tags" : ""} - className="bg-transparent" - badgeClassName="" - inputProps={{ className: "py-1" }} - /> -
- ); - }, - }, { id: "settings", cell: ({ row }) => { @@ -1118,6 +1248,7 @@ export function ProfilesDataTable({ renameError, isRenamingSaving, handleRename, + openTagsEditorFor, ], );