diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs
index 10db798..32d9f69 100644
--- a/src-tauri/src/auto_updater.rs
+++ b/src-tauri/src/auto_updater.rs
@@ -517,6 +517,7 @@ mod tests {
camoufox_config: None,
group_id: None,
tags: Vec::new(),
+ note: None,
}
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 18b1be2..5e404c7 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -40,7 +40,8 @@ use browser_runner::{
use profile::manager::{
check_browser_status, create_browser_profile_new, delete_profile, list_browser_profiles,
- rename_profile, update_camoufox_config, update_profile_proxy, update_profile_tags,
+ rename_profile, update_camoufox_config, update_profile_note, update_profile_proxy,
+ update_profile_tags,
};
use browser_version_manager::{
@@ -710,6 +711,7 @@ pub fn run() {
get_browser_release_types,
update_profile_proxy,
update_profile_tags,
+ update_profile_note,
check_browser_status,
kill_browser_profile,
rename_profile,
diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs
index 6dc78d8..246b7fc 100644
--- a/src-tauri/src/profile/manager.rs
+++ b/src-tauri/src/profile/manager.rs
@@ -165,6 +165,7 @@ impl ProfileManager {
camoufox_config: None,
group_id: group_id.clone(),
tags: Vec::new(),
+ note: None,
};
match self
@@ -207,6 +208,7 @@ impl ProfileManager {
camoufox_config: final_camoufox_config,
group_id: group_id.clone(),
tags: Vec::new(),
+ note: None,
};
// Save profile info
@@ -522,6 +524,35 @@ impl ProfileManager {
Ok(profile)
}
+ pub fn update_profile_note(
+ &self,
+ app_handle: &tauri::AppHandle,
+ profile_id: &str,
+ note: Option,
+ ) -> Result> {
+ // Find the profile by ID
+ let profile_uuid =
+ uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
+ let profiles = self.list_profiles()?;
+ let mut profile = profiles
+ .into_iter()
+ .find(|p| p.id == profile_uuid)
+ .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
+
+ // Update note (trim whitespace, set to None if empty)
+ profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
+
+ // Save profile
+ self.save_profile(&profile)?;
+
+ // Emit profile note update event
+ if let Err(e) = app_handle.emit("profiles-changed", ()) {
+ log::warn!("Warning: Failed to emit profiles-changed event: {e}");
+ }
+
+ Ok(profile)
+ }
+
pub fn delete_multiple_profiles(
&self,
app_handle: &tauri::AppHandle,
@@ -1445,6 +1476,18 @@ pub fn update_profile_tags(
.map_err(|e| format!("Failed to update profile tags: {e}"))
}
+#[tauri::command]
+pub fn update_profile_note(
+ app_handle: tauri::AppHandle,
+ profile_id: String,
+ note: Option,
+) -> Result {
+ let profile_manager = ProfileManager::instance();
+ profile_manager
+ .update_profile_note(&app_handle, &profile_id, note)
+ .map_err(|e| format!("Failed to update profile note: {e}"))
+}
+
#[tauri::command]
pub async fn check_browser_status(
app_handle: tauri::AppHandle,
diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs
index c991d36..37e1dbc 100644
--- a/src-tauri/src/profile/types.rs
+++ b/src-tauri/src/profile/types.rs
@@ -22,6 +22,8 @@ pub struct BrowserProfile {
pub group_id: Option, // Reference to profile group
#[serde(default)]
pub tags: Vec, // Free-form tags
+ #[serde(default)]
+ pub note: Option, // User note
}
pub fn default_release_type() -> String {
diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs
index 3c61b25..21024c9 100644
--- a/src-tauri/src/profile_importer.rs
+++ b/src-tauri/src/profile_importer.rs
@@ -561,6 +561,7 @@ impl ProfileImporter {
camoufox_config: None,
group_id: None,
tags: Vec::new(),
+ note: None,
};
// Save the profile metadata
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 7962e73..ca5984d 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -676,8 +676,8 @@ export default function Home() {
// Search in profile name
if (profile.name.toLowerCase().includes(query)) return true;
- // Search in browser name
- if (profile.browser.toLowerCase().includes(query)) return true;
+ // Search in note
+ if (profile.note?.toLowerCase().includes(query)) return true;
// Search in tags
if (profile.tags?.some((tag) => tag.toLowerCase().includes(query)))
diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx
index 48e5cbb..2d44b03 100644
--- a/src/components/profile-data-table.tsx
+++ b/src/components/profile-data-table.tsx
@@ -74,7 +74,6 @@ import {
DataTableActionBarAction,
DataTableActionBarSelection,
} from "./data-table-action-bar";
-import { LoadingButton } from "./loading-button";
import MultipleSelector, { type Option } from "./multiple-selector";
import { ProxyCheckButton } from "./proxy-check-button";
import { Input } from "./ui/input";
@@ -103,6 +102,14 @@ type TableMeta = {
React.SetStateAction>
>;
+ // Note editor state
+ noteOverrides: Record;
+ openNoteEditorFor: string | null;
+ setOpenNoteEditorFor: React.Dispatch>;
+ setNoteOverrides: React.Dispatch<
+ React.SetStateAction>
+ >;
+
// Proxy selector state
openProxySelectorFor: string | null;
setOpenProxySelectorFor: React.Dispatch>;
@@ -402,6 +409,243 @@ const TagsCell = React.memo<{
TagsCell.displayName = "TagsCell";
+const NonHoverableTooltip = React.memo<{
+ children: React.ReactNode;
+ content: React.ReactNode;
+ sideOffset?: number;
+ alignOffset?: number;
+ horizontalOffset?: number;
+}>(
+ ({
+ children,
+ content,
+ sideOffset = 4,
+ alignOffset = 0,
+ horizontalOffset = 0,
+ }) => {
+ const [isOpen, setIsOpen] = React.useState(false);
+
+ return (
+
+ setIsOpen(true)}
+ onMouseLeave={() => setIsOpen(false)}
+ >
+ {children}
+
+ e.preventDefault()}
+ onPointerLeave={() => setIsOpen(false)}
+ className="pointer-events-none"
+ style={
+ horizontalOffset !== 0
+ ? { transform: `translateX(${horizontalOffset}px)` }
+ : undefined
+ }
+ >
+ {content}
+
+
+ );
+ },
+);
+
+NonHoverableTooltip.displayName = "NonHoverableTooltip";
+
+const NoteCell = React.memo<{
+ profile: BrowserProfile;
+ isDisabled: boolean;
+ noteOverrides: Record;
+ openNoteEditorFor: string | null;
+ setOpenNoteEditorFor: React.Dispatch>;
+ setNoteOverrides: React.Dispatch<
+ React.SetStateAction>
+ >;
+}>(
+ ({
+ profile,
+ isDisabled,
+ noteOverrides,
+ openNoteEditorFor,
+ setOpenNoteEditorFor,
+ setNoteOverrides,
+ }) => {
+ const effectiveNote: string | null = Object.hasOwn(
+ noteOverrides,
+ profile.id,
+ )
+ ? noteOverrides[profile.id]
+ : (profile.note ?? null);
+
+ const onNoteChange = React.useCallback(
+ async (newNote: string | null) => {
+ const trimmedNote = newNote?.trim() || null;
+ setNoteOverrides((prev) => ({ ...prev, [profile.id]: trimmedNote }));
+ try {
+ await invoke("update_profile_note", {
+ profileId: profile.id,
+ note: trimmedNote,
+ });
+ } catch (error) {
+ console.error("Failed to update note:", error);
+ }
+ },
+ [profile.id, setNoteOverrides],
+ );
+
+ const editorRef = React.useRef(null);
+ const textareaRef = React.useRef(null);
+ const [noteValue, setNoteValue] = React.useState(effectiveNote || "");
+
+ // Update local state when effective note changes (from outside)
+ React.useEffect(() => {
+ if (openNoteEditorFor !== profile.id) {
+ setNoteValue(effectiveNote || "");
+ }
+ }, [effectiveNote, openNoteEditorFor, profile.id]);
+
+ // Auto-resize textarea on open
+ React.useEffect(() => {
+ if (openNoteEditorFor === profile.id && textareaRef.current) {
+ const textarea = textareaRef.current;
+ textarea.style.height = "auto";
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
+ }
+ }, [openNoteEditorFor, profile.id]);
+
+ const handleTextareaChange = React.useCallback(
+ (e: React.ChangeEvent) => {
+ const newValue = e.target.value;
+ setNoteValue(newValue);
+ // Auto-resize
+ const textarea = e.target;
+ textarea.style.height = "auto";
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
+ },
+ [],
+ );
+
+ React.useEffect(() => {
+ if (openNoteEditorFor !== profile.id) return;
+ const handleClick = (e: MouseEvent) => {
+ const target = e.target as Node | null;
+ if (
+ editorRef.current &&
+ target &&
+ !editorRef.current.contains(target)
+ ) {
+ const currentValue = textareaRef.current?.value || "";
+ void onNoteChange(currentValue);
+ setOpenNoteEditorFor(null);
+ }
+ };
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, [openNoteEditorFor, profile.id, setOpenNoteEditorFor, onNoteChange]);
+
+ React.useEffect(() => {
+ if (openNoteEditorFor === profile.id && textareaRef.current) {
+ textareaRef.current.focus();
+ // Move cursor to end
+ const len = textareaRef.current.value.length;
+ textareaRef.current.setSelectionRange(len, len);
+ }
+ }, [openNoteEditorFor, profile.id]);
+
+ const displayNote = effectiveNote || "";
+ const trimmedNote =
+ displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote;
+ const showTooltip = displayNote.length > 12 || displayNote.length > 0;
+
+ if (openNoteEditorFor !== profile.id) {
+ return (
+
+
+
+
+
+ {showTooltip && (
+
+
+ {effectiveNote || "No Note"}
+
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+ );
+ },
+);
+
+NoteCell.displayName = "NoteCell";
+
interface ProfilesDataTableProps {
profiles: BrowserProfile[];
onLaunchProfile: (profile: BrowserProfile) => void | Promise;
@@ -526,6 +770,12 @@ export function ProfilesDataTable({
const [proxyCheckResults, setProxyCheckResults] = React.useState<
Record
>({});
+ const [noteOverrides, setNoteOverrides] = React.useState<
+ Record
+ >({});
+ const [openNoteEditorFor, setOpenNoteEditorFor] = React.useState<
+ string | null
+ >(null);
// Load cached check results for proxies
React.useEffect(() => {
@@ -892,6 +1142,12 @@ export function ProfilesDataTable({
setOpenTagsEditorFor,
setTagsOverrides,
+ // Note editor state
+ noteOverrides,
+ openNoteEditorFor,
+ setOpenNoteEditorFor,
+ setNoteOverrides,
+
// Proxy selector state
openProxySelectorFor,
setOpenProxySelectorFor,
@@ -940,6 +1196,8 @@ export function ProfilesDataTable({
tagsOverrides,
allTags,
openTagsEditorFor,
+ noteOverrides,
+ openNoteEditorFor,
openProxySelectorFor,
proxyOverrides,
storedProxies,
@@ -1021,37 +1279,51 @@ export function ProfilesDataTable({
);
}
+ const browserName = getBrowserDisplayName(browser);
+
if (meta.showCheckboxes || isSelected) {
return (
-
-
- meta.handleCheckboxChange(profile.id, !!value)
- }
- aria-label="Select row"
- className="w-4 h-4"
- />
-
+ {browserName}
}
+ sideOffset={4}
+ horizontalOffset={8}
+ >
+
+
+ meta.handleCheckboxChange(profile.id, !!value)
+ }
+ aria-label="Select row"
+ className="w-4 h-4"
+ />
+
+
);
}
return (
-
-
-
+ {browserName}}
+ sideOffset={4}
+ horizontalOffset={8}
+ >
+
+
+
+
);
},
enableSorting: false,
@@ -1172,11 +1444,6 @@ export function ProfilesDataTable({
const isEditing = meta.profileToRename?.id === profile.id;
if (isEditing) {
- const isSaveDisabled =
- meta.isRenamingSaving ||
- meta.newProfileName.trim().length === 0 ||
- meta.newProfileName.trim() === profile.name;
-
return (
{
- if (e.key === "Enter") {
+ if (e.key === "Enter" && !(e.metaKey || e.ctrlKey)) {
+ void meta.handleRename();
+ } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
void meta.handleRename();
} else if (e.key === "Escape") {
meta.setProfileToRename(null);
@@ -1198,20 +1467,20 @@ export function ProfilesDataTable({
meta.setRenameError(null);
}
}}
+ onBlur={() => {
+ if (
+ meta.newProfileName.trim().length > 0 &&
+ meta.newProfileName.trim() !== profile.name
+ ) {
+ void meta.handleRename();
+ } else {
+ meta.setProfileToRename(null);
+ meta.setNewProfileName("");
+ meta.setRenameError(null);
+ }
+ }}
className="w-30 h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
/>
-
- void meta.handleRename()}
- >
- Save
-
-
);
}
@@ -1295,51 +1564,28 @@ export function ProfilesDataTable({
},
},
{
- accessorKey: "browser",
- header: ({ column }) => {
- return (
-
- );
- },
- cell: ({ row }) => {
- const browser: string = row.getValue("browser");
- const name = getBrowserDisplayName(browser);
- if (name.length < 14) {
- return (
-
- {name}
-
- );
- }
+ id: "note",
+ header: "Note",
+ cell: ({ row, table }) => {
+ const meta = table.options.meta as TableMeta;
+ const profile = row.original;
+ const isRunning =
+ meta.isClient && meta.runningProfiles.has(profile.id);
+ const isLaunching = meta.launchingProfiles.has(profile.id);
+ const isStopping = meta.stoppingProfiles.has(profile.id);
+ const isBrowserUpdating = meta.isUpdating(profile.browser);
+ const isDisabled =
+ isRunning || isLaunching || isStopping || isBrowserUpdating;
return (
-
-
- {trimName(name, 14)}
-
- {name}
-
- );
- },
- enableSorting: true,
- sortingFn: (rowA, rowB, columnId) => {
- const browserA: string = rowA.getValue(columnId);
- const browserB: string = rowB.getValue(columnId);
- return getBrowserDisplayName(browserA).localeCompare(
- getBrowserDisplayName(browserB),
+
);
},
},
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
index f10a83c..52f9ac3 100644
--- a/src/components/ui/tooltip.tsx
+++ b/src/components/ui/tooltip.tsx
@@ -37,14 +37,19 @@ function TooltipTrigger({
function TooltipContent({
className,
sideOffset = 0,
+ alignOffset,
+ arrowOffset = 0,
children,
...props
-}: React.ComponentProps) {
+}: React.ComponentProps & {
+ arrowOffset?: number;
+}) {
return (
{children}
-
+
);
diff --git a/src/types.ts b/src/types.ts
index 050e935..cb4cef8 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -7,7 +7,7 @@ export interface ProxySettings {
}
export interface TableSortingSettings {
- column: string; // "name", "browser", "status"
+ column: string; // "name", "note", "status"
direction: string; // "asc" or "desc"
}
@@ -23,6 +23,7 @@ export interface BrowserProfile {
camoufox_config?: CamoufoxConfig; // Camoufox configuration
group_id?: string; // Reference to profile group
tags?: string[];
+ note?: string; // User note
}
export interface ProxyCheckResult {