From fac99f4a5133ad694d6ef5e29d5da45ebcb5d578 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 15 Aug 2025 00:55:10 +0400 Subject: [PATCH] refactor: tags --- src-tauri/src/profile/manager.rs | 10 ++- src/components/multiple-selector.tsx | 34 +++++++++ src/components/profile-data-table.tsx | 99 ++++++++++++++++----------- 3 files changed, 102 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 1915b98..8ac3d7d 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -427,8 +427,14 @@ impl ProfileManager { .find(|p| p.name == profile_name) .ok_or_else(|| format!("Profile {profile_name} not found"))?; - // Update tags as-is; preserve characters and order given by caller - profile.tags = tags; + let mut seen = std::collections::HashSet::new(); + let mut deduped: Vec = Vec::with_capacity(tags.len()); + for t in tags.into_iter() { + if seen.insert(t.clone()) { + deduped.push(t); + } + } + profile.tags = deduped; // Save profile self.save_profile(&profile)?; diff --git a/src/components/multiple-selector.tsx b/src/components/multiple-selector.tsx index 283d42f..d6471dd 100644 --- a/src/components/multiple-selector.tsx +++ b/src/components/multiple-selector.tsx @@ -445,6 +445,40 @@ const MultipleSelector = React.forwardRef< setInputValue(value); inputProps?.onValueChange?.(value); }} + onKeyDown={(e) => { + // Allow consumer to handle first + inputProps?.onKeyDown?.( + e as unknown as React.KeyboardEvent, + ); + if (e.defaultPrevented) return; + if (e.key === "Enter") { + const value = inputValue.trim(); + if (value.length === 0) return; + // If option already exists among available options, pick that; otherwise create + const entries = Object.values(options).flat(); + const existing = entries.find( + (o) => o.value === value && !o.disable, + ); + // Prevent duplicates in the current selection + if ( + selected.some((s) => s.value === (existing?.value ?? value)) + ) { + e.preventDefault(); + setInputValue(""); + return; + } + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + e.preventDefault(); + setInputValue(""); + const picked = existing ?? { value, label: value }; + const newOptions = [...selected, picked]; + setSelected(newOptions); + onChange?.(newOptions); + } + }} onBlur={(event) => { setOpen(false); inputProps?.onBlur?.(event); diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index f4ddcc5..d5d4e8b 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -100,18 +100,18 @@ const TagsCell: React.FC<{ [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); + const newTagsRaw = opts.map((o) => o.value); + // Dedupe while preserving order + const seen = new Set(); + const newTags: string[] = []; + for (const t of newTagsRaw) { + if (!seen.has(t)) { + seen.add(t); + newTags.push(t); + } + } setTagsOverrides((prev) => ({ ...prev, [profile.name]: newTags })); try { await invoke("update_profile_tags", { @@ -131,11 +131,13 @@ const TagsCell: React.FC<{ ); const containerRef = React.useRef(null); + const editorRef = React.useRef(null); const [visibleCount, setVisibleCount] = React.useState( effectiveTags.length, ); React.useLayoutEffect(() => { + // Only measure when not editing this profile's tags if (openTagsEditorFor === profile.name) return; const container = containerRef.current; if (!container) return; @@ -181,62 +183,81 @@ const TagsCell: React.FC<{ return () => ro.disconnect(); }, [effectiveTags, openTagsEditorFor, profile.name]); + React.useEffect(() => { + if (openTagsEditorFor !== profile.name) return; + const handleClick = (e: MouseEvent) => { + const target = e.target as Node | null; + if (editorRef.current && target && !editorRef.current.contains(target)) { + setOpenTagsEditorFor(null); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [openTagsEditorFor, profile.name, setOpenTagsEditorFor]); + + React.useEffect(() => { + if (openTagsEditorFor === profile.name && editorRef.current) { + // Focus the inner input of MultipleSelector on open + const inputEl = editorRef.current.querySelector("input"); + if (inputEl) { + (inputEl as HTMLInputElement).focus(); + } + } + }, [openTagsEditorFor, profile.name]); + if (openTagsEditorFor !== profile.name) { const hiddenCount = Math.max(0, effectiveTags.length - visibleCount); return (
-
} className={cn( - "flex items-center gap-1 overflow-hidden", + "flex items-center gap-1 overflow-hidden cursor-pointer bg-transparent border-none p-1 w-full h-full", isDisabled && "opacity-60", )} - role="button" - tabIndex={0} onClick={() => { 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} ))} + {effectiveTags.length === 0 && ( + + )} {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); - }, - }} - /> +
+ void handleChange(opts)} + creatable + selectFirstItem={false} + placeholder={effectiveTags.length === 0 ? "Add tags" : ""} + className="bg-transparent" + badgeClassName="" + inputProps={{ + className: "py-1", + onKeyDown: (e) => { + if (e.key === "Escape") setOpenTagsEditorFor(null); + }, + }} + /> +
); };