mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-29 09:59:55 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a98eedba0 | |||
| 95ee807f3b | |||
| fac99f4a51 | |||
| 88cb154fca | |||
| a6af568d9e | |||
| 7c2ed1e0fc | |||
| 334f894e68 |
@@ -46,9 +46,12 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "^15.4.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -60,6 +63,7 @@
|
||||
"@biomejs/biome": "2.1.4",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tauri-apps/cli": "^2.7.1",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
|
||||
Generated
+1197
File diff suppressed because it is too large
Load Diff
@@ -520,6 +520,7 @@ mod tests {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,12 +140,23 @@ impl BrowserRunner {
|
||||
|
||||
pub fn save_profile(&self, profile: &BrowserProfile) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager.save_profile(profile)
|
||||
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());
|
||||
});
|
||||
result
|
||||
}
|
||||
|
||||
pub fn list_profiles(&self) -> Result<Vec<BrowserProfile>, Box<dyn std::error::Error>> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager.list_profiles()
|
||||
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);
|
||||
});
|
||||
}
|
||||
profiles
|
||||
}
|
||||
|
||||
pub async fn launch_browser(
|
||||
@@ -251,6 +262,10 @@ impl BrowserRunner {
|
||||
|
||||
// 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!(
|
||||
"Updated profile with process info: {}",
|
||||
updated_profile.name
|
||||
@@ -450,6 +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());
|
||||
});
|
||||
|
||||
// Apply proxy settings if needed (for Firefox-based browsers)
|
||||
if profile.proxy_id.is_some()
|
||||
@@ -815,6 +833,11 @@ impl BrowserRunner {
|
||||
println!("Warning: Failed to cleanup unused binaries: {e}");
|
||||
}
|
||||
|
||||
// Rebuild tags after deletion
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1442,6 +1465,11 @@ impl BrowserRunner {
|
||||
|
||||
files_exist
|
||||
}
|
||||
|
||||
pub fn get_all_tags(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let tag_manager = crate::tag_manager::TAG_MANAGER.lock().unwrap();
|
||||
tag_manager.get_all_tags()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1603,6 +1631,17 @@ pub async fn update_profile_proxy(
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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)
|
||||
.map_err(|e| format!("Failed to update profile tags: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_browser_status(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1738,6 +1777,14 @@ pub fn is_browser_downloaded(browser_str: String, version: String) -> bool {
|
||||
browser_runner.is_browser_downloaded(&browser_str, &version)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_all_tags() -> Result<Vec<String>, String> {
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
browser_runner
|
||||
.get_all_tags()
|
||||
.map_err(|e| format!("Failed to get tags: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_browser_exists(browser_str: String, version: String) -> bool {
|
||||
// This is an alias for is_browser_downloaded to provide clearer semantics for auto-updates
|
||||
|
||||
@@ -400,6 +400,7 @@ mod tests {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: Some(group1.id.clone()),
|
||||
tags: Vec::new(),
|
||||
},
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
@@ -412,6 +413,7 @@ mod tests {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: Some(group1.id.clone()),
|
||||
tags: Vec::new(),
|
||||
},
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
@@ -424,6 +426,7 @@ mod tests {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None, // Default group
|
||||
tags: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ mod profile_importer;
|
||||
mod proxy_manager;
|
||||
mod settings_manager;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
mod tag_manager;
|
||||
mod version_updater;
|
||||
|
||||
extern crate lazy_static;
|
||||
@@ -34,10 +35,10 @@ use browser_runner::{
|
||||
check_browser_exists, check_browser_status, check_missing_binaries, check_missing_geoip_database,
|
||||
create_browser_profile_new, delete_profile, download_browser, ensure_all_binaries_exist,
|
||||
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
|
||||
fetch_browser_versions_with_count_cached_first, get_downloaded_browser_versions,
|
||||
fetch_browser_versions_with_count_cached_first, get_all_tags, get_downloaded_browser_versions,
|
||||
get_supported_browsers, is_browser_supported_on_platform, kill_browser_profile,
|
||||
launch_browser_profile, list_browser_profiles, rename_profile, update_camoufox_config,
|
||||
update_profile_proxy,
|
||||
update_profile_proxy, update_profile_tags,
|
||||
};
|
||||
|
||||
use settings_manager::{
|
||||
@@ -62,8 +63,6 @@ use app_auto_updater::{
|
||||
|
||||
use profile_importer::{detect_existing_profiles, import_browser_profile};
|
||||
|
||||
// use theme_detector::get_system_theme;
|
||||
|
||||
use group_manager::{
|
||||
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
|
||||
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
|
||||
@@ -506,8 +505,10 @@ pub fn run() {
|
||||
fetch_browser_versions_cached_first,
|
||||
fetch_browser_versions_with_count_cached_first,
|
||||
get_downloaded_browser_versions,
|
||||
get_all_tags,
|
||||
get_browser_release_types,
|
||||
update_profile_proxy,
|
||||
update_profile_tags,
|
||||
check_browser_status,
|
||||
kill_browser_profile,
|
||||
rename_profile,
|
||||
|
||||
@@ -151,6 +151,7 @@ impl ProfileManager {
|
||||
release_type: release_type.to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: group_id.clone(),
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
match camoufox_launcher
|
||||
@@ -191,6 +192,7 @@ impl ProfileManager {
|
||||
release_type: release_type.to_string(),
|
||||
camoufox_config: final_camoufox_config,
|
||||
group_id: group_id.clone(),
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -284,6 +286,11 @@ impl ProfileManager {
|
||||
// Save profile with new name
|
||||
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());
|
||||
});
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
@@ -321,6 +328,11 @@ 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());
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -395,9 +407,46 @@ impl ProfileManager {
|
||||
self.save_profile(&profile)?;
|
||||
}
|
||||
|
||||
// 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());
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_profile_tags(
|
||||
&self,
|
||||
profile_name: &str,
|
||||
tags: Vec<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
// Find the profile by name
|
||||
let profiles = self.list_profiles()?;
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile {profile_name} not found"))?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut deduped: Vec<String> = 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)?;
|
||||
|
||||
// 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());
|
||||
});
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn delete_multiple_profiles(
|
||||
&self,
|
||||
profile_names: Vec<String>,
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct BrowserProfile {
|
||||
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>, // Reference to profile group
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>, // Free-form tags
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
|
||||
@@ -555,6 +555,7 @@ impl ProfileImporter {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct AppSettings {
|
||||
pub set_as_default_browser: bool,
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String, // "light", "dark", or "system"
|
||||
#[serde(default)]
|
||||
pub custom_theme: Option<std::collections::HashMap<String, String>>, // CSS var name -> value (e.g., "--background": "#1a1b26")
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
@@ -38,6 +40,7 @@ impl Default for AppSettings {
|
||||
Self {
|
||||
set_as_default_browser: false,
|
||||
theme: "system".to_string(),
|
||||
custom_theme: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,6 +324,7 @@ mod tests {
|
||||
let test_settings = AppSettings {
|
||||
set_as_default_browser: true,
|
||||
theme: "dark".to_string(),
|
||||
custom_theme: None,
|
||||
};
|
||||
|
||||
// Save settings
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
struct TagsData {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct TagManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_data_dir_override(dir: &Path) -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: Some(dir.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tags_file_path(&self) -> PathBuf {
|
||||
if let Some(dir) = &self.data_dir_override {
|
||||
let mut override_path = dir.clone();
|
||||
let _ = fs::create_dir_all(&override_path);
|
||||
override_path.push("tags.json");
|
||||
return override_path;
|
||||
}
|
||||
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("tags.json");
|
||||
path
|
||||
}
|
||||
|
||||
fn load_tags_data(&self) -> Result<TagsData, Box<dyn std::error::Error>> {
|
||||
let file_path = self.get_tags_file_path();
|
||||
if !file_path.exists() {
|
||||
return Ok(TagsData::default());
|
||||
}
|
||||
let content = fs::read_to_string(file_path)?;
|
||||
let data: TagsData = serde_json::from_str(&content)?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn save_tags_data(&self, data: &TagsData) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let file_path = self.get_tags_file_path();
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(data)?;
|
||||
fs::write(file_path, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_tags(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let mut all = self.load_tags_data()?.tags;
|
||||
// Ensure deterministic order
|
||||
all.sort();
|
||||
all.dedup();
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn rebuild_from_profiles(
|
||||
&self,
|
||||
profiles: &[BrowserProfile],
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
// Build a set of all tags currently used by any profile
|
||||
let mut set: BTreeSet<String> = BTreeSet::new();
|
||||
for profile in profiles {
|
||||
for tag in &profile.tags {
|
||||
// Store exactly as provided (no normalization) to preserve characters
|
||||
set.insert(tag.clone());
|
||||
}
|
||||
}
|
||||
let combined: Vec<String> = set.into_iter().collect();
|
||||
self.save_tags_data(&TagsData {
|
||||
tags: combined.clone(),
|
||||
})?;
|
||||
Ok(combined)
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref TAG_MANAGER: std::sync::Mutex<TagManager> = std::sync::Mutex::new(TagManager::new());
|
||||
}
|
||||
+1
-1
@@ -24,7 +24,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden bg-background`}
|
||||
>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
|
||||
@@ -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<HTMLInputElement>,
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
@@ -61,10 +62,224 @@ import { trimName } from "@/lib/name-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
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.memo<{
|
||||
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 handleChange = React.useCallback(
|
||||
async (opts: Option[]) => {
|
||||
const newTagsRaw = opts.map((o) => o.value);
|
||||
// Dedupe while preserving order
|
||||
const seen = new Set<string>();
|
||||
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<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 editorRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [visibleCount, setVisibleCount] = React.useState<number>(
|
||||
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;
|
||||
|
||||
let timeoutId: number | undefined;
|
||||
const compute = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(() => {
|
||||
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);
|
||||
}, 16); // Debounce with RAF timing
|
||||
};
|
||||
compute();
|
||||
const ro = new ResizeObserver(compute);
|
||||
ro.observe(container);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [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 (
|
||||
<div className="w-48 h-full cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
ref={containerRef as unknown as React.RefObject<HTMLButtonElement>}
|
||||
className={cn(
|
||||
"flex items-center gap-1 overflow-hidden cursor-pointer bg-transparent border-none p-1 w-full h-full",
|
||||
isDisabled && "opacity-60",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isDisabled) setOpenTagsEditorFor(profile.name);
|
||||
}}
|
||||
>
|
||||
{effectiveTags.slice(0, visibleCount).map((t) => (
|
||||
<Badge key={t} variant="secondary" className="px-2 py-0 text-xs">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
{effectiveTags.length === 0 && (
|
||||
<span className="inline-block h-4 min-w-10" />
|
||||
)}
|
||||
{hiddenCount > 0 && (
|
||||
<Badge variant="outline" className="px-2 py-0 text-xs">
|
||||
+{hiddenCount}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-48", isDisabled && "opacity-60 pointer-events-none")}
|
||||
>
|
||||
<div ref={editorRef}>
|
||||
<MultipleSelector
|
||||
value={valueOptions}
|
||||
options={allOptions}
|
||||
onChange={(opts) => 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);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TagsCell.displayName = "TagsCell";
|
||||
|
||||
interface ProfilesDataTableProps {
|
||||
data: BrowserProfile[];
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
@@ -81,6 +296,40 @@ interface ProfilesDataTableProps {
|
||||
onSelectedProfilesChange?: (profiles: string[]) => void;
|
||||
}
|
||||
|
||||
interface TableMeta {
|
||||
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[]>>
|
||||
>;
|
||||
selectedProfiles: Set<string>;
|
||||
showCheckboxes: boolean;
|
||||
browserState: {
|
||||
isClient: boolean;
|
||||
canLaunchProfile: (profile: BrowserProfile) => boolean;
|
||||
canSelectProfile: (profile: BrowserProfile) => boolean;
|
||||
getLaunchTooltipContent: (profile: BrowserProfile) => string | null;
|
||||
};
|
||||
runningProfiles: Set<string>;
|
||||
launchingProfiles: Set<string>;
|
||||
stoppingProfiles: Set<string>;
|
||||
isUpdating: (browser: string) => boolean;
|
||||
proxyOverrides: Record<string, string | null>;
|
||||
storedProxies: StoredProxy[];
|
||||
openProxySelectorFor: string | null;
|
||||
profileToRename: BrowserProfile | null;
|
||||
newProfileName: string;
|
||||
renameError: string | null;
|
||||
isRenamingSaving: boolean;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
||||
onAssignProfilesToGroup?: (profileNames: string[]) => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
data,
|
||||
onLaunchProfile,
|
||||
@@ -124,6 +373,22 @@ export function ProfilesDataTable({
|
||||
new Set(externalSelectedProfiles),
|
||||
);
|
||||
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
|
||||
const [tagsOverrides, setTagsOverrides] = React.useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
const [allTags, setAllTags] = React.useState<string[]>([]);
|
||||
const [openTagsEditorFor, setOpenTagsEditorFor] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const loadAllTags = React.useCallback(async () => {
|
||||
try {
|
||||
const tags = await invoke<string[]>("get_all_tags");
|
||||
setAllTags(tags);
|
||||
} catch (error) {
|
||||
console.error("Failed to load tags:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleProxySelection = React.useCallback(
|
||||
async (profileName: string, proxyId: string | null) => {
|
||||
@@ -187,6 +452,10 @@ export function ProfilesDataTable({
|
||||
unlisten = await listen("stored-proxies-changed", () => {
|
||||
void loadStoredProxies();
|
||||
});
|
||||
// Also refresh tags on profile updates
|
||||
await listen("profile-updated", () => {
|
||||
void loadAllTags();
|
||||
});
|
||||
} catch (_err) {
|
||||
// Best-effort only
|
||||
}
|
||||
@@ -194,7 +463,7 @@ export function ProfilesDataTable({
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [browserState.isClient, loadStoredProxies]);
|
||||
}, [browserState.isClient, loadStoredProxies, loadAllTags]);
|
||||
|
||||
// Automatically deselect profiles that become running, updating, launching, or stopping
|
||||
React.useEffect(() => {
|
||||
@@ -350,6 +619,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) => {
|
||||
@@ -419,22 +694,55 @@ export function ProfilesDataTable({
|
||||
],
|
||||
);
|
||||
|
||||
// Memoize selectableProfiles calculation
|
||||
const selectableProfiles = React.useMemo(() => {
|
||||
return filteredData.filter((profile) => {
|
||||
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);
|
||||
return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating;
|
||||
});
|
||||
}, [
|
||||
filteredData,
|
||||
browserState.isClient,
|
||||
runningProfiles,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
isUpdating,
|
||||
]);
|
||||
|
||||
// Stable handlers that don't change on every render
|
||||
const stableHandlers = React.useMemo(
|
||||
() => ({
|
||||
handleToggleAll,
|
||||
handleCheckboxChange,
|
||||
handleIconClick,
|
||||
handleProxySelection,
|
||||
handleRename,
|
||||
setStoppingProfiles,
|
||||
setLaunchingProfiles,
|
||||
setProfileToRename,
|
||||
setNewProfileName,
|
||||
setRenameError,
|
||||
setProfileToDelete,
|
||||
setOpenProxySelectorFor,
|
||||
}),
|
||||
[
|
||||
handleToggleAll,
|
||||
handleCheckboxChange,
|
||||
handleIconClick,
|
||||
handleProxySelection,
|
||||
handleRename,
|
||||
],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "select",
|
||||
header: () => {
|
||||
const selectableProfiles = filteredData.filter((profile) => {
|
||||
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);
|
||||
return (
|
||||
!isRunning && !isLaunching && !isStopping && !isBrowserUpdating
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Checkbox
|
||||
@@ -442,25 +750,35 @@ export function ProfilesDataTable({
|
||||
selectedProfiles.size === selectableProfiles.length &&
|
||||
selectableProfiles.length !== 0
|
||||
}
|
||||
onCheckedChange={(value) => handleToggleAll(!!value)}
|
||||
onCheckedChange={(value) =>
|
||||
stableHandlers.handleToggleAll(!!value)
|
||||
}
|
||||
aria-label="Select all"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original;
|
||||
const browser = profile.browser;
|
||||
const IconComponent = getBrowserIcon(browser);
|
||||
const isSelected = selectedProfiles.has(profile.name);
|
||||
|
||||
// Get dynamic state from table meta
|
||||
const tableMeta = table.options.meta as TableMeta;
|
||||
const isSelected =
|
||||
tableMeta?.selectedProfiles?.has(profile.name) || false;
|
||||
const isRunning =
|
||||
browserState.isClient && runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles.has(profile.name);
|
||||
const isStopping = stoppingProfiles.has(profile.name);
|
||||
const isBrowserUpdating = isUpdating(browser);
|
||||
tableMeta?.browserState?.isClient &&
|
||||
tableMeta?.runningProfiles?.has(profile.name);
|
||||
const isLaunching =
|
||||
tableMeta?.launchingProfiles?.has(profile.name) || false;
|
||||
const isStopping =
|
||||
tableMeta?.stoppingProfiles?.has(profile.name) || false;
|
||||
const isBrowserUpdating = tableMeta?.isUpdating?.(browser) || false;
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
const showCheckboxes = tableMeta?.showCheckboxes || false;
|
||||
|
||||
// Show tooltip for disabled profiles
|
||||
if (isDisabled) {
|
||||
@@ -494,7 +812,7 @@ export function ProfilesDataTable({
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) =>
|
||||
handleCheckboxChange(profile.name, !!value)
|
||||
stableHandlers.handleCheckboxChange(profile.name, !!value)
|
||||
}
|
||||
aria-label="Select row"
|
||||
className="w-4 h-4"
|
||||
@@ -508,7 +826,7 @@ export function ProfilesDataTable({
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
||||
onClick={() => handleIconClick(profile.name)}
|
||||
onClick={() => stableHandlers.handleIconClick(profile.name)}
|
||||
aria-label="Select profile"
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
@@ -527,23 +845,31 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original;
|
||||
const tableMeta = table.options.meta as TableMeta;
|
||||
const isRunning =
|
||||
browserState.isClient && runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles.has(profile.name);
|
||||
const isStopping = stoppingProfiles.has(profile.name);
|
||||
const canLaunch = browserState.canLaunchProfile(profile);
|
||||
const tooltipContent = browserState.getLaunchTooltipContent(profile);
|
||||
tableMeta?.browserState?.isClient &&
|
||||
tableMeta?.runningProfiles?.has(profile.name);
|
||||
const isLaunching =
|
||||
tableMeta?.launchingProfiles?.has(profile.name) || false;
|
||||
const isStopping =
|
||||
tableMeta?.stoppingProfiles?.has(profile.name) || false;
|
||||
const canLaunch =
|
||||
tableMeta?.browserState?.canLaunchProfile(profile) || false;
|
||||
const tooltipContent =
|
||||
tableMeta?.browserState?.getLaunchTooltipContent(profile);
|
||||
|
||||
const handleLaunchClick = async () => {
|
||||
if (isRunning) {
|
||||
console.log(
|
||||
`Stopping ${profile.browser} profile: ${profile.name}`,
|
||||
);
|
||||
setStoppingProfiles((prev) => new Set(prev).add(profile.name));
|
||||
stableHandlers.setStoppingProfiles((prev: Set<string>) =>
|
||||
new Set(prev).add(profile.name),
|
||||
);
|
||||
try {
|
||||
await onKillProfile(profile);
|
||||
await tableMeta?.onKillProfile(profile);
|
||||
console.log(
|
||||
`Successfully stopped ${profile.browser} profile: ${profile.name}`,
|
||||
);
|
||||
@@ -553,7 +879,7 @@ export function ProfilesDataTable({
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setStoppingProfiles((prev) => {
|
||||
stableHandlers.setStoppingProfiles((prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(profile.name);
|
||||
return next;
|
||||
@@ -563,9 +889,11 @@ export function ProfilesDataTable({
|
||||
console.log(
|
||||
`Launching ${profile.browser} profile: ${profile.name}`,
|
||||
);
|
||||
setLaunchingProfiles((prev) => new Set(prev).add(profile.name));
|
||||
stableHandlers.setLaunchingProfiles((prev: Set<string>) =>
|
||||
new Set(prev).add(profile.name),
|
||||
);
|
||||
try {
|
||||
await onLaunchProfile(profile);
|
||||
await tableMeta?.onLaunchProfile(profile);
|
||||
console.log(
|
||||
`Successfully launched ${profile.browser} profile: ${profile.name}`,
|
||||
);
|
||||
@@ -575,7 +903,7 @@ export function ProfilesDataTable({
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setLaunchingProfiles((prev) => {
|
||||
stableHandlers.setLaunchingProfiles((prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(profile.name);
|
||||
return next;
|
||||
@@ -641,11 +969,15 @@ export function ProfilesDataTable({
|
||||
},
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original as BrowserProfile;
|
||||
const rawName: string = row.getValue("name");
|
||||
const name = getBrowserDisplayName(rawName);
|
||||
const isEditing = profileToRename?.name === profile.name;
|
||||
const tableMeta = table.options.meta as TableMeta;
|
||||
const isEditing = tableMeta?.profileToRename?.name === profile.name;
|
||||
const newProfileName = tableMeta?.newProfileName || "";
|
||||
const renameError = tableMeta?.renameError || null;
|
||||
const isRenamingSaving = tableMeta?.isRenamingSaving || false;
|
||||
|
||||
if (isEditing) {
|
||||
const isSaveDisabled =
|
||||
@@ -662,16 +994,16 @@ export function ProfilesDataTable({
|
||||
autoFocus
|
||||
value={newProfileName}
|
||||
onChange={(e) => {
|
||||
setNewProfileName(e.target.value);
|
||||
if (renameError) setRenameError(null);
|
||||
stableHandlers.setNewProfileName(e.target.value);
|
||||
if (renameError) stableHandlers.setRenameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleRename();
|
||||
void stableHandlers.handleRename();
|
||||
} else if (e.key === "Escape") {
|
||||
setProfileToRename(null);
|
||||
setNewProfileName("");
|
||||
setRenameError(null);
|
||||
stableHandlers.setProfileToRename(null);
|
||||
stableHandlers.setNewProfileName("");
|
||||
stableHandlers.setRenameError(null);
|
||||
}
|
||||
}}
|
||||
className="inline-block w-full"
|
||||
@@ -683,7 +1015,7 @@ export function ProfilesDataTable({
|
||||
variant="default"
|
||||
disabled={isSaveDisabled}
|
||||
className="cursor-pointer"
|
||||
onClick={() => void handleRename()}
|
||||
onClick={() => void stableHandlers.handleRename()}
|
||||
>
|
||||
Save
|
||||
</LoadingButton>
|
||||
@@ -705,10 +1037,14 @@ export function ProfilesDataTable({
|
||||
);
|
||||
|
||||
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);
|
||||
tableMeta?.browserState?.isClient &&
|
||||
tableMeta?.runningProfiles?.has(profile.name);
|
||||
const isLaunching =
|
||||
tableMeta?.launchingProfiles?.has(profile.name) || false;
|
||||
const isStopping =
|
||||
tableMeta?.stoppingProfiles?.has(profile.name) || false;
|
||||
const isBrowserUpdating =
|
||||
tableMeta?.isUpdating?.(profile.browser) || false;
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
|
||||
@@ -723,17 +1059,17 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
setProfileToRename(profile);
|
||||
setNewProfileName(profile.name);
|
||||
setRenameError(null);
|
||||
stableHandlers.setProfileToRename(profile);
|
||||
stableHandlers.setNewProfileName(profile.name);
|
||||
stableHandlers.setRenameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setProfileToRename(profile);
|
||||
setNewProfileName(profile.name);
|
||||
setRenameError(null);
|
||||
stableHandlers.setProfileToRename(profile);
|
||||
stableHandlers.setNewProfileName(profile.name);
|
||||
stableHandlers.setRenameError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -742,6 +1078,40 @@ export function ProfilesDataTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
header: "Tags",
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original;
|
||||
const tableMeta = table.options.meta as TableMeta;
|
||||
const isRunning =
|
||||
tableMeta?.browserState?.isClient &&
|
||||
tableMeta?.runningProfiles?.has(profile.name);
|
||||
const isLaunching =
|
||||
tableMeta?.launchingProfiles?.has(profile.name) || false;
|
||||
const isStopping =
|
||||
tableMeta?.stoppingProfiles?.has(profile.name) || false;
|
||||
const isBrowserUpdating =
|
||||
tableMeta?.isUpdating?.(profile.browser) || false;
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
|
||||
return (
|
||||
<TagsCell
|
||||
profile={profile}
|
||||
isDisabled={isDisabled}
|
||||
tagsOverrides={tableMeta?.tagsOverrides || {}}
|
||||
allTags={tableMeta?.allTags || []}
|
||||
setAllTags={tableMeta?.setAllTags || (() => {})}
|
||||
openTagsEditorFor={tableMeta?.openTagsEditorFor || null}
|
||||
setOpenTagsEditorFor={
|
||||
tableMeta?.setOpenTagsEditorFor || (() => {})
|
||||
}
|
||||
setTagsOverrides={tableMeta?.setTagsOverrides || (() => {})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "browser",
|
||||
header: ({ column }) => {
|
||||
@@ -794,16 +1164,25 @@ export function ProfilesDataTable({
|
||||
{
|
||||
id: "proxy",
|
||||
header: "Proxy",
|
||||
cell: ({ row }) => {
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original;
|
||||
const tableMeta = table.options.meta as TableMeta;
|
||||
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);
|
||||
tableMeta?.browserState?.isClient &&
|
||||
tableMeta?.runningProfiles?.has(profile.name);
|
||||
const isLaunching =
|
||||
tableMeta?.launchingProfiles?.has(profile.name) || false;
|
||||
const isStopping =
|
||||
tableMeta?.stoppingProfiles?.has(profile.name) || false;
|
||||
const isBrowserUpdating =
|
||||
tableMeta?.isUpdating?.(profile.browser) || false;
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
|
||||
const proxyOverrides = tableMeta?.proxyOverrides || {};
|
||||
const storedProxies = tableMeta?.storedProxies || [];
|
||||
const openProxySelectorFor = tableMeta?.openProxySelectorFor || null;
|
||||
|
||||
const hasOverride = Object.hasOwn(proxyOverrides, profile.name);
|
||||
const effectiveProxyId = hasOverride
|
||||
? proxyOverrides[profile.name]
|
||||
@@ -845,7 +1224,9 @@ export function ProfilesDataTable({
|
||||
<Popover
|
||||
open={isSelectorOpen}
|
||||
onOpenChange={(open) =>
|
||||
setOpenProxySelectorFor(open ? profile.name : null)
|
||||
stableHandlers.setOpenProxySelectorFor(
|
||||
open ? profile.name : null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
@@ -887,7 +1268,10 @@ export function ProfilesDataTable({
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() =>
|
||||
void handleProxySelection(profile.name, null)
|
||||
void stableHandlers.handleProxySelection(
|
||||
profile.name,
|
||||
null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
@@ -905,7 +1289,10 @@ export function ProfilesDataTable({
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() =>
|
||||
void handleProxySelection(profile.name, proxy.id)
|
||||
void stableHandlers.handleProxySelection(
|
||||
profile.name,
|
||||
proxy.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
@@ -930,14 +1317,19 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
cell: ({ row }) => {
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original;
|
||||
const tableMeta = table.options.meta as TableMeta;
|
||||
const isRunning =
|
||||
browserState.isClient && runningProfiles.has(profile.name);
|
||||
tableMeta?.browserState?.isClient &&
|
||||
tableMeta?.runningProfiles?.has(profile.name);
|
||||
const isBrowserUpdating =
|
||||
browserState.isClient && isUpdating(profile.browser);
|
||||
const isLaunching = launchingProfiles.has(profile.name);
|
||||
const isStopping = stoppingProfiles.has(profile.name);
|
||||
tableMeta?.browserState?.isClient &&
|
||||
tableMeta?.isUpdating?.(profile.browser);
|
||||
const isLaunching =
|
||||
tableMeta?.launchingProfiles?.has(profile.name) || false;
|
||||
const isStopping =
|
||||
tableMeta?.stoppingProfiles?.has(profile.name) || false;
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
|
||||
@@ -948,7 +1340,7 @@ export function ProfilesDataTable({
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 w-8 h-8"
|
||||
disabled={!browserState.isClient}
|
||||
disabled={!tableMeta?.browserState?.isClient}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<IoEllipsisHorizontal className="w-4 h-4" />
|
||||
@@ -957,28 +1349,28 @@ export function ProfilesDataTable({
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (onAssignProfilesToGroup) {
|
||||
onAssignProfilesToGroup([profile.name]);
|
||||
if (tableMeta?.onAssignProfilesToGroup) {
|
||||
tableMeta.onAssignProfilesToGroup([profile.name]);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Assign to Group
|
||||
</DropdownMenuItem>
|
||||
{profile.browser === "camoufox" && onConfigureCamoufox && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onConfigureCamoufox(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Configure Fingerprint
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Rename removed from menu; inline on name click */}
|
||||
{profile.browser === "camoufox" &&
|
||||
tableMeta?.onConfigureCamoufox && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
tableMeta.onConfigureCamoufox?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Configure Fingerprint
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProfileToDelete(profile);
|
||||
stableHandlers.setProfileToDelete(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
@@ -991,32 +1383,60 @@ export function ProfilesDataTable({
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
showCheckboxes,
|
||||
[stableHandlers, selectableProfiles, selectedProfiles],
|
||||
);
|
||||
|
||||
// Memoize table meta to prevent unnecessary re-renders
|
||||
const tableMeta = React.useMemo(
|
||||
() => ({
|
||||
tagsOverrides,
|
||||
allTags,
|
||||
setAllTags,
|
||||
openTagsEditorFor,
|
||||
setOpenTagsEditorFor,
|
||||
setTagsOverrides,
|
||||
// Include all the state needed by columns
|
||||
selectedProfiles,
|
||||
handleToggleAll,
|
||||
handleCheckboxChange,
|
||||
handleIconClick,
|
||||
runningProfiles,
|
||||
showCheckboxes,
|
||||
browserState,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onConfigureCamoufox,
|
||||
onAssignProfilesToGroup,
|
||||
isUpdating,
|
||||
runningProfiles,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
filteredData,
|
||||
browserState.isClient,
|
||||
isUpdating,
|
||||
proxyOverrides,
|
||||
storedProxies,
|
||||
openProxySelectorFor,
|
||||
proxyOverrides,
|
||||
handleProxySelection,
|
||||
profileToRename,
|
||||
newProfileName,
|
||||
renameError,
|
||||
isRenamingSaving,
|
||||
handleRename,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onConfigureCamoufox,
|
||||
onAssignProfilesToGroup,
|
||||
}),
|
||||
[
|
||||
tagsOverrides,
|
||||
allTags,
|
||||
openTagsEditorFor,
|
||||
selectedProfiles,
|
||||
showCheckboxes,
|
||||
browserState,
|
||||
runningProfiles,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
isUpdating,
|
||||
proxyOverrides,
|
||||
storedProxies,
|
||||
openProxySelectorFor,
|
||||
profileToRename,
|
||||
newProfileName,
|
||||
renameError,
|
||||
isRenamingSaving,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onConfigureCamoufox,
|
||||
onAssignProfilesToGroup,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1026,6 +1446,7 @@ export function ProfilesDataTable({
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
meta: tableMeta,
|
||||
onSortingChange: handleSortingChange,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import Color from "color";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import {
|
||||
ColorPicker,
|
||||
ColorPickerAlpha,
|
||||
ColorPickerEyeDropper,
|
||||
ColorPickerFormat,
|
||||
ColorPickerHue,
|
||||
ColorPickerOutput,
|
||||
ColorPickerSelection,
|
||||
} from "@/components/ui/color-picker";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,6 +24,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,6 +44,7 @@ import { RippleButton } from "./ui/ripple";
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
theme: string;
|
||||
custom_theme?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PermissionInfo {
|
||||
@@ -49,10 +64,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
theme: "system",
|
||||
custom_theme: undefined,
|
||||
});
|
||||
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
theme: "system",
|
||||
custom_theme: undefined,
|
||||
});
|
||||
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -109,12 +126,60 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
return "Access to camera for browser applications";
|
||||
}
|
||||
}, []);
|
||||
const TOKYO_NIGHT_DEFAULTS: Record<string, string> = {
|
||||
"--background": "#1a1b26",
|
||||
"--foreground": "#c0caf5",
|
||||
"--card": "#24283b",
|
||||
"--card-foreground": "#c0caf5",
|
||||
"--popover": "#24283b",
|
||||
"--popover-foreground": "#c0caf5",
|
||||
"--primary": "#7aa2f7",
|
||||
"--primary-foreground": "#1a1b26",
|
||||
"--secondary": "#2ac3de",
|
||||
"--secondary-foreground": "#1a1b26",
|
||||
"--muted": "#3b4261",
|
||||
"--muted-foreground": "#a9b1d6",
|
||||
"--accent": "#bb9af7",
|
||||
"--accent-foreground": "#1a1b26",
|
||||
"--destructive": "#f7768e",
|
||||
"--destructive-foreground": "#1a1b26",
|
||||
"--border": "#3b4261",
|
||||
};
|
||||
|
||||
const THEME_VARIABLES: Array<{ key: string; label: string }> = [
|
||||
{ key: "--background", label: "Background" },
|
||||
{ key: "--foreground", label: "Foreground" },
|
||||
{ key: "--card", label: "Card" },
|
||||
{ key: "--card-foreground", label: "Card FG" },
|
||||
{ key: "--popover", label: "Popover" },
|
||||
{ key: "--popover-foreground", label: "Popover FG" },
|
||||
{ key: "--primary", label: "Primary" },
|
||||
{ key: "--primary-foreground", label: "Primary FG" },
|
||||
{ key: "--secondary", label: "Secondary" },
|
||||
{ key: "--secondary-foreground", label: "Secondary FG" },
|
||||
{ key: "--muted", label: "Muted" },
|
||||
{ key: "--muted-foreground", label: "Muted FG" },
|
||||
{ key: "--accent", label: "Accent" },
|
||||
{ key: "--accent-foreground", label: "Accent FG" },
|
||||
{ key: "--destructive", label: "Destructive" },
|
||||
{ key: "--destructive-foreground", label: "Destructive FG" },
|
||||
{ key: "--border", label: "Border" },
|
||||
];
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const appSettings = await invoke<AppSettings>("get_app_settings");
|
||||
setSettings(appSettings);
|
||||
setOriginalSettings(appSettings);
|
||||
const merged: AppSettings = {
|
||||
...appSettings,
|
||||
custom_theme:
|
||||
appSettings.custom_theme &&
|
||||
Object.keys(appSettings.custom_theme).length > 0
|
||||
? appSettings.custom_theme
|
||||
: TOKYO_NIGHT_DEFAULTS,
|
||||
};
|
||||
setSettings(merged);
|
||||
setOriginalSettings(merged);
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
} finally {
|
||||
@@ -122,6 +187,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyCustomTheme = useCallback((vars: Record<string, string>) => {
|
||||
const root = document.documentElement;
|
||||
Object.entries(vars).forEach(([k, v]) =>
|
||||
root.style.setProperty(k, v, "important"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearCustomTheme = useCallback(() => {
|
||||
const root = document.documentElement;
|
||||
THEME_VARIABLES.forEach(({ key }) => root.style.removeProperty(key));
|
||||
}, []);
|
||||
|
||||
const loadPermissions = useCallback(async () => {
|
||||
setIsLoadingPermissions(true);
|
||||
try {
|
||||
@@ -215,7 +292,27 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("save_app_settings", { settings });
|
||||
setTheme(settings.theme);
|
||||
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
|
||||
// Apply or clear custom variables only on Save
|
||||
if (settings.theme === "custom") {
|
||||
if (settings.custom_theme) {
|
||||
try {
|
||||
const root = document.documentElement;
|
||||
// Clear any previous custom vars first
|
||||
THEME_VARIABLES.forEach(({ key }) =>
|
||||
root.style.removeProperty(key),
|
||||
);
|
||||
Object.entries(settings.custom_theme).forEach(([k, v]) =>
|
||||
root.style.setProperty(k, v, "important"),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const root = document.documentElement;
|
||||
THEME_VARIABLES.forEach(({ key }) => root.style.removeProperty(key));
|
||||
} catch {}
|
||||
}
|
||||
setOriginalSettings(settings);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -226,12 +323,45 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
}, [onClose, setTheme, settings]);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
(key: keyof AppSettings, value: boolean | string) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
(
|
||||
key: keyof AppSettings,
|
||||
value: boolean | string | Record<string, string> | undefined,
|
||||
) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value as unknown as never }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Restore original theme when closing without saving
|
||||
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
|
||||
applyCustomTheme(originalSettings.custom_theme);
|
||||
} else {
|
||||
clearCustomTheme();
|
||||
}
|
||||
onClose();
|
||||
}, [
|
||||
originalSettings.theme,
|
||||
originalSettings.custom_theme,
|
||||
applyCustomTheme,
|
||||
clearCustomTheme,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
// Apply custom theme live when editing
|
||||
useEffect(() => {
|
||||
if (settings.theme === "custom" && settings.custom_theme) {
|
||||
applyCustomTheme(settings.custom_theme);
|
||||
} else if (settings.theme !== "custom") {
|
||||
clearCustomTheme();
|
||||
}
|
||||
}, [
|
||||
settings.theme,
|
||||
settings.custom_theme,
|
||||
applyCustomTheme,
|
||||
clearCustomTheme,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSettings().catch(console.error);
|
||||
@@ -285,10 +415,13 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
]);
|
||||
|
||||
// Check if settings have changed (excluding default browser setting)
|
||||
const hasChanges = settings.theme !== originalSettings.theme;
|
||||
const hasChanges =
|
||||
settings.theme !== originalSettings.theme ||
|
||||
JSON.stringify(settings.custom_theme ?? {}) !==
|
||||
JSON.stringify(originalSettings.custom_theme ?? {});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
@@ -307,6 +440,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
value={settings.theme}
|
||||
onValueChange={(value) => {
|
||||
updateSetting("theme", value);
|
||||
if (value === "custom" && !settings.custom_theme) {
|
||||
updateSetting("custom_theme", TOKYO_NIGHT_DEFAULTS);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-select">
|
||||
@@ -316,6 +452,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -323,6 +460,72 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your preferred theme or follow your system settings.
|
||||
</p>
|
||||
|
||||
{settings.theme === "custom" && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Custom theme</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{THEME_VARIABLES.map(({ key, label }) => {
|
||||
const colorValue =
|
||||
settings.custom_theme?.[key] ??
|
||||
TOKYO_NIGHT_DEFAULTS[key] ??
|
||||
"#000000";
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1 items-center"
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="w-8 h-8 rounded-md border shadow-sm cursor-pointer"
|
||||
style={{ backgroundColor: colorValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[320px] p-3"
|
||||
sideOffset={6}
|
||||
>
|
||||
<ColorPicker
|
||||
className="p-3 rounded-md border shadow-sm bg-background"
|
||||
value={colorValue}
|
||||
onColorChange={([r, g, b, a]) => {
|
||||
const next = Color({ r, g, b }).alpha(a);
|
||||
const nextStr = next.hexa();
|
||||
const nextTheme = {
|
||||
...(settings.custom_theme ?? {}),
|
||||
[key]: nextStr,
|
||||
} as Record<string, string>;
|
||||
updateSetting("custom_theme", nextTheme);
|
||||
// No live preview; applied on Save
|
||||
}}
|
||||
>
|
||||
<ColorPickerSelection className="h-36 rounded" />
|
||||
<div className="flex gap-3 items-center mt-3">
|
||||
<ColorPickerEyeDropper />
|
||||
<div className="grid gap-1 w-full">
|
||||
<ColorPickerHue />
|
||||
<ColorPickerAlpha />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<ColorPickerOutput />
|
||||
<ColorPickerFormat />
|
||||
</div>
|
||||
</ColorPicker>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="text-[10px] text-muted-foreground text-center leading-tight">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default Browser Section */}
|
||||
@@ -441,7 +644,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
theme: string;
|
||||
custom_theme?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CustomThemeProviderProps {
|
||||
@@ -27,12 +28,30 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
// Lazy import to avoid pulling Tauri API on SSR
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
const themeValue = settings?.theme ?? "system";
|
||||
|
||||
if (
|
||||
settings?.theme === "light" ||
|
||||
settings?.theme === "dark" ||
|
||||
settings?.theme === "system"
|
||||
themeValue === "light" ||
|
||||
themeValue === "dark" ||
|
||||
themeValue === "system"
|
||||
) {
|
||||
setDefaultTheme(settings.theme);
|
||||
setDefaultTheme(themeValue);
|
||||
} else if (themeValue === "custom") {
|
||||
setDefaultTheme("light");
|
||||
if (
|
||||
settings.custom_theme &&
|
||||
Object.keys(settings.custom_theme).length > 0
|
||||
) {
|
||||
try {
|
||||
const root = document.documentElement;
|
||||
// Apply with !important to override CSS defaults
|
||||
Object.entries(settings.custom_theme).forEach(([k, v]) => {
|
||||
root.style.setProperty(k, v, "important");
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Failed to apply custom theme variables:", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setDefaultTheme("system");
|
||||
}
|
||||
@@ -51,6 +70,30 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
void loadTheme();
|
||||
}, []);
|
||||
|
||||
// Additional effect to ensure custom theme is applied after mount
|
||||
useEffect(() => {
|
||||
if (!isLoading && _mounted) {
|
||||
const reapplyCustomTheme = async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
|
||||
if (settings?.theme === "custom" && settings.custom_theme) {
|
||||
const root = document.documentElement;
|
||||
Object.entries(settings.custom_theme).forEach(([k, v]) => {
|
||||
root.style.setProperty(k, v, "important");
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to reapply custom theme:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply after a short delay to ensure CSS has loaded
|
||||
setTimeout(reapplyCustomTheme, 100);
|
||||
}
|
||||
}, [isLoading, _mounted]);
|
||||
|
||||
if (isLoading) {
|
||||
// Keep UI simple during initial settings load to avoid flicker
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"use client";
|
||||
|
||||
import Color from "color";
|
||||
import { Slider } from "radix-ui";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { LuPipette } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ColorPickerContextValue {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
lightness: number;
|
||||
alpha: number;
|
||||
mode: string;
|
||||
setHue: (hue: number) => void;
|
||||
setSaturation: (saturation: number) => void;
|
||||
setLightness: (lightness: number) => void;
|
||||
setAlpha: (alpha: number) => void;
|
||||
setMode: (mode: string) => void;
|
||||
}
|
||||
|
||||
const ColorPickerContext = createContext<ColorPickerContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useColorPicker = () => {
|
||||
const context = useContext(ColorPickerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useColorPicker must be used within a ColorPickerProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ColorPickerProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange"
|
||||
> & {
|
||||
value?: Parameters<typeof Color>[0];
|
||||
defaultValue?: Parameters<typeof Color>[0];
|
||||
onColorChange?: (value: [number, number, number, number]) => void;
|
||||
};
|
||||
|
||||
export const ColorPicker = ({
|
||||
value,
|
||||
defaultValue = "#000000",
|
||||
onColorChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ColorPickerProps) => {
|
||||
const selectedColor = Color(value ?? defaultValue);
|
||||
const defaultColor = Color(defaultValue);
|
||||
|
||||
const initialHue = Number.isFinite(selectedColor.hue())
|
||||
? selectedColor.hue()
|
||||
: Number.isFinite(defaultColor.hue())
|
||||
? defaultColor.hue()
|
||||
: 0;
|
||||
const initialSaturation = Number.isFinite(selectedColor.saturationl())
|
||||
? selectedColor.saturationl()
|
||||
: Number.isFinite(defaultColor.saturationl())
|
||||
? defaultColor.saturationl()
|
||||
: 100;
|
||||
const initialLightness = Number.isFinite(selectedColor.lightness())
|
||||
? selectedColor.lightness()
|
||||
: Number.isFinite(defaultColor.lightness())
|
||||
? defaultColor.lightness()
|
||||
: 50;
|
||||
const initialAlpha = Number.isFinite(selectedColor.alpha())
|
||||
? Math.round(selectedColor.alpha() * 100)
|
||||
: Math.round(defaultColor.alpha() * 100);
|
||||
|
||||
const [hue, setHue] = useState(initialHue);
|
||||
const [saturation, setSaturation] = useState(initialSaturation);
|
||||
const [lightness, setLightness] = useState(initialLightness);
|
||||
const [alpha, setAlpha] = useState(initialAlpha);
|
||||
const [mode, setMode] = useState("hex");
|
||||
const lastEmittedRef = useRef<string>(
|
||||
`${Math.round(initialHue)}|${Math.round(initialSaturation)}|${Math.round(initialLightness)}|${Math.round(initialAlpha)}`,
|
||||
);
|
||||
|
||||
// Update color when controlled value changes
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
const c = Color(value).hsl();
|
||||
const nextHue = Number.isFinite(c.hue()) ? c.hue() : 0;
|
||||
const nextSat = Number.isFinite(c.saturationl()) ? c.saturationl() : 0;
|
||||
const nextLight = Number.isFinite(c.lightness()) ? c.lightness() : 0;
|
||||
const nextAlpha = Math.round(
|
||||
(Number.isFinite(c.alpha()) ? c.alpha() : 1) * 100,
|
||||
);
|
||||
|
||||
// Update internal state unconditionally when value prop changes
|
||||
setHue(nextHue);
|
||||
setSaturation(nextSat);
|
||||
setLightness(nextLight);
|
||||
setAlpha(nextAlpha);
|
||||
}
|
||||
}, [value]); // Remove state values from dependency array to prevent infinite loop
|
||||
|
||||
// Notify parent of changes
|
||||
useEffect(() => {
|
||||
if (onColorChange) {
|
||||
const key = `${Math.round(hue)}|${Math.round(saturation)}|${Math.round(lightness)}|${Math.round(alpha)}`;
|
||||
if (key === lastEmittedRef.current) {
|
||||
return;
|
||||
}
|
||||
lastEmittedRef.current = key;
|
||||
|
||||
const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100);
|
||||
const rgba = color.rgb().array();
|
||||
onColorChange([rgba[0], rgba[1], rgba[2], alpha / 100]);
|
||||
}
|
||||
}, [hue, saturation, lightness, alpha, onColorChange]);
|
||||
|
||||
return (
|
||||
<ColorPickerContext.Provider
|
||||
value={{
|
||||
hue,
|
||||
saturation,
|
||||
lightness,
|
||||
alpha,
|
||||
mode,
|
||||
setHue,
|
||||
setSaturation,
|
||||
setLightness,
|
||||
setAlpha,
|
||||
setMode,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 size-full", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ColorPickerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerSelectionProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ColorPickerSelection = memo(
|
||||
({ className, ...props }: ColorPickerSelectionProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [positionX, setPositionX] = useState(0);
|
||||
const [positionY, setPositionY] = useState(0);
|
||||
const { hue, saturation, lightness, setSaturation, setLightness } =
|
||||
useColorPicker();
|
||||
|
||||
const backgroundGradient = useMemo(() => {
|
||||
return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)),
|
||||
linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)),
|
||||
hsl(${hue}, 100%, 50%)`;
|
||||
}, [hue]);
|
||||
|
||||
// Update position indicators when saturation/lightness change externally
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
const x = saturation / 100;
|
||||
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x);
|
||||
const y = topLightness > 0 ? 1 - lightness / topLightness : 0;
|
||||
setPositionX(x);
|
||||
setPositionY(Math.max(0, Math.min(1, y)));
|
||||
}
|
||||
}, [saturation, lightness, isDragging]);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
if (!(isDragging && containerRef.current)) {
|
||||
return;
|
||||
}
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(1, (event.clientX - rect.left) / rect.width),
|
||||
);
|
||||
const y = Math.max(
|
||||
0,
|
||||
Math.min(1, (event.clientY - rect.top) / rect.height),
|
||||
);
|
||||
setPositionX(x);
|
||||
setPositionY(y);
|
||||
setSaturation(x * 100);
|
||||
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x);
|
||||
const lightness = topLightness * (1 - y);
|
||||
|
||||
setLightness(lightness);
|
||||
},
|
||||
[isDragging, setSaturation, setLightness],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerUp = () => setIsDragging(false);
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
}, [isDragging, handlePointerMove]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative rounded cursor-pointer size-full", className)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
handlePointerMove(e.nativeEvent);
|
||||
}}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
background: backgroundGradient,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute w-4 h-4 rounded-full border-2 border-white -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{
|
||||
left: `${positionX * 100}%`,
|
||||
top: `${positionY * 100}%`,
|
||||
boxShadow: "0 0 0 1px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ColorPickerSelection.displayName = "ColorPickerSelection";
|
||||
|
||||
export type ColorPickerHueProps = ComponentProps<typeof Slider.Root>;
|
||||
|
||||
export const ColorPickerHue = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerHueProps) => {
|
||||
const { hue, setHue } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||
max={360}
|
||||
onValueChange={([hue]) => setHue(hue)}
|
||||
step={1}
|
||||
value={[hue]}
|
||||
{...props}
|
||||
>
|
||||
<Slider.Track className="relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]">
|
||||
<Slider.Range className="absolute h-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Slider.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerAlphaProps = ComponentProps<typeof Slider.Root>;
|
||||
|
||||
export const ColorPickerAlpha = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerAlphaProps) => {
|
||||
const { alpha, setAlpha } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||
max={100}
|
||||
onValueChange={([alpha]) => setAlpha(alpha)}
|
||||
step={1}
|
||||
value={[alpha]}
|
||||
{...props}
|
||||
>
|
||||
<Slider.Track
|
||||
className="relative my-0.5 h-3 w-full grow rounded-full"
|
||||
style={{
|
||||
background:
|
||||
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
|
||||
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Slider.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerEyeDropperProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ColorPickerEyeDropper = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerEyeDropperProps) => {
|
||||
const { setHue, setSaturation, setLightness, setAlpha } = useColorPicker();
|
||||
|
||||
const handleEyeDropper = async () => {
|
||||
try {
|
||||
// @ts-expect-error - EyeDropper API is experimental
|
||||
const eyeDropper = new EyeDropper();
|
||||
const result = await eyeDropper.open();
|
||||
const color = Color(result.sRGBHex);
|
||||
const [h, s, l] = color.hsl().array();
|
||||
|
||||
setHue(h);
|
||||
setSaturation(s);
|
||||
setLightness(l);
|
||||
setAlpha(100);
|
||||
} catch (error) {
|
||||
console.error("EyeDropper failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0 text-muted-foreground", className)}
|
||||
onClick={handleEyeDropper}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<LuPipette size={16} />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerOutputProps = ComponentProps<typeof SelectTrigger>;
|
||||
|
||||
const formats = ["hex", "rgb", "css", "hsl"];
|
||||
|
||||
export const ColorPickerOutput = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerOutputProps) => {
|
||||
const { mode, setMode } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Select onValueChange={setMode} value={mode}>
|
||||
<SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}>
|
||||
<SelectValue placeholder="Mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formats.map((format) => (
|
||||
<SelectItem className="text-xs" key={format} value={format}>
|
||||
{format.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
type PercentageInputProps = ComponentProps<typeof Input>;
|
||||
|
||||
const PercentageInput = ({ className, ...props }: PercentageInputProps) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
readOnly
|
||||
type="text"
|
||||
{...props}
|
||||
className={cn(
|
||||
"h-8 w-[3.25rem] rounded-l-none bg-secondary px-2 text-xs shadow-none",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 text-xs -translate-y-1/2 text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerFormatProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ColorPickerFormat = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerFormatProps) => {
|
||||
const { hue, saturation, lightness, alpha, mode } = useColorPicker();
|
||||
const color = Color.hsl(hue, saturation, lightness, alpha / 100);
|
||||
|
||||
if (mode === "hex") {
|
||||
const hex = color.hex();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex relative items-center -space-x-px w-full rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Input
|
||||
className="px-2 h-8 text-xs rounded-r-none shadow-none bg-secondary"
|
||||
readOnly
|
||||
type="text"
|
||||
value={hex}
|
||||
/>
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "rgb") {
|
||||
const rgb = color
|
||||
.rgb()
|
||||
.array()
|
||||
.map((value) => Math.round(value));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center -space-x-px rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{rgb.map((value, index) => (
|
||||
<Input
|
||||
className={cn(
|
||||
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
|
||||
index && "rounded-l-none",
|
||||
className,
|
||||
)}
|
||||
key={`rgb-${value.toString()}`}
|
||||
readOnly
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "css") {
|
||||
const rgb = color
|
||||
.rgb()
|
||||
.array()
|
||||
.map((value) => Math.round(value));
|
||||
|
||||
return (
|
||||
<div className={cn("w-full rounded-md shadow-sm", className)} {...props}>
|
||||
<Input
|
||||
className="px-2 w-full h-8 text-xs shadow-none bg-secondary"
|
||||
readOnly
|
||||
type="text"
|
||||
value={`rgba(${rgb.join(", ")}, ${alpha}%)`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "hsl") {
|
||||
const hsl = color
|
||||
.hsl()
|
||||
.array()
|
||||
.map((value) => Math.round(value));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center -space-x-px rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{hsl.map((value, index) => (
|
||||
<Input
|
||||
className={cn(
|
||||
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
|
||||
index && "rounded-l-none",
|
||||
className,
|
||||
)}
|
||||
key={`hsl-${value.toString()}`}
|
||||
readOnly
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -22,6 +22,7 @@ export interface BrowserProfile {
|
||||
release_type: string; // "stable" or "nightly"
|
||||
camoufox_config?: CamoufoxConfig; // Camoufox configuration
|
||||
group_id?: string; // Reference to profile group
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface StoredProxy {
|
||||
|
||||
Reference in New Issue
Block a user