mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-10 21:20:24 +02:00
refactor: tags
This commit is contained in:
@@ -142,11 +142,9 @@ impl BrowserRunner {
|
|||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
let result = profile_manager.save_profile(profile);
|
let result = profile_manager.save_profile(profile);
|
||||||
// Update tag suggestions after any save
|
// Update tag suggestions after any save
|
||||||
let _ = crate::tag_manager::TAG_MANAGER
|
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||||
.lock()
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
.map(|tm| {
|
});
|
||||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
|
||||||
});
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +152,9 @@ impl BrowserRunner {
|
|||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
let profiles = profile_manager.list_profiles();
|
let profiles = profile_manager.list_profiles();
|
||||||
if let Ok(ref ps) = profiles {
|
if let Ok(ref ps) = profiles {
|
||||||
let _ = crate::tag_manager::TAG_MANAGER
|
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||||
.lock()
|
let _ = tm.rebuild_from_profiles(ps);
|
||||||
.map(|tm| {
|
});
|
||||||
let _ = tm.rebuild_from_profiles(ps);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
profiles
|
profiles
|
||||||
}
|
}
|
||||||
@@ -264,12 +260,10 @@ impl BrowserRunner {
|
|||||||
println!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
println!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the updated profile
|
// Save the updated profile
|
||||||
self.save_process_info(&updated_profile)?;
|
self.save_process_info(&updated_profile)?;
|
||||||
// Ensure tag suggestions include any tags from this profile
|
// Ensure tag suggestions include any tags from this profile
|
||||||
let _ = crate::tag_manager::TAG_MANAGER
|
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||||
.lock()
|
|
||||||
.map(|tm| {
|
|
||||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
});
|
});
|
||||||
println!(
|
println!(
|
||||||
@@ -471,11 +465,9 @@ impl BrowserRunner {
|
|||||||
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
||||||
|
|
||||||
self.save_process_info(&updated_profile)?;
|
self.save_process_info(&updated_profile)?;
|
||||||
let _ = crate::tag_manager::TAG_MANAGER
|
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||||
.lock()
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
.map(|tm| {
|
});
|
||||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply proxy settings if needed (for Firefox-based browsers)
|
// Apply proxy settings if needed (for Firefox-based browsers)
|
||||||
if profile.proxy_id.is_some()
|
if profile.proxy_id.is_some()
|
||||||
@@ -842,11 +834,9 @@ impl BrowserRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild tags after deletion
|
// Rebuild tags after deletion
|
||||||
let _ = crate::tag_manager::TAG_MANAGER
|
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||||
.lock()
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
.map(|tm| {
|
});
|
||||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1642,7 +1632,10 @@ pub async fn update_profile_proxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_profile_tags(profile_name: String, tags: Vec<String>) -> Result<BrowserProfile, String> {
|
pub fn update_profile_tags(
|
||||||
|
profile_name: String,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<BrowserProfile, String> {
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
profile_manager
|
profile_manager
|
||||||
.update_profile_tags(&profile_name, tags)
|
.update_profile_tags(&profile_name, tags)
|
||||||
|
|||||||
@@ -287,11 +287,9 @@ impl ProfileManager {
|
|||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
// Keep tag suggestions up to date after name change (rebuild from all profiles)
|
// Keep tag suggestions up to date after name change (rebuild from all profiles)
|
||||||
let _ = crate::tag_manager::TAG_MANAGER
|
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||||
.lock()
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
.map(|tm| {
|
});
|
||||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
@@ -331,11 +329,9 @@ impl ProfileManager {
|
|||||||
println!("Profile '{profile_name}' deleted successfully");
|
println!("Profile '{profile_name}' deleted successfully");
|
||||||
|
|
||||||
// Rebuild tag suggestions after deletion
|
// Rebuild tag suggestions after deletion
|
||||||
let _ = crate::tag_manager::TAG_MANAGER
|
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||||
.lock()
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
.map(|tm| {
|
});
|
||||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -412,11 +408,9 @@ impl ProfileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild tag suggestions after group changes just in case
|
// Rebuild tag suggestions after group changes just in case
|
||||||
let _ = crate::tag_manager::TAG_MANAGER
|
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||||
.lock()
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
.map(|tm| {
|
});
|
||||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -440,11 +434,9 @@ impl ProfileManager {
|
|||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
// Update global tag suggestions from all profiles
|
// Update global tag suggestions from all profiles
|
||||||
let _ = crate::tag_manager::TAG_MANAGER
|
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||||
.lock()
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
.map(|tm| {
|
});
|
||||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ impl TagManager {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
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();
|
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("data");
|
||||||
path.push("tags.json");
|
path.push("tags.json");
|
||||||
path
|
path
|
||||||
@@ -74,21 +80,6 @@ impl TagManager {
|
|||||||
Ok(all)
|
Ok(all)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn suggest_tags(&self, query: Option<&str>) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
|
||||||
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(
|
pub fn rebuild_from_profiles(
|
||||||
&self,
|
&self,
|
||||||
profiles: &[BrowserProfile],
|
profiles: &[BrowserProfile],
|
||||||
@@ -102,21 +93,13 @@ impl TagManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let combined: Vec<String> = set.into_iter().collect();
|
let combined: Vec<String> = set.into_iter().collect();
|
||||||
self.save_tags_data(&TagsData { tags: combined.clone() })?;
|
self.save_tags_data(&TagsData {
|
||||||
|
tags: combined.clone(),
|
||||||
|
})?;
|
||||||
Ok(combined)
|
Ok(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_tags(&self, tags: &[String]) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
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! {
|
lazy_static::lazy_static! {
|
||||||
pub static ref TAG_MANAGER: std::sync::Mutex<TagManager> = std::sync::Mutex::new(TagManager::new());
|
pub static ref TAG_MANAGER: std::sync::Mutex<TagManager> = std::sync::Mutex::new(TagManager::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CiCircleCheck } from "react-icons/ci";
|
|||||||
import { IoEllipsisHorizontal } from "react-icons/io5";
|
import { IoEllipsisHorizontal } from "react-icons/io5";
|
||||||
import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu";
|
import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu";
|
||||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
@@ -63,9 +64,183 @@ import type { BrowserProfile, StoredProxy } from "@/types";
|
|||||||
import { LoadingButton } from "./loading-button";
|
import { LoadingButton } from "./loading-button";
|
||||||
import MultipleSelector, { type Option } from "./multiple-selector";
|
import MultipleSelector, { type Option } from "./multiple-selector";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
// Label no longer needed after removing dialog-based renaming
|
|
||||||
import { RippleButton } from "./ui/ripple";
|
import { RippleButton } from "./ui/ripple";
|
||||||
|
|
||||||
|
const TagsCell: React.FC<{
|
||||||
|
profile: BrowserProfile;
|
||||||
|
isDisabled: boolean;
|
||||||
|
tagsOverrides: Record<string, string[]>;
|
||||||
|
allTags: string[];
|
||||||
|
setAllTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
openTagsEditorFor: string | null;
|
||||||
|
setOpenTagsEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
setTagsOverrides: React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, string[]>>
|
||||||
|
>;
|
||||||
|
}> = ({
|
||||||
|
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<Option[]> => {
|
||||||
|
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<BrowserProfile>("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<HTMLDivElement | null>(null);
|
||||||
|
const [visibleCount, setVisibleCount] = React.useState<number>(
|
||||||
|
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 (
|
||||||
|
<div className="w-48 h-full cursor-pointer">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 overflow-hidden",
|
||||||
|
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) => (
|
||||||
|
<Badge key={t} variant="secondary" className="px-2 py-0 text-xs">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<Badge variant="outline" className="px-2 py-0 text-xs">
|
||||||
|
+{hiddenCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("w-48", isDisabled && "opacity-60 pointer-events-none")}>
|
||||||
|
<MultipleSelector
|
||||||
|
value={valueOptions}
|
||||||
|
options={allOptions}
|
||||||
|
onChange={(opts) => 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);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface ProfilesDataTableProps {
|
interface ProfilesDataTableProps {
|
||||||
data: BrowserProfile[];
|
data: BrowserProfile[];
|
||||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||||
@@ -129,6 +304,9 @@ export function ProfilesDataTable({
|
|||||||
Record<string, string[]>
|
Record<string, string[]>
|
||||||
>({});
|
>({});
|
||||||
const [allTags, setAllTags] = React.useState<string[]>([]);
|
const [allTags, setAllTags] = React.useState<string[]>([]);
|
||||||
|
const [openTagsEditorFor, setOpenTagsEditorFor] = React.useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const loadAllTags = React.useCallback(async () => {
|
const loadAllTags = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -368,6 +546,12 @@ export function ProfilesDataTable({
|
|||||||
[filteredData, browserState.canSelectProfile, onSelectedProfilesChange],
|
[filteredData, browserState.canSelectProfile, onSelectedProfilesChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (browserState.isClient) {
|
||||||
|
void loadAllTags();
|
||||||
|
}
|
||||||
|
}, [browserState.isClient, loadAllTags]);
|
||||||
|
|
||||||
// Handle checkbox change
|
// Handle checkbox change
|
||||||
const handleCheckboxChange = React.useCallback(
|
const handleCheckboxChange = React.useCallback(
|
||||||
(profileName: string, checked: boolean) => {
|
(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 (
|
||||||
|
<TagsCell
|
||||||
|
profile={profile}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
tagsOverrides={tagsOverrides}
|
||||||
|
allTags={allTags}
|
||||||
|
setAllTags={setAllTags}
|
||||||
|
openTagsEditorFor={openTagsEditorFor}
|
||||||
|
setOpenTagsEditorFor={setOpenTagsEditorFor}
|
||||||
|
setTagsOverrides={setTagsOverrides}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "browser",
|
accessorKey: "browser",
|
||||||
header: ({ column }) => {
|
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<BrowserProfile>("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 (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"min-w-[220px]",
|
|
||||||
isDisabled && "opacity-60 pointer-events-none",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MultipleSelector
|
|
||||||
value={valueOptions}
|
|
||||||
defaultOptions={defaultOptions}
|
|
||||||
options={options}
|
|
||||||
onChange={(opts) => void handleChange(opts)}
|
|
||||||
onSearch={onSearch}
|
|
||||||
creatable
|
|
||||||
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
|
|
||||||
className="bg-transparent"
|
|
||||||
badgeClassName=""
|
|
||||||
inputProps={{ className: "py-1" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "settings",
|
id: "settings",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -1118,6 +1248,7 @@ export function ProfilesDataTable({
|
|||||||
renameError,
|
renameError,
|
||||||
isRenamingSaving,
|
isRenamingSaving,
|
||||||
handleRename,
|
handleRename,
|
||||||
|
openTagsEditorFor,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user