mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-31 00:17:28 +02:00
feat: add profile groups
This commit is contained in:
Vendored
+8
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"adwaita",
|
||||||
"ahooks",
|
"ahooks",
|
||||||
"akhilmhdh",
|
"akhilmhdh",
|
||||||
"appimage",
|
"appimage",
|
||||||
@@ -9,7 +10,9 @@
|
|||||||
"autoconfig",
|
"autoconfig",
|
||||||
"autologin",
|
"autologin",
|
||||||
"biomejs",
|
"biomejs",
|
||||||
|
"breezedark",
|
||||||
"browserforge",
|
"browserforge",
|
||||||
|
"busctl",
|
||||||
"CAMOU",
|
"CAMOU",
|
||||||
"camoufox",
|
"camoufox",
|
||||||
"cdylib",
|
"cdylib",
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
"dataclasses",
|
"dataclasses",
|
||||||
"datareporting",
|
"datareporting",
|
||||||
"datas",
|
"datas",
|
||||||
|
"dconf",
|
||||||
"devedition",
|
"devedition",
|
||||||
"doctest",
|
"doctest",
|
||||||
"doesn",
|
"doesn",
|
||||||
@@ -54,8 +58,10 @@
|
|||||||
"idletime",
|
"idletime",
|
||||||
"idna",
|
"idna",
|
||||||
"Inno",
|
"Inno",
|
||||||
|
"kdeglobals",
|
||||||
"keras",
|
"keras",
|
||||||
"KHTML",
|
"KHTML",
|
||||||
|
"kreadconfig",
|
||||||
"launchservices",
|
"launchservices",
|
||||||
"letterboxing",
|
"letterboxing",
|
||||||
"libatk",
|
"libatk",
|
||||||
@@ -157,6 +163,8 @@
|
|||||||
"winreg",
|
"winreg",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
"xattr",
|
"xattr",
|
||||||
|
"xfconf",
|
||||||
|
"xsettings",
|
||||||
"zhom",
|
"zhom",
|
||||||
"zoneinfo"
|
"zoneinfo"
|
||||||
]
|
]
|
||||||
|
|||||||
+10
-9
@@ -30,22 +30,23 @@
|
|||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tauri-apps/api": "^2.6.0",
|
"@tauri-apps/api": "^2.7.0",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.4.0",
|
"@tauri-apps/plugin-deep-link": "^2.4.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.3.0",
|
"@tauri-apps/plugin-dialog": "^2.3.1",
|
||||||
"@tauri-apps/plugin-fs": "~2.4.0",
|
"@tauri-apps/plugin-fs": "~2.4.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||||
"ahooks": "^3.9.0",
|
"ahooks": "^3.9.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"next": "^15.3.5",
|
"next": "^15.4.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@@ -57,16 +58,16 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.1.1",
|
"@biomejs/biome": "2.1.1",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@tauri-apps/cli": "^2.6.2",
|
"@tauri-apps/cli": "^2.7.1",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.1.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.1.2",
|
"lint-staged": "^16.1.2",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"ts-unused-exports": "^11.0.1",
|
"ts-unused-exports": "^11.0.1",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.6",
|
||||||
"typescript": "~5.8.3"
|
"typescript": "~5.8.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.13.1",
|
"packageManager": "pnpm@10.13.1",
|
||||||
|
|||||||
Generated
+43
-9
@@ -26,6 +26,9 @@ importers:
|
|||||||
'@radix-ui/react-progress':
|
'@radix-ui/react-progress':
|
||||||
specifier: ^1.1.7
|
specifier: ^1.1.7
|
||||||
version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-radio-group':
|
||||||
|
specifier: ^1.3.7
|
||||||
|
version: 1.3.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@radix-ui/react-scroll-area':
|
'@radix-ui/react-scroll-area':
|
||||||
specifier: ^1.2.9
|
specifier: ^1.2.9
|
||||||
version: 1.2.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 1.2.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@@ -45,16 +48,16 @@ importers:
|
|||||||
specifier: ^8.21.3
|
specifier: ^8.21.3
|
||||||
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2.6.0
|
specifier: ^2.7.0
|
||||||
version: 2.7.0
|
version: 2.7.0
|
||||||
'@tauri-apps/plugin-deep-link':
|
'@tauri-apps/plugin-deep-link':
|
||||||
specifier: ^2.4.0
|
specifier: ^2.4.1
|
||||||
version: 2.4.1
|
version: 2.4.1
|
||||||
'@tauri-apps/plugin-dialog':
|
'@tauri-apps/plugin-dialog':
|
||||||
specifier: ^2.3.0
|
specifier: ^2.3.1
|
||||||
version: 2.3.1
|
version: 2.3.1
|
||||||
'@tauri-apps/plugin-fs':
|
'@tauri-apps/plugin-fs':
|
||||||
specifier: ~2.4.0
|
specifier: ~2.4.1
|
||||||
version: 2.4.1
|
version: 2.4.1
|
||||||
'@tauri-apps/plugin-opener':
|
'@tauri-apps/plugin-opener':
|
||||||
specifier: ^2.4.0
|
specifier: ^2.4.0
|
||||||
@@ -72,7 +75,7 @@ importers:
|
|||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 1.1.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
next:
|
next:
|
||||||
specifier: ^15.3.5
|
specifier: ^15.4.4
|
||||||
version: 15.4.4(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 15.4.4(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
@@ -103,10 +106,10 @@ importers:
|
|||||||
specifier: ^4.1.11
|
specifier: ^4.1.11
|
||||||
version: 4.1.11
|
version: 4.1.11
|
||||||
'@tauri-apps/cli':
|
'@tauri-apps/cli':
|
||||||
specifier: ^2.6.2
|
specifier: ^2.7.1
|
||||||
version: 2.7.1
|
version: 2.7.1
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.0.13
|
specifier: ^24.1.0
|
||||||
version: 24.1.0
|
version: 24.1.0
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.1.8
|
specifier: ^19.1.8
|
||||||
@@ -115,7 +118,7 @@ importers:
|
|||||||
specifier: ^19.1.6
|
specifier: ^19.1.6
|
||||||
version: 19.1.6(@types/react@19.1.8)
|
version: 19.1.6(@types/react@19.1.8)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.6.0
|
specifier: ^4.7.0
|
||||||
version: 4.7.0(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0))
|
version: 4.7.0(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0))
|
||||||
husky:
|
husky:
|
||||||
specifier: ^9.1.7
|
specifier: ^9.1.7
|
||||||
@@ -130,7 +133,7 @@ importers:
|
|||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.0.1(typescript@5.8.3)
|
version: 11.0.1(typescript@5.8.3)
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.3.5
|
specifier: ^1.3.6
|
||||||
version: 1.3.6
|
version: 1.3.6
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.8.3
|
specifier: ~5.8.3
|
||||||
@@ -954,6 +957,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-radio-group@1.3.7':
|
||||||
|
resolution: {integrity: sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-roving-focus@1.1.10':
|
'@radix-ui/react-roving-focus@1.1.10':
|
||||||
resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==}
|
resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3618,6 +3634,24 @@ snapshots:
|
|||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
|
'@radix-ui/react-radio-group@1.3.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.2
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.1.8
|
||||||
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
'@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
'@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.2
|
'@radix-ui/primitive': 1.1.2
|
||||||
|
|||||||
@@ -518,6 +518,7 @@ mod tests {
|
|||||||
last_launch: None,
|
last_launch: None,
|
||||||
release_type: "stable".to_string(),
|
release_type: "stable".to_string(),
|
||||||
camoufox_config: None,
|
camoufox_config: None,
|
||||||
|
group_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ pub struct BrowserProfile {
|
|||||||
pub release_type: String, // "stable" or "nightly"
|
pub release_type: String, // "stable" or "nightly"
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
|
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
|
||||||
|
#[serde(default)]
|
||||||
|
pub group_id: Option<String>, // Reference to profile group
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_release_type() -> String {
|
fn default_release_type() -> String {
|
||||||
@@ -1351,6 +1353,28 @@ impl BrowserRunner {
|
|||||||
release_type: &str,
|
release_type: &str,
|
||||||
proxy_id: Option<String>,
|
proxy_id: Option<String>,
|
||||||
camoufox_config: Option<CamoufoxConfig>,
|
camoufox_config: Option<CamoufoxConfig>,
|
||||||
|
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||||
|
self.create_profile_with_group(
|
||||||
|
name,
|
||||||
|
browser,
|
||||||
|
version,
|
||||||
|
release_type,
|
||||||
|
proxy_id,
|
||||||
|
camoufox_config,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn create_profile_with_group(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
browser: &str,
|
||||||
|
version: &str,
|
||||||
|
release_type: &str,
|
||||||
|
proxy_id: Option<String>,
|
||||||
|
camoufox_config: Option<CamoufoxConfig>,
|
||||||
|
group_id: Option<String>,
|
||||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||||
println!("Attempting to create profile: {name}");
|
println!("Attempting to create profile: {name}");
|
||||||
|
|
||||||
@@ -1384,6 +1408,7 @@ impl BrowserRunner {
|
|||||||
last_launch: None,
|
last_launch: None,
|
||||||
release_type: release_type.to_string(),
|
release_type: release_type.to_string(),
|
||||||
camoufox_config: camoufox_config.clone(),
|
camoufox_config: camoufox_config.clone(),
|
||||||
|
group_id: group_id.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save profile info
|
// Save profile info
|
||||||
@@ -1614,6 +1639,73 @@ impl BrowserRunner {
|
|||||||
Ok(cleaned_up)
|
Ok(cleaned_up)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn assign_profiles_to_group(
|
||||||
|
&self,
|
||||||
|
profile_names: Vec<String>,
|
||||||
|
group_id: Option<String>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let profiles = self.list_profiles()?;
|
||||||
|
|
||||||
|
for profile_name in profile_names {
|
||||||
|
let mut profile = profiles
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name == profile_name)
|
||||||
|
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Check if browser is running
|
||||||
|
if profile.process_id.is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot modify group for profile '{profile_name}' while browser is running. Please stop the browser first."
|
||||||
|
).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.group_id = group_id.clone();
|
||||||
|
self.save_profile(&profile)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_multiple_profiles(
|
||||||
|
&self,
|
||||||
|
profile_names: Vec<String>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let profiles = self.list_profiles()?;
|
||||||
|
|
||||||
|
for profile_name in profile_names {
|
||||||
|
let profile = profiles
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name == profile_name)
|
||||||
|
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
|
||||||
|
|
||||||
|
// Check if browser is running
|
||||||
|
if profile.process_id.is_some() {
|
||||||
|
return Err(
|
||||||
|
format!(
|
||||||
|
"Cannot delete profile '{profile_name}' while browser is running. Please stop the browser first."
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the profile
|
||||||
|
let profiles_dir = self.get_profiles_dir();
|
||||||
|
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||||
|
|
||||||
|
if profile_uuid_dir.exists() {
|
||||||
|
std::fs::remove_dir_all(&profile_uuid_dir)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always perform cleanup after profile deletion to remove unused binaries
|
||||||
|
if let Err(e) = self.cleanup_unused_binaries_internal() {
|
||||||
|
println!("Warning: Failed to cleanup unused binaries: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn get_common_firefox_preferences(&self) -> Vec<String> {
|
fn get_common_firefox_preferences(&self) -> Vec<String> {
|
||||||
vec![
|
vec![
|
||||||
// Disable default browser check
|
// Disable default browser check
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
use directories::BaseDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileGroup {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GroupWithCount {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct GroupsData {
|
||||||
|
groups: Vec<ProfileGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GroupManager {
|
||||||
|
base_dirs: BaseDirs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_groups_file_path(&self) -> PathBuf {
|
||||||
|
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("groups.json");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_groups_data(&self) -> Result<GroupsData, Box<dyn std::error::Error>> {
|
||||||
|
let groups_file = self.get_groups_file_path();
|
||||||
|
|
||||||
|
if !groups_file.exists() {
|
||||||
|
return Ok(GroupsData { groups: Vec::new() });
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(groups_file)?;
|
||||||
|
let groups_data: GroupsData = serde_json::from_str(&content)?;
|
||||||
|
Ok(groups_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_groups_data(&self, groups_data: &GroupsData) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let groups_file = self.get_groups_file_path();
|
||||||
|
|
||||||
|
// Ensure the parent directory exists
|
||||||
|
if let Some(parent) = groups_file.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(groups_data)?;
|
||||||
|
fs::write(groups_file, json)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_groups(&self) -> Result<Vec<ProfileGroup>, Box<dyn std::error::Error>> {
|
||||||
|
let groups_data = self.load_groups_data()?;
|
||||||
|
Ok(groups_data.groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_group(&self, name: String) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||||
|
let mut groups_data = self.load_groups_data()?;
|
||||||
|
|
||||||
|
// Check if group with this name already exists
|
||||||
|
if groups_data.groups.iter().any(|g| g.name == name) {
|
||||||
|
return Err(format!("Group with name '{name}' already exists").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = ProfileGroup {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
|
||||||
|
groups_data.groups.push(group.clone());
|
||||||
|
self.save_groups_data(&groups_data)?;
|
||||||
|
|
||||||
|
Ok(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_group(
|
||||||
|
&self,
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||||
|
let mut groups_data = self.load_groups_data()?;
|
||||||
|
|
||||||
|
// Check if another group with this name already exists
|
||||||
|
if groups_data
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.any(|g| g.name == name && g.id != id)
|
||||||
|
{
|
||||||
|
return Err(format!("Group with name '{name}' already exists").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = groups_data
|
||||||
|
.groups
|
||||||
|
.iter_mut()
|
||||||
|
.find(|g| g.id == id)
|
||||||
|
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
|
||||||
|
|
||||||
|
group.name = name;
|
||||||
|
let updated_group = group.clone();
|
||||||
|
|
||||||
|
self.save_groups_data(&groups_data)?;
|
||||||
|
Ok(updated_group)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_group(&self, id: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut groups_data = self.load_groups_data()?;
|
||||||
|
|
||||||
|
let initial_len = groups_data.groups.len();
|
||||||
|
groups_data.groups.retain(|g| g.id != id);
|
||||||
|
|
||||||
|
if groups_data.groups.len() == initial_len {
|
||||||
|
return Err(format!("Group with id '{id}' not found").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save_groups_data(&groups_data)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_groups_with_profile_counts(
|
||||||
|
&self,
|
||||||
|
profiles: &[crate::browser_runner::BrowserProfile],
|
||||||
|
) -> Result<Vec<GroupWithCount>, Box<dyn std::error::Error>> {
|
||||||
|
let groups = self.get_all_groups()?;
|
||||||
|
let mut group_counts = HashMap::new();
|
||||||
|
|
||||||
|
// Count profiles in each group
|
||||||
|
for profile in profiles {
|
||||||
|
if let Some(group_id) = &profile.group_id {
|
||||||
|
*group_counts.entry(group_id.clone()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create result with counts
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for group in groups {
|
||||||
|
let count = group_counts.get(&group.id).copied().unwrap_or(0);
|
||||||
|
if count > 0 {
|
||||||
|
result.push(GroupWithCount {
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add default group count (profiles without group_id)
|
||||||
|
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
|
||||||
|
if default_count > 0 {
|
||||||
|
let default_group = GroupWithCount {
|
||||||
|
id: "default".to_string(),
|
||||||
|
name: "Default".to_string(),
|
||||||
|
count: default_count,
|
||||||
|
};
|
||||||
|
result.insert(0, default_group);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
pub static ref GROUP_MANAGER: Mutex<GroupManager> = Mutex::new(GroupManager::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get groups with counts
|
||||||
|
pub fn get_groups_with_counts(
|
||||||
|
profiles: &[crate::browser_runner::BrowserProfile],
|
||||||
|
) -> Vec<GroupWithCount> {
|
||||||
|
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||||
|
group_manager
|
||||||
|
.get_groups_with_profile_counts(profiles)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tauri commands
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_profile_groups() -> Result<Vec<ProfileGroup>, String> {
|
||||||
|
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||||
|
group_manager
|
||||||
|
.get_all_groups()
|
||||||
|
.map_err(|e| format!("Failed to get profile groups: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_groups_with_profile_counts() -> Result<Vec<GroupWithCount>, String> {
|
||||||
|
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||||
|
let profiles = browser_runner
|
||||||
|
.list_profiles()
|
||||||
|
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||||
|
Ok(get_groups_with_counts(&profiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_profile_group(name: String) -> Result<ProfileGroup, String> {
|
||||||
|
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||||
|
group_manager
|
||||||
|
.create_group(name)
|
||||||
|
.map_err(|e| format!("Failed to create group: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_profile_group(group_id: String, name: String) -> Result<ProfileGroup, String> {
|
||||||
|
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||||
|
group_manager
|
||||||
|
.update_group(group_id, name)
|
||||||
|
.map_err(|e| format!("Failed to update group: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_profile_group(group_id: String) -> Result<(), String> {
|
||||||
|
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||||
|
group_manager
|
||||||
|
.delete_group(group_id)
|
||||||
|
.map_err(|e| format!("Failed to delete group: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn assign_profiles_to_group(
|
||||||
|
profile_names: Vec<String>,
|
||||||
|
group_id: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||||
|
browser_runner
|
||||||
|
.assign_profiles_to_group(profile_names, group_id)
|
||||||
|
.map_err(|e| format!("Failed to assign profiles to group: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_selected_profiles(profile_names: Vec<String>) -> Result<(), String> {
|
||||||
|
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||||
|
browser_runner
|
||||||
|
.delete_multiple_profiles(profile_names)
|
||||||
|
.map_err(|e| format!("Failed to delete profiles: {e}"))
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ mod download;
|
|||||||
mod downloaded_browsers;
|
mod downloaded_browsers;
|
||||||
mod extraction;
|
mod extraction;
|
||||||
mod geoip_downloader;
|
mod geoip_downloader;
|
||||||
|
mod group_manager;
|
||||||
|
|
||||||
mod profile_importer;
|
mod profile_importer;
|
||||||
mod proxy_manager;
|
mod proxy_manager;
|
||||||
@@ -66,6 +67,11 @@ use theme_detector::get_system_theme;
|
|||||||
|
|
||||||
use system_utils::{get_system_locale, get_system_timezone};
|
use system_utils::{get_system_locale, get_system_timezone};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
// Trait to extend WebviewWindow with transparent titlebar functionality
|
// Trait to extend WebviewWindow with transparent titlebar functionality
|
||||||
pub trait WindowExt {
|
pub trait WindowExt {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -434,6 +440,13 @@ pub fn run() {
|
|||||||
update_camoufox_config,
|
update_camoufox_config,
|
||||||
get_system_locale,
|
get_system_locale,
|
||||||
get_system_timezone,
|
get_system_timezone,
|
||||||
|
get_profile_groups,
|
||||||
|
get_groups_with_profile_counts,
|
||||||
|
create_profile_group,
|
||||||
|
update_profile_group,
|
||||||
|
delete_profile_group,
|
||||||
|
assign_profiles_to_group,
|
||||||
|
delete_selected_profiles,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -690,6 +690,7 @@ impl ProfileImporter {
|
|||||||
last_launch: None,
|
last_launch: None,
|
||||||
release_type: "stable".to_string(),
|
release_type: "stable".to_string(),
|
||||||
camoufox_config: None,
|
camoufox_config: None,
|
||||||
|
group_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save the profile metadata
|
// Save the profile metadata
|
||||||
|
|||||||
@@ -412,26 +412,6 @@ impl ProxyManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get proxy settings for a browser process ID
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn get_proxy_settings(&self, browser_pid: u32) -> Option<ProxySettings> {
|
|
||||||
let proxies = self.active_proxies.lock().unwrap();
|
|
||||||
proxies.get(&browser_pid).map(|proxy| ProxySettings {
|
|
||||||
proxy_type: "http".to_string(),
|
|
||||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
|
||||||
port: proxy.local_port,
|
|
||||||
username: None,
|
|
||||||
password: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get stored proxy info for a profile
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<ProxySettings> {
|
|
||||||
let profile_proxies = self.profile_proxies.lock().unwrap();
|
|
||||||
profile_proxies.get(profile_name).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the PID mapping for an existing proxy
|
// Update the PID mapping for an existing proxy
|
||||||
pub fn update_proxy_pid(&self, old_pid: u32, new_pid: u32) -> Result<(), String> {
|
pub fn update_proxy_pid(&self, old_pid: u32, new_pid: u32) -> Result<(), String> {
|
||||||
let mut proxies = self.active_proxies.lock().unwrap();
|
let mut proxies = self.active_proxies.lock().unwrap();
|
||||||
@@ -530,70 +510,6 @@ mod tests {
|
|||||||
Ok(nodecar_binary)
|
Ok(nodecar_binary)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_proxy_manager_profile_persistence() {
|
|
||||||
let proxy_manager = ProxyManager::new();
|
|
||||||
|
|
||||||
let proxy_settings = ProxySettings {
|
|
||||||
proxy_type: "socks5".to_string(),
|
|
||||||
host: "127.0.0.1".to_string(),
|
|
||||||
port: 1080,
|
|
||||||
username: None,
|
|
||||||
password: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test profile proxy info storage
|
|
||||||
{
|
|
||||||
let mut profile_proxies = proxy_manager.profile_proxies.lock().unwrap();
|
|
||||||
profile_proxies.insert("test_profile".to_string(), proxy_settings.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test retrieval
|
|
||||||
let retrieved = proxy_manager.get_profile_proxy_info("test_profile");
|
|
||||||
assert!(retrieved.is_some());
|
|
||||||
let retrieved = retrieved.unwrap();
|
|
||||||
assert_eq!(retrieved.proxy_type, "socks5");
|
|
||||||
assert_eq!(retrieved.host, "127.0.0.1");
|
|
||||||
assert_eq!(retrieved.port, 1080);
|
|
||||||
|
|
||||||
// Test non-existent profile
|
|
||||||
let non_existent = proxy_manager.get_profile_proxy_info("non_existent");
|
|
||||||
assert!(non_existent.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_proxy_manager_active_proxy_tracking() {
|
|
||||||
let proxy_manager = ProxyManager::new();
|
|
||||||
|
|
||||||
let proxy_info = ProxyInfo {
|
|
||||||
id: "test_proxy_123".to_string(),
|
|
||||||
local_url: "http://localhost:8080".to_string(),
|
|
||||||
upstream_host: "proxy.example.com".to_string(),
|
|
||||||
upstream_port: 3128,
|
|
||||||
upstream_type: "http".to_string(),
|
|
||||||
local_port: 8080,
|
|
||||||
};
|
|
||||||
|
|
||||||
let browser_pid = 54321u32;
|
|
||||||
|
|
||||||
// Add active proxy
|
|
||||||
{
|
|
||||||
let mut active_proxies = proxy_manager.active_proxies.lock().unwrap();
|
|
||||||
active_proxies.insert(browser_pid, proxy_info.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test retrieval of proxy settings
|
|
||||||
let proxy_settings = proxy_manager.get_proxy_settings(browser_pid);
|
|
||||||
assert!(proxy_settings.is_some());
|
|
||||||
let settings = proxy_settings.unwrap();
|
|
||||||
assert!(settings.host == "127.0.0.1");
|
|
||||||
assert!(settings.port == 8080);
|
|
||||||
|
|
||||||
// Test non-existent browser PID
|
|
||||||
let non_existent = proxy_manager.get_proxy_settings(99999);
|
|
||||||
assert!(non_existent.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_proxy_settings_validation() {
|
fn test_proxy_settings_validation() {
|
||||||
// Test valid proxy settings
|
// Test valid proxy settings
|
||||||
@@ -647,10 +563,6 @@ mod tests {
|
|||||||
active_proxies.insert(browser_pid, proxy_info);
|
active_proxies.insert(browser_pid, proxy_info);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read proxy
|
|
||||||
let settings = pm.get_proxy_settings(browser_pid);
|
|
||||||
assert!(settings.is_some());
|
|
||||||
|
|
||||||
browser_pid
|
browser_pid
|
||||||
});
|
});
|
||||||
handles.push(handle);
|
handles.push(handle);
|
||||||
|
|||||||
+126
-87
@@ -4,12 +4,13 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { FaDownload } from "react-icons/fa";
|
|
||||||
import { FiWifi } from "react-icons/fi";
|
|
||||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
|
||||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||||
import { ChangeVersionDialog } from "@/components/change-version-dialog";
|
import { ChangeVersionDialog } from "@/components/change-version-dialog";
|
||||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||||
|
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||||
|
import { GroupBadges } from "@/components/group-badges";
|
||||||
|
import { GroupManagementDialog } from "@/components/group-management-dialog";
|
||||||
|
import HomeHeader from "@/components/home-header";
|
||||||
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||||
import { PermissionDialog } from "@/components/permission-dialog";
|
import { PermissionDialog } from "@/components/permission-dialog";
|
||||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||||
@@ -17,19 +18,7 @@ import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
|||||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||||
import { ProxySettingsDialog } from "@/components/proxy-settings-dialog";
|
import { ProxySettingsDialog } from "@/components/proxy-settings-dialog";
|
||||||
import { SettingsDialog } from "@/components/settings-dialog";
|
import { SettingsDialog } from "@/components/settings-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||||
import type { PermissionType } from "@/hooks/use-permissions";
|
import type { PermissionType } from "@/hooks/use-permissions";
|
||||||
import { usePermissions } from "@/hooks/use-permissions";
|
import { usePermissions } from "@/hooks/use-permissions";
|
||||||
@@ -37,7 +26,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
|||||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||||
import { showErrorToast } from "@/lib/toast-utils";
|
import { showErrorToast } from "@/lib/toast-utils";
|
||||||
import { sleep } from "@/lib/utils";
|
import { sleep } from "@/lib/utils";
|
||||||
import type { BrowserProfile, CamoufoxConfig } from "@/types";
|
import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types";
|
||||||
|
|
||||||
type BrowserTypeString =
|
type BrowserTypeString =
|
||||||
| "mullvad-browser"
|
| "mullvad-browser"
|
||||||
@@ -66,6 +55,15 @@ export default function Home() {
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [camoufoxConfigDialogOpen, setCamoufoxConfigDialogOpen] =
|
const [camoufoxConfigDialogOpen, setCamoufoxConfigDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [groupManagementDialogOpen, setGroupManagementDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
|
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
|
||||||
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
|
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
|
||||||
const [currentProfileForProxy, setCurrentProfileForProxy] =
|
const [currentProfileForProxy, setCurrentProfileForProxy] =
|
||||||
useState<BrowserProfile | null>(null);
|
useState<BrowserProfile | null>(null);
|
||||||
@@ -75,12 +73,18 @@ export default function Home() {
|
|||||||
useState<BrowserProfile | null>(null);
|
useState<BrowserProfile | null>(null);
|
||||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
||||||
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
|
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
|
||||||
|
const [groups, setGroups] = useState<GroupWithCount[]>([]);
|
||||||
|
const [areGroupsLoading, setGroupsLoading] = useState(true);
|
||||||
const [currentPermissionType, setCurrentPermissionType] =
|
const [currentPermissionType, setCurrentPermissionType] =
|
||||||
useState<PermissionType>("microphone");
|
useState<PermissionType>("microphone");
|
||||||
const [proxyDataReloadTrigger, setProxyDataReloadTrigger] = useState(0);
|
|
||||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||||
usePermissions();
|
usePermissions();
|
||||||
|
|
||||||
|
const handleSelectGroup = useCallback((groupId: string | null) => {
|
||||||
|
setSelectedGroupId(groupId);
|
||||||
|
setSelectedProfiles([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Check for missing binaries and offer to download them
|
// Check for missing binaries and offer to download them
|
||||||
const checkMissingBinaries = useCallback(async () => {
|
const checkMissingBinaries = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -151,11 +155,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [checkMissingBinaries]);
|
}, [checkMissingBinaries]);
|
||||||
|
|
||||||
// Trigger proxy data reload in ProfilesDataTable
|
|
||||||
const triggerProxyDataReload = useCallback(() => {
|
|
||||||
setProxyDataReloadTrigger((prev) => prev + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
|
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const handleUrlOpen = useCallback(
|
const handleUrlOpen = useCallback(
|
||||||
@@ -379,13 +378,12 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
// Trigger proxy data reload in the table
|
// Trigger proxy data reload in the table
|
||||||
triggerProxyDataReload();
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error("Failed to update proxy settings:", err);
|
console.error("Failed to update proxy settings:", err);
|
||||||
setError(`Failed to update proxy settings: ${JSON.stringify(err)}`);
|
setError(`Failed to update proxy settings: ${JSON.stringify(err)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProfileForProxy, loadProfiles, triggerProxyDataReload],
|
[currentProfileForProxy, loadProfiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateProfile = useCallback(
|
const handleCreateProfile = useCallback(
|
||||||
@@ -414,7 +412,6 @@ export default function Home() {
|
|||||||
|
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
// Trigger proxy data reload in the table
|
// Trigger proxy data reload in the table
|
||||||
triggerProxyDataReload();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
`Failed to create profile: ${
|
`Failed to create profile: ${
|
||||||
@@ -424,7 +421,7 @@ export default function Home() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadProfiles, triggerProxyDataReload],
|
[loadProfiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
|
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
|
||||||
@@ -563,8 +560,71 @@ export default function Home() {
|
|||||||
[loadProfiles],
|
[loadProfiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setGroupsLoading(true);
|
||||||
|
try {
|
||||||
|
const groupsWithCounts = await invoke<GroupWithCount[]>(
|
||||||
|
"get_groups_with_profile_counts",
|
||||||
|
);
|
||||||
|
setGroups(groupsWithCounts);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load groups with counts:", err);
|
||||||
|
setGroups([]);
|
||||||
|
} finally {
|
||||||
|
setGroupsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteSelectedProfiles = useCallback(
|
||||||
|
async (profileNames: string[]) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await invoke("delete_selected_profiles", { profileNames });
|
||||||
|
await loadProfiles();
|
||||||
|
await loadGroups();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Failed to delete selected profiles:", err);
|
||||||
|
setError(`Failed to delete selected profiles: ${JSON.stringify(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadProfiles, loadGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAssignProfilesToGroup = useCallback((profileNames: string[]) => {
|
||||||
|
setSelectedProfilesForGroup(profileNames);
|
||||||
|
setGroupAssignmentDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkDelete = useCallback(async () => {
|
||||||
|
if (selectedProfiles.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("delete_selected_profiles", {
|
||||||
|
profileNames: selectedProfiles,
|
||||||
|
});
|
||||||
|
await loadProfiles();
|
||||||
|
setSelectedProfiles([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete selected profiles:", error);
|
||||||
|
}
|
||||||
|
}, [selectedProfiles, loadProfiles]);
|
||||||
|
|
||||||
|
const handleBulkGroupAssignment = useCallback(() => {
|
||||||
|
if (selectedProfiles.length === 0) return;
|
||||||
|
handleAssignProfilesToGroup(selectedProfiles);
|
||||||
|
setSelectedProfiles([]);
|
||||||
|
}, [selectedProfiles, handleAssignProfilesToGroup]);
|
||||||
|
|
||||||
|
const handleGroupAssignmentComplete = useCallback(async () => {
|
||||||
|
await loadProfiles();
|
||||||
|
await loadGroups();
|
||||||
|
setGroupAssignmentDialogOpen(false);
|
||||||
|
setSelectedProfilesForGroup([]);
|
||||||
|
}, [loadProfiles, loadGroups]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadProfilesWithUpdateCheck();
|
void loadProfilesWithUpdateCheck();
|
||||||
|
void loadGroups();
|
||||||
|
|
||||||
// Check for startup default browser prompt
|
// Check for startup default browser prompt
|
||||||
void checkStartupPrompt();
|
void checkStartupPrompt();
|
||||||
@@ -592,6 +652,7 @@ export default function Home() {
|
|||||||
checkStartupPrompt,
|
checkStartupPrompt,
|
||||||
listenForUrlEvents,
|
listenForUrlEvents,
|
||||||
checkCurrentUrl,
|
checkCurrentUrl,
|
||||||
|
loadGroups,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -629,66 +690,26 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
|
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
|
||||||
<main className="flex flex-col row-start-2 gap-8 items-center w-full max-w-3xl">
|
<main className="flex flex-col row-start-2 gap-8 items-center w-full max-w-3xl">
|
||||||
<Card className="w-full">
|
<Card className="w-full gap-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between items-center">
|
<HomeHeader
|
||||||
<CardTitle>Profiles</CardTitle>
|
selectedProfiles={selectedProfiles}
|
||||||
<div className="flex gap-2 items-center">
|
onBulkDelete={handleBulkDelete}
|
||||||
<DropdownMenu>
|
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||||
<DropdownMenuTrigger asChild>
|
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||||
<Button
|
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
|
||||||
size="sm"
|
onImportProfileDialogOpen={setImportProfileDialogOpen}
|
||||||
variant="outline"
|
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
|
||||||
className="flex gap-2 items-center"
|
onSettingsDialogOpen={setSettingsDialogOpen}
|
||||||
>
|
/>
|
||||||
<GoKebabHorizontal className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setSettingsDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GoGear className="mr-2 w-4 h-4" />
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setProxyManagementDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiWifi className="mr-2 w-4 h-4" />
|
|
||||||
Proxies
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setImportProfileDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaDownload className="mr-2 w-4 h-4" />
|
|
||||||
Import Profile
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setCreateProfileDialogOpen(true);
|
|
||||||
}}
|
|
||||||
className="flex gap-2 items-center"
|
|
||||||
>
|
|
||||||
<GoPlus className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Create a new profile</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<GroupBadges
|
||||||
|
selectedGroupId={selectedGroupId}
|
||||||
|
onGroupSelect={handleSelectGroup}
|
||||||
|
groups={groups}
|
||||||
|
isLoading={areGroupsLoading}
|
||||||
|
/>
|
||||||
<ProfilesDataTable
|
<ProfilesDataTable
|
||||||
data={profiles}
|
data={profiles}
|
||||||
onLaunchProfile={launchProfile}
|
onLaunchProfile={launchProfile}
|
||||||
@@ -700,9 +721,11 @@ export default function Home() {
|
|||||||
onConfigureCamoufox={handleConfigureCamoufox}
|
onConfigureCamoufox={handleConfigureCamoufox}
|
||||||
runningProfiles={runningProfiles}
|
runningProfiles={runningProfiles}
|
||||||
isUpdating={isUpdating}
|
isUpdating={isUpdating}
|
||||||
onReloadProxyData={
|
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
||||||
proxyDataReloadTrigger > 0 ? triggerProxyDataReload : undefined
|
onAssignProfilesToGroup={handleAssignProfilesToGroup}
|
||||||
}
|
selectedGroupId={selectedGroupId}
|
||||||
|
selectedProfiles={selectedProfiles}
|
||||||
|
onSelectedProfilesChange={setSelectedProfiles}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -788,6 +811,22 @@ export default function Home() {
|
|||||||
profile={currentProfileForCamoufoxConfig}
|
profile={currentProfileForCamoufoxConfig}
|
||||||
onSave={handleSaveCamoufoxConfig}
|
onSave={handleSaveCamoufoxConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GroupManagementDialog
|
||||||
|
isOpen={groupManagementDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setGroupManagementDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GroupAssignmentDialog
|
||||||
|
isOpen={groupAssignmentDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setGroupAssignmentDialogOpen(false);
|
||||||
|
}}
|
||||||
|
selectedProfiles={selectedProfilesForGroup}
|
||||||
|
onAssignmentComplete={handleGroupAssignmentComplete}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { ProfileGroup } from "@/types";
|
||||||
|
|
||||||
|
interface CreateGroupDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onGroupCreated: (group: ProfileGroup) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateGroupDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onGroupCreated,
|
||||||
|
}: CreateGroupDialogProps) {
|
||||||
|
const [groupName, setGroupName] = useState("");
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async () => {
|
||||||
|
if (!groupName.trim()) return;
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const newGroup = await invoke<ProfileGroup>("create_profile_group", {
|
||||||
|
name: groupName.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Group created successfully");
|
||||||
|
onGroupCreated(newGroup);
|
||||||
|
setGroupName("");
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create group:", err);
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : "Failed to create group";
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}, [groupName, onGroupCreated, onClose]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setGroupName("");
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Group</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new group to organize your browser profiles.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="group-name">Group Name</Label>
|
||||||
|
<Input
|
||||||
|
id="group-name"
|
||||||
|
placeholder="Enter group name..."
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => setGroupName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && groupName.trim()) {
|
||||||
|
void handleCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
isLoading={isCreating}
|
||||||
|
onClick={() => void handleCreate()}
|
||||||
|
disabled={!groupName.trim()}
|
||||||
|
>
|
||||||
|
Create Group
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import type { BrowserProfile, ProfileGroup } from "@/types";
|
||||||
|
|
||||||
|
interface DeleteGroupDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
group: ProfileGroup | null;
|
||||||
|
onGroupDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteGroupDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
group,
|
||||||
|
onGroupDeleted,
|
||||||
|
}: DeleteGroupDialogProps) {
|
||||||
|
const [associatedProfiles, setAssociatedProfiles] = useState<
|
||||||
|
BrowserProfile[]
|
||||||
|
>([]);
|
||||||
|
const [deleteAction, setDeleteAction] = useState<"move" | "delete">("move");
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadAssociatedProfiles = useCallback(async () => {
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const allProfiles = await invoke<BrowserProfile[]>(
|
||||||
|
"list_browser_profiles",
|
||||||
|
);
|
||||||
|
const groupProfiles = allProfiles.filter(
|
||||||
|
(profile) => profile.group_id === group.id,
|
||||||
|
);
|
||||||
|
setAssociatedProfiles(groupProfiles);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load associated profiles:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load profiles");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [group]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && group) {
|
||||||
|
void loadAssociatedProfiles();
|
||||||
|
}
|
||||||
|
}, [isOpen, group, loadAssociatedProfiles]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (deleteAction === "delete" && associatedProfiles.length > 0) {
|
||||||
|
// Delete all associated profiles first
|
||||||
|
const profileNames = associatedProfiles.map((p) => p.name);
|
||||||
|
await invoke("delete_selected_profiles", { profileNames });
|
||||||
|
} else if (deleteAction === "move" && associatedProfiles.length > 0) {
|
||||||
|
// Move profiles to default group (null group_id)
|
||||||
|
const profileNames = associatedProfiles.map((p) => p.name);
|
||||||
|
await invoke("assign_profiles_to_group", {
|
||||||
|
profileNames,
|
||||||
|
groupId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the group
|
||||||
|
await invoke("delete_profile_group", { groupId: group.id });
|
||||||
|
|
||||||
|
toast.success("Group deleted successfully");
|
||||||
|
onGroupDeleted();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete group:", err);
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : "Failed to delete group";
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
setDeleteAction("move");
|
||||||
|
setAssociatedProfiles([]);
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Group</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the group
|
||||||
|
"{group?.name}".
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Loading associated profiles...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{associatedProfiles.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
Associated Profiles ({associatedProfiles.length})
|
||||||
|
</Label>
|
||||||
|
<ScrollArea className="h-32 w-full border rounded-md p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{associatedProfiles.map((profile) => (
|
||||||
|
<div key={profile.id} className="text-sm">
|
||||||
|
• {profile.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>What should happen to these profiles?</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={deleteAction}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setDeleteAction(value as "move" | "delete")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="move" id="move" />
|
||||||
|
<Label htmlFor="move" className="text-sm">
|
||||||
|
Move profiles to Default group
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="delete" id="delete" />
|
||||||
|
<Label
|
||||||
|
htmlFor="delete"
|
||||||
|
className="text-sm text-red-600"
|
||||||
|
>
|
||||||
|
Delete profiles along with the group
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{associatedProfiles.length === 0 && !isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
This group has no associated profiles.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleClose} disabled={isDeleting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
variant="destructive"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Delete Group
|
||||||
|
{deleteAction === "delete" &&
|
||||||
|
associatedProfiles.length > 0 &&
|
||||||
|
" & Profiles"}
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { ProfileGroup } from "@/types";
|
||||||
|
|
||||||
|
interface EditGroupDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
group: ProfileGroup | null;
|
||||||
|
onGroupUpdated: (group: ProfileGroup) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditGroupDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
group,
|
||||||
|
onGroupUpdated,
|
||||||
|
}: EditGroupDialogProps) {
|
||||||
|
const [groupName, setGroupName] = useState("");
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (group) {
|
||||||
|
setGroupName(group.name);
|
||||||
|
} else {
|
||||||
|
setGroupName("");
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
}, [group]);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(async () => {
|
||||||
|
if (!group || !groupName.trim()) return;
|
||||||
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updatedGroup = await invoke<ProfileGroup>("update_profile_group", {
|
||||||
|
groupId: group.id,
|
||||||
|
name: groupName.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Group updated successfully");
|
||||||
|
onGroupUpdated(updatedGroup);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update group:", err);
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : "Failed to update group";
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
}, [group, groupName, onGroupUpdated, onClose]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Group</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update the name of the group "{group?.name}".
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="group-name">Group Name</Label>
|
||||||
|
<Input
|
||||||
|
id="group-name"
|
||||||
|
placeholder="Enter group name..."
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => setGroupName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && groupName.trim()) {
|
||||||
|
void handleUpdate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleClose} disabled={isUpdating}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
isLoading={isUpdating}
|
||||||
|
onClick={() => void handleUpdate()}
|
||||||
|
disabled={!groupName.trim() || groupName === group?.name}
|
||||||
|
>
|
||||||
|
Update Group
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import type { ProfileGroup } from "@/types";
|
||||||
|
|
||||||
|
interface GroupAssignmentDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedProfiles: string[];
|
||||||
|
onAssignmentComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupAssignmentDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
selectedProfiles,
|
||||||
|
onAssignmentComplete,
|
||||||
|
}: GroupAssignmentDialogProps) {
|
||||||
|
const [groups, setGroups] = useState<ProfileGroup[]>([]);
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isAssigning, setIsAssigning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const groupList = await invoke<ProfileGroup[]>("get_profile_groups");
|
||||||
|
setGroups(groupList);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load groups:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAssign = useCallback(async () => {
|
||||||
|
setIsAssigning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await invoke("assign_profiles_to_group", {
|
||||||
|
profileNames: selectedProfiles,
|
||||||
|
groupId: selectedGroupId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupName = selectedGroupId
|
||||||
|
? groups.find((g) => g.id === selectedGroupId)?.name || "Unknown Group"
|
||||||
|
: "Default";
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Successfully assigned ${selectedProfiles.length} profile(s) to ${groupName}`,
|
||||||
|
);
|
||||||
|
onAssignmentComplete();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to assign profiles to group:", err);
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to assign profiles to group";
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsAssigning(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
selectedProfiles,
|
||||||
|
selectedGroupId,
|
||||||
|
groups,
|
||||||
|
onAssignmentComplete,
|
||||||
|
onClose,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void loadGroups();
|
||||||
|
setSelectedGroupId(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [isOpen, loadGroups]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Assign to Group</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Assign {selectedProfiles.length} selected profile(s) to a group.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Selected Profiles:</Label>
|
||||||
|
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{selectedProfiles.map((profileName) => (
|
||||||
|
<li key={profileName} className="truncate">
|
||||||
|
• {profileName}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="group-select">Assign to Group:</Label>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Loading groups...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={selectedGroupId || "default"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedGroupId(value === "default" ? null : value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a group" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default (No Group)</SelectItem>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<SelectItem key={group.id} value={group.id}>
|
||||||
|
{group.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isAssigning}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
isLoading={isAssigning}
|
||||||
|
onClick={() => void handleAssign()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Assign
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { GroupWithCount } from "@/types";
|
||||||
|
|
||||||
|
interface GroupBadgesProps {
|
||||||
|
selectedGroupId: string | null;
|
||||||
|
onGroupSelect: (groupId: string | null) => void;
|
||||||
|
refreshTrigger?: number;
|
||||||
|
groups: GroupWithCount[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupBadges({
|
||||||
|
selectedGroupId,
|
||||||
|
onGroupSelect,
|
||||||
|
groups,
|
||||||
|
isLoading,
|
||||||
|
}: GroupBadgesProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<div className="text-sm text-muted-foreground">Loading groups...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Badge
|
||||||
|
key={group.id}
|
||||||
|
variant={selectedGroupId === group.id ? "default" : "secondary"}
|
||||||
|
className="cursor-pointer hover:bg-primary/80 transition-colors flex items-center gap-2 px-3 py-1"
|
||||||
|
onClick={() => {
|
||||||
|
onGroupSelect(selectedGroupId === group.id ? null : group.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{group.name}</span>
|
||||||
|
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
|
||||||
|
{group.count}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { GoPlus } from "react-icons/go";
|
||||||
|
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||||
|
import { CreateGroupDialog } from "@/components/create-group-dialog";
|
||||||
|
import { DeleteGroupDialog } from "@/components/delete-group-dialog";
|
||||||
|
import { EditGroupDialog } from "@/components/edit-group-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { ProfileGroup } from "@/types";
|
||||||
|
|
||||||
|
interface GroupManagementDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupManagementDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: GroupManagementDialogProps) {
|
||||||
|
const [groups, setGroups] = useState<ProfileGroup[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<ProfileGroup | null>(null);
|
||||||
|
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const groupList = await invoke<ProfileGroup[]>("get_profile_groups");
|
||||||
|
setGroups(groupList);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load groups:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGroupCreated = useCallback((newGroup: ProfileGroup) => {
|
||||||
|
setGroups((prev) => [...prev, newGroup]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGroupUpdated = useCallback((updatedGroup: ProfileGroup) => {
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((group) =>
|
||||||
|
group.id === updatedGroup.id ? updatedGroup : group,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGroupDeleted = useCallback(() => {
|
||||||
|
void loadGroups();
|
||||||
|
}, [loadGroups]);
|
||||||
|
|
||||||
|
const handleEditGroup = useCallback((group: ProfileGroup) => {
|
||||||
|
setSelectedGroup(group);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteGroup = useCallback((group: ProfileGroup) => {
|
||||||
|
setSelectedGroup(group);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void loadGroups();
|
||||||
|
}
|
||||||
|
}, [isOpen, loadGroups]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Manage Profile Groups</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create, edit, and delete profile groups. Profiles without a group
|
||||||
|
will appear in the "Default" group.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Create new group button */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label>Groups</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<GoPlus className="w-4 h-4" />
|
||||||
|
Create Group
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Groups list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Loading groups...
|
||||||
|
</div>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No groups created yet. Create your first group using the button
|
||||||
|
above.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<TableRow key={group.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{group.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditGroup(group)}
|
||||||
|
>
|
||||||
|
<LuPencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteGroup(group)}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<CreateGroupDialog
|
||||||
|
isOpen={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
onGroupCreated={handleGroupCreated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditGroupDialog
|
||||||
|
isOpen={editDialogOpen}
|
||||||
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
group={selectedGroup}
|
||||||
|
onGroupUpdated={handleGroupUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteGroupDialog
|
||||||
|
isOpen={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
group={selectedGroup}
|
||||||
|
onGroupDeleted={handleGroupDeleted}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { FaDownload } from "react-icons/fa";
|
||||||
|
import { FiWifi } from "react-icons/fi";
|
||||||
|
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||||
|
import { LuTrash2, LuUsers } from "react-icons/lu";
|
||||||
|
import { Logo } from "./icons/logo";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { CardTitle } from "./ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "./ui/dropdown-menu";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedProfiles: string[];
|
||||||
|
onBulkGroupAssignment: () => void;
|
||||||
|
onBulkDelete: () => Promise<void>;
|
||||||
|
onSettingsDialogOpen: (open: boolean) => void;
|
||||||
|
onProxyManagementDialogOpen: (open: boolean) => void;
|
||||||
|
onGroupManagementDialogOpen: (open: boolean) => void;
|
||||||
|
onImportProfileDialogOpen: (open: boolean) => void;
|
||||||
|
onCreateProfileDialogOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HomeHeader = ({
|
||||||
|
selectedProfiles,
|
||||||
|
onBulkGroupAssignment,
|
||||||
|
onBulkDelete,
|
||||||
|
onSettingsDialogOpen,
|
||||||
|
onProxyManagementDialogOpen,
|
||||||
|
onGroupManagementDialogOpen,
|
||||||
|
onImportProfileDialogOpen,
|
||||||
|
onCreateProfileDialogOpen,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Logo className="w-10 h-10" />
|
||||||
|
{selectedProfiles.length > 0 ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedProfiles.length} profile
|
||||||
|
{selectedProfiles.length !== 1 ? "s" : ""} selected
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onBulkGroupAssignment}
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<LuUsers className="w-4 h-4" />
|
||||||
|
Assign to Group
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void onBulkDelete()}
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<LuTrash2 className="w-4 h-4" />
|
||||||
|
Delete Selected
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CardTitle>Donut</CardTitle>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<GoKebabHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onSettingsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GoGear className="mr-2 w-4 h-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onProxyManagementDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiWifi className="mr-2 w-4 h-4" />
|
||||||
|
Proxies
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onGroupManagementDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuUsers className="mr-2 w-4 h-4" />
|
||||||
|
Groups
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onImportProfileDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaDownload className="mr-2 w-4 h-4" />
|
||||||
|
Import Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
onCreateProfileDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<GoPlus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Create a new profile</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomeHeader;
|
||||||
File diff suppressed because one or more lines are too long
@@ -10,9 +10,11 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { CiCircleCheck } from "react-icons/ci";
|
||||||
import { IoEllipsisHorizontal } from "react-icons/io5";
|
import { IoEllipsisHorizontal } from "react-icons/io5";
|
||||||
import { LuChevronDown, LuChevronUp } from "react-icons/lu";
|
import { LuChevronDown, LuChevronUp } from "react-icons/lu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -67,6 +69,11 @@ interface ProfilesDataTableProps {
|
|||||||
runningProfiles: Set<string>;
|
runningProfiles: Set<string>;
|
||||||
isUpdating?: (browser: string) => boolean;
|
isUpdating?: (browser: string) => boolean;
|
||||||
onReloadProxyData?: () => void | Promise<void>;
|
onReloadProxyData?: () => void | Promise<void>;
|
||||||
|
onDeleteSelectedProfiles?: (profileNames: string[]) => Promise<void>;
|
||||||
|
onAssignProfilesToGroup?: (profileNames: string[]) => void;
|
||||||
|
selectedGroupId?: string | null;
|
||||||
|
selectedProfiles?: string[];
|
||||||
|
onSelectedProfilesChange?: (profiles: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfilesDataTable({
|
export function ProfilesDataTable({
|
||||||
@@ -80,7 +87,11 @@ export function ProfilesDataTable({
|
|||||||
onConfigureCamoufox,
|
onConfigureCamoufox,
|
||||||
runningProfiles,
|
runningProfiles,
|
||||||
isUpdating = () => false,
|
isUpdating = () => false,
|
||||||
onReloadProxyData,
|
onDeleteSelectedProfiles: _onDeleteSelectedProfiles,
|
||||||
|
onAssignProfilesToGroup,
|
||||||
|
selectedGroupId,
|
||||||
|
selectedProfiles: externalSelectedProfiles = [],
|
||||||
|
onSelectedProfilesChange,
|
||||||
}: ProfilesDataTableProps) {
|
}: ProfilesDataTableProps) {
|
||||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
@@ -95,6 +106,10 @@ export function ProfilesDataTable({
|
|||||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [storedProxies, setStoredProxies] = React.useState<StoredProxy[]>([]);
|
const [storedProxies, setStoredProxies] = React.useState<StoredProxy[]>([]);
|
||||||
|
const [selectedProfiles, setSelectedProfiles] = React.useState<Set<string>>(
|
||||||
|
new Set(externalSelectedProfiles),
|
||||||
|
);
|
||||||
|
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
|
||||||
|
|
||||||
// Helper function to check if a profile has a proxy
|
// Helper function to check if a profile has a proxy
|
||||||
const hasProxy = React.useCallback(
|
const hasProxy = React.useCallback(
|
||||||
@@ -125,8 +140,21 @@ export function ProfilesDataTable({
|
|||||||
[storedProxies],
|
[storedProxies],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Filter data by selected group
|
||||||
|
const filteredData = React.useMemo(() => {
|
||||||
|
if (!selectedGroupId) return data;
|
||||||
|
if (selectedGroupId === "default") {
|
||||||
|
return data.filter((profile) => !profile.group_id);
|
||||||
|
}
|
||||||
|
return data.filter((profile) => profile.group_id === selectedGroupId);
|
||||||
|
}, [data, selectedGroupId]);
|
||||||
|
|
||||||
// Use shared browser state hook
|
// Use shared browser state hook
|
||||||
const browserState = useBrowserState(data, runningProfiles, isUpdating);
|
const browserState = useBrowserState(
|
||||||
|
filteredData,
|
||||||
|
runningProfiles,
|
||||||
|
isUpdating,
|
||||||
|
);
|
||||||
|
|
||||||
// Load stored proxies
|
// Load stored proxies
|
||||||
const loadStoredProxies = React.useCallback(async () => {
|
const loadStoredProxies = React.useCallback(async () => {
|
||||||
@@ -144,12 +172,12 @@ export function ProfilesDataTable({
|
|||||||
}
|
}
|
||||||
}, [browserState.isClient, loadStoredProxies]);
|
}, [browserState.isClient, loadStoredProxies]);
|
||||||
|
|
||||||
// Reload proxy data when requested from parent
|
// Sync external selected profiles with internal state
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (onReloadProxyData) {
|
const newSet = new Set(externalSelectedProfiles);
|
||||||
void loadStoredProxies();
|
setSelectedProfiles(newSet);
|
||||||
}
|
setShowCheckboxes(newSet.size > 0);
|
||||||
}, [onReloadProxyData, loadStoredProxies]);
|
}, [externalSelectedProfiles]);
|
||||||
|
|
||||||
// Update local sorting state when settings are loaded
|
// Update local sorting state when settings are loaded
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -203,8 +231,139 @@ export function ProfilesDataTable({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle icon/checkbox click
|
||||||
|
const handleIconClick = React.useCallback(
|
||||||
|
(profileName: string) => {
|
||||||
|
setShowCheckboxes(true);
|
||||||
|
setSelectedProfiles((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(profileName)) {
|
||||||
|
newSet.delete(profileName);
|
||||||
|
} else {
|
||||||
|
newSet.add(profileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide checkboxes if no profiles are selected
|
||||||
|
if (newSet.size === 0) {
|
||||||
|
setShowCheckboxes(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify parent component
|
||||||
|
if (onSelectedProfilesChange) {
|
||||||
|
onSelectedProfilesChange(Array.from(newSet));
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onSelectedProfilesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle checkbox change
|
||||||
|
const handleCheckboxChange = React.useCallback(
|
||||||
|
(profileName: string, checked: boolean) => {
|
||||||
|
setSelectedProfiles((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (checked) {
|
||||||
|
newSet.add(profileName);
|
||||||
|
} else {
|
||||||
|
newSet.delete(profileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide checkboxes if no profiles are selected
|
||||||
|
if (newSet.size === 0) {
|
||||||
|
setShowCheckboxes(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify parent component
|
||||||
|
if (onSelectedProfilesChange) {
|
||||||
|
onSelectedProfilesChange(Array.from(newSet));
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onSelectedProfilesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle select all checkbox
|
||||||
|
const handleToggleAll = React.useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
const newSet = checked
|
||||||
|
? new Set(filteredData.map((profile) => profile.name))
|
||||||
|
: new Set<string>();
|
||||||
|
|
||||||
|
setSelectedProfiles(newSet);
|
||||||
|
setShowCheckboxes(checked);
|
||||||
|
|
||||||
|
// Notify parent component
|
||||||
|
if (onSelectedProfilesChange) {
|
||||||
|
onSelectedProfilesChange(Array.from(newSet));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filteredData, onSelectedProfilesChange],
|
||||||
|
);
|
||||||
|
|
||||||
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
|
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: () => (
|
||||||
|
<span>
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
selectedProfiles.size === filteredData.length &&
|
||||||
|
filteredData.length !== 0
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) => handleToggleAll(!!value)}
|
||||||
|
aria-label="Select all"
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const profile = row.original;
|
||||||
|
const browser = profile.browser;
|
||||||
|
const IconComponent = getBrowserIcon(browser);
|
||||||
|
const isSelected = selectedProfiles.has(profile.name);
|
||||||
|
|
||||||
|
if (showCheckboxes || isSelected) {
|
||||||
|
return (
|
||||||
|
<span className="w-4 h-4 flex items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
handleCheckboxChange(profile.name, !!value)
|
||||||
|
}
|
||||||
|
aria-label="Select row"
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative flex items-center justify-center w-4 h-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-center cursor-pointer border-none p-0"
|
||||||
|
onClick={() => handleIconClick(profile.name)}
|
||||||
|
aria-label="Select profile"
|
||||||
|
>
|
||||||
|
<span className="w-4 h-4 group">
|
||||||
|
{IconComponent && (
|
||||||
|
<IconComponent className="w-4 h-4 group-hover:hidden" />
|
||||||
|
)}
|
||||||
|
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
size: 40,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -289,10 +448,8 @@ export function ProfilesDataTable({
|
|||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const browser: string = row.getValue("browser");
|
const browser: string = row.getValue("browser");
|
||||||
const IconComponent = getBrowserIcon(browser);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center">
|
||||||
{IconComponent && <IconComponent className="w-4 h-4" />}
|
|
||||||
<span>{getBrowserDisplayName(browser)}</span>
|
<span>{getBrowserDisplayName(browser)}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -358,21 +515,21 @@ export function ProfilesDataTable({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="inline-flex">
|
<span className="flex gap-2 items-center">
|
||||||
<Button
|
{profileHasProxy && (
|
||||||
variant="outline"
|
<CiCircleCheck className="w-4 h-4 text-green-500" />
|
||||||
size="sm"
|
)}
|
||||||
onClick={() => onProxySettings(profile)}
|
{proxyDisplayName.length > 10 ? (
|
||||||
disabled={
|
<span className="text-sm truncate text-muted-foreground">
|
||||||
!browserState.isClient ||
|
{proxyDisplayName.slice(0, 10)}...
|
||||||
profile.browser === "tor-browser"
|
</span>
|
||||||
}
|
) : (
|
||||||
className={
|
<span className="text-sm text-muted-foreground">
|
||||||
profile.browser === "tor-browser" ? "opacity-50" : ""
|
{profile.browser === "tor-browser"
|
||||||
}
|
? "Not supported"
|
||||||
>
|
: proxyDisplayName}
|
||||||
{profileHasProxy ? "Configured" : "Configure"}
|
</span>
|
||||||
</Button>
|
)}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{tooltipText}</TooltipContent>
|
<TooltipContent>{tooltipText}</TooltipContent>
|
||||||
@@ -414,6 +571,16 @@ export function ProfilesDataTable({
|
|||||||
>
|
>
|
||||||
Configure Proxy
|
Configure Proxy
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (onAssignProfilesToGroup) {
|
||||||
|
onAssignProfilesToGroup([profile.name]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!browserState.isClient || isBrowserUpdating}
|
||||||
|
>
|
||||||
|
Assign to Group
|
||||||
|
</DropdownMenuItem>
|
||||||
{profile.browser === "camoufox" && onConfigureCamoufox && (
|
{profile.browser === "camoufox" && onConfigureCamoufox && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -470,6 +637,11 @@ export function ProfilesDataTable({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
showCheckboxes,
|
||||||
|
selectedProfiles,
|
||||||
|
handleToggleAll,
|
||||||
|
handleCheckboxChange,
|
||||||
|
handleIconClick,
|
||||||
runningProfiles,
|
runningProfiles,
|
||||||
browserState,
|
browserState,
|
||||||
hasProxy,
|
hasProxy,
|
||||||
@@ -480,12 +652,14 @@ export function ProfilesDataTable({
|
|||||||
onKillProfile,
|
onKillProfile,
|
||||||
onConfigureCamoufox,
|
onConfigureCamoufox,
|
||||||
onChangeVersion,
|
onChangeVersion,
|
||||||
|
onAssignProfilesToGroup,
|
||||||
isUpdating,
|
isUpdating,
|
||||||
|
filteredData.length,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data: filteredData,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
@@ -502,7 +676,7 @@ export function ProfilesDataTable({
|
|||||||
<ScrollArea
|
<ScrollArea
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-md border",
|
"rounded-md border",
|
||||||
platform === "macos" ? "h-[380px]" : "h-[320px]",
|
platform === "macos" ? "h-[340px]" : "h-[280px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Table>
|
<Table>
|
||||||
@@ -530,6 +704,7 @@ export function ProfilesDataTable({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className="hover:bg-accent/50"
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||||
|
import * as React from "react";
|
||||||
|
import { LuCircle } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<LuCircle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|||||||
<th
|
<th
|
||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -82,10 +82,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn("p-2 align-middle whitespace-nowrap", className)}
|
||||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface BrowserProfile {
|
|||||||
last_launch?: number;
|
last_launch?: number;
|
||||||
release_type: string; // "stable" or "nightly"
|
release_type: string; // "stable" or "nightly"
|
||||||
camoufox_config?: CamoufoxConfig; // Camoufox configuration
|
camoufox_config?: CamoufoxConfig; // Camoufox configuration
|
||||||
|
group_id?: string; // Reference to profile group
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredProxy {
|
export interface StoredProxy {
|
||||||
@@ -29,6 +30,17 @@ export interface StoredProxy {
|
|||||||
proxy_settings: ProxySettings;
|
proxy_settings: ProxySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProfileGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupWithCount {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DetectedProfile {
|
export interface DetectedProfile {
|
||||||
browser: string;
|
browser: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user