Compare commits

...

7 Commits

Author SHA1 Message Date
zhom 4a98eedba0 refactor: reduce table re-renders 2025-08-15 10:33:22 +04:00
zhom 95ee807f3b refactor: better theme initialization 2025-08-15 10:08:15 +04:00
zhom fac99f4a51 refactor: tags 2025-08-15 00:55:10 +04:00
zhom 88cb154fca refactor: color picker 2025-08-15 00:54:57 +04:00
zhom a6af568d9e feat: custom theme 2025-08-15 00:04:31 +04:00
zhom 7c2ed1e0fc refactor: tags 2025-08-15 00:04:10 +04:00
zhom 334f894e68 chore: version bump 2025-08-14 23:06:17 +04:00
18 changed files with 2750 additions and 117 deletions
+4
View File
@@ -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",
+1197
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -520,6 +520,7 @@ mod tests {
release_type: "stable".to_string(),
camoufox_config: None,
group_id: None,
tags: Vec::new(),
}
}
+49 -2
View File
@@ -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
+3
View File
@@ -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(),
},
];
+5 -4
View File
@@ -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,
+49
View File
@@ -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>,
+2
View File
@@ -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 {
+1
View File
@@ -555,6 +555,7 @@ impl ProfileImporter {
release_type: "stable".to_string(),
camoufox_config: None,
group_id: None,
tags: Vec::new(),
};
// Save the profile metadata
+4
View File
@@ -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
+105
View File
@@ -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
View File
@@ -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 />
+34
View File
@@ -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);
+518 -97
View File
@@ -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(),
+212 -9
View File
@@ -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
+47 -4
View File
@@ -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;
+517
View File
@@ -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;
};
+1
View File
@@ -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 {