Compare commits

..

15 Commits

Author SHA1 Message Date
Julian Freeman
1c43a318c6 fix custom config ui 2026-04-17 17:17:08 -04:00
Julian Freeman
2d319b7f7a op placeholder 2026-04-17 17:09:41 -04:00
Julian Freeman
09a3465197 fix bg blur 2026-04-17 17:01:21 -04:00
Julian Freeman
3478c44860 fix table header ui 2026-04-17 16:55:28 -04:00
Julian Freeman
5f835731fc fix blur 2026-04-17 16:45:17 -04:00
Julian Freeman
ac01e66d26 change ext profiles col width 2026-04-17 16:39:33 -04:00
Julian Freeman
941e499c17 shrink table height 2026-04-17 16:36:01 -04:00
Julian Freeman
45662dc642 support delete bookmarks 2026-04-17 16:27:57 -04:00
Julian Freeman
42905bf6d3 op bookmarks 2026-04-17 15:47:03 -04:00
Julian Freeman
ee076fc9aa fix extensions bug 2026-04-17 15:29:46 -04:00
Julian Freeman
f9066d8e60 fix bug 2026-04-17 15:07:10 -04:00
Julian Freeman
469b684876 support delete extensions 2026-04-17 15:01:29 -04:00
Julian Freeman
d724db6f0f add remove sessions 2026-04-17 14:04:44 -04:00
Julian Freeman
ad838086ed fix clean history ui 2026-04-17 13:56:38 -04:00
Julian Freeman
6d2b117200 change history to clean 2026-04-17 13:44:41 -04:00
21 changed files with 3502 additions and 414 deletions

View File

@@ -1,11 +1,22 @@
use std::{path::PathBuf, process::Command}; use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
use crate::{ use crate::{
config_store, config_store,
models::{BrowserConfigListResponse, CreateCustomBrowserConfigInput, ScanResponse}, models::{
BookmarkRemovalRequest, BrowserConfigListResponse, CleanupHistoryInput,
CleanupHistoryResponse, CleanupHistoryResult, CreateCustomBrowserConfigInput,
ExtensionInstallSourceSummary, RemoveBookmarkResult, RemoveBookmarksInput,
RemoveBookmarksResponse, RemoveExtensionResult, RemoveExtensionsInput,
RemoveExtensionsResponse, ScanResponse,
},
scanner, scanner,
}; };
use tauri::AppHandle; use tauri::AppHandle;
use serde_json::Value;
#[tauri::command] #[tauri::command]
pub fn scan_browsers(app: AppHandle) -> Result<ScanResponse, String> { pub fn scan_browsers(app: AppHandle) -> Result<ScanResponse, String> {
@@ -61,6 +72,89 @@ pub fn open_browser_profile(
spawn_browser_process(executable_path, user_data_dir, profile_id) spawn_browser_process(executable_path, user_data_dir, profile_id)
} }
#[tauri::command]
pub fn cleanup_history_files(
app: AppHandle,
input: CleanupHistoryInput,
) -> Result<CleanupHistoryResponse, String> {
let config = config_store::find_browser_config(&app, &input.browser_id)?;
let user_data_dir = PathBuf::from(&config.user_data_path);
if !user_data_dir.is_dir() {
return Err(format!(
"User data directory does not exist: {}",
user_data_dir.display()
));
}
let mut results = Vec::new();
for profile_id in input.profile_ids {
let profile_path = user_data_dir.join(&profile_id);
let result = cleanup_profile_history_files(&profile_path, &profile_id);
results.push(result);
}
Ok(CleanupHistoryResponse { results })
}
#[tauri::command]
pub fn remove_extensions(
app: AppHandle,
input: RemoveExtensionsInput,
) -> Result<RemoveExtensionsResponse, String> {
let config = config_store::find_browser_config(&app, &input.browser_id)?;
let user_data_dir = PathBuf::from(&config.user_data_path);
if !user_data_dir.is_dir() {
return Err(format!(
"User data directory does not exist: {}",
user_data_dir.display()
));
}
let mut results = Vec::new();
for removal in input.removals {
for profile_id in removal.profile_ids {
results.push(remove_extension_from_profile(
&user_data_dir.join(&profile_id),
&removal.extension_id,
&profile_id,
));
}
}
Ok(RemoveExtensionsResponse { results })
}
#[tauri::command]
pub fn remove_bookmarks(
app: AppHandle,
input: RemoveBookmarksInput,
) -> Result<RemoveBookmarksResponse, String> {
let config = config_store::find_browser_config(&app, &input.browser_id)?;
let user_data_dir = PathBuf::from(&config.user_data_path);
if !user_data_dir.is_dir() {
return Err(format!(
"User data directory does not exist: {}",
user_data_dir.display()
));
}
let mut results = Vec::new();
for removal in input.removals {
for profile_id in &removal.profile_ids {
results.push(remove_bookmark_from_profile(
&user_data_dir.join(profile_id),
&removal,
profile_id,
));
}
}
Ok(RemoveBookmarksResponse { results })
}
fn spawn_browser_process( fn spawn_browser_process(
executable_path: PathBuf, executable_path: PathBuf,
user_data_dir: PathBuf, user_data_dir: PathBuf,
@@ -79,3 +173,454 @@ fn spawn_browser_process(
) )
}) })
} }
fn cleanup_profile_history_files(profile_path: &Path, profile_id: &str) -> CleanupHistoryResult {
if !profile_path.is_dir() {
return CleanupHistoryResult {
profile_id: profile_id.to_string(),
deleted_files: Vec::new(),
skipped_files: Vec::new(),
error: Some(format!(
"Profile directory does not exist: {}",
profile_path.display()
)),
};
}
let mut deleted_files = Vec::new();
let mut skipped_files = Vec::new();
for file_name in ["History", "Top Sites", "Visited Links"] {
let file_path = profile_path.join(file_name);
if !file_path.exists() {
skipped_files.push(file_name.to_string());
continue;
}
if let Err(error) = fs::remove_file(&file_path) {
return CleanupHistoryResult {
profile_id: profile_id.to_string(),
deleted_files,
skipped_files,
error: Some(format!("Failed to delete {}: {error}", file_path.display())),
};
}
deleted_files.push(file_name.to_string());
remove_sidecar_files(&file_path);
}
let sessions_directory = profile_path.join("Sessions");
match cleanup_sessions_directory(&sessions_directory) {
Ok(session_deleted) => {
if session_deleted {
deleted_files.push("Sessions".to_string());
} else {
skipped_files.push("Sessions".to_string());
}
}
Err(error) => {
return CleanupHistoryResult {
profile_id: profile_id.to_string(),
deleted_files,
skipped_files,
error: Some(format!(
"Failed to clean {}: {error}",
sessions_directory.display()
)),
};
}
}
CleanupHistoryResult {
profile_id: profile_id.to_string(),
deleted_files,
skipped_files,
error: None,
}
}
fn remove_extension_from_profile(
profile_path: &Path,
extension_id: &str,
profile_id: &str,
) -> RemoveExtensionResult {
if !profile_path.is_dir() {
return RemoveExtensionResult {
extension_id: extension_id.to_string(),
profile_id: profile_id.to_string(),
removed_files: Vec::new(),
skipped_files: Vec::new(),
error: Some(format!(
"Profile directory does not exist: {}",
profile_path.display()
)),
};
}
let secure_preferences_path = profile_path.join("Secure Preferences");
let preferences_path = profile_path.join("Preferences");
let mut removed_files = Vec::new();
let mut skipped_files = Vec::new();
let secure_preferences_outcome =
remove_extension_from_secure_preferences(&secure_preferences_path, extension_id);
let install_source = match secure_preferences_outcome {
Ok(Some(source)) => {
removed_files.push("Secure Preferences".to_string());
source
}
Ok(None) => {
skipped_files.push("Secure Preferences".to_string());
ExtensionInstallSourceSummary::External
}
Err(error) => {
return RemoveExtensionResult {
extension_id: extension_id.to_string(),
profile_id: profile_id.to_string(),
removed_files,
skipped_files,
error: Some(error),
};
}
};
match remove_extension_from_preferences(&preferences_path, extension_id) {
Ok(true) => removed_files.push("Preferences".to_string()),
Ok(false) => skipped_files.push("Preferences".to_string()),
Err(error) => {
return RemoveExtensionResult {
extension_id: extension_id.to_string(),
profile_id: profile_id.to_string(),
removed_files,
skipped_files,
error: Some(error),
};
}
}
if install_source == ExtensionInstallSourceSummary::Store {
let extension_directory = profile_path.join("Extensions").join(extension_id);
if extension_directory.is_dir() {
if let Err(error) = fs::remove_dir_all(&extension_directory) {
return RemoveExtensionResult {
extension_id: extension_id.to_string(),
profile_id: profile_id.to_string(),
removed_files,
skipped_files,
error: Some(format!(
"Failed to delete {}: {error}",
extension_directory.display()
)),
};
}
removed_files.push("Extensions".to_string());
} else {
skipped_files.push("Extensions".to_string());
}
}
RemoveExtensionResult {
extension_id: extension_id.to_string(),
profile_id: profile_id.to_string(),
removed_files,
skipped_files,
error: None,
}
}
fn remove_bookmark_from_profile(
profile_path: &Path,
removal: &BookmarkRemovalRequest,
profile_id: &str,
) -> RemoveBookmarkResult {
if !profile_path.is_dir() {
return RemoveBookmarkResult {
url: removal.url.clone(),
profile_id: profile_id.to_string(),
removed_count: 0,
removed_files: Vec::new(),
skipped_files: Vec::new(),
error: Some(format!(
"Profile directory does not exist: {}",
profile_path.display()
)),
};
}
let mut removed_files = Vec::new();
let mut skipped_files = Vec::new();
let removed_backup = remove_bookmark_backups(profile_path).map_err(|error| RemoveBookmarkResult {
url: removal.url.clone(),
profile_id: profile_id.to_string(),
removed_count: 0,
removed_files: removed_files.clone(),
skipped_files: skipped_files.clone(),
error: Some(error),
});
let removed_backup = match removed_backup {
Ok(value) => value,
Err(result) => return result,
};
if removed_backup {
removed_files.push("Bookmarks.bak".to_string());
} else {
skipped_files.push("Bookmarks.bak".to_string());
}
let Some(bookmarks_path) = resolve_bookmarks_path(profile_path) else {
return RemoveBookmarkResult {
url: removal.url.clone(),
profile_id: profile_id.to_string(),
removed_count: 0,
removed_files,
skipped_files,
error: Some(format!(
"Bookmarks file does not exist in {}",
profile_path.display()
)),
};
};
let mut document = match read_json_document(&bookmarks_path) {
Ok(document) => document,
Err(error) => {
return RemoveBookmarkResult {
url: removal.url.clone(),
profile_id: profile_id.to_string(),
removed_count: 0,
removed_files,
skipped_files,
error: Some(error),
};
}
};
let checksum_removed = document
.as_object_mut()
.and_then(|object| object.remove("checksum"))
.is_some();
let removed_count = remove_matching_bookmarks(&mut document, &removal.url);
if checksum_removed || removed_count > 0 {
if let Err(error) = write_json_document(&bookmarks_path, &document) {
return RemoveBookmarkResult {
url: removal.url.clone(),
profile_id: profile_id.to_string(),
removed_count: 0,
removed_files,
skipped_files,
error: Some(error),
};
}
removed_files.push("Bookmarks".to_string());
} else {
skipped_files.push("Bookmarks".to_string());
}
RemoveBookmarkResult {
url: removal.url.clone(),
profile_id: profile_id.to_string(),
removed_count,
removed_files,
skipped_files,
error: None,
}
}
fn remove_extension_from_secure_preferences(
path: &Path,
extension_id: &str,
) -> Result<Option<ExtensionInstallSourceSummary>, String> {
let mut document = read_json_document(path)?;
let install_source = document
.get("extensions")
.and_then(|value| value.get("settings"))
.and_then(|value| value.get(extension_id))
.and_then(|value| value.get("path"))
.and_then(Value::as_str)
.map(detect_extension_install_source)
.unwrap_or(ExtensionInstallSourceSummary::External);
let mut changed = false;
changed |= remove_object_key(
&mut document,
&["extensions", "settings"],
extension_id,
);
changed |= remove_object_key(
&mut document,
&["protection", "macs", "extensions", "settings"],
extension_id,
);
changed |= remove_object_key(
&mut document,
&["protection", "macs", "extensions", "settings_encrypted_hash"],
extension_id,
);
if changed {
write_json_document(path, &document)?;
Ok(Some(install_source))
} else {
Ok(None)
}
}
fn remove_extension_from_preferences(path: &Path, extension_id: &str) -> Result<bool, String> {
let mut document = read_json_document(path)?;
let mut changed = false;
if let Some(pinned_extensions) = get_value_mut(&mut document, &["extensions", "pinned_extensions"])
{
if let Some(array) = pinned_extensions.as_array_mut() {
let original_len = array.len();
array.retain(|value| value.as_str() != Some(extension_id));
changed |= array.len() != original_len;
} else if let Some(object) = pinned_extensions.as_object_mut() {
changed |= object.remove(extension_id).is_some();
}
}
if changed {
write_json_document(path, &document)?;
}
Ok(changed)
}
fn detect_extension_install_source(raw_path: &str) -> ExtensionInstallSourceSummary {
let normalized_path = raw_path.trim().trim_start_matches('/');
if normalized_path.is_empty() {
return ExtensionInstallSourceSummary::External;
}
let candidate = PathBuf::from(normalized_path);
if candidate.is_absolute() {
ExtensionInstallSourceSummary::External
} else {
ExtensionInstallSourceSummary::Store
}
}
fn read_json_document(path: &Path) -> Result<Value, String> {
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
serde_json::from_str(&content)
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))
}
fn write_json_document(path: &Path, document: &Value) -> Result<(), String> {
let content = serde_json::to_string_pretty(document)
.map_err(|error| format!("Failed to serialize {}: {error}", path.display()))?;
fs::write(path, content).map_err(|error| format!("Failed to write {}: {error}", path.display()))
}
fn remove_object_key(document: &mut Value, object_path: &[&str], key: &str) -> bool {
get_value_mut(document, object_path)
.and_then(Value::as_object_mut)
.and_then(|object| object.remove(key))
.is_some()
}
fn get_value_mut<'a>(document: &'a mut Value, path: &[&str]) -> Option<&'a mut Value> {
let mut current = document;
for segment in path {
current = current.get_mut(*segment)?;
}
Some(current)
}
fn remove_sidecar_files(path: &Path) {
for suffix in ["-journal", "-wal", "-shm"] {
let sidecar = PathBuf::from(format!("{}{}", path.display(), suffix));
if sidecar.is_file() {
let _ = fs::remove_file(sidecar);
}
}
}
fn cleanup_sessions_directory(path: &Path) -> Result<bool, std::io::Error> {
if !path.is_dir() {
return Ok(false);
}
let mut deleted_any = false;
for entry in path.read_dir()? {
let entry = entry?;
let entry_path = entry.path();
if entry_path.is_file() {
fs::remove_file(&entry_path)?;
deleted_any = true;
}
}
Ok(deleted_any)
}
fn remove_bookmark_backups(profile_path: &Path) -> Result<bool, String> {
let mut deleted_any = false;
for backup_name in ["Bookmarks.bak", "Bookmark.bak"] {
let backup_path = profile_path.join(backup_name);
if !backup_path.is_file() {
continue;
}
fs::remove_file(&backup_path)
.map_err(|error| format!("Failed to delete {}: {error}", backup_path.display()))?;
deleted_any = true;
}
Ok(deleted_any)
}
fn resolve_bookmarks_path(profile_path: &Path) -> Option<PathBuf> {
["Bookmarks", "Bookmark"]
.into_iter()
.map(|name| profile_path.join(name))
.find(|path| path.is_file())
}
fn remove_matching_bookmarks(value: &mut Value, target_url: &str) -> usize {
match value {
Value::Object(object) => {
let mut removed_count = 0;
if let Some(children) = object.get_mut("children").and_then(Value::as_array_mut) {
let mut index = 0;
while index < children.len() {
let matches_url = children[index]
.as_object()
.map(|child| {
child.get("type").and_then(Value::as_str) == Some("url")
&& child.get("url").and_then(Value::as_str) == Some(target_url)
})
.unwrap_or(false);
if matches_url {
children.remove(index);
removed_count += 1;
continue;
}
removed_count += remove_matching_bookmarks(&mut children[index], target_url);
index += 1;
}
}
for (key, child) in object.iter_mut() {
if key == "children" {
continue;
}
removed_count += remove_matching_bookmarks(child, target_url);
}
removed_count
}
Value::Array(array) => array
.iter_mut()
.map(|item| remove_matching_bookmarks(item, target_url))
.sum(),
_ => 0,
}
}

View File

@@ -15,7 +15,10 @@ pub fn run() {
commands::list_browser_configs, commands::list_browser_configs,
commands::create_custom_browser_config, commands::create_custom_browser_config,
commands::delete_custom_browser_config, commands::delete_custom_browser_config,
commands::open_browser_profile commands::open_browser_profile,
commands::cleanup_history_files,
commands::remove_extensions,
commands::remove_bookmarks
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -20,7 +20,6 @@ pub struct BrowserView {
pub extensions: Vec<ExtensionSummary>, pub extensions: Vec<ExtensionSummary>,
pub bookmarks: Vec<BookmarkSummary>, pub bookmarks: Vec<BookmarkSummary>,
pub password_sites: Vec<PasswordSiteSummary>, pub password_sites: Vec<PasswordSiteSummary>,
pub history_domains: Vec<HistoryDomainSummary>,
pub stats: BrowserStats, pub stats: BrowserStats,
} }
@@ -31,7 +30,7 @@ pub struct BrowserStats {
pub extension_count: usize, pub extension_count: usize,
pub bookmark_count: usize, pub bookmark_count: usize,
pub password_site_count: usize, pub password_site_count: usize,
pub history_domain_count: usize, pub history_cleanup_profile_count: usize,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -46,6 +45,7 @@ pub struct ProfileSummary {
pub default_avatar_stroke_color: Option<i64>, pub default_avatar_stroke_color: Option<i64>,
pub avatar_label: String, pub avatar_label: String,
pub path: String, pub path: String,
pub history_cleanup: HistoryCleanupSummary,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -56,7 +56,7 @@ pub struct ExtensionSummary {
pub version: Option<String>, pub version: Option<String>,
pub icon_data_url: Option<String>, pub icon_data_url: Option<String>,
pub profile_ids: Vec<String>, pub profile_ids: Vec<String>,
pub profiles: Vec<AssociatedProfileSummary>, pub profiles: Vec<ExtensionAssociatedProfileSummary>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -77,13 +77,103 @@ pub struct PasswordSiteSummary {
pub profiles: Vec<AssociatedProfileSummary>, pub profiles: Vec<AssociatedProfileSummary>,
} }
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct HistoryCleanupSummary {
pub history: CleanupFileStatus,
pub top_sites: CleanupFileStatus,
pub visited_links: CleanupFileStatus,
pub sessions: CleanupFileStatus,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum CleanupFileStatus {
Found,
Missing,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CleanupHistoryInput {
pub browser_id: String,
pub profile_ids: Vec<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct HistoryDomainSummary { pub struct CleanupHistoryResponse {
pub domain: String, pub results: Vec<CleanupHistoryResult>,
pub visit_count: i64, }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CleanupHistoryResult {
pub profile_id: String,
pub deleted_files: Vec<String>,
pub skipped_files: Vec<String>,
pub error: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveExtensionsInput {
pub browser_id: String,
pub removals: Vec<ExtensionRemovalRequest>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveBookmarksInput {
pub browser_id: String,
pub removals: Vec<BookmarkRemovalRequest>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtensionRemovalRequest {
pub extension_id: String,
pub profile_ids: Vec<String>, pub profile_ids: Vec<String>,
pub profiles: Vec<AssociatedProfileSummary>, }
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BookmarkRemovalRequest {
pub url: String,
pub profile_ids: Vec<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveExtensionsResponse {
pub results: Vec<RemoveExtensionResult>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveBookmarksResponse {
pub results: Vec<RemoveBookmarkResult>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveExtensionResult {
pub extension_id: String,
pub profile_id: String,
pub removed_files: Vec<String>,
pub skipped_files: Vec<String>,
pub error: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveBookmarkResult {
pub url: String,
pub profile_id: String,
pub removed_count: usize,
pub removed_files: Vec<String>,
pub skipped_files: Vec<String>,
pub error: Option<String>,
} }
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
@@ -98,6 +188,26 @@ pub struct AssociatedProfileSummary {
pub avatar_label: String, pub avatar_label: String,
} }
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ExtensionAssociatedProfileSummary {
pub id: String,
pub name: String,
pub avatar_data_url: Option<String>,
pub avatar_icon: Option<String>,
pub default_avatar_fill_color: Option<i64>,
pub default_avatar_stroke_color: Option<i64>,
pub avatar_label: String,
pub install_source: ExtensionInstallSourceSummary,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum ExtensionInstallSourceSummary {
Store,
External,
}
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BookmarkAssociatedProfileSummary { pub struct BookmarkAssociatedProfileSummary {
@@ -178,7 +288,7 @@ pub struct TempExtension {
pub version: Option<String>, pub version: Option<String>,
pub icon_data_url: Option<String>, pub icon_data_url: Option<String>,
pub profile_ids: BTreeSet<String>, pub profile_ids: BTreeSet<String>,
pub profiles: BTreeMap<String, AssociatedProfileSummary>, pub profiles: BTreeMap<String, ExtensionAssociatedProfileSummary>,
} }
pub struct TempBookmark { pub struct TempBookmark {
@@ -194,10 +304,3 @@ pub struct TempPasswordSite {
pub profile_ids: BTreeSet<String>, pub profile_ids: BTreeSet<String>,
pub profiles: BTreeMap<String, AssociatedProfileSummary>, pub profiles: BTreeMap<String, AssociatedProfileSummary>,
} }
pub struct TempHistoryDomain {
pub domain: String,
pub visit_count: i64,
pub profile_ids: BTreeSet<String>,
pub profiles: BTreeMap<String, AssociatedProfileSummary>,
}

View File

@@ -11,9 +11,10 @@ use crate::{
config_store, config_store,
models::{ models::{
AssociatedProfileSummary, BookmarkAssociatedProfileSummary, BookmarkSummary, AssociatedProfileSummary, BookmarkAssociatedProfileSummary, BookmarkSummary,
BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary, HistoryDomainSummary, BrowserConfigEntry, BrowserStats, BrowserView, CleanupFileStatus, ExtensionSummary,
ExtensionAssociatedProfileSummary, ExtensionInstallSourceSummary, HistoryCleanupSummary,
PasswordSiteSummary, ProfileSummary, ScanResponse, TempBookmark, TempExtension, PasswordSiteSummary, ProfileSummary, ScanResponse, TempBookmark, TempExtension,
TempHistoryDomain, TempPasswordSite, TempPasswordSite,
}, },
utils::{ utils::{
copy_sqlite_database_to_temp, first_non_empty, load_image_as_data_url, read_json_file, copy_sqlite_database_to_temp, first_non_empty, load_image_as_data_url, read_json_file,
@@ -48,7 +49,6 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
let mut extensions = BTreeMap::<String, TempExtension>::new(); let mut extensions = BTreeMap::<String, TempExtension>::new();
let mut bookmarks = BTreeMap::<String, TempBookmark>::new(); let mut bookmarks = BTreeMap::<String, TempBookmark>::new();
let mut password_sites = BTreeMap::<String, TempPasswordSite>::new(); let mut password_sites = BTreeMap::<String, TempPasswordSite>::new();
let mut history_domains = BTreeMap::<String, TempHistoryDomain>::new();
for profile_id in profile_ids { for profile_id in profile_ids {
let profile_path = root.join(&profile_id); let profile_path = root.join(&profile_id);
@@ -62,7 +62,6 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
scan_extensions_for_profile(&profile_path, &profile_summary, &mut extensions); scan_extensions_for_profile(&profile_path, &profile_summary, &mut extensions);
scan_bookmarks_for_profile(&profile_path, &profile_summary, &mut bookmarks); scan_bookmarks_for_profile(&profile_path, &profile_summary, &mut bookmarks);
scan_password_sites_for_profile(&profile_path, &profile_summary, &mut password_sites); scan_password_sites_for_profile(&profile_path, &profile_summary, &mut password_sites);
scan_history_domains_for_profile(&profile_path, &profile_summary, &mut history_domains);
profiles.push(profile_summary); profiles.push(profile_summary);
} }
@@ -96,15 +95,16 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
profiles: entry.profiles.into_values().collect(), profiles: entry.profiles.into_values().collect(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let history_domains = history_domains let history_cleanup_profile_count = profiles
.into_values() .iter()
.map(|entry| HistoryDomainSummary { .filter(|profile| {
domain: entry.domain, let cleanup = &profile.history_cleanup;
visit_count: entry.visit_count, cleanup.history == CleanupFileStatus::Found
profile_ids: entry.profile_ids.into_iter().collect(), || cleanup.top_sites == CleanupFileStatus::Found
profiles: entry.profiles.into_values().collect(), || cleanup.visited_links == CleanupFileStatus::Found
|| cleanup.sessions == CleanupFileStatus::Found
}) })
.collect::<Vec<_>>(); .count();
Some(BrowserView { Some(BrowserView {
browser_id: config.id, browser_id: config.id,
@@ -117,13 +117,12 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
extension_count: extensions.len(), extension_count: extensions.len(),
bookmark_count: bookmarks.len(), bookmark_count: bookmarks.len(),
password_site_count: password_sites.len(), password_site_count: password_sites.len(),
history_domain_count: history_domains.len(), history_cleanup_profile_count,
}, },
profiles, profiles,
extensions: sort_extensions(extensions), extensions: sort_extensions(extensions),
bookmarks: sort_bookmarks(bookmarks), bookmarks: sort_bookmarks(bookmarks),
password_sites: sort_password_sites(password_sites), password_sites: sort_password_sites(password_sites),
history_domains: sort_history_domains(history_domains),
}) })
} }
@@ -189,6 +188,36 @@ fn build_profile_summary(
default_avatar_stroke_color, default_avatar_stroke_color,
avatar_label, avatar_label,
path: profile_path.display().to_string(), path: profile_path.display().to_string(),
history_cleanup: scan_history_cleanup_status(profile_path),
}
}
fn scan_history_cleanup_status(profile_path: &Path) -> HistoryCleanupSummary {
HistoryCleanupSummary {
history: cleanup_file_status(&profile_path.join("History")),
top_sites: cleanup_file_status(&profile_path.join("Top Sites")),
visited_links: cleanup_file_status(&profile_path.join("Visited Links")),
sessions: cleanup_sessions_status(&profile_path.join("Sessions")),
}
}
fn cleanup_file_status(path: &Path) -> CleanupFileStatus {
if path.is_file() {
CleanupFileStatus::Found
} else {
CleanupFileStatus::Missing
}
}
fn cleanup_sessions_status(path: &Path) -> CleanupFileStatus {
let Ok(entries) = path.read_dir() else {
return CleanupFileStatus::Missing;
};
if entries.flatten().any(|entry| entry.path().is_file()) {
CleanupFileStatus::Found
} else {
CleanupFileStatus::Missing
} }
} }
@@ -284,7 +313,7 @@ fn scan_extensions_for_profile(
entry entry
.profiles .profiles
.entry(profile.id.clone()) .entry(profile.id.clone())
.or_insert_with(|| AssociatedProfileSummary { .or_insert_with(|| ExtensionAssociatedProfileSummary {
id: profile.id.clone(), id: profile.id.clone(),
name: profile.name.clone(), name: profile.name.clone(),
avatar_data_url: profile.avatar_data_url.clone(), avatar_data_url: profile.avatar_data_url.clone(),
@@ -292,6 +321,7 @@ fn scan_extensions_for_profile(
default_avatar_fill_color: profile.default_avatar_fill_color, default_avatar_fill_color: profile.default_avatar_fill_color,
default_avatar_stroke_color: profile.default_avatar_stroke_color, default_avatar_stroke_color: profile.default_avatar_stroke_color,
avatar_label: profile.avatar_label.clone(), avatar_label: profile.avatar_label.clone(),
install_source: install_source.summary(),
}); });
} }
} }
@@ -331,6 +361,15 @@ enum ExtensionInstallSource {
ExternalAbsolute, ExternalAbsolute,
} }
impl ExtensionInstallSource {
fn summary(&self) -> ExtensionInstallSourceSummary {
match self {
ExtensionInstallSource::StoreRelative => ExtensionInstallSourceSummary::Store,
ExtensionInstallSource::ExternalAbsolute => ExtensionInstallSourceSummary::External,
}
}
}
fn resolve_extension_name(manifest: &Value, version_path: &Path) -> Option<String> { fn resolve_extension_name(manifest: &Value, version_path: &Path) -> Option<String> {
let raw_name = manifest.get("name").and_then(Value::as_str)?; let raw_name = manifest.get("name").and_then(Value::as_str)?;
if let Some(localized_name) = resolve_localized_manifest_value(raw_name, manifest, version_path) if let Some(localized_name) = resolve_localized_manifest_value(raw_name, manifest, version_path)
@@ -623,71 +662,6 @@ fn scan_password_sites_for_profile(
} }
} }
fn scan_history_domains_for_profile(
profile_path: &Path,
profile: &ProfileSummary,
history_domains: &mut BTreeMap<String, TempHistoryDomain>,
) {
let history_path = profile_path.join("History");
if !history_path.is_file() {
return;
}
let Some(temp_copy) = copy_sqlite_database_to_temp(&history_path) else {
return;
};
let Ok(connection) = Connection::open_with_flags(
temp_copy.path(),
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) else {
return;
};
let Ok(mut statement) = connection
.prepare("SELECT url, visit_count FROM urls WHERE hidden = 0 AND visit_count > 0")
else {
return;
};
let Ok(rows) = statement.query_map([], |row| {
Ok((row.get::<_, Option<String>>(0)?, row.get::<_, i64>(1)?))
}) else {
return;
};
for row in rows.flatten() {
let Some(url) = row.0.as_deref() else {
continue;
};
let Some(domain) = domain_from_url(url) else {
continue;
};
let entry = history_domains
.entry(domain.clone())
.or_insert_with(|| TempHistoryDomain {
domain: domain.clone(),
visit_count: 0,
profile_ids: BTreeSet::new(),
profiles: BTreeMap::new(),
});
entry.visit_count += row.1.max(0);
entry.profile_ids.insert(profile.id.clone());
entry
.profiles
.entry(profile.id.clone())
.or_insert_with(|| AssociatedProfileSummary {
id: profile.id.clone(),
name: profile.name.clone(),
avatar_data_url: profile.avatar_data_url.clone(),
avatar_icon: profile.avatar_icon.clone(),
default_avatar_fill_color: profile.default_avatar_fill_color,
default_avatar_stroke_color: profile.default_avatar_stroke_color,
avatar_label: profile.avatar_label.clone(),
});
}
}
fn normalize_login_site(origin_url: Option<&str>, signon_realm: Option<&str>) -> Option<String> { fn normalize_login_site(origin_url: Option<&str>, signon_realm: Option<&str>) -> Option<String> {
let candidate = [signon_realm, origin_url] let candidate = [signon_realm, origin_url]
.into_iter() .into_iter()
@@ -719,16 +693,3 @@ fn sort_password_sites(mut password_sites: Vec<PasswordSiteSummary>) -> Vec<Pass
}); });
password_sites password_sites
} }
fn sort_history_domains(
mut history_domains: Vec<HistoryDomainSummary>,
) -> Vec<HistoryDomainSummary> {
history_domains.sort_by(|left, right| {
right
.visit_count
.cmp(&left.visit_count)
.then_with(|| left.domain.to_lowercase().cmp(&right.domain.to_lowercase()))
.then_with(|| left.domain.cmp(&right.domain))
});
history_domains
}

View File

@@ -8,6 +8,14 @@ const {
activeSection, activeSection,
associatedProfilesModal, associatedProfilesModal,
bookmarkSortKey, bookmarkSortKey,
bookmarkDeleteBusy,
bookmarkModalSelectedProfileIds,
bookmarkRemovalConfirmBookmarkCount,
bookmarkRemovalConfirmProfileCount,
bookmarkRemovalError,
bookmarkRemovalResultOpen,
bookmarkRemovalResults,
bookmarkSelectedUrls,
browserConfigs, browserConfigs,
browserMonogram, browserMonogram,
browsers, browsers,
@@ -16,13 +24,38 @@ const {
configsLoading, configsLoading,
createConfigForm, createConfigForm,
createCustomBrowserConfig, createCustomBrowserConfig,
deleteBookmarkFromAllProfiles,
deleteBookmarkFromProfile,
cleanupHistoryError,
cleanupHistoryForProfile,
cleanupHistoryResults,
cleanupHistorySelectedProfiles,
cleanupSelectedHistoryProfiles,
closeHistoryCleanupConfirm,
closeHistoryCleanupResult,
confirmHistoryCleanup,
currentBrowser, currentBrowser,
deleteCustomBrowserConfig, deleteCustomBrowserConfig,
domainFromUrl, deleteSelectedBookmarkProfiles,
deleteSelectedBookmarks,
deleteExtensionFromAllProfiles,
deleteExtensionFromProfile,
deleteSelectedExtensionProfiles,
deleteSelectedExtensions,
error, error,
extensionMonogram, extensionMonogram,
extensionDeleteBusy,
extensionModalSelectedProfileIds,
extensionRemovalConfirmExtensions,
extensionRemovalConfirmProfiles,
extensionRemovalError,
extensionRemovalResultOpen,
extensionRemovalResults,
extensionSelectedIds,
extensionSortKey, extensionSortKey,
historyDomainSortKey, historyCleanupBusy,
historyCleanupConfirmProfiles,
historyCleanupResultOpen,
isDeletingConfig, isDeletingConfig,
isOpeningProfile, isOpeningProfile,
loading, loading,
@@ -37,15 +70,29 @@ const {
savingConfig, savingConfig,
sectionCount, sectionCount,
selectedBrowserId, selectedBrowserId,
closeBookmarkRemovalConfirm,
closeBookmarkRemovalResult,
showBookmarkProfilesModal, showBookmarkProfilesModal,
showExtensionProfilesModal, showExtensionProfilesModal,
showHistoryDomainProfilesModal,
showPasswordSiteProfilesModal, showPasswordSiteProfilesModal,
sortedBookmarks, sortedBookmarks,
sortedExtensions, sortedExtensions,
sortedHistoryDomains,
sortedPasswordSites, sortedPasswordSites,
sortedProfiles, sortedProfiles,
confirmBookmarkRemoval,
closeExtensionRemovalConfirm,
closeExtensionRemovalResult,
confirmExtensionRemoval,
toggleAllBookmarks,
toggleAllBookmarkModalProfiles,
toggleAllExtensions,
toggleBookmarkModalProfileSelection,
toggleBookmarkSelection,
toggleAllExtensionModalProfiles,
toggleExtensionModalProfileSelection,
toggleExtensionSelection,
toggleAllHistoryProfiles,
toggleHistoryProfile,
closeAssociatedProfilesModal, closeAssociatedProfilesModal,
} = useBrowserManager(); } = useBrowserManager();
</script> </script>
@@ -86,10 +133,26 @@ const {
</template> </template>
<template v-else-if="loading"> <template v-else-if="loading">
<section class="state-panel"> <section class="state-panel scanning-panel">
<div class="scan-hero" aria-hidden="true">
<div class="scan-orbit orbit-one"></div>
<div class="scan-orbit orbit-two"></div>
<div class="scan-core">
<div class="scan-core-ring"></div>
<div class="scan-core-ring secondary"></div>
<div class="scan-dot dot-one"></div>
<div class="scan-dot dot-two"></div>
<div class="scan-dot dot-three"></div>
</div>
</div>
<p class="eyebrow">Scanning</p> <p class="eyebrow">Scanning</p>
<h2>Reading local browser data</h2> <h2>Reading local browser data</h2>
<p>Profiles, installed extensions, and bookmarks are being collected.</p> <p>Profiles, extensions, bookmarks, and saved login sites are being collected.</p>
<div class="loading-steps" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</section> </section>
</template> </template>
@@ -109,29 +172,75 @@ const {
:extension-sort-key="extensionSortKey" :extension-sort-key="extensionSortKey"
:bookmark-sort-key="bookmarkSortKey" :bookmark-sort-key="bookmarkSortKey"
:password-site-sort-key="passwordSiteSortKey" :password-site-sort-key="passwordSiteSortKey"
:history-domain-sort-key="historyDomainSortKey"
:sorted-profiles="sortedProfiles" :sorted-profiles="sortedProfiles"
:sorted-extensions="sortedExtensions" :sorted-extensions="sortedExtensions"
:sorted-bookmarks="sortedBookmarks" :sorted-bookmarks="sortedBookmarks"
:sorted-password-sites="sortedPasswordSites" :sorted-password-sites="sortedPasswordSites"
:sorted-history-domains="sortedHistoryDomains" :history-selected-profile-ids="cleanupHistorySelectedProfiles"
:cleanup-history-busy="historyCleanupBusy"
:history-cleanup-confirm-profiles="historyCleanupConfirmProfiles"
:history-cleanup-result-open="historyCleanupResultOpen"
:cleanup-history-error="cleanupHistoryError"
:cleanup-history-results="cleanupHistoryResults"
:bookmark-selected-urls="bookmarkSelectedUrls"
:bookmark-modal-selected-profile-ids="bookmarkModalSelectedProfileIds"
:bookmark-delete-busy="bookmarkDeleteBusy"
:bookmark-removal-confirm-bookmark-count="bookmarkRemovalConfirmBookmarkCount"
:bookmark-removal-confirm-profile-count="bookmarkRemovalConfirmProfileCount"
:bookmark-removal-result-open="bookmarkRemovalResultOpen"
:bookmark-removal-error="bookmarkRemovalError"
:bookmark-removal-results="bookmarkRemovalResults"
:extension-selected-ids="extensionSelectedIds"
:extension-modal-selected-profile-ids="extensionModalSelectedProfileIds"
:extension-delete-busy="extensionDeleteBusy"
:extension-removal-confirm-extensions="extensionRemovalConfirmExtensions"
:extension-removal-confirm-profiles="extensionRemovalConfirmProfiles"
:extension-removal-result-open="extensionRemovalResultOpen"
:extension-removal-error="extensionRemovalError"
:extension-removal-results="extensionRemovalResults"
:open-profile-error="openProfileError" :open-profile-error="openProfileError"
:section-count="sectionCount" :section-count="sectionCount"
:is-opening-profile="isOpeningProfile" :is-opening-profile="isOpeningProfile"
:extension-monogram="extensionMonogram" :extension-monogram="extensionMonogram"
:domain-from-url="domainFromUrl"
:associated-profiles-modal="associatedProfilesModal" :associated-profiles-modal="associatedProfilesModal"
@update:active-section="activeSection = $event" @update:active-section="activeSection = $event"
@update:profile-sort-key="profileSortKey = $event" @update:profile-sort-key="profileSortKey = $event"
@update:extension-sort-key="extensionSortKey = $event" @update:extension-sort-key="extensionSortKey = $event"
@update:bookmark-sort-key="bookmarkSortKey = $event" @update:bookmark-sort-key="bookmarkSortKey = $event"
@update:password-site-sort-key="passwordSiteSortKey = $event" @update:password-site-sort-key="passwordSiteSortKey = $event"
@update:history-domain-sort-key="historyDomainSortKey = $event"
@open-profile="(browserId, profileId) => openBrowserProfile(browserId, profileId)" @open-profile="(browserId, profileId) => openBrowserProfile(browserId, profileId)"
@show-extension-profiles="showExtensionProfilesModal" @show-extension-profiles="showExtensionProfilesModal"
@show-bookmark-profiles="showBookmarkProfilesModal" @show-bookmark-profiles="showBookmarkProfilesModal"
@show-password-site-profiles="showPasswordSiteProfilesModal" @show-password-site-profiles="showPasswordSiteProfilesModal"
@show-history-domain-profiles="showHistoryDomainProfilesModal" @toggle-bookmark-selection="toggleBookmarkSelection"
@toggle-all-bookmarks="toggleAllBookmarks"
@delete-bookmark-from-all-profiles="deleteBookmarkFromAllProfiles"
@delete-selected-bookmarks="deleteSelectedBookmarks"
@toggle-bookmark-modal-profile-selection="toggleBookmarkModalProfileSelection"
@toggle-all-bookmark-modal-profiles="toggleAllBookmarkModalProfiles"
@delete-bookmark-from-profile="deleteBookmarkFromProfile"
@delete-selected-bookmark-profiles="deleteSelectedBookmarkProfiles"
@confirm-bookmark-removal="confirmBookmarkRemoval"
@close-bookmark-removal-confirm="closeBookmarkRemovalConfirm"
@close-bookmark-removal-result="closeBookmarkRemovalResult"
@toggle-extension-selection="toggleExtensionSelection"
@toggle-all-extensions="toggleAllExtensions"
@delete-extension-from-all-profiles="deleteExtensionFromAllProfiles"
@delete-selected-extensions="deleteSelectedExtensions"
@toggle-extension-modal-profile-selection="toggleExtensionModalProfileSelection"
@toggle-all-extension-modal-profiles="toggleAllExtensionModalProfiles"
@delete-extension-from-profile="deleteExtensionFromProfile"
@delete-selected-extension-profiles="deleteSelectedExtensionProfiles"
@confirm-extension-removal="confirmExtensionRemoval"
@close-extension-removal-confirm="closeExtensionRemovalConfirm"
@close-extension-removal-result="closeExtensionRemovalResult"
@toggle-history-profile="toggleHistoryProfile"
@toggle-all-history-profiles="toggleAllHistoryProfiles"
@cleanup-selected-history="cleanupSelectedHistoryProfiles"
@cleanup-history-for-profile="cleanupHistoryForProfile"
@confirm-history-cleanup="confirmHistoryCleanup"
@close-history-cleanup-confirm="closeHistoryCleanupConfirm"
@close-history-cleanup-result="closeHistoryCleanupResult"
@close-associated-profiles="closeAssociatedProfilesModal" @close-associated-profiles="closeAssociatedProfilesModal"
/> />

View File

@@ -4,11 +4,15 @@ import type {
AssociatedProfileSortKey, AssociatedProfileSortKey,
AssociatedProfileSummary, AssociatedProfileSummary,
BookmarkAssociatedProfileSummary, BookmarkAssociatedProfileSummary,
ExtensionAssociatedProfileSummary,
} from "../../types/browser"; } from "../../types/browser";
import { profileAvatarSrc } from "../../utils/icons"; import { profileAvatarSrc } from "../../utils/icons";
import { sortAssociatedProfiles } from "../../utils/sort"; import { sortAssociatedProfiles } from "../../utils/sort";
type ModalProfile = AssociatedProfileSummary | BookmarkAssociatedProfileSummary; type ModalProfile =
| AssociatedProfileSummary
| BookmarkAssociatedProfileSummary
| ExtensionAssociatedProfileSummary;
const props = defineProps<{ const props = defineProps<{
title: string; title: string;
@@ -16,20 +20,42 @@ const props = defineProps<{
browserId: string; browserId: string;
browserFamilyId: string | null; browserFamilyId: string | null;
isBookmark: boolean; isBookmark: boolean;
isExtension?: boolean;
selectedProfileIds?: string[];
deleteBusy?: boolean;
isOpeningProfile: (browserId: string, profileId: string) => boolean; isOpeningProfile: (browserId: string, profileId: string) => boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
close: []; close: [];
openProfile: [browserId: string, profileId: string]; openProfile: [browserId: string, profileId: string];
toggleProfileSelection: [profileId: string];
toggleAllProfileSelection: [];
deleteProfile: [profileId: string];
deleteSelectedProfiles: [];
}>(); }>();
const sortKey = ref<AssociatedProfileSortKey>("id"); const sortKey = ref<AssociatedProfileSortKey>("id");
const sortedProfiles = computed(() => sortAssociatedProfiles(props.profiles, sortKey.value)); const sortedProfiles = computed(() => sortAssociatedProfiles(props.profiles, sortKey.value));
const selectedProfileIds = computed(() => props.selectedProfileIds ?? []);
const allSelected = computed(
() =>
sortedProfiles.value.length > 0 &&
sortedProfiles.value.every((profile) => selectedProfileIds.value.includes(profile.id)),
);
const isSelectableMode = computed(() => props.isExtension || props.isBookmark);
function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedProfileSummary { function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedProfileSummary {
return "bookmarkPath" in profile; return "bookmarkPath" in profile;
} }
function hasInstallSource(profile: ModalProfile): profile is ExtensionAssociatedProfileSummary {
return "installSource" in profile;
}
function isSelected(profileId: string) {
return selectedProfileIds.value.includes(profileId);
}
</script> </script>
<template> <template>
@@ -43,10 +69,46 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
</div> </div>
<div class="modal-table"> <div class="modal-table">
<div class="modal-table-header modal-grid" :class="{ bookmark: isBookmark }"> <div v-if="isSelectableMode" class="modal-toolbar">
<label class="toolbar-checkbox" :class="{ disabled: !sortedProfiles.length }">
<input
type="checkbox"
class="native-checkbox"
:checked="allSelected"
:disabled="!sortedProfiles.length || deleteBusy"
@change="emit('toggleAllProfileSelection')"
/>
<span class="custom-checkbox" :class="{ checked: allSelected }" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M3.5 8.2L6.4 11.1L12.5 4.9" />
</svg>
</span>
<span>Select All</span>
</label>
<button
class="danger-button"
type="button"
:disabled="!selectedProfileIds.length || deleteBusy"
@click="emit('deleteSelectedProfiles')"
>
{{ deleteBusy ? "Deleting..." : `Delete Selected (${selectedProfileIds.length})` }}
</button>
</div>
<div class="modal-table-header modal-grid" :class="{ bookmark: isBookmark, extension: isExtension }">
<div v-if="isSelectableMode" class="header-cell checkbox-cell">Pick</div>
<div class="header-cell icon-cell">Avatar</div> <div class="header-cell icon-cell">Avatar</div>
<button class="header-cell sortable" :class="{ active: sortKey === 'name' }" type="button" @click="sortKey = 'name'">Name</button> <button class="header-cell sortable" :class="{ active: sortKey === 'name' }" type="button" @click="sortKey = 'name'">Name</button>
<button class="header-cell sortable" :class="{ active: sortKey === 'id' }" type="button" @click="sortKey = 'id'">Profile ID</button> <button
v-if="!isExtension && !isBookmark"
class="header-cell sortable"
:class="{ active: sortKey === 'id' }"
type="button"
@click="sortKey = 'id'"
>
Profile ID
</button>
<div v-if="isExtension" class="header-cell">Source</div>
<div v-if="isBookmark" class="header-cell">Bookmark Path</div> <div v-if="isBookmark" class="header-cell">Bookmark Path</div>
<div class="header-cell actions-cell">Action</div> <div class="header-cell actions-cell">Action</div>
</div> </div>
@@ -55,8 +117,24 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
v-for="profile in sortedProfiles" v-for="profile in sortedProfiles"
:key="profile.id" :key="profile.id"
class="modal-table-row modal-grid" class="modal-table-row modal-grid"
:class="{ bookmark: isBookmark }" :class="{ bookmark: isBookmark, extension: isExtension }"
> >
<div v-if="isSelectableMode" class="row-cell checkbox-cell">
<label class="table-checkbox" :class="{ disabled: deleteBusy }">
<input
type="checkbox"
class="native-checkbox"
:checked="isSelected(profile.id)"
:disabled="deleteBusy"
@change="emit('toggleProfileSelection', profile.id)"
/>
<span class="custom-checkbox" :class="{ checked: isSelected(profile.id) }" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M3.5 8.2L6.4 11.1L12.5 4.9" />
</svg>
</span>
</label>
</div>
<div class="modal-profile-avatar"> <div class="modal-profile-avatar">
<img <img
v-if="profileAvatarSrc(profile, browserFamilyId)" v-if="profileAvatarSrc(profile, browserFamilyId)"
@@ -67,10 +145,14 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
</div> </div>
<div class="row-cell primary-cell"> <div class="row-cell primary-cell">
<strong>{{ profile.name }}</strong> <strong>{{ profile.name }}</strong>
<span v-if="isExtension || isBookmark" class="subtle-line">{{ profile.id }}</span>
</div> </div>
<div class="row-cell"> <div v-if="!isExtension && !isBookmark" class="row-cell">
<span class="badge neutral">{{ profile.id }}</span> <span class="badge neutral">{{ profile.id }}</span>
</div> </div>
<div v-if="isExtension && hasInstallSource(profile)" class="row-cell muted-cell">
{{ profile.installSource === "store" ? "Store" : "External" }}
</div>
<div <div
v-if="isBookmark && hasBookmarkPath(profile)" v-if="isBookmark && hasBookmarkPath(profile)"
class="row-cell muted-cell" class="row-cell muted-cell"
@@ -87,6 +169,15 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
> >
{{ isOpeningProfile(browserId, profile.id) ? "Opening..." : "Open" }} {{ isOpeningProfile(browserId, profile.id) ? "Opening..." : "Open" }}
</button> </button>
<button
v-if="isSelectableMode"
class="danger-button inline-danger-button"
type="button"
:disabled="deleteBusy"
@click="emit('deleteProfile', profile.id)"
>
Delete
</button>
</div> </div>
</article> </article>
</div> </div>
@@ -152,11 +243,25 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
} }
.modal-grid.bookmark { .modal-grid.bookmark {
grid-template-columns: 56px minmax(140px, 0.9fr) 120px minmax(180px, 1fr) 110px; grid-template-columns: 52px 56px minmax(180px, 0.78fr) minmax(180px, 1fr) 168px;
}
.modal-table-header.modal-grid.extension,
.modal-table-row.modal-grid.extension {
grid-template-columns: 52px 56px minmax(180px, 0.78fr) minmax(180px, 1fr) 168px;
}
.modal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px 8px;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
} }
.modal-table-header { .modal-table-header {
padding: 10px 24px 10px 14px; padding: 8px 24px 8px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14); border-bottom: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.94); background: rgba(248, 250, 252, 0.94);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
@@ -187,8 +292,78 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
color: var(--text); color: var(--text);
} }
.toolbar-checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-size: 0.88rem;
cursor: pointer;
}
.toolbar-checkbox.disabled {
opacity: 0.55;
cursor: default;
}
.native-checkbox {
position: absolute;
opacity: 0;
pointer-events: none;
}
.table-checkbox {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.table-checkbox.disabled {
cursor: default;
opacity: 0.5;
}
.custom-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 7px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(241, 245, 249, 0.92));
}
.custom-checkbox svg {
width: 12px;
height: 12px;
}
.custom-checkbox path {
fill: none;
stroke: #fff;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
.custom-checkbox.checked {
border-color: rgba(47, 111, 237, 0.2);
background: linear-gradient(135deg, #2f6fed, #5aa1f7);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
0 8px 18px rgba(47, 111, 237, 0.22);
}
.custom-checkbox.checked path {
opacity: 1;
}
.modal-table-row { .modal-table-row {
padding: 12px 14px; padding: 10px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.12); border-bottom: 1px solid rgba(148, 163, 184, 0.12);
} }
@@ -230,6 +405,13 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
line-height: 1.3; line-height: 1.3;
} }
.subtle-line {
display: block;
margin-top: 3px;
color: var(--muted-soft);
font-size: 0.8rem;
}
.muted-cell { .muted-cell {
color: var(--muted); color: var(--muted);
font-size: 0.86rem; font-size: 0.86rem;
@@ -241,24 +423,40 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
.actions-cell { .actions-cell {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px;
} }
.icon-cell { .icon-cell {
padding-left: 4px; padding-left: 4px;
} }
.checkbox-cell {
display: flex;
justify-content: center;
}
.inline-danger-button {
padding: 6px 10px;
border-radius: 10px;
font-size: 0.8rem;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.modal-backdrop { .modal-backdrop {
padding: 12px; padding: 12px;
} }
.modal-grid, .modal-grid,
.modal-grid.bookmark { .modal-grid.bookmark,
grid-template-columns: 56px minmax(0, 1fr) 96px; .modal-table-header.modal-grid.extension,
.modal-table-row.modal-grid.extension {
grid-template-columns: 56px minmax(0, 1fr) 120px;
} }
.modal-grid > :nth-child(4), .modal-grid > :nth-child(4),
.modal-grid.bookmark > :nth-child(4) { .modal-grid.bookmark > :nth-child(4),
.modal-table-header.modal-grid.extension > :nth-child(4),
.modal-table-row.modal-grid.extension > :nth-child(4) {
display: none; display: none;
} }
} }

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { computed } from "vue";
import type { RemoveBookmarkResult } from "../../types/browser";
const props = defineProps<{
mode: "confirm" | "result";
title: string;
bookmarkCount: number;
profileCount: number;
results: RemoveBookmarkResult[];
busy?: boolean;
generalError?: string;
}>();
const emit = defineEmits<{
close: [];
confirm: [];
}>();
const resultSummary = computed(() => {
const statusByUrl = new Map<string, boolean>();
for (const result of props.results) {
const previous = statusByUrl.get(result.url);
const succeeded = !result.error;
if (previous === undefined) {
statusByUrl.set(result.url, succeeded);
continue;
}
statusByUrl.set(result.url, previous && succeeded);
}
let successCount = 0;
let failedCount = 0;
for (const succeeded of statusByUrl.values()) {
if (succeeded) {
successCount += 1;
} else {
failedCount += 1;
}
}
return { successCount, failedCount };
});
</script>
<template>
<div class="modal-backdrop" @click.self="emit('close')">
<section class="modal-card">
<div class="modal-header">
<h3>{{ title }}</h3>
<button class="secondary-button" type="button" @click="emit('close')">Close</button>
</div>
<template v-if="mode === 'confirm'">
<p class="modal-copy">
This will remove {{ bookmarkCount }} bookmark{{ bookmarkCount === 1 ? "" : "s" }} from
{{ profileCount }} profile{{ profileCount === 1 ? "" : "s" }}.
</p>
<div class="modal-actions">
<button class="secondary-button" type="button" @click="emit('close')">Cancel</button>
<button class="danger-button" type="button" :disabled="busy" @click="emit('confirm')">
{{ busy ? "Deleting..." : "Confirm Delete" }}
</button>
</div>
</template>
<template v-else>
<p v-if="generalError" class="result-banner error">{{ generalError }}</p>
<p class="modal-copy">
Successfully removed {{ resultSummary.successCount }} bookmark{{
resultSummary.successCount === 1 ? "" : "s"
}}. Failed to remove {{ resultSummary.failedCount }} bookmark{{
resultSummary.failedCount === 1 ? "" : "s"
}}.
</p>
<div class="modal-actions">
<button class="primary-button" type="button" @click="emit('close')">Close</button>
</div>
</template>
</section>
</div>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.26);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.modal-card {
width: min(560px, 100%);
max-height: min(72vh, 820px);
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid var(--panel-border);
border-radius: 22px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.18);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modal-header h3,
.modal-copy {
margin: 0;
}
.modal-copy {
color: var(--muted);
line-height: 1.55;
}
.result-banner {
margin: 0;
padding: 12px 14px;
border-radius: 14px;
font-size: 0.9rem;
}
.result-banner.error {
background: rgba(254, 242, 242, 0.96);
color: #b42318;
border: 1px solid rgba(239, 68, 68, 0.18);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -1,29 +1,90 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import type { BookmarkSortKey, BookmarkSummary } from "../../types/browser"; import type { BookmarkSortKey, BookmarkSummary } from "../../types/browser";
defineProps<{ const props = defineProps<{
bookmarks: BookmarkSummary[]; bookmarks: BookmarkSummary[];
sortKey: BookmarkSortKey; sortKey: BookmarkSortKey;
selectedBookmarkUrls: string[];
deleteBusy: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
"update:sortKey": [value: BookmarkSortKey]; "update:sortKey": [value: BookmarkSortKey];
showProfiles: [url: string]; showProfiles: [url: string];
toggleBookmark: [url: string];
toggleAllBookmarks: [];
deleteBookmark: [url: string];
deleteSelected: [];
}>(); }>();
const allSelected = computed(
() =>
props.bookmarks.length > 0 &&
props.bookmarks.every((bookmark) => props.selectedBookmarkUrls.includes(bookmark.url)),
);
function isSelected(url: string) {
return props.selectedBookmarkUrls.includes(url);
}
</script> </script>
<template> <template>
<section class="table-section"> <section class="table-section">
<div v-if="bookmarks.length" class="data-table"> <div v-if="bookmarks.length" class="data-table">
<div class="bookmarks-toolbar">
<label class="toolbar-checkbox" :class="{ disabled: !bookmarks.length }">
<input
type="checkbox"
class="native-checkbox"
:checked="allSelected"
:disabled="!bookmarks.length || deleteBusy"
@change="emit('toggleAllBookmarks')"
/>
<span class="custom-checkbox" :class="{ checked: allSelected }" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M3.5 8.2L6.4 11.1L12.5 4.9" />
</svg>
</span>
<span>Select All</span>
</label>
<button
class="danger-button"
type="button"
:disabled="!selectedBookmarkUrls.length || deleteBusy"
@click="emit('deleteSelected')"
>
{{ deleteBusy ? "Deleting..." : `Delete Selected (${selectedBookmarkUrls.length})` }}
</button>
</div>
<div class="data-table-header bookmarks-grid"> <div class="data-table-header bookmarks-grid">
<div class="header-cell checkbox-cell">Pick</div>
<button class="header-cell sortable" :class="{ active: sortKey === 'title' }" type="button" @click="emit('update:sortKey', 'title')">Name</button> <button class="header-cell sortable" :class="{ active: sortKey === 'title' }" type="button" @click="emit('update:sortKey', 'title')">Name</button>
<button class="header-cell sortable" :class="{ active: sortKey === 'url' }" type="button" @click="emit('update:sortKey', 'url')">URL</button> <button class="header-cell sortable" :class="{ active: sortKey === 'url' }" type="button" @click="emit('update:sortKey', 'url')">URL</button>
<div class="header-cell actions-cell">Profiles</div> <div class="header-cell actions-cell">Actions</div>
</div> </div>
<div class="data-table-body styled-scrollbar"> <div class="data-table-body styled-scrollbar">
<article v-for="bookmark in bookmarks" :key="bookmark.url" class="data-table-row bookmarks-grid"> <article v-for="bookmark in bookmarks" :key="bookmark.url" class="data-table-row bookmarks-grid">
<div class="row-cell checkbox-cell">
<label class="table-checkbox" :class="{ disabled: deleteBusy }">
<input
type="checkbox"
class="native-checkbox"
:checked="isSelected(bookmark.url)"
:disabled="deleteBusy"
@change="emit('toggleBookmark', bookmark.url)"
/>
<span class="custom-checkbox" :class="{ checked: isSelected(bookmark.url) }" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M3.5 8.2L6.4 11.1L12.5 4.9" />
</svg>
</span>
</label>
</div>
<div class="row-cell primary-cell"> <div class="row-cell primary-cell">
<strong>{{ bookmark.title }}</strong> <strong :title="bookmark.title">{{ bookmark.title }}</strong>
</div> </div>
<div class="row-cell muted-cell" :title="bookmark.url">{{ bookmark.url }}</div> <div class="row-cell muted-cell" :title="bookmark.url">{{ bookmark.url }}</div>
<div class="row-cell actions-cell"> <div class="row-cell actions-cell">
@@ -31,6 +92,14 @@ const emit = defineEmits<{
<span>View</span> <span>View</span>
<span class="badge neutral">{{ bookmark.profileIds.length }}</span> <span class="badge neutral">{{ bookmark.profileIds.length }}</span>
</button> </button>
<button
class="danger-button inline-danger-button"
type="button"
:disabled="deleteBusy"
@click="emit('deleteBookmark', bookmark.url)"
>
Delete
</button>
</div> </div>
</article> </article>
</div> </div>
@@ -48,6 +117,15 @@ const emit = defineEmits<{
min-height: 0; min-height: 0;
} }
.bookmarks-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px 8px;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.data-table { .data-table {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -68,7 +146,7 @@ const emit = defineEmits<{
.bookmarks-grid { .bookmarks-grid {
display: grid; display: grid;
grid-template-columns: minmax(180px, 0.9fr) minmax(260px, 1.2fr) 154px; grid-template-columns: 52px minmax(180px, 0.9fr) minmax(260px, 1.2fr) 250px;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
} }
@@ -77,7 +155,7 @@ const emit = defineEmits<{
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; z-index: 2;
padding: 10px 24px 10px 14px; padding: 8px 24px 8px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14); border-bottom: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.94); background: rgba(248, 250, 252, 0.94);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
@@ -102,8 +180,81 @@ const emit = defineEmits<{
color: var(--text); color: var(--text);
} }
.toolbar-checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-size: 0.88rem;
cursor: pointer;
}
.toolbar-checkbox.disabled {
opacity: 0.55;
cursor: default;
}
.native-checkbox {
position: absolute;
opacity: 0;
pointer-events: none;
}
.table-checkbox {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.table-checkbox.disabled {
cursor: default;
opacity: 0.5;
}
.custom-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 7px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(241, 245, 249, 0.92));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
0 4px 10px rgba(15, 23, 42, 0.06);
}
.custom-checkbox svg {
width: 12px;
height: 12px;
}
.custom-checkbox path {
fill: none;
stroke: #fff;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
.custom-checkbox.checked {
border-color: rgba(47, 111, 237, 0.2);
background: linear-gradient(135deg, #2f6fed, #5aa1f7);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
0 8px 18px rgba(47, 111, 237, 0.22);
}
.custom-checkbox.checked path {
opacity: 1;
}
.data-table-row { .data-table-row {
padding: 12px 14px; padding: 10px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.12); border-bottom: 1px solid rgba(148, 163, 184, 0.12);
} }
@@ -123,6 +274,9 @@ const emit = defineEmits<{
display: block; display: block;
font-size: 0.93rem; font-size: 0.93rem;
line-height: 1.3; line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.muted-cell { .muted-cell {
@@ -150,20 +304,32 @@ const emit = defineEmits<{
.actions-cell { .actions-cell {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px;
}
.checkbox-cell {
display: flex;
justify-content: center;
}
.inline-danger-button {
padding: 6px 10px;
border-radius: 10px;
font-size: 0.8rem;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.bookmarks-grid { .bookmarks-grid {
grid-template-columns: minmax(160px, 0.9fr) minmax(200px, 1fr) 148px; grid-template-columns: 52px minmax(160px, 0.9fr) minmax(200px, 1fr) 236px;
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.bookmarks-grid { .bookmarks-grid {
grid-template-columns: minmax(0, 1fr) 132px; grid-template-columns: 52px minmax(0, 1fr) 152px;
} }
.bookmarks-grid > :nth-child(2) { .bookmarks-grid > :nth-child(3) {
display: none; display: none;
} }
} }

View File

@@ -6,14 +6,20 @@ import type {
BookmarkSortKey, BookmarkSortKey,
BrowserView, BrowserView,
ExtensionSortKey, ExtensionSortKey,
HistoryDomainSortKey, CleanupHistoryResult,
ExtensionAssociatedProfileSummary,
RemoveBookmarkResult,
PasswordSiteSortKey, PasswordSiteSortKey,
ProfileSortKey, ProfileSortKey,
RemoveExtensionResult,
} from "../../types/browser"; } from "../../types/browser";
import AssociatedProfilesModal from "./AssociatedProfilesModal.vue"; import AssociatedProfilesModal from "./AssociatedProfilesModal.vue";
import BookmarkRemovalModal from "./BookmarkRemovalModal.vue";
import BookmarksList from "./BookmarksList.vue"; import BookmarksList from "./BookmarksList.vue";
import ExtensionRemovalModal from "./ExtensionRemovalModal.vue";
import ExtensionsList from "./ExtensionsList.vue"; import ExtensionsList from "./ExtensionsList.vue";
import HistoryDomainsList from "./HistoryDomainsList.vue"; import HistoryCleanupList from "./HistoryCleanupList.vue";
import HistoryCleanupModal from "./HistoryCleanupModal.vue";
import PasswordSitesList from "./PasswordSitesList.vue"; import PasswordSitesList from "./PasswordSitesList.vue";
import ProfilesList from "./ProfilesList.vue"; import ProfilesList from "./ProfilesList.vue";
@@ -24,22 +30,48 @@ defineProps<{
extensionSortKey: ExtensionSortKey; extensionSortKey: ExtensionSortKey;
bookmarkSortKey: BookmarkSortKey; bookmarkSortKey: BookmarkSortKey;
passwordSiteSortKey: PasswordSiteSortKey; passwordSiteSortKey: PasswordSiteSortKey;
historyDomainSortKey: HistoryDomainSortKey;
sortedProfiles: BrowserView["profiles"]; sortedProfiles: BrowserView["profiles"];
sortedExtensions: BrowserView["extensions"]; sortedExtensions: BrowserView["extensions"];
sortedBookmarks: BrowserView["bookmarks"]; sortedBookmarks: BrowserView["bookmarks"];
sortedPasswordSites: BrowserView["passwordSites"]; sortedPasswordSites: BrowserView["passwordSites"];
sortedHistoryDomains: BrowserView["historyDomains"]; historySelectedProfileIds: string[];
cleanupHistoryBusy: boolean;
historyCleanupConfirmProfiles: BrowserView["profiles"];
historyCleanupResultOpen: boolean;
cleanupHistoryError: string;
cleanupHistoryResults: CleanupHistoryResult[];
bookmarkSelectedUrls: string[];
bookmarkModalSelectedProfileIds: string[];
bookmarkDeleteBusy: boolean;
bookmarkRemovalConfirmBookmarkCount: number;
bookmarkRemovalConfirmProfileCount: number;
bookmarkRemovalResultOpen: boolean;
bookmarkRemovalError: string;
bookmarkRemovalResults: RemoveBookmarkResult[];
extensionSelectedIds: string[];
extensionModalSelectedProfileIds: string[];
extensionDeleteBusy: boolean;
extensionRemovalConfirmExtensions: BrowserView["extensions"];
extensionRemovalConfirmProfiles: BrowserView["profiles"];
extensionRemovalResultOpen: boolean;
extensionRemovalError: string;
extensionRemovalResults: RemoveExtensionResult[];
openProfileError: string; openProfileError: string;
sectionCount: (section: ActiveSection) => number; sectionCount: (section: ActiveSection) => number;
isOpeningProfile: (browserId: string, profileId: string) => boolean; isOpeningProfile: (browserId: string, profileId: string) => boolean;
extensionMonogram: (name: string) => string; extensionMonogram: (name: string) => string;
domainFromUrl: (url: string) => string;
associatedProfilesModal: { associatedProfilesModal: {
title: string; title: string;
browserId: string; browserId: string;
profiles: (AssociatedProfileSummary | BookmarkAssociatedProfileSummary)[]; profiles: (
| AssociatedProfileSummary
| BookmarkAssociatedProfileSummary
| ExtensionAssociatedProfileSummary
)[];
isBookmark: boolean; isBookmark: boolean;
isExtension?: boolean;
extensionId?: string;
bookmarkUrl?: string;
} | null; } | null;
}>(); }>();
@@ -49,12 +81,39 @@ const emit = defineEmits<{
"update:extensionSortKey": [value: ExtensionSortKey]; "update:extensionSortKey": [value: ExtensionSortKey];
"update:bookmarkSortKey": [value: BookmarkSortKey]; "update:bookmarkSortKey": [value: BookmarkSortKey];
"update:passwordSiteSortKey": [value: PasswordSiteSortKey]; "update:passwordSiteSortKey": [value: PasswordSiteSortKey];
"update:historyDomainSortKey": [value: HistoryDomainSortKey];
openProfile: [browserId: string, profileId: string]; openProfile: [browserId: string, profileId: string];
showExtensionProfiles: [extensionId: string]; showExtensionProfiles: [extensionId: string];
showBookmarkProfiles: [url: string]; showBookmarkProfiles: [url: string];
showPasswordSiteProfiles: [url: string]; showPasswordSiteProfiles: [url: string];
showHistoryDomainProfiles: [domain: string]; toggleBookmarkSelection: [url: string];
toggleAllBookmarks: [];
deleteBookmarkFromAllProfiles: [url: string];
deleteSelectedBookmarks: [];
toggleBookmarkModalProfileSelection: [profileId: string];
toggleAllBookmarkModalProfiles: [];
deleteBookmarkFromProfile: [profileId: string];
deleteSelectedBookmarkProfiles: [];
confirmBookmarkRemoval: [];
closeBookmarkRemovalConfirm: [];
closeBookmarkRemovalResult: [];
toggleExtensionSelection: [extensionId: string];
toggleAllExtensions: [];
deleteExtensionFromAllProfiles: [extensionId: string];
deleteSelectedExtensions: [];
toggleExtensionModalProfileSelection: [profileId: string];
toggleAllExtensionModalProfiles: [];
deleteExtensionFromProfile: [profileId: string];
deleteSelectedExtensionProfiles: [];
confirmExtensionRemoval: [];
closeExtensionRemovalConfirm: [];
closeExtensionRemovalResult: [];
toggleHistoryProfile: [profileId: string];
toggleAllHistoryProfiles: [];
cleanupSelectedHistory: [];
cleanupHistoryForProfile: [profileId: string];
confirmHistoryCleanup: [];
closeHistoryCleanupConfirm: [];
closeHistoryCleanupResult: [];
closeAssociatedProfiles: []; closeAssociatedProfiles: [];
}>(); }>();
</script> </script>
@@ -126,16 +185,28 @@ const emit = defineEmits<{
:extensions="sortedExtensions" :extensions="sortedExtensions"
:sort-key="extensionSortKey" :sort-key="extensionSortKey"
:extension-monogram="extensionMonogram" :extension-monogram="extensionMonogram"
:selected-extension-ids="extensionSelectedIds"
:delete-busy="extensionDeleteBusy"
@update:sort-key="emit('update:extensionSortKey', $event)" @update:sort-key="emit('update:extensionSortKey', $event)"
@show-profiles="emit('showExtensionProfiles', $event)" @show-profiles="emit('showExtensionProfiles', $event)"
@toggle-extension="emit('toggleExtensionSelection', $event)"
@toggle-all-extensions="emit('toggleAllExtensions')"
@delete-extension="emit('deleteExtensionFromAllProfiles', $event)"
@delete-selected="emit('deleteSelectedExtensions')"
/> />
<BookmarksList <BookmarksList
v-else-if="activeSection === 'bookmarks'" v-else-if="activeSection === 'bookmarks'"
:bookmarks="sortedBookmarks" :bookmarks="sortedBookmarks"
:sort-key="bookmarkSortKey" :sort-key="bookmarkSortKey"
:selected-bookmark-urls="bookmarkSelectedUrls"
:delete-busy="bookmarkDeleteBusy"
@update:sort-key="emit('update:bookmarkSortKey', $event)" @update:sort-key="emit('update:bookmarkSortKey', $event)"
@show-profiles="emit('showBookmarkProfiles', $event)" @show-profiles="emit('showBookmarkProfiles', $event)"
@toggle-bookmark="emit('toggleBookmarkSelection', $event)"
@toggle-all-bookmarks="emit('toggleAllBookmarks')"
@delete-bookmark="emit('deleteBookmarkFromAllProfiles', $event)"
@delete-selected="emit('deleteSelectedBookmarks')"
/> />
<PasswordSitesList <PasswordSitesList
@@ -146,15 +217,86 @@ const emit = defineEmits<{
@show-profiles="emit('showPasswordSiteProfiles', $event)" @show-profiles="emit('showPasswordSiteProfiles', $event)"
/> />
<HistoryDomainsList <HistoryCleanupList
v-else v-else
:history-domains="sortedHistoryDomains" :browser-family-id="currentBrowser.browserFamilyId"
:sort-key="historyDomainSortKey" :profiles="sortedProfiles"
@update:sort-key="emit('update:historyDomainSortKey', $event)" :selected-profile-ids="historySelectedProfileIds"
@show-profiles="emit('showHistoryDomainProfiles', $event)" :cleanup-busy="cleanupHistoryBusy"
@toggle-profile="emit('toggleHistoryProfile', $event)"
@toggle-all-profiles="emit('toggleAllHistoryProfiles')"
@cleanup-selected="emit('cleanupSelectedHistory')"
@cleanup-profile="emit('cleanupHistoryForProfile', $event)"
/> />
</div> </div>
<HistoryCleanupModal
v-if="historyCleanupConfirmProfiles.length"
mode="confirm"
title="Confirm History Cleanup"
:profiles="historyCleanupConfirmProfiles"
:results="[]"
:busy="cleanupHistoryBusy"
@close="emit('closeHistoryCleanupConfirm')"
@confirm="emit('confirmHistoryCleanup')"
/>
<HistoryCleanupModal
v-if="historyCleanupResultOpen"
mode="result"
title="Cleanup Result"
:profiles="[]"
:results="cleanupHistoryResults"
:general-error="cleanupHistoryError"
@close="emit('closeHistoryCleanupResult')"
/>
<BookmarkRemovalModal
v-if="bookmarkRemovalConfirmBookmarkCount > 0"
mode="confirm"
title="Confirm Bookmark Removal"
:bookmark-count="bookmarkRemovalConfirmBookmarkCount"
:profile-count="bookmarkRemovalConfirmProfileCount"
:results="[]"
:busy="bookmarkDeleteBusy"
@close="emit('closeBookmarkRemovalConfirm')"
@confirm="emit('confirmBookmarkRemoval')"
/>
<BookmarkRemovalModal
v-if="bookmarkRemovalResultOpen"
mode="result"
title="Bookmark Removal Result"
:bookmark-count="0"
:profile-count="0"
:results="bookmarkRemovalResults"
:general-error="bookmarkRemovalError"
@close="emit('closeBookmarkRemovalResult')"
/>
<ExtensionRemovalModal
v-if="extensionRemovalConfirmExtensions.length || extensionRemovalConfirmProfiles.length"
mode="confirm"
title="Confirm Extension Removal"
:extensions="extensionRemovalConfirmExtensions"
:profiles="extensionRemovalConfirmProfiles"
:results="[]"
:busy="extensionDeleteBusy"
@close="emit('closeExtensionRemovalConfirm')"
@confirm="emit('confirmExtensionRemoval')"
/>
<ExtensionRemovalModal
v-if="extensionRemovalResultOpen"
mode="result"
title="Extension Removal Result"
:extensions="[]"
:profiles="[]"
:results="extensionRemovalResults"
:general-error="extensionRemovalError"
@close="emit('closeExtensionRemovalResult')"
/>
<AssociatedProfilesModal <AssociatedProfilesModal
v-if="associatedProfilesModal" v-if="associatedProfilesModal"
:title="associatedProfilesModal.title" :title="associatedProfilesModal.title"
@@ -162,9 +304,36 @@ const emit = defineEmits<{
:browser-id="associatedProfilesModal.browserId" :browser-id="associatedProfilesModal.browserId"
:browser-family-id="currentBrowser.browserFamilyId" :browser-family-id="currentBrowser.browserFamilyId"
:is-bookmark="associatedProfilesModal.isBookmark" :is-bookmark="associatedProfilesModal.isBookmark"
:is-extension="associatedProfilesModal.isExtension"
:selected-profile-ids="
associatedProfilesModal.isExtension
? extensionModalSelectedProfileIds
: bookmarkModalSelectedProfileIds
"
:delete-busy="associatedProfilesModal.isExtension ? extensionDeleteBusy : bookmarkDeleteBusy"
:is-opening-profile="isOpeningProfile" :is-opening-profile="isOpeningProfile"
@close="emit('closeAssociatedProfiles')" @close="emit('closeAssociatedProfiles')"
@open-profile="(browserId, profileId) => emit('openProfile', browserId, profileId)" @open-profile="(browserId, profileId) => emit('openProfile', browserId, profileId)"
@toggle-profile-selection="
associatedProfilesModal.isExtension
? emit('toggleExtensionModalProfileSelection', $event)
: emit('toggleBookmarkModalProfileSelection', $event)
"
@toggle-all-profile-selection="
associatedProfilesModal.isExtension
? emit('toggleAllExtensionModalProfiles')
: emit('toggleAllBookmarkModalProfiles')
"
@delete-profile="
associatedProfilesModal.isExtension
? emit('deleteExtensionFromProfile', $event)
: emit('deleteBookmarkFromProfile', $event)
"
@delete-selected-profiles="
associatedProfilesModal.isExtension
? emit('deleteSelectedExtensionProfiles')
: emit('deleteSelectedBookmarkProfiles')
"
/> />
</template> </template>
@@ -173,7 +342,7 @@ const emit = defineEmits<{
display: flex; display: flex;
gap: 10px; gap: 10px;
margin-top: 0; margin-top: 0;
padding: 8px; padding: 6px;
flex-shrink: 0; flex-shrink: 0;
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: 22px; border-radius: 22px;
@@ -188,8 +357,8 @@ const emit = defineEmits<{
gap: 12px; gap: 12px;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
padding: 10px 12px; padding: 8px 10px;
border-radius: 15px; border-radius: 13px;
color: var(--muted); color: var(--muted);
background: rgba(255, 255, 255, 0.58); background: rgba(255, 255, 255, 0.58);
cursor: pointer; cursor: pointer;

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { computed } from "vue";
import type { ExtensionSummary, ProfileSummary, RemoveExtensionResult } from "../../types/browser";
const props = defineProps<{
mode: "confirm" | "result";
title: string;
extensions: ExtensionSummary[];
profiles: ProfileSummary[];
results: RemoveExtensionResult[];
busy?: boolean;
generalError?: string;
}>();
const emit = defineEmits<{
close: [];
confirm: [];
}>();
const confirmSummary = computed(() => ({
extensionCount: props.extensions.length,
profileCount: props.profiles.length,
}));
const resultSummary = computed(() => {
const statusByExtension = new Map<string, boolean>();
for (const result of props.results) {
const previous = statusByExtension.get(result.extensionId);
const succeeded = !result.error;
if (previous === undefined) {
statusByExtension.set(result.extensionId, succeeded);
continue;
}
statusByExtension.set(result.extensionId, previous && succeeded);
}
let successCount = 0;
let failedCount = 0;
for (const succeeded of statusByExtension.values()) {
if (succeeded) {
successCount += 1;
} else {
failedCount += 1;
}
}
return { successCount, failedCount };
});
</script>
<template>
<div class="modal-backdrop" @click.self="emit('close')">
<section class="modal-card">
<div class="modal-header">
<h3>{{ title }}</h3>
<button class="secondary-button" type="button" @click="emit('close')">Close</button>
</div>
<template v-if="mode === 'confirm'">
<p class="modal-copy">
This will remove {{ confirmSummary.extensionCount }} extension{{
confirmSummary.extensionCount === 1 ? "" : "s"
}} from {{ confirmSummary.profileCount }} profile{{
confirmSummary.profileCount === 1 ? "" : "s"
}}.
</p>
<div class="modal-actions">
<button class="secondary-button" type="button" @click="emit('close')">Cancel</button>
<button class="danger-button" type="button" :disabled="busy" @click="emit('confirm')">
{{ busy ? "Deleting..." : "Confirm Delete" }}
</button>
</div>
</template>
<template v-else>
<p v-if="generalError" class="result-banner error">{{ generalError }}</p>
<p class="modal-copy">
Successfully removed {{ resultSummary.successCount }} extension{{
resultSummary.successCount === 1 ? "" : "s"
}}. Failed to remove {{ resultSummary.failedCount }} extension{{
resultSummary.failedCount === 1 ? "" : "s"
}}.
</p>
<div class="modal-actions">
<button class="primary-button" type="button" @click="emit('close')">Close</button>
</div>
</template>
</section>
</div>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.26);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.modal-card {
width: min(680px, 100%);
max-height: min(76vh, 820px);
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid var(--panel-border);
border-radius: 22px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.18);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modal-header h3,
.modal-copy {
margin: 0;
}
.modal-copy {
color: var(--muted);
line-height: 1.55;
}
.result-banner {
margin: 0;
padding: 12px 14px;
border-radius: 14px;
font-size: 0.9rem;
}
.result-banner.error {
background: rgba(254, 242, 242, 0.96);
color: #b42318;
border: 1px solid rgba(239, 68, 68, 0.18);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.muted-line {
color: var(--muted-soft);
}
code {
padding: 1px 5px;
border-radius: 7px;
background: rgba(226, 232, 240, 0.72);
color: var(--text);
}
</style>

View File

@@ -1,29 +1,89 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import type { ExtensionSortKey, ExtensionSummary } from "../../types/browser"; import type { ExtensionSortKey, ExtensionSummary } from "../../types/browser";
defineProps<{ const props = defineProps<{
extensions: ExtensionSummary[]; extensions: ExtensionSummary[];
sortKey: ExtensionSortKey; sortKey: ExtensionSortKey;
extensionMonogram: (name: string) => string; extensionMonogram: (name: string) => string;
selectedExtensionIds: string[];
deleteBusy: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
"update:sortKey": [value: ExtensionSortKey]; "update:sortKey": [value: ExtensionSortKey];
showProfiles: [extensionId: string]; showProfiles: [extensionId: string];
toggleExtension: [extensionId: string];
toggleAllExtensions: [];
deleteExtension: [extensionId: string];
deleteSelected: [];
}>(); }>();
const allSelected = computed(
() =>
props.extensions.length > 0 &&
props.extensions.every((extension) => props.selectedExtensionIds.includes(extension.id)),
);
function isSelected(extensionId: string) {
return props.selectedExtensionIds.includes(extensionId);
}
</script> </script>
<template> <template>
<section class="table-section"> <section class="table-section">
<div v-if="extensions.length" class="data-table"> <div v-if="extensions.length" class="data-table">
<div class="extensions-toolbar">
<label class="toolbar-checkbox" :class="{ disabled: !extensions.length }">
<input
type="checkbox"
class="native-checkbox"
:checked="allSelected"
:disabled="!extensions.length || deleteBusy"
@change="emit('toggleAllExtensions')"
/>
<span class="custom-checkbox" :class="{ checked: allSelected }" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M3.5 8.2L6.4 11.1L12.5 4.9" />
</svg>
</span>
<span>Select All</span>
</label>
<button
class="danger-button"
type="button"
:disabled="!selectedExtensionIds.length || deleteBusy"
@click="emit('deleteSelected')"
>
{{ deleteBusy ? "Deleting..." : `Delete Selected (${selectedExtensionIds.length})` }}
</button>
</div>
<div class="data-table-header extensions-grid"> <div class="data-table-header extensions-grid">
<div class="header-cell checkbox-cell">Pick</div>
<div class="header-cell icon-cell">Icon</div> <div class="header-cell icon-cell">Icon</div>
<button class="header-cell sortable" :class="{ active: sortKey === 'name' }" type="button" @click="emit('update:sortKey', 'name')">Name</button> <button class="header-cell sortable" :class="{ active: sortKey === 'name' }" type="button" @click="emit('update:sortKey', 'name')">Name</button>
<button class="header-cell sortable" :class="{ active: sortKey === 'id' }" type="button" @click="emit('update:sortKey', 'id')">Extension ID</button> <button class="header-cell sortable" :class="{ active: sortKey === 'id' }" type="button" @click="emit('update:sortKey', 'id')">Extension ID</button>
<div class="header-cell actions-cell">Profiles</div> <div class="header-cell actions-cell">Actions</div>
</div> </div>
<div class="data-table-body styled-scrollbar"> <div class="data-table-body styled-scrollbar">
<article v-for="extension in extensions" :key="extension.id" class="data-table-row extensions-grid"> <article v-for="extension in extensions" :key="extension.id" class="data-table-row extensions-grid">
<div class="row-cell checkbox-cell">
<label class="table-checkbox" :class="{ disabled: deleteBusy }">
<input
type="checkbox"
class="native-checkbox"
:checked="isSelected(extension.id)"
:disabled="deleteBusy"
@change="emit('toggleExtension', extension.id)"
/>
<span class="custom-checkbox" :class="{ checked: isSelected(extension.id) }" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M3.5 8.2L6.4 11.1L12.5 4.9" />
</svg>
</span>
</label>
</div>
<div class="extension-icon table-icon" :class="{ filled: Boolean(extension.iconDataUrl) }"> <div class="extension-icon table-icon" :class="{ filled: Boolean(extension.iconDataUrl) }">
<img v-if="extension.iconDataUrl" :src="extension.iconDataUrl" :alt="`${extension.name} icon`" /> <img v-if="extension.iconDataUrl" :src="extension.iconDataUrl" :alt="`${extension.name} icon`" />
<span v-else>{{ extensionMonogram(extension.name) }}</span> <span v-else>{{ extensionMonogram(extension.name) }}</span>
@@ -37,6 +97,14 @@ const emit = defineEmits<{
<span>View</span> <span>View</span>
<span class="badge neutral">{{ extension.profileIds.length }}</span> <span class="badge neutral">{{ extension.profileIds.length }}</span>
</button> </button>
<button
class="danger-button inline-danger-button"
type="button"
:disabled="deleteBusy"
@click="emit('deleteExtension', extension.id)"
>
Delete
</button>
</div> </div>
</article> </article>
</div> </div>
@@ -54,6 +122,88 @@ const emit = defineEmits<{
min-height: 0; min-height: 0;
} }
.extensions-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px 8px;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.toolbar-checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-size: 0.88rem;
cursor: pointer;
}
.toolbar-checkbox.disabled {
opacity: 0.55;
cursor: default;
}
.native-checkbox {
position: absolute;
opacity: 0;
pointer-events: none;
}
.table-checkbox {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.table-checkbox.disabled {
cursor: default;
opacity: 0.5;
}
.custom-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 7px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(241, 245, 249, 0.92));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
0 4px 10px rgba(15, 23, 42, 0.06);
}
.custom-checkbox svg {
width: 12px;
height: 12px;
}
.custom-checkbox path {
fill: none;
stroke: #fff;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
.custom-checkbox.checked {
border-color: rgba(47, 111, 237, 0.2);
background: linear-gradient(135deg, #2f6fed, #5aa1f7);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
0 8px 18px rgba(47, 111, 237, 0.22);
}
.custom-checkbox.checked path {
opacity: 1;
}
.data-table { .data-table {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -74,7 +224,7 @@ const emit = defineEmits<{
.extensions-grid { .extensions-grid {
display: grid; display: grid;
grid-template-columns: 60px minmax(180px, 1.1fr) minmax(220px, 1fr) 154px; grid-template-columns: 52px 60px minmax(180px, 1.1fr) minmax(220px, 1fr) 250px;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
} }
@@ -83,7 +233,7 @@ const emit = defineEmits<{
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; z-index: 2;
padding: 10px 24px 10px 14px; padding: 8px 24px 8px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14); border-bottom: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.94); background: rgba(248, 250, 252, 0.94);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
@@ -109,7 +259,7 @@ const emit = defineEmits<{
} }
.data-table-row { .data-table-row {
padding: 12px 14px; padding: 10px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.12); border-bottom: 1px solid rgba(148, 163, 184, 0.12);
} }
@@ -182,6 +332,18 @@ const emit = defineEmits<{
.actions-cell { .actions-cell {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px;
}
.checkbox-cell {
display: flex;
justify-content: center;
}
.inline-danger-button {
padding: 7px 10px;
border-radius: 12px;
font-size: 0.8rem;
} }
.icon-cell { .icon-cell {
@@ -190,16 +352,22 @@ const emit = defineEmits<{
@media (max-width: 900px) { @media (max-width: 900px) {
.extensions-grid { .extensions-grid {
grid-template-columns: 56px minmax(160px, 1fr) minmax(160px, 1fr) 148px; grid-template-columns: 52px 56px minmax(160px, 1fr) minmax(160px, 1fr) 220px;
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.extensions-grid { .extensions-toolbar {
grid-template-columns: 56px minmax(0, 1fr) 132px; flex-direction: column;
align-items: stretch;
} }
.extensions-grid > :nth-child(3) { .extensions-grid {
grid-template-columns: 52px 56px minmax(0, 1fr) 132px;
}
.extensions-grid > :nth-child(4),
.extensions-grid > :nth-child(5) {
display: none; display: none;
} }
} }

View File

@@ -0,0 +1,432 @@
<script setup lang="ts">
import { computed } from "vue";
import type { CleanupFileStatus, ProfileSummary } from "../../types/browser";
import { profileAvatarSrc } from "../../utils/icons";
const props = defineProps<{
browserFamilyId: string | null;
profiles: ProfileSummary[];
selectedProfileIds: string[];
cleanupBusy: boolean;
}>();
const emit = defineEmits<{
toggleProfile: [profileId: string];
toggleAllProfiles: [];
cleanupSelected: [];
cleanupProfile: [profileId: string];
}>();
const selectableProfiles = computed(() =>
props.profiles.filter((profile) =>
hasAnyHistoryFile([
profile.historyCleanup.history,
profile.historyCleanup.topSites,
profile.historyCleanup.visitedLinks,
profile.historyCleanup.sessions,
]),
),
);
const allSelected = computed(
() =>
selectableProfiles.value.length > 0 &&
selectableProfiles.value.every((profile) =>
props.selectedProfileIds.includes(profile.id),
),
);
function statusLabel(status: CleanupFileStatus) {
return status === "found" ? "Found" : "Missing";
}
function statusClass(status: CleanupFileStatus) {
return status === "found" ? "found" : "missing";
}
function isSelected(profileId: string) {
return props.selectedProfileIds.includes(profileId);
}
function isSelectable(profile: ProfileSummary) {
return hasAnyHistoryFile([
profile.historyCleanup.history,
profile.historyCleanup.topSites,
profile.historyCleanup.visitedLinks,
profile.historyCleanup.sessions,
]);
}
function hasAnyHistoryFile(statuses: CleanupFileStatus[]) {
return statuses.some((status) => status === "found");
}
</script>
<template>
<section class="table-section">
<div v-if="profiles.length" class="data-table">
<div class="history-toolbar">
<label class="toolbar-checkbox" :class="{ disabled: !selectableProfiles.length }">
<input
type="checkbox"
class="native-checkbox"
:checked="allSelected"
:disabled="!selectableProfiles.length || cleanupBusy"
@change="emit('toggleAllProfiles')"
/>
<span class="custom-checkbox" :class="{ checked: allSelected }" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M3.5 8.2L6.4 11.1L12.5 4.9" />
</svg>
</span>
<span>Select All</span>
</label>
<button
class="danger-button"
type="button"
:disabled="!selectedProfileIds.length || cleanupBusy"
@click="emit('cleanupSelected')"
>
{{ cleanupBusy ? "Cleaning..." : `Clean Selected (${selectedProfileIds.length})` }}
</button>
</div>
<div class="data-table-header history-grid">
<div class="header-cell checkbox-cell">Pick</div>
<div class="header-cell icon-cell">Avatar</div>
<div class="header-cell">Profile</div>
<div class="header-cell">History</div>
<div class="header-cell">Top Sites</div>
<div class="header-cell">Visited Links</div>
<div class="header-cell">Sessions</div>
<div class="header-cell actions-cell">Action</div>
</div>
<div class="data-table-body styled-scrollbar">
<article v-for="profile in profiles" :key="profile.id" class="data-table-row history-grid">
<div class="row-cell checkbox-cell">
<label class="table-checkbox" :class="{ disabled: !isSelectable(profile) || cleanupBusy }">
<input
type="checkbox"
class="native-checkbox"
:checked="isSelected(profile.id)"
:disabled="!isSelectable(profile) || cleanupBusy"
@change="emit('toggleProfile', profile.id)"
/>
<span class="custom-checkbox" :class="{ checked: isSelected(profile.id) }" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M3.5 8.2L6.4 11.1L12.5 4.9" />
</svg>
</span>
</label>
</div>
<div class="profile-avatar table-avatar">
<img
v-if="profileAvatarSrc(profile, browserFamilyId)"
:src="profileAvatarSrc(profile, browserFamilyId) ?? undefined"
:alt="`${profile.name} avatar`"
/>
<span v-else>{{ profile.avatarLabel }}</span>
</div>
<div class="row-cell primary-cell">
<strong>{{ profile.name }}</strong>
<span class="subtle-line">{{ profile.id }}</span>
</div>
<div class="row-cell">
<span class="status-pill" :class="statusClass(profile.historyCleanup.history)">
{{ statusLabel(profile.historyCleanup.history) }}
</span>
</div>
<div class="row-cell">
<span class="status-pill" :class="statusClass(profile.historyCleanup.topSites)">
{{ statusLabel(profile.historyCleanup.topSites) }}
</span>
</div>
<div class="row-cell">
<span class="status-pill" :class="statusClass(profile.historyCleanup.visitedLinks)">
{{ statusLabel(profile.historyCleanup.visitedLinks) }}
</span>
</div>
<div class="row-cell">
<span class="status-pill" :class="statusClass(profile.historyCleanup.sessions)">
{{ statusLabel(profile.historyCleanup.sessions) }}
</span>
</div>
<div class="row-cell actions-cell">
<button
class="danger-button action-button"
type="button"
:disabled="!isSelectable(profile) || cleanupBusy"
@click="emit('cleanupProfile', profile.id)"
>
Clean
</button>
</div>
</article>
</div>
</div>
<div v-else class="empty-card">
<p>No profile directories were found for this browser.</p>
</div>
</section>
</template>
<style scoped>
.table-section {
padding: 0;
height: 100%;
min-height: 0;
}
.history-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px 8px;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.toolbar-checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-size: 0.88rem;
cursor: pointer;
}
.toolbar-checkbox.disabled {
opacity: 0.55;
cursor: default;
}
.native-checkbox {
position: absolute;
opacity: 0;
pointer-events: none;
}
.table-checkbox {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.table-checkbox.disabled {
cursor: default;
opacity: 0.5;
}
.custom-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 7px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(241, 245, 249, 0.92));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
0 4px 10px rgba(15, 23, 42, 0.06);
transition:
border-color 160ms ease,
background 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.custom-checkbox svg {
width: 12px;
height: 12px;
}
.custom-checkbox path {
fill: none;
stroke: #fff;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
transition: opacity 160ms ease;
}
.toolbar-checkbox:hover .custom-checkbox,
.table-checkbox:hover .custom-checkbox {
border-color: rgba(47, 111, 237, 0.38);
transform: translateY(-1px);
}
.custom-checkbox.checked {
border-color: rgba(47, 111, 237, 0.2);
background: linear-gradient(135deg, #2f6fed, #5aa1f7);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
0 8px 18px rgba(47, 111, 237, 0.22);
}
.custom-checkbox.checked path {
opacity: 1;
}
.data-table {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 22px;
background: var(--panel);
box-shadow: var(--shadow);
overflow: hidden;
}
.data-table-body {
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.history-grid {
display: grid;
grid-template-columns: 52px 56px minmax(170px, 1fr) 118px 118px 128px 118px 108px;
gap: 12px;
align-items: center;
}
.data-table-header {
position: sticky;
top: 0;
z-index: 2;
padding: 8px 24px 8px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.94);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.data-table-row {
padding: 10px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.data-table-row:last-child {
border-bottom: 0;
}
.data-table-row:hover {
background: rgba(248, 250, 252, 0.65);
}
.header-cell {
color: var(--muted);
font-size: 0.81rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.checkbox-cell {
display: flex;
justify-content: center;
}
.icon-cell {
padding-left: 4px;
}
.row-cell {
min-width: 0;
}
.profile-avatar {
display: grid;
place-items: center;
flex-shrink: 0;
background: linear-gradient(135deg, #dbeafe, #eff6ff);
color: #1d4ed8;
font-size: 0.96rem;
font-weight: 700;
overflow: hidden;
}
.table-avatar {
width: 36px;
height: 36px;
border-radius: 999px;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.primary-cell strong {
display: block;
font-size: 0.93rem;
line-height: 1.3;
}
.subtle-line {
display: block;
margin-top: 3px;
color: var(--muted-soft);
font-size: 0.8rem;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 78px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.79rem;
font-weight: 700;
}
.status-pill.found {
background: rgba(37, 99, 235, 0.12);
color: #1d4ed8;
}
.status-pill.missing {
background: rgba(226, 232, 240, 0.7);
color: var(--badge-text);
}
.actions-cell {
display: flex;
justify-content: flex-end;
}
.action-button {
padding-inline: 12px;
}
@media (max-width: 900px) {
.history-grid {
grid-template-columns: 52px 56px minmax(160px, 1fr) 110px 110px 118px 110px 100px;
}
}
@media (max-width: 720px) {
.history-toolbar {
flex-direction: column;
align-items: stretch;
}
.history-grid {
grid-template-columns: 52px 56px minmax(0, 1fr) 108px;
}
.history-grid > :nth-child(5),
.history-grid > :nth-child(6),
.history-grid > :nth-child(7),
.history-grid > :nth-child(8) {
display: none;
}
}
</style>

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import type { CleanupHistoryResult, ProfileSummary } from "../../types/browser";
defineProps<{
mode: "confirm" | "result";
title: string;
profiles: ProfileSummary[];
results: CleanupHistoryResult[];
busy?: boolean;
generalError?: string;
}>();
const emit = defineEmits<{
close: [];
confirm: [];
}>();
</script>
<template>
<div class="modal-backdrop" @click.self="emit('close')">
<section class="modal-card">
<div class="modal-header">
<h3>{{ title }}</h3>
<button class="secondary-button modal-close-button" type="button" @click="emit('close')">
Close
</button>
</div>
<template v-if="mode === 'confirm'">
<p class="modal-copy">
The selected profiles will have <code>History</code>, <code>Top Sites</code>, and
<code>Visited Links</code> removed, and all files inside <code>Sessions</code> cleared.
</p>
<div class="profile-list styled-scrollbar">
<article v-for="profile in profiles" :key="profile.id" class="profile-item">
<strong>{{ profile.name }}</strong>
<span>{{ profile.id }}</span>
</article>
</div>
<div class="modal-actions">
<button class="secondary-button" type="button" @click="emit('close')">Cancel</button>
<button class="danger-button" type="button" :disabled="busy" @click="emit('confirm')">
{{ busy ? "Cleaning..." : "Confirm Cleanup" }}
</button>
</div>
</template>
<template v-else>
<p v-if="generalError" class="result-banner error">{{ generalError }}</p>
<div class="result-list styled-scrollbar">
<article
v-for="result in results"
:key="result.profileId"
class="result-card"
:class="{ error: result.error }"
>
<strong>{{ result.profileId }}</strong>
<p v-if="result.error">{{ result.error }}</p>
<p v-else-if="result.deletedFiles.length">
Deleted {{ result.deletedFiles.join(", ") }}
</p>
<p v-else>
Nothing was deleted.
</p>
<p v-if="result.skippedFiles.length" class="muted-line">
Missing {{ result.skippedFiles.join(", ") }}
</p>
</article>
</div>
<div class="modal-actions">
<button class="primary-button" type="button" @click="emit('close')">Close</button>
</div>
</template>
</section>
</div>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.26);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.modal-card {
width: min(640px, 100%);
max-height: min(76vh, 820px);
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid var(--panel-border);
border-radius: 22px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.18);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modal-header h3 {
margin: 0;
font-weight: 600;
letter-spacing: -0.03em;
}
.modal-copy {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.profile-list,
.result-list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
overflow: auto;
}
.profile-item,
.result-card {
display: grid;
gap: 4px;
padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 16px;
background: rgba(248, 250, 252, 0.84);
}
.profile-item span,
.muted-line {
color: var(--muted);
font-size: 0.85rem;
}
.result-card p {
margin: 0;
color: var(--text);
line-height: 1.5;
}
.result-card.error {
border-color: rgba(239, 68, 68, 0.18);
background: rgba(254, 242, 242, 0.96);
}
.result-banner {
margin: 0;
padding: 12px 14px;
border-radius: 14px;
font-size: 0.9rem;
}
.result-banner.error {
background: rgba(254, 242, 242, 0.96);
color: #b42318;
border: 1px solid rgba(239, 68, 68, 0.18);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
code {
padding: 1px 5px;
border-radius: 7px;
background: rgba(226, 232, 240, 0.72);
color: var(--text);
}
@media (max-width: 720px) {
.modal-backdrop {
padding: 12px;
}
.modal-actions {
flex-direction: column-reverse;
}
}
</style>

View File

@@ -1,165 +0,0 @@
<script setup lang="ts">
import type { HistoryDomainSortKey, HistoryDomainSummary } from "../../types/browser";
defineProps<{
historyDomains: HistoryDomainSummary[];
sortKey: HistoryDomainSortKey;
}>();
const emit = defineEmits<{
"update:sortKey": [value: HistoryDomainSortKey];
showProfiles: [domain: string];
}>();
</script>
<template>
<section class="table-section">
<div v-if="historyDomains.length" class="data-table">
<div class="data-table-header history-grid">
<button class="header-cell sortable" :class="{ active: sortKey === 'domain' }" type="button" @click="emit('update:sortKey', 'domain')">Domain</button>
<button class="header-cell sortable" :class="{ active: sortKey === 'visits' }" type="button" @click="emit('update:sortKey', 'visits')">Visits</button>
<div class="header-cell actions-cell">Profiles</div>
</div>
<div class="data-table-body styled-scrollbar">
<article
v-for="historyDomain in historyDomains"
:key="historyDomain.domain"
class="data-table-row history-grid"
>
<div class="row-cell primary-cell">
<strong>{{ historyDomain.domain }}</strong>
</div>
<div class="row-cell muted-cell">{{ historyDomain.visitCount.toLocaleString() }}</div>
<div class="row-cell actions-cell">
<button class="disclosure-button" type="button" @click="emit('showProfiles', historyDomain.domain)">
<span>View</span>
<span class="badge neutral">{{ historyDomain.profileIds.length }}</span>
</button>
</div>
</article>
</div>
</div>
<div v-else class="empty-card">
<p>No browsing history domains were discovered for this browser.</p>
</div>
</section>
</template>
<style scoped>
.table-section {
padding: 0;
height: 100%;
min-height: 0;
}
.data-table {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 22px;
background: var(--panel);
box-shadow: var(--shadow);
overflow: hidden;
}
.data-table-body {
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.history-grid {
display: grid;
grid-template-columns: minmax(240px, 1.2fr) 140px 154px;
gap: 12px;
align-items: center;
}
.data-table-header {
position: sticky;
top: 0;
z-index: 2;
padding: 10px 24px 10px 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.94);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.header-cell {
color: var(--muted);
font-size: 0.81rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.header-cell.sortable {
padding: 0;
text-align: left;
background: transparent;
cursor: pointer;
}
.header-cell.sortable.active {
color: var(--text);
}
.data-table-row {
padding: 12px 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.data-table-row:last-child {
border-bottom: 0;
}
.data-table-row:hover {
background: rgba(248, 250, 252, 0.65);
}
.row-cell {
min-width: 0;
}
.primary-cell strong {
display: block;
font-size: 0.93rem;
line-height: 1.3;
}
.muted-cell {
color: var(--muted);
font-size: 0.87rem;
}
.disclosure-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: fit-content;
min-width: 120px;
padding: 7px 10px;
border-radius: 12px;
background: rgba(241, 245, 249, 0.9);
color: var(--badge-text);
cursor: pointer;
}
.actions-cell {
display: flex;
justify-content: flex-end;
}
@media (max-width: 720px) {
.history-grid {
grid-template-columns: minmax(0, 1fr) 120px;
}
.history-grid > :nth-child(2) {
display: none;
}
}
</style>

View File

@@ -81,7 +81,7 @@ const emit = defineEmits<{
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; z-index: 2;
padding: 10px 24px 10px 14px; padding: 8px 24px 8px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14); border-bottom: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.94); background: rgba(248, 250, 252, 0.94);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
@@ -107,7 +107,7 @@ const emit = defineEmits<{
} }
.data-table-row { .data-table-row {
padding: 12px 14px; padding: 10px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.12); border-bottom: 1px solid rgba(148, 163, 184, 0.12);
} }

View File

@@ -105,7 +105,7 @@ const emit = defineEmits<{
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; z-index: 2;
padding: 10px 24px 10px 14px; padding: 8px 24px 8px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14); border-bottom: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.94); background: rgba(248, 250, 252, 0.94);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
@@ -131,7 +131,7 @@ const emit = defineEmits<{
} }
.data-table-row { .data-table-row {
padding: 12px 14px; padding: 10px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.12); border-bottom: 1px solid rgba(148, 163, 184, 0.12);
} }

View File

@@ -46,7 +46,6 @@ const iconOptions = computed(() =>
<div class="config-form-header collapsible"> <div class="config-form-header collapsible">
<div> <div>
<h3>Add Custom Browser</h3> <h3>Add Custom Browser</h3>
<p>Add a custom executable and Chromium user data path when needed.</p>
</div> </div>
<button <button
class="secondary-button config-toggle-button" class="secondary-button config-toggle-button"
@@ -112,9 +111,6 @@ const iconOptions = computed(() =>
</button> </button>
</div> </div>
</div> </div>
<div v-else class="config-form-collapsed-note">
<span>Collapsed by default to keep this page focused on existing configs.</span>
</div>
</div> </div>
<div v-if="configsLoading" class="empty-card"> <div v-if="configsLoading" class="empty-card">
@@ -188,7 +184,8 @@ const iconOptions = computed(() =>
.config-form-header h3, .config-form-header h3,
.config-title-row h4 { .config-title-row h4 {
margin: 0; margin: 0;
font-size: 0.94rem; font-size: 1.1rem;
line-height: 1.2;
} }
.config-form-header.collapsible { .config-form-header.collapsible {
@@ -198,7 +195,6 @@ const iconOptions = computed(() =>
gap: 12px; gap: 12px;
} }
.config-form-header p,
.config-meta-row p { .config-meta-row p {
margin: 6px 0 0; margin: 6px 0 0;
color: var(--muted); color: var(--muted);
@@ -221,16 +217,6 @@ const iconOptions = computed(() =>
align-items: end; align-items: end;
} }
.config-form-collapsed-note {
margin-top: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(248, 250, 252, 0.78);
border: 1px solid rgba(148, 163, 184, 0.12);
color: var(--muted);
font-size: 0.82rem;
}
.field-group { .field-group {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -265,11 +251,12 @@ const iconOptions = computed(() =>
.field-group :deep(.sort-dropdown-trigger) { .field-group :deep(.sort-dropdown-trigger) {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
min-height: 40px;
} }
.path-input-row { .path-input-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) 118px;
gap: 10px; gap: 10px;
} }
@@ -277,6 +264,11 @@ const iconOptions = computed(() =>
white-space: nowrap; white-space: nowrap;
} }
.path-input-row .secondary-button {
width: 118px;
justify-content: center;
}
.config-form-actions { .config-form-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -2,13 +2,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { import { sortBookmarks, sortExtensions, sortPasswordSites, sortProfiles } from "../utils/sort";
sortBookmarks,
sortExtensions,
sortHistoryDomains,
sortPasswordSites,
sortProfiles,
} from "../utils/sort";
import type { import type {
ActiveSection, ActiveSection,
AppPage, AppPage,
@@ -18,11 +12,19 @@ import type {
BrowserConfigEntry, BrowserConfigEntry,
BrowserConfigListResponse, BrowserConfigListResponse,
BrowserView, BrowserView,
BookmarkRemovalRequest,
CleanupHistoryInput,
CleanupHistoryResponse,
CreateCustomBrowserConfigInput, CreateCustomBrowserConfigInput,
ExtensionRemovalRequest,
ExtensionSummary,
ExtensionSortKey, ExtensionSortKey,
HistoryDomainSortKey,
PasswordSiteSortKey, PasswordSiteSortKey,
ProfileSortKey, ProfileSortKey,
RemoveBookmarksInput,
RemoveBookmarksResponse,
RemoveExtensionsInput,
RemoveExtensionsResponse,
ScanResponse, ScanResponse,
} from "../types/browser"; } from "../types/browser";
@@ -49,14 +51,44 @@ export function useBrowserManager() {
const associatedProfilesModal = ref<{ const associatedProfilesModal = ref<{
title: string; title: string;
browserId: string; browserId: string;
profiles: (AssociatedProfileSummary | BookmarkAssociatedProfileSummary)[]; profiles: (
| AssociatedProfileSummary
| BookmarkAssociatedProfileSummary
| ExtensionSummary["profiles"][number]
)[];
isBookmark: boolean; isBookmark: boolean;
isExtension?: boolean;
extensionId?: string;
bookmarkUrl?: string;
} | null>(null); } | null>(null);
const profileSortKey = ref<ProfileSortKey>("name"); const profileSortKey = ref<ProfileSortKey>("name");
const extensionSortKey = ref<ExtensionSortKey>("name"); const extensionSortKey = ref<ExtensionSortKey>("name");
const bookmarkSortKey = ref<BookmarkSortKey>("title"); const bookmarkSortKey = ref<BookmarkSortKey>("title");
const passwordSiteSortKey = ref<PasswordSiteSortKey>("domain"); const passwordSiteSortKey = ref<PasswordSiteSortKey>("domain");
const historyDomainSortKey = ref<HistoryDomainSortKey>("visits"); const bookmarkSelectedUrls = ref<string[]>([]);
const bookmarkModalSelectedProfileIds = ref<string[]>([]);
const bookmarkDeleteBusy = ref(false);
const bookmarkRemovalError = ref("");
const bookmarkRemovalResults = ref<RemoveBookmarksResponse["results"]>([]);
const bookmarkRemovalResultOpen = ref(false);
const bookmarkRemovalConfirmRemovals = ref<BookmarkRemovalRequest[]>([]);
const bookmarkRemovalConfirmUrls = ref<string[]>([]);
const bookmarkRemovalConfirmProfileIds = ref<string[]>([]);
const extensionSelectedIds = ref<string[]>([]);
const extensionModalSelectedProfileIds = ref<string[]>([]);
const extensionDeleteBusy = ref(false);
const extensionRemovalError = ref("");
const extensionRemovalResults = ref<RemoveExtensionsResponse["results"]>([]);
const extensionRemovalResultOpen = ref(false);
const extensionRemovalConfirmRemovals = ref<ExtensionRemovalRequest[]>([]);
const extensionRemovalConfirmExtensionIds = ref<string[]>([]);
const extensionRemovalConfirmProfileIds = ref<string[]>([]);
const cleanupHistorySelectedProfiles = ref<string[]>([]);
const historyCleanupBusy = ref(false);
const cleanupHistoryError = ref("");
const cleanupHistoryResults = ref<CleanupHistoryResponse["results"]>([]);
const historyCleanupConfirmProfileIds = ref<string[]>([]);
const historyCleanupResultOpen = ref(false);
const browsers = computed(() => response.value.browsers); const browsers = computed(() => response.value.browsers);
const currentBrowser = computed<BrowserView | null>( const currentBrowser = computed<BrowserView | null>(
@@ -78,9 +110,6 @@ export function useBrowserManager() {
const sortedPasswordSites = computed(() => const sortedPasswordSites = computed(() =>
sortPasswordSites(currentBrowser.value?.passwordSites ?? [], passwordSiteSortKey.value), sortPasswordSites(currentBrowser.value?.passwordSites ?? [], passwordSiteSortKey.value),
); );
const sortedHistoryDomains = computed(() =>
sortHistoryDomains(currentBrowser.value?.historyDomains ?? [], historyDomainSortKey.value),
);
watch( watch(
browsers, browsers,
@@ -104,6 +133,27 @@ export function useBrowserManager() {
watch(selectedBrowserId, () => { watch(selectedBrowserId, () => {
openProfileError.value = ""; openProfileError.value = "";
associatedProfilesModal.value = null; associatedProfilesModal.value = null;
cleanupHistorySelectedProfiles.value = [];
cleanupHistoryResults.value = [];
cleanupHistoryError.value = "";
bookmarkSelectedUrls.value = [];
bookmarkModalSelectedProfileIds.value = [];
bookmarkRemovalError.value = "";
bookmarkRemovalResults.value = [];
bookmarkRemovalResultOpen.value = false;
bookmarkRemovalConfirmRemovals.value = [];
bookmarkRemovalConfirmUrls.value = [];
bookmarkRemovalConfirmProfileIds.value = [];
extensionSelectedIds.value = [];
extensionModalSelectedProfileIds.value = [];
extensionRemovalError.value = "";
extensionRemovalResults.value = [];
extensionRemovalResultOpen.value = false;
extensionRemovalConfirmRemovals.value = [];
extensionRemovalConfirmExtensionIds.value = [];
extensionRemovalConfirmProfileIds.value = [];
historyCleanupConfirmProfileIds.value = [];
historyCleanupResultOpen.value = false;
}); });
async function loadBrowserConfigs() { async function loadBrowserConfigs() {
@@ -284,42 +334,39 @@ export function useBrowserManager() {
return name.trim().slice(0, 1).toUpperCase() || "?"; return name.trim().slice(0, 1).toUpperCase() || "?";
} }
function domainFromUrl(url: string) {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
function sectionCount(section: ActiveSection) { function sectionCount(section: ActiveSection) {
if (!currentBrowser.value) return 0; if (!currentBrowser.value) return 0;
if (section === "profiles") return currentBrowser.value.profiles.length; if (section === "profiles") return currentBrowser.value.profiles.length;
if (section === "extensions") return currentBrowser.value.extensions.length; if (section === "extensions") return currentBrowser.value.extensions.length;
if (section === "bookmarks") return currentBrowser.value.bookmarks.length; if (section === "bookmarks") return currentBrowser.value.bookmarks.length;
if (section === "passwords") return currentBrowser.value.passwordSites.length; if (section === "passwords") return currentBrowser.value.passwordSites.length;
return currentBrowser.value.historyDomains.length; return currentBrowser.value.stats.historyCleanupProfileCount;
} }
function showExtensionProfilesModal(extensionId: string) { function showExtensionProfilesModal(extensionId: string) {
const extension = currentBrowser.value?.extensions.find((item) => item.id === extensionId); const extension = currentBrowser.value?.extensions.find((item) => item.id === extensionId);
if (!extension || !currentBrowser.value) return; if (!extension || !currentBrowser.value) return;
extensionModalSelectedProfileIds.value = [];
associatedProfilesModal.value = { associatedProfilesModal.value = {
title: `${extension.name} Profiles`, title: `${extension.name} Profiles`,
browserId: currentBrowser.value.browserId, browserId: currentBrowser.value.browserId,
profiles: extension.profiles, profiles: extension.profiles,
isBookmark: false, isBookmark: false,
isExtension: true,
extensionId,
}; };
} }
function showBookmarkProfilesModal(url: string) { function showBookmarkProfilesModal(url: string) {
const bookmark = currentBrowser.value?.bookmarks.find((item) => item.url === url); const bookmark = currentBrowser.value?.bookmarks.find((item) => item.url === url);
if (!bookmark || !currentBrowser.value) return; if (!bookmark || !currentBrowser.value) return;
bookmarkModalSelectedProfileIds.value = [];
associatedProfilesModal.value = { associatedProfilesModal.value = {
title: `${bookmark.title} Profiles`, title: `${bookmark.title} Profiles`,
browserId: currentBrowser.value.browserId, browserId: currentBrowser.value.browserId,
profiles: bookmark.profiles, profiles: bookmark.profiles,
isBookmark: true, isBookmark: true,
bookmarkUrl: url,
}; };
} }
@@ -334,21 +381,562 @@ export function useBrowserManager() {
}; };
} }
function showHistoryDomainProfilesModal(domain: string) { function toggleHistoryProfile(profileId: string) {
const historyDomain = currentBrowser.value?.historyDomains.find( if (cleanupHistorySelectedProfiles.value.includes(profileId)) {
(item) => item.domain === domain, cleanupHistorySelectedProfiles.value = cleanupHistorySelectedProfiles.value.filter(
(selectedId) => selectedId !== profileId,
); );
if (!historyDomain || !currentBrowser.value) return; return;
associatedProfilesModal.value = { }
title: `${historyDomain.domain} Profiles`,
browserId: currentBrowser.value.browserId, cleanupHistorySelectedProfiles.value = [
profiles: historyDomain.profiles, ...cleanupHistorySelectedProfiles.value,
isBookmark: false, profileId,
];
}
function toggleAllHistoryProfiles() {
const current = currentBrowser.value;
if (!current) return;
const selectableIds = current.profiles
.filter((profile) => {
const cleanup = profile.historyCleanup;
return (
cleanup.history === "found" ||
cleanup.topSites === "found" ||
cleanup.visitedLinks === "found" ||
cleanup.sessions === "found"
);
})
.map((profile) => profile.id);
const allSelected =
selectableIds.length > 0 &&
selectableIds.every((profileId) =>
cleanupHistorySelectedProfiles.value.includes(profileId),
);
cleanupHistorySelectedProfiles.value = allSelected ? [] : selectableIds;
}
function cleanupProfileIdsWithHistory(browser: BrowserView) {
return browser.profiles
.filter((profile) => {
const cleanup = profile.historyCleanup;
return (
cleanup.history === "found" ||
cleanup.topSites === "found" ||
cleanup.visitedLinks === "found" ||
cleanup.sessions === "found"
);
})
.map((profile) => profile.id);
}
function historyCleanupConfirmProfiles() {
const browser = currentBrowser.value;
if (!browser) return [];
return browser.profiles.filter((profile) =>
historyCleanupConfirmProfileIds.value.includes(profile.id),
);
}
function cleanupSelectedHistoryProfiles() {
if (!cleanupHistorySelectedProfiles.value.length) return;
historyCleanupConfirmProfileIds.value = [...cleanupHistorySelectedProfiles.value];
}
function cleanupHistoryForProfile(profileId: string) {
historyCleanupConfirmProfileIds.value = [profileId];
}
function closeHistoryCleanupConfirm() {
if (historyCleanupBusy.value) return;
historyCleanupConfirmProfileIds.value = [];
}
function closeHistoryCleanupResult() {
historyCleanupResultOpen.value = false;
cleanupHistoryResults.value = [];
cleanupHistoryError.value = "";
}
function applyCleanupHistoryResults(results: CleanupHistoryResponse["results"]) {
const browser = currentBrowser.value;
if (!browser) return;
const succeededProfileIds = results
.filter((result) => !result.error)
.map((result) => result.profileId);
if (!succeededProfileIds.length) return;
for (const profile of browser.profiles) {
if (!succeededProfileIds.includes(profile.id)) continue;
const deletedFiles = results.find((result) => result.profileId === profile.id)?.deletedFiles ?? [];
if (deletedFiles.includes("History")) {
profile.historyCleanup.history = "missing";
}
if (deletedFiles.includes("Top Sites")) {
profile.historyCleanup.topSites = "missing";
}
if (deletedFiles.includes("Visited Links")) {
profile.historyCleanup.visitedLinks = "missing";
}
if (deletedFiles.includes("Sessions")) {
profile.historyCleanup.sessions = "missing";
}
}
browser.stats.historyCleanupProfileCount = cleanupProfileIdsWithHistory(browser).length;
}
async function confirmHistoryCleanup() {
const browser = currentBrowser.value;
const profileIds = [...historyCleanupConfirmProfileIds.value];
if (!browser || profileIds.length === 0) return;
if (!currentBrowser.value || profileIds.length === 0) return;
historyCleanupBusy.value = true;
cleanupHistoryError.value = "";
cleanupHistoryResults.value = [];
historyCleanupResultOpen.value = false;
try {
const input: CleanupHistoryInput = {
browserId: browser.browserId,
profileIds,
}; };
const result = await invoke<CleanupHistoryResponse>("cleanup_history_files", { input });
applyCleanupHistoryResults(result.results);
cleanupHistoryResults.value = result.results;
cleanupHistorySelectedProfiles.value = cleanupHistorySelectedProfiles.value.filter(
(profileId) => !profileIds.includes(profileId),
);
historyCleanupConfirmProfileIds.value = [];
historyCleanupResultOpen.value = true;
} catch (cleanupErrorValue) {
historyCleanupConfirmProfileIds.value = [];
cleanupHistoryError.value =
cleanupErrorValue instanceof Error
? cleanupErrorValue.message
: "Failed to clean history files.";
historyCleanupResultOpen.value = true;
} finally {
historyCleanupBusy.value = false;
}
} }
function closeAssociatedProfilesModal() { function closeAssociatedProfilesModal() {
associatedProfilesModal.value = null; associatedProfilesModal.value = null;
extensionModalSelectedProfileIds.value = [];
bookmarkModalSelectedProfileIds.value = [];
}
function toggleBookmarkSelection(url: string) {
if (bookmarkSelectedUrls.value.includes(url)) {
bookmarkSelectedUrls.value = bookmarkSelectedUrls.value.filter((item) => item !== url);
return;
}
bookmarkSelectedUrls.value = [...bookmarkSelectedUrls.value, url];
}
function toggleAllBookmarks() {
const bookmarkUrls = currentBrowser.value?.bookmarks.map((bookmark) => bookmark.url) ?? [];
const allSelected =
bookmarkUrls.length > 0 &&
bookmarkUrls.every((url) => bookmarkSelectedUrls.value.includes(url));
bookmarkSelectedUrls.value = allSelected ? [] : bookmarkUrls;
}
function toggleBookmarkModalProfileSelection(profileId: string) {
if (bookmarkModalSelectedProfileIds.value.includes(profileId)) {
bookmarkModalSelectedProfileIds.value = bookmarkModalSelectedProfileIds.value.filter(
(id) => id !== profileId,
);
return;
}
bookmarkModalSelectedProfileIds.value = [...bookmarkModalSelectedProfileIds.value, profileId];
}
function toggleAllBookmarkModalProfiles() {
if (!associatedProfilesModal.value?.isBookmark) return;
const profileIds = associatedProfilesModal.value.profiles.map((profile) => profile.id);
const allSelected =
profileIds.length > 0 &&
profileIds.every((profileId) => bookmarkModalSelectedProfileIds.value.includes(profileId));
bookmarkModalSelectedProfileIds.value = allSelected ? [] : profileIds;
}
function bookmarkRemovalConfirmBookmarkCount() {
return bookmarkRemovalConfirmUrls.value.length;
}
function bookmarkRemovalConfirmProfileCount() {
return bookmarkRemovalConfirmProfileIds.value.length;
}
function requestBookmarkRemoval(removals: BookmarkRemovalRequest[]) {
if (!removals.length) return;
bookmarkRemovalConfirmRemovals.value = removals;
bookmarkRemovalConfirmUrls.value = [...new Set(removals.map((item) => item.url))];
bookmarkRemovalConfirmProfileIds.value = [...new Set(removals.flatMap((item) => item.profileIds))];
}
function resetBookmarkRemovalConfirmState() {
bookmarkRemovalConfirmRemovals.value = [];
bookmarkRemovalConfirmUrls.value = [];
bookmarkRemovalConfirmProfileIds.value = [];
}
function deleteBookmarkFromAllProfiles(url: string) {
const bookmark = currentBrowser.value?.bookmarks.find((item) => item.url === url);
if (!bookmark) return;
requestBookmarkRemoval([
{
url,
profileIds: [...bookmark.profileIds],
},
]);
}
function deleteSelectedBookmarks() {
const browser = currentBrowser.value;
if (!browser || !bookmarkSelectedUrls.value.length) return;
const removals = browser.bookmarks
.filter((bookmark) => bookmarkSelectedUrls.value.includes(bookmark.url))
.map((bookmark) => ({
url: bookmark.url,
profileIds: [...bookmark.profileIds],
}));
requestBookmarkRemoval(removals);
}
function deleteBookmarkFromProfile(profileId: string) {
const modal = associatedProfilesModal.value;
if (!modal?.isBookmark || !modal.bookmarkUrl) return;
requestBookmarkRemoval([
{
url: modal.bookmarkUrl,
profileIds: [profileId],
},
]);
}
function deleteSelectedBookmarkProfiles() {
const modal = associatedProfilesModal.value;
if (!modal?.isBookmark || !modal.bookmarkUrl || !bookmarkModalSelectedProfileIds.value.length) {
return;
}
requestBookmarkRemoval([
{
url: modal.bookmarkUrl,
profileIds: [...bookmarkModalSelectedProfileIds.value],
},
]);
}
function closeBookmarkRemovalConfirm() {
if (bookmarkDeleteBusy.value) return;
resetBookmarkRemovalConfirmState();
}
function closeBookmarkRemovalResult() {
bookmarkRemovalResultOpen.value = false;
bookmarkRemovalResults.value = [];
bookmarkRemovalError.value = "";
}
function applyBookmarkRemovalResults(results: RemoveBookmarksResponse["results"]) {
const browser = currentBrowser.value;
if (!browser) return;
for (const result of results) {
if (result.error || result.removedCount === 0) continue;
const bookmark = browser.bookmarks.find((item) => item.url === result.url);
if (!bookmark) continue;
bookmark.profileIds = bookmark.profileIds.filter((id) => id !== result.profileId);
bookmark.profiles = bookmark.profiles.filter((profile) => profile.id !== result.profileId);
}
browser.bookmarks = browser.bookmarks.filter((bookmark) => bookmark.profileIds.length > 0);
browser.stats.bookmarkCount = browser.bookmarks.length;
bookmarkSelectedUrls.value = bookmarkSelectedUrls.value.filter((selectedUrl) =>
browser.bookmarks.some((bookmark) => bookmark.url === selectedUrl),
);
if (associatedProfilesModal.value?.isBookmark) {
const currentBookmark = browser.bookmarks.find(
(bookmark) => bookmark.url === associatedProfilesModal.value?.bookmarkUrl,
);
if (!currentBookmark) {
associatedProfilesModal.value = null;
bookmarkModalSelectedProfileIds.value = [];
} else {
associatedProfilesModal.value = {
...associatedProfilesModal.value,
title: `${currentBookmark.title} Profiles`,
profiles: currentBookmark.profiles,
};
bookmarkModalSelectedProfileIds.value = bookmarkModalSelectedProfileIds.value.filter((id) =>
currentBookmark.profiles.some((profile) => profile.id === id),
);
}
}
}
async function confirmBookmarkRemoval() {
const browser = currentBrowser.value;
const removals = bookmarkRemovalConfirmRemovals.value.map((item) => ({
url: item.url,
profileIds: [...item.profileIds],
}));
if (!browser || !removals.length) return;
bookmarkDeleteBusy.value = true;
bookmarkRemovalError.value = "";
bookmarkRemovalResults.value = [];
bookmarkRemovalResultOpen.value = false;
try {
const input: RemoveBookmarksInput = {
browserId: browser.browserId,
removals,
};
const result = await invoke<RemoveBookmarksResponse>("remove_bookmarks", { input });
applyBookmarkRemovalResults(result.results);
bookmarkRemovalResults.value = result.results;
resetBookmarkRemovalConfirmState();
bookmarkRemovalResultOpen.value = true;
} catch (removeError) {
resetBookmarkRemovalConfirmState();
bookmarkRemovalError.value =
removeError instanceof Error ? removeError.message : "Failed to remove bookmarks.";
bookmarkRemovalResultOpen.value = true;
} finally {
bookmarkDeleteBusy.value = false;
}
}
function toggleExtensionSelection(extensionId: string) {
if (extensionSelectedIds.value.includes(extensionId)) {
extensionSelectedIds.value = extensionSelectedIds.value.filter((id) => id !== extensionId);
return;
}
extensionSelectedIds.value = [...extensionSelectedIds.value, extensionId];
}
function toggleAllExtensions() {
const extensionIds = currentBrowser.value?.extensions.map((extension) => extension.id) ?? [];
const allSelected =
extensionIds.length > 0 &&
extensionIds.every((extensionId) => extensionSelectedIds.value.includes(extensionId));
extensionSelectedIds.value = allSelected ? [] : extensionIds;
}
function toggleExtensionModalProfileSelection(profileId: string) {
if (extensionModalSelectedProfileIds.value.includes(profileId)) {
extensionModalSelectedProfileIds.value = extensionModalSelectedProfileIds.value.filter(
(id) => id !== profileId,
);
return;
}
extensionModalSelectedProfileIds.value = [
...extensionModalSelectedProfileIds.value,
profileId,
];
}
function toggleAllExtensionModalProfiles() {
if (!associatedProfilesModal.value?.isExtension) return;
const profileIds = associatedProfilesModal.value.profiles.map((profile) => profile.id);
const allSelected =
profileIds.length > 0 &&
profileIds.every((profileId) => extensionModalSelectedProfileIds.value.includes(profileId));
extensionModalSelectedProfileIds.value = allSelected ? [] : profileIds;
}
function extensionRemovalConfirmExtensions() {
const browser = currentBrowser.value;
if (!browser) return [];
return browser.extensions.filter((extension) =>
extensionRemovalConfirmExtensionIds.value.includes(extension.id),
);
}
function extensionRemovalConfirmProfiles() {
const browser = currentBrowser.value;
if (!browser) return [];
return browser.profiles.filter((profile) =>
extensionRemovalConfirmProfileIds.value.includes(profile.id),
);
}
function requestExtensionRemoval(removals: ExtensionRemovalRequest[]) {
if (!removals.length) return;
extensionRemovalConfirmRemovals.value = removals;
extensionRemovalConfirmExtensionIds.value = [...new Set(removals.map((item) => item.extensionId))];
extensionRemovalConfirmProfileIds.value = [
...new Set(removals.flatMap((item) => item.profileIds)),
];
}
function resetExtensionRemovalConfirmState() {
extensionRemovalConfirmRemovals.value = [];
extensionRemovalConfirmExtensionIds.value = [];
extensionRemovalConfirmProfileIds.value = [];
}
function deleteExtensionFromAllProfiles(extensionId: string) {
const extension = currentBrowser.value?.extensions.find((item) => item.id === extensionId);
if (!extension) return;
requestExtensionRemoval([
{
extensionId,
profileIds: [...extension.profileIds],
},
]);
}
function deleteSelectedExtensions() {
const browser = currentBrowser.value;
if (!browser || !extensionSelectedIds.value.length) return;
const removals = browser.extensions
.filter((extension) => extensionSelectedIds.value.includes(extension.id))
.map((extension) => ({
extensionId: extension.id,
profileIds: [...extension.profileIds],
}));
requestExtensionRemoval(removals);
}
function deleteExtensionFromProfile(profileId: string) {
const modal = associatedProfilesModal.value;
if (!modal?.isExtension || !modal.extensionId) {
return;
}
requestExtensionRemoval([
{
extensionId: modal.extensionId,
profileIds: [profileId],
},
]);
}
function deleteSelectedExtensionProfiles() {
const modal = associatedProfilesModal.value;
if (
!modal?.isExtension ||
!modal.extensionId ||
!extensionModalSelectedProfileIds.value.length
) {
return;
}
requestExtensionRemoval([
{
extensionId: modal.extensionId,
profileIds: [...extensionModalSelectedProfileIds.value],
},
]);
}
function closeExtensionRemovalConfirm() {
if (extensionDeleteBusy.value) return;
resetExtensionRemovalConfirmState();
}
function closeExtensionRemovalResult() {
extensionRemovalResultOpen.value = false;
extensionRemovalResults.value = [];
extensionRemovalError.value = "";
}
function applyExtensionRemovalResults(results: RemoveExtensionsResponse["results"]) {
const browser = currentBrowser.value;
if (!browser) return;
for (const result of results) {
if (result.error) continue;
const extension = browser.extensions.find((item) => item.id === result.extensionId);
if (!extension) continue;
extension.profileIds = extension.profileIds.filter((id) => id !== result.profileId);
extension.profiles = extension.profiles.filter((profile) => profile.id !== result.profileId);
}
browser.extensions = browser.extensions.filter((extension) => extension.profileIds.length > 0);
browser.stats.extensionCount = browser.extensions.length;
extensionSelectedIds.value = extensionSelectedIds.value.filter((selectedId) =>
browser.extensions.some((extension) => extension.id === selectedId),
);
if (associatedProfilesModal.value?.isExtension && "extensionId" in associatedProfilesModal.value) {
const currentExtension = browser.extensions.find(
(extension) => extension.id === associatedProfilesModal.value?.extensionId,
);
if (!currentExtension) {
associatedProfilesModal.value = null;
extensionModalSelectedProfileIds.value = [];
} else {
associatedProfilesModal.value = {
...associatedProfilesModal.value,
profiles: currentExtension.profiles,
};
extensionModalSelectedProfileIds.value = extensionModalSelectedProfileIds.value.filter((id) =>
currentExtension.profiles.some((profile) => profile.id === id),
);
}
}
}
async function confirmExtensionRemoval() {
const browser = currentBrowser.value;
const removals = extensionRemovalConfirmRemovals.value.map((item) => ({
extensionId: item.extensionId,
profileIds: [...item.profileIds],
}));
if (!browser || !removals.length) return;
extensionDeleteBusy.value = true;
extensionRemovalError.value = "";
extensionRemovalResults.value = [];
extensionRemovalResultOpen.value = false;
try {
const input: RemoveExtensionsInput = {
browserId: browser.browserId,
removals,
};
const result = await invoke<RemoveExtensionsResponse>("remove_extensions", { input });
applyExtensionRemovalResults(result.results);
extensionRemovalResults.value = result.results;
resetExtensionRemovalConfirmState();
extensionRemovalResultOpen.value = true;
} catch (removeError) {
resetExtensionRemovalConfirmState();
extensionRemovalError.value =
removeError instanceof Error ? removeError.message : "Failed to remove extensions.";
extensionRemovalResultOpen.value = true;
} finally {
extensionDeleteBusy.value = false;
}
} }
onMounted(() => { onMounted(() => {
@@ -359,6 +947,14 @@ export function useBrowserManager() {
activeSection, activeSection,
associatedProfilesModal, associatedProfilesModal,
bookmarkSortKey, bookmarkSortKey,
bookmarkDeleteBusy,
bookmarkModalSelectedProfileIds,
bookmarkRemovalConfirmBookmarkCount: computed(bookmarkRemovalConfirmBookmarkCount),
bookmarkRemovalConfirmProfileCount: computed(bookmarkRemovalConfirmProfileCount),
bookmarkRemovalError,
bookmarkRemovalResultOpen,
bookmarkRemovalResults,
bookmarkSelectedUrls,
browserConfigs, browserConfigs,
browserMonogram, browserMonogram,
browsers, browsers,
@@ -369,11 +965,35 @@ export function useBrowserManager() {
createCustomBrowserConfig, createCustomBrowserConfig,
currentBrowser, currentBrowser,
deleteCustomBrowserConfig, deleteCustomBrowserConfig,
domainFromUrl, deleteBookmarkFromAllProfiles,
deleteBookmarkFromProfile,
deleteSelectedBookmarkProfiles,
deleteSelectedBookmarks,
deleteExtensionFromAllProfiles,
deleteExtensionFromProfile,
deleteSelectedExtensionProfiles,
deleteSelectedExtensions,
error, error,
extensionMonogram, extensionMonogram,
extensionDeleteBusy,
extensionModalSelectedProfileIds,
extensionRemovalConfirmExtensions: computed(extensionRemovalConfirmExtensions),
extensionRemovalConfirmProfiles: computed(extensionRemovalConfirmProfiles),
extensionRemovalError,
extensionRemovalResultOpen,
extensionRemovalResults,
extensionSelectedIds,
extensionSortKey, extensionSortKey,
historyDomainSortKey, cleanupHistoryError,
cleanupHistoryResults,
cleanupHistorySelectedProfiles,
cleanupSelectedHistoryProfiles,
closeHistoryCleanupConfirm,
closeHistoryCleanupResult,
confirmHistoryCleanup,
historyCleanupBusy,
historyCleanupConfirmProfiles: computed(historyCleanupConfirmProfiles),
historyCleanupResultOpen,
isDeletingConfig, isDeletingConfig,
isOpeningProfile, isOpeningProfile,
loading, loading,
@@ -390,13 +1010,28 @@ export function useBrowserManager() {
selectedBrowserId, selectedBrowserId,
showBookmarkProfilesModal, showBookmarkProfilesModal,
showExtensionProfilesModal, showExtensionProfilesModal,
showHistoryDomainProfilesModal,
showPasswordSiteProfilesModal, showPasswordSiteProfilesModal,
sortedBookmarks, sortedBookmarks,
sortedExtensions, sortedExtensions,
sortedHistoryDomains,
sortedPasswordSites, sortedPasswordSites,
sortedProfiles, sortedProfiles,
closeExtensionRemovalConfirm,
closeExtensionRemovalResult,
closeBookmarkRemovalConfirm,
closeBookmarkRemovalResult,
confirmExtensionRemoval,
confirmBookmarkRemoval,
cleanupHistoryForProfile,
toggleAllBookmarks,
toggleAllExtensions,
toggleAllBookmarkModalProfiles,
toggleAllExtensionModalProfiles,
toggleBookmarkModalProfileSelection,
toggleBookmarkSelection,
toggleExtensionModalProfileSelection,
toggleExtensionSelection,
toggleAllHistoryProfiles,
toggleHistoryProfile,
closeAssociatedProfilesModal, closeAssociatedProfilesModal,
}; };
} }

View File

@@ -7,8 +7,7 @@
overflow: hidden; overflow: hidden;
} }
.sidebar, .sidebar {
.content-panel {
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
} }
@@ -19,7 +18,7 @@
gap: 10px; gap: 10px;
min-height: 0; min-height: 0;
padding: 4px 0; padding: 4px 0;
overflow: hidden; overflow: visible;
} }
.content-section, .content-section,
@@ -86,7 +85,7 @@
.content-scroll-area { .content-scroll-area {
min-height: 0; min-height: 0;
overflow: hidden; overflow: visible;
padding-right: 0; padding-right: 0;
} }
@@ -174,6 +173,148 @@
padding: 28px; padding: 28px;
} }
.scanning-panel {
justify-items: center;
text-align: center;
overflow: hidden;
background:
radial-gradient(circle at top, rgba(47, 111, 237, 0.16), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(243, 247, 252, 0.9));
}
.scan-hero {
position: relative;
display: grid;
place-items: center;
width: 148px;
height: 148px;
margin-bottom: 8px;
}
.scan-core {
position: relative;
display: grid;
place-items: center;
width: 82px;
height: 82px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(229, 238, 255, 0.92));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
0 16px 40px rgba(47, 111, 237, 0.14);
}
.scan-core-ring {
position: absolute;
inset: 12px;
border: 1px solid rgba(47, 111, 237, 0.18);
border-radius: 50%;
}
.scan-core-ring.secondary {
inset: 22px;
border-color: rgba(16, 24, 40, 0.08);
}
.scan-orbit {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(47, 111, 237, 0.12);
}
.orbit-one {
animation: scan-spin 10s linear infinite;
}
.orbit-two {
inset: 18px;
border-color: rgba(37, 99, 235, 0.18);
animation: scan-spin-reverse 7.5s linear infinite;
}
.scan-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
background: linear-gradient(135deg, #2f6fed, #7db4ff);
box-shadow: 0 0 0 6px rgba(47, 111, 237, 0.1);
}
.dot-one {
top: -4px;
}
.dot-two {
right: 6px;
bottom: 14px;
width: 8px;
height: 8px;
}
.dot-three {
left: 10px;
bottom: 10px;
width: 7px;
height: 7px;
}
.state-panel h2,
.empty-card h3 {
margin: 0;
font-size: 1.12rem;
line-height: 1.2;
}
.state-panel p,
.empty-card p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.loading-steps {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.loading-steps span {
width: 36px;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(47, 111, 237, 0.16), rgba(47, 111, 237, 0.44));
animation: loading-pulse 1.6s ease-in-out infinite;
}
.loading-steps span:nth-child(2) {
animation-delay: 0.2s;
}
.loading-steps span:nth-child(3) {
animation-delay: 0.4s;
}
.empty-card {
min-height: 220px;
place-content: center;
justify-items: center;
padding: 28px;
border: 1px dashed rgba(148, 163, 184, 0.26);
border-radius: 22px;
background:
radial-gradient(circle at top, rgba(47, 111, 237, 0.08), transparent 42%),
linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(244, 247, 251, 0.78));
text-align: center;
}
.empty-card p {
max-width: 460px;
font-size: 0.94rem;
}
.browser-nav, .browser-nav,
.styled-scrollbar { .styled-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
@@ -201,8 +342,51 @@
background: linear-gradient(180deg, rgba(254, 242, 242, 0.92), rgba(255, 255, 255, 0.86)); background: linear-gradient(180deg, rgba(254, 242, 242, 0.92), rgba(255, 255, 255, 0.86));
} }
@keyframes scan-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes scan-spin-reverse {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
@keyframes loading-pulse {
0%,
100% {
transform: scaleX(0.82);
opacity: 0.4;
}
50% {
transform: scaleX(1);
opacity: 1;
}
}
@media (max-width: 720px) { @media (max-width: 720px) {
.sort-bar { .sort-bar {
justify-content: stretch; justify-content: stretch;
} }
.scan-hero {
width: 132px;
height: 132px;
}
.empty-card {
min-height: 180px;
padding: 22px;
}
} }

View File

@@ -3,7 +3,7 @@ export type BrowserStats = {
extensionCount: number; extensionCount: number;
bookmarkCount: number; bookmarkCount: number;
passwordSiteCount: number; passwordSiteCount: number;
historyDomainCount: number; historyCleanupProfileCount: number;
}; };
export type ProfileSummary = { export type ProfileSummary = {
@@ -16,6 +16,7 @@ export type ProfileSummary = {
defaultAvatarStrokeColor: number | null; defaultAvatarStrokeColor: number | null;
avatarLabel: string; avatarLabel: string;
path: string; path: string;
historyCleanup: HistoryCleanupSummary;
}; };
export type ExtensionSummary = { export type ExtensionSummary = {
@@ -24,7 +25,7 @@ export type ExtensionSummary = {
version: string | null; version: string | null;
iconDataUrl: string | null; iconDataUrl: string | null;
profileIds: string[]; profileIds: string[];
profiles: AssociatedProfileSummary[]; profiles: ExtensionAssociatedProfileSummary[];
}; };
export type BookmarkSummary = { export type BookmarkSummary = {
@@ -41,11 +42,74 @@ export type PasswordSiteSummary = {
profiles: AssociatedProfileSummary[]; profiles: AssociatedProfileSummary[];
}; };
export type HistoryDomainSummary = { export type HistoryCleanupSummary = {
domain: string; history: CleanupFileStatus;
visitCount: number; topSites: CleanupFileStatus;
visitedLinks: CleanupFileStatus;
sessions: CleanupFileStatus;
};
export type CleanupFileStatus = "found" | "missing";
export type CleanupHistoryInput = {
browserId: string;
profileIds: string[]; profileIds: string[];
profiles: AssociatedProfileSummary[]; };
export type CleanupHistoryResult = {
profileId: string;
deletedFiles: string[];
skippedFiles: string[];
error: string | null;
};
export type CleanupHistoryResponse = {
results: CleanupHistoryResult[];
};
export type RemoveExtensionsInput = {
browserId: string;
removals: ExtensionRemovalRequest[];
};
export type RemoveBookmarksInput = {
browserId: string;
removals: BookmarkRemovalRequest[];
};
export type ExtensionRemovalRequest = {
extensionId: string;
profileIds: string[];
};
export type BookmarkRemovalRequest = {
url: string;
profileIds: string[];
};
export type RemoveExtensionsResponse = {
results: RemoveExtensionResult[];
};
export type RemoveBookmarksResponse = {
results: RemoveBookmarkResult[];
};
export type RemoveExtensionResult = {
extensionId: string;
profileId: string;
removedFiles: string[];
skippedFiles: string[];
error: string | null;
};
export type RemoveBookmarkResult = {
url: string;
profileId: string;
removedCount: number;
removedFiles: string[];
skippedFiles: string[];
error: string | null;
}; };
export type AssociatedProfileSummary = { export type AssociatedProfileSummary = {
@@ -58,6 +122,19 @@ export type AssociatedProfileSummary = {
avatarLabel: string; avatarLabel: string;
}; };
export type ExtensionInstallSource = "store" | "external";
export type ExtensionAssociatedProfileSummary = {
id: string;
name: string;
avatarDataUrl: string | null;
avatarIcon: string | null;
defaultAvatarFillColor: number | null;
defaultAvatarStrokeColor: number | null;
avatarLabel: string;
installSource: ExtensionInstallSource;
};
export type BookmarkAssociatedProfileSummary = { export type BookmarkAssociatedProfileSummary = {
id: string; id: string;
name: string; name: string;
@@ -73,7 +150,6 @@ export type ProfileSortKey = "name" | "email" | "id";
export type ExtensionSortKey = "name" | "id"; export type ExtensionSortKey = "name" | "id";
export type BookmarkSortKey = "title" | "url"; export type BookmarkSortKey = "title" | "url";
export type PasswordSiteSortKey = "domain" | "url"; export type PasswordSiteSortKey = "domain" | "url";
export type HistoryDomainSortKey = "visits" | "domain";
export type AssociatedProfileSortKey = "id" | "name"; export type AssociatedProfileSortKey = "id" | "name";
export type ActiveSection = "profiles" | "extensions" | "bookmarks" | "passwords" | "history"; export type ActiveSection = "profiles" | "extensions" | "bookmarks" | "passwords" | "history";
export type AppPage = "browserData" | "configuration"; export type AppPage = "browserData" | "configuration";
@@ -111,7 +187,6 @@ export type BrowserView = {
extensions: ExtensionSummary[]; extensions: ExtensionSummary[];
bookmarks: BookmarkSummary[]; bookmarks: BookmarkSummary[];
passwordSites: PasswordSiteSummary[]; passwordSites: PasswordSiteSummary[];
historyDomains: HistoryDomainSummary[];
stats: BrowserStats; stats: BrowserStats;
}; };

View File

@@ -3,13 +3,12 @@ import type {
BookmarkSummary, BookmarkSummary,
ExtensionSortKey, ExtensionSortKey,
ExtensionSummary, ExtensionSummary,
HistoryDomainSortKey,
HistoryDomainSummary,
PasswordSiteSortKey, PasswordSiteSortKey,
PasswordSiteSummary, PasswordSiteSummary,
AssociatedProfileSortKey, AssociatedProfileSortKey,
AssociatedProfileSummary, AssociatedProfileSummary,
BookmarkAssociatedProfileSummary, BookmarkAssociatedProfileSummary,
ExtensionAssociatedProfileSummary,
ProfileSortKey, ProfileSortKey,
ProfileSummary, ProfileSummary,
} from "../types/browser"; } from "../types/browser";
@@ -90,18 +89,12 @@ export function sortPasswordSites(items: PasswordSiteSummary[], sortKey: Passwor
}); });
} }
export function sortHistoryDomains(items: HistoryDomainSummary[], sortKey: HistoryDomainSortKey) {
const historyDomains = [...items];
return historyDomains.sort((left, right) => {
if (sortKey === "domain") {
return compareOptionalText(left.domain, right.domain) || right.visitCount - left.visitCount;
}
return right.visitCount - left.visitCount || compareOptionalText(left.domain, right.domain);
});
}
export function sortAssociatedProfiles( export function sortAssociatedProfiles(
items: (AssociatedProfileSummary | BookmarkAssociatedProfileSummary)[], items: (
| AssociatedProfileSummary
| BookmarkAssociatedProfileSummary
| ExtensionAssociatedProfileSummary
)[],
sortKey: AssociatedProfileSortKey, sortKey: AssociatedProfileSortKey,
) { ) {
const profiles = [...items]; const profiles = [...items];