mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-08 19:34:51 +02:00
refactor: better custom name
This commit is contained in:
@@ -39,6 +39,7 @@ pub struct ApiProfile {
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub is_running: bool,
|
||||
pub proxy_bypass_rules: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
@@ -79,6 +80,7 @@ pub struct UpdateProfileRequest {
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub extension_group_id: Option<String>,
|
||||
pub proxy_bypass_rules: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -487,6 +489,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -541,6 +544,7 @@ async fn get_profile(
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
@@ -639,6 +643,7 @@ async fn create_profile(
|
||||
group_id: profile.group_id,
|
||||
tags: profile.tags,
|
||||
is_running: false,
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -756,6 +761,15 @@ async fn update_profile(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(proxy_bypass_rules) = request.proxy_bypass_rules {
|
||||
if profile_manager
|
||||
.update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated profile
|
||||
get_profile(Path(id), State(state)).await
|
||||
}
|
||||
|
||||
@@ -2726,6 +2726,53 @@ pub async fn kill_browser_profile(
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
|
||||
// Auto-update non-running profiles and cleanup unused binaries
|
||||
let browser_for_update = profile.browser.clone();
|
||||
let app_handle_for_update = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
let mut versions = registry.get_downloaded_versions(&browser_for_update);
|
||||
if !versions.is_empty() {
|
||||
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
|
||||
let latest_version = &versions[0];
|
||||
|
||||
let auto_updater = crate::auto_updater::AutoUpdater::instance();
|
||||
match auto_updater
|
||||
.auto_update_profile_versions(
|
||||
&app_handle_for_update,
|
||||
&browser_for_update,
|
||||
latest_version,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated) => {
|
||||
if !updated.is_empty() {
|
||||
log::info!(
|
||||
"Auto-updated {} profiles after stop: {:?}",
|
||||
updated.len(),
|
||||
updated
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to auto-update profile versions after stop: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match registry.cleanup_unused_binaries() {
|
||||
Ok(cleaned) => {
|
||||
if !cleaned.is_empty() {
|
||||
log::info!("Cleaned up unused binaries after stop: {:?}", cleaned);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to cleanup unused binaries after stop: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -1033,6 +1033,51 @@ impl Downloader {
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
|
||||
// Auto-update non-running profiles to the new version and cleanup unused binaries
|
||||
{
|
||||
let browser_for_update = browser_str.clone();
|
||||
let version_for_update = version.clone();
|
||||
let app_handle_for_update = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let auto_updater = crate::auto_updater::AutoUpdater::instance();
|
||||
match auto_updater
|
||||
.auto_update_profile_versions(
|
||||
&app_handle_for_update,
|
||||
&browser_for_update,
|
||||
&version_for_update,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated) => {
|
||||
if !updated.is_empty() {
|
||||
log::info!(
|
||||
"Auto-updated {} profiles to {} {}: {:?}",
|
||||
updated.len(),
|
||||
browser_for_update,
|
||||
version_for_update,
|
||||
updated
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to auto-update profile versions: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
match registry.cleanup_unused_binaries() {
|
||||
Ok(cleaned) => {
|
||||
if !cleaned.is_empty() {
|
||||
log::info!("Cleaned up unused binaries after download: {:?}", cleaned);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to cleanup unused binaries: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,6 +725,27 @@ impl McpServer {
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "update_profile_proxy_bypass_rules".to_string(),
|
||||
description:
|
||||
"Update proxy bypass rules for a profile. Requests matching these rules will connect directly, bypassing the proxy."
|
||||
.to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile to update"
|
||||
},
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Array of bypass rules. Supports hostnames (e.g. 'example.com'), IP addresses, and regex patterns."
|
||||
}
|
||||
},
|
||||
"required": ["profile_id", "rules"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "list_extensions".to_string(),
|
||||
description: "List all managed browser extensions. Requires Pro subscription.".to_string(),
|
||||
@@ -889,6 +910,11 @@ impl McpServer {
|
||||
// Fingerprint management
|
||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await,
|
||||
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await,
|
||||
"update_profile_proxy_bypass_rules" => {
|
||||
self
|
||||
.handle_update_profile_proxy_bypass_rules(&arguments)
|
||||
.await
|
||||
}
|
||||
// Extension management
|
||||
"list_extensions" => self.handle_list_extensions().await,
|
||||
"list_extension_groups" => self.handle_list_extension_groups().await,
|
||||
@@ -2141,6 +2167,54 @@ impl McpServer {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_update_profile_proxy_bypass_rules(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
|
||||
let rules: Vec<String> = arguments
|
||||
.get("rules")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing rules array".to_string(),
|
||||
})?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
let inner = self.inner.lock().await;
|
||||
let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?;
|
||||
|
||||
let profile = ProfileManager::instance()
|
||||
.update_profile_proxy_bypass_rules(app_handle, profile_id, rules.clone())
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update proxy bypass rules: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Proxy bypass rules updated for profile '{}': {} rule(s) configured",
|
||||
profile.name,
|
||||
rules.len()
|
||||
)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_list_extensions(&self) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
@@ -2366,6 +2440,7 @@ mod tests {
|
||||
// Fingerprint tools
|
||||
assert!(tool_names.contains(&"get_profile_fingerprint"));
|
||||
assert!(tool_names.contains(&"update_profile_fingerprint"));
|
||||
assert!(tool_names.contains(&"update_profile_proxy_bypass_rules"));
|
||||
// Extension tools
|
||||
assert!(tool_names.contains(&"list_extensions"));
|
||||
assert!(tool_names.contains(&"list_extension_groups"));
|
||||
|
||||
@@ -849,6 +849,7 @@ impl ProfileManager {
|
||||
pub fn clone_profile(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
custom_name: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
@@ -865,7 +866,10 @@ impl ProfileManager {
|
||||
}
|
||||
|
||||
let new_id = uuid::Uuid::new_v4();
|
||||
let clone_name = self.generate_clone_name(&source.name)?;
|
||||
let clone_name = match custom_name {
|
||||
Some(name) if !name.trim().is_empty() => name.trim().to_string(),
|
||||
_ => self.generate_clone_name(&source.name)?,
|
||||
};
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let source_dir = profiles_dir.join(source.id.to_string());
|
||||
@@ -2185,9 +2189,9 @@ pub async fn update_wayfern_config(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clone_profile(profile_id: String) -> Result<BrowserProfile, String> {
|
||||
pub fn clone_profile(profile_id: String, name: Option<String>) -> Result<BrowserProfile, String> {
|
||||
ProfileManager::instance()
|
||||
.clone_profile(&profile_id)
|
||||
.clone_profile(&profile_id, name)
|
||||
.map_err(|e| format!("Failed to clone profile: {e}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,46 @@ fn create_test_profile_bundle(temp_dir: &Path) -> Vec<u8> {
|
||||
encoder.finish().unwrap()
|
||||
}
|
||||
|
||||
fn create_test_profile_bundle_with_bypass_rules(temp_dir: &Path, bypass_rules: &[&str]) -> Vec<u8> {
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use tar::Builder;
|
||||
|
||||
let metadata = json!({
|
||||
"id": "test-bypass-profile-id",
|
||||
"name": "Bypass Rules Profile",
|
||||
"browser": "camoufox",
|
||||
"version": "120.0.0",
|
||||
"release_type": "stable",
|
||||
"sync_enabled": true,
|
||||
"tags": [],
|
||||
"proxy_bypass_rules": bypass_rules
|
||||
});
|
||||
|
||||
let profile_dir = temp_dir.join("bypass_profile");
|
||||
fs::create_dir_all(&profile_dir).unwrap();
|
||||
fs::write(profile_dir.join("test_file.txt"), "bypass test content").unwrap();
|
||||
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
{
|
||||
let mut tar = Builder::new(&mut encoder);
|
||||
|
||||
let metadata_json = serde_json::to_string_pretty(&metadata).unwrap();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(metadata_json.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar
|
||||
.append_data(&mut header, "metadata.json", metadata_json.as_bytes())
|
||||
.unwrap();
|
||||
|
||||
tar.append_dir_all("profile", &profile_dir).unwrap();
|
||||
tar.finish().unwrap();
|
||||
}
|
||||
|
||||
encoder.finish().unwrap()
|
||||
}
|
||||
|
||||
fn extract_bundle(data: &[u8], target_dir: &Path) -> serde_json::Value {
|
||||
use flate2::read::GzDecoder;
|
||||
use tar::Archive;
|
||||
@@ -727,3 +767,77 @@ async fn test_delta_sync_only_changed_files() {
|
||||
client.delete(&file2_key, None).await.unwrap();
|
||||
client.delete(&file3_key, None).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_bypass_rules_sync() {
|
||||
ensure_sync_server_available().await;
|
||||
let client = TestClient::new();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let profile_id = uuid::Uuid::new_v4().to_string();
|
||||
let test_key = format!("profiles/{}.tar.gz", profile_id);
|
||||
|
||||
let bypass_rules = vec!["example.com", "192.168.1.0/24", ".*\\.internal\\.net"];
|
||||
|
||||
let bundle = create_test_profile_bundle_with_bypass_rules(temp_dir.path(), &bypass_rules);
|
||||
|
||||
let presign = client
|
||||
.presign_upload(&test_key, "application/gzip")
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.upload_bytes(&presign.url, &bundle, "application/gzip")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stat = client.stat(&test_key).await.unwrap();
|
||||
assert!(stat.exists);
|
||||
|
||||
// Download and verify bypass rules survive the round-trip
|
||||
let download_presign = client.presign_download(&test_key).await.unwrap();
|
||||
let downloaded = client.download_bytes(&download_presign.url).await.unwrap();
|
||||
assert_eq!(downloaded.len(), bundle.len());
|
||||
|
||||
let extract_dir = temp_dir.path().join("extracted");
|
||||
fs::create_dir_all(&extract_dir).unwrap();
|
||||
let metadata = extract_bundle(&downloaded, &extract_dir);
|
||||
|
||||
assert_eq!(metadata["name"], "Bypass Rules Profile");
|
||||
assert_eq!(metadata["browser"], "camoufox");
|
||||
|
||||
let synced_rules = metadata["proxy_bypass_rules"]
|
||||
.as_array()
|
||||
.expect("proxy_bypass_rules should be an array");
|
||||
assert_eq!(synced_rules.len(), 3);
|
||||
assert_eq!(synced_rules[0], "example.com");
|
||||
assert_eq!(synced_rules[1], "192.168.1.0/24");
|
||||
assert_eq!(synced_rules[2], ".*\\.internal\\.net");
|
||||
|
||||
// Also verify empty bypass rules are handled correctly
|
||||
let empty_bundle = create_test_profile_bundle_with_bypass_rules(temp_dir.path(), &[]);
|
||||
let empty_key = format!("profiles/{}.tar.gz", uuid::Uuid::new_v4());
|
||||
|
||||
let presign2 = client
|
||||
.presign_upload(&empty_key, "application/gzip")
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.upload_bytes(&presign2.url, &empty_bundle, "application/gzip")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let download_presign2 = client.presign_download(&empty_key).await.unwrap();
|
||||
let downloaded2 = client.download_bytes(&download_presign2.url).await.unwrap();
|
||||
|
||||
let extract_dir2 = temp_dir.path().join("extracted2");
|
||||
fs::create_dir_all(&extract_dir2).unwrap();
|
||||
let metadata2 = extract_bundle(&downloaded2, &extract_dir2);
|
||||
|
||||
let empty_rules = metadata2["proxy_bypass_rules"]
|
||||
.as_array()
|
||||
.expect("proxy_bypass_rules should be an array");
|
||||
assert!(empty_rules.is_empty());
|
||||
|
||||
// Cleanup
|
||||
client.delete(&test_key, None).await.unwrap();
|
||||
client.delete(&empty_key, None).await.unwrap();
|
||||
}
|
||||
|
||||
+10
-10
@@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
@@ -168,6 +169,7 @@ export default function Home() {
|
||||
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
|
||||
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [cloneProfile, setCloneProfile] = useState<BrowserProfile | null>(null);
|
||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
||||
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
|
||||
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
|
||||
@@ -585,16 +587,8 @@ export default function Home() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloneProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
try {
|
||||
await invoke<BrowserProfile>("clone_profile", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to clone profile:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to clone profile: ${errorMessage}`);
|
||||
}
|
||||
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
|
||||
setCloneProfile(profile);
|
||||
}, []);
|
||||
|
||||
const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
@@ -1139,6 +1133,12 @@ export default function Home() {
|
||||
onPermissionGranted={checkNextPermission}
|
||||
/>
|
||||
|
||||
<CloneProfileDialog
|
||||
isOpen={!!cloneProfile}
|
||||
onClose={() => setCloneProfile(null)}
|
||||
profile={cloneProfile}
|
||||
/>
|
||||
|
||||
<CamoufoxConfigDialog
|
||||
isOpen={camoufoxConfigDialogOpen}
|
||||
onClose={() => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface CloneProfileDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
onCloneComplete?: () => void;
|
||||
}
|
||||
|
||||
export function CloneProfileDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
onCloneComplete,
|
||||
}: CloneProfileDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState("");
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen && profile) {
|
||||
const defaultName = `${profile.name} (Copy)`;
|
||||
setName(defaultName);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isOpen, profile]);
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
const handleClone = async () => {
|
||||
if (!name.trim() || isLoading) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await invoke<BrowserProfile>("clone_profile", {
|
||||
profileId: profile.id,
|
||||
name: name.trim(),
|
||||
});
|
||||
onClose();
|
||||
onCloneComplete?.();
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to clone profile: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("profileInfo.clone.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleClone();
|
||||
}}
|
||||
placeholder={t("profileInfo.clone.namePlaceholder")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
onClick={() => void handleClone()}
|
||||
isLoading={isLoading}
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
{t("profileInfo.clone.button")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2279,6 +2279,7 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
size: 40,
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -2341,7 +2342,14 @@ export function ProfilesDataTable({
|
||||
<TableRow key={headerGroup.id} className="overflow-visible">
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
@@ -2374,7 +2382,15 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="overflow-visible">
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="overflow-visible"
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
|
||||
@@ -398,36 +398,63 @@ export function ProfileInfoDialog({
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ProfileIcon className="w-5 h-5" />
|
||||
{profile.name}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="info">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
{t("profileInfo.tabs.info")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="network" className="flex-1">
|
||||
{t("profileInfo.tabs.network")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
{t("profileInfo.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 py-2">
|
||||
{infoFields.map((field) => (
|
||||
<React.Fragment key={field.label}>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="text-sm">{field.value}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div className="flex flex-col items-center gap-1 py-3">
|
||||
<ProfileIcon className="w-12 h-12 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">{profile.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getBrowserDisplayName(profile.browser)} {profile.version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 py-2">
|
||||
{infoFields.map((field) => (
|
||||
<React.Fragment key={field.label}>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="text-sm">{field.value}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="network">
|
||||
<TabsContent value="settings">
|
||||
<div className="flex flex-col py-1">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
|
||||
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
|
||||
action.destructive &&
|
||||
"text-destructive hover:bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="flex-1 flex items-center gap-2">
|
||||
{action.label}
|
||||
{action.proBadge && <ProBadge />}
|
||||
</span>
|
||||
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t my-2" />
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
@@ -484,31 +511,6 @@ export function ProfileInfoDialog({
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<div className="flex flex-col py-1">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
|
||||
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
|
||||
action.destructive &&
|
||||
"text-destructive hover:bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="flex-1 flex items-center gap-2">
|
||||
{action.label}
|
||||
{action.proBadge && <ProBadge />}
|
||||
</span>
|
||||
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -683,7 +683,6 @@
|
||||
"title": "Profile Details",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"network": "Network",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"fields": {
|
||||
@@ -716,6 +715,12 @@
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Manage Cookies"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Clone Profile",
|
||||
"description": "Enter a name for the cloned profile",
|
||||
"namePlaceholder": "Profile name",
|
||||
"button": "Clone"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
|
||||
@@ -683,7 +683,6 @@
|
||||
"title": "Detalles del Perfil",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"network": "Red",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"fields": {
|
||||
@@ -716,6 +715,12 @@
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Administrar Cookies"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Clonar Perfil",
|
||||
"description": "Ingrese un nombre para el perfil clonado",
|
||||
"namePlaceholder": "Nombre del perfil",
|
||||
"button": "Clonar"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
|
||||
@@ -683,7 +683,6 @@
|
||||
"title": "Détails du Profil",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"network": "Réseau",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"fields": {
|
||||
@@ -716,6 +715,12 @@
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Gérer les Cookies"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Cloner le Profil",
|
||||
"description": "Entrez un nom pour le profil cloné",
|
||||
"namePlaceholder": "Nom du profil",
|
||||
"button": "Cloner"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
|
||||
@@ -683,7 +683,6 @@
|
||||
"title": "プロフィール詳細",
|
||||
"tabs": {
|
||||
"info": "情報",
|
||||
"network": "ネットワーク",
|
||||
"settings": "設定"
|
||||
},
|
||||
"fields": {
|
||||
@@ -716,6 +715,12 @@
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Cookieを管理"
|
||||
},
|
||||
"clone": {
|
||||
"title": "プロフィールを複製",
|
||||
"description": "複製されたプロフィールの名前を入力してください",
|
||||
"namePlaceholder": "プロフィール名",
|
||||
"button": "複製"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
|
||||
@@ -683,7 +683,6 @@
|
||||
"title": "Detalhes do Perfil",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"network": "Rede",
|
||||
"settings": "Configurações"
|
||||
},
|
||||
"fields": {
|
||||
@@ -716,6 +715,12 @@
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Gerenciar Cookies"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Clonar Perfil",
|
||||
"description": "Digite um nome para o perfil clonado",
|
||||
"namePlaceholder": "Nome do perfil",
|
||||
"button": "Clonar"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
|
||||
@@ -683,7 +683,6 @@
|
||||
"title": "Детали профиля",
|
||||
"tabs": {
|
||||
"info": "Информация",
|
||||
"network": "Сеть",
|
||||
"settings": "Настройки"
|
||||
},
|
||||
"fields": {
|
||||
@@ -716,6 +715,12 @@
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Управление Cookie"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Клонировать профиль",
|
||||
"description": "Введите имя для клонированного профиля",
|
||||
"namePlaceholder": "Имя профиля",
|
||||
"button": "Клонировать"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
|
||||
@@ -683,7 +683,6 @@
|
||||
"title": "配置文件详情",
|
||||
"tabs": {
|
||||
"info": "信息",
|
||||
"network": "网络",
|
||||
"settings": "设置"
|
||||
},
|
||||
"fields": {
|
||||
@@ -716,6 +715,12 @@
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "管理 Cookie"
|
||||
},
|
||||
"clone": {
|
||||
"title": "克隆配置文件",
|
||||
"description": "输入克隆配置文件的名称",
|
||||
"namePlaceholder": "配置文件名称",
|
||||
"button": "克隆"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
|
||||
Reference in New Issue
Block a user