mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-23 20:36:09 +02:00
refactor: tags
This commit is contained in:
@@ -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<String>) -> Result<BrowserProfile, String> {
|
||||
pub fn update_profile_tags(
|
||||
profile_name: String,
|
||||
tags: Vec<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_tags(&profile_name, tags)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<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(
|
||||
&self,
|
||||
profiles: &[BrowserProfile],
|
||||
@@ -102,21 +93,13 @@ impl TagManager {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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! {
|
||||
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 { 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<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 {
|
||||
data: BrowserProfile[];
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
@@ -129,6 +304,9 @@ export function ProfilesDataTable({
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
const [allTags, setAllTags] = React.useState<string[]>([]);
|
||||
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 (
|
||||
<TagsCell
|
||||
profile={profile}
|
||||
isDisabled={isDisabled}
|
||||
tagsOverrides={tagsOverrides}
|
||||
allTags={allTags}
|
||||
setAllTags={setAllTags}
|
||||
openTagsEditorFor={openTagsEditorFor}
|
||||
setOpenTagsEditorFor={setOpenTagsEditorFor}
|
||||
setTagsOverrides={setTagsOverrides}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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<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",
|
||||
cell: ({ row }) => {
|
||||
@@ -1118,6 +1248,7 @@ export function ProfilesDataTable({
|
||||
renameError,
|
||||
isRenamingSaving,
|
||||
handleRename,
|
||||
openTagsEditorFor,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user