JSX element rendering the storage debugger panel
export function StorageDebugger() {
const [electronStoreItems, setElectronStoreItems] = useState<StorageItem[]>(
[],
);
const [localStorageItems, setLocalStorageItems] = useState<StorageItem[]>([]);
const [editingItem, setEditingItem] = useState<{
key: string;
value: string;
isElectronStore: boolean;
} | null>(null);
const [newItem, setNewItem] = useState<{
key: string;
value: string;
isElectronStore: boolean;
} | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [shouldSearchInValues, setShouldSearchInValues] = useState(true);
// Add formatter helper
const tryFormatJSON = (raw: string): string | null => {
const parsed = tryParseJSON(raw);
if (!parsed.ok) return null;
try {
return JSON.stringify(parsed.data, null, 2);
} catch {
return null;
}
};
// Load localStorage items
const loadLocalStorageItems = () => {
const items: StorageItem[] = [];
// Iterate through all localStorage entries
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
const value = localStorage.getItem(key) || "";
const { type, size } = getValueInfo(value);
items.push({ key, value, type, size });
}
}
// Sort by key for consistent display
items.sort((a, b) => a.key.localeCompare(b.key));
setLocalStorageItems(items);
};
// Get all localStorage keys
const getLocalStorageKeys = (): string[] => {
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) keys.push(key);
}
return keys;
};
// Get all keys to check for electron store
const getAllKeysToCheck = (): string[] => {
const localKeys = getLocalStorageKeys();
const electronSpecificKeys = [
"window-bounds",
"app-preferences",
"cache-settings",
"sync-config",
"match-config",
"kenmei-data",
"saved-match-results",
"pending-manga",
"auth-token",
"theme-preferences",
"anilist-credentials",
"app-version",
"last-sync-date",
];
return [...new Set([...localKeys, ...electronSpecificKeys])];
};
// Process a single electron store item
const processElectronStoreItem = async (
key: string,
): Promise<StorageItem | null> => {
try {
const value = await globalThis.electronStore.getItem(key);
if (value === null || value === undefined) return null;
const str = typeof value === "string" ? value : JSON.stringify(value);
const { type, size } = getValueInfo(str);
return { key, value: str, type, size };
} catch (error) {
console.warn(`Failed to get electron store item "${key}":`, error);
return null;
}
};
// Load electron store items (using localStorage keys as reference)
const loadElectronStoreItems = async () => {
if (!globalThis.electronStore) return;
try {
const items: StorageItem[] = [];
// Check both localStorage keys and known electron store keys
const keysToCheck = getAllKeysToCheck();
for (const key of keysToCheck) {
const item = await processElectronStoreItem(key);
if (item) {
items.push(item);
}
}
items.sort((a, b) => a.key.localeCompare(b.key));
setElectronStoreItems(items);
} catch (error) {
console.error("Failed to load electron store items:", error);
toast.error("Failed to load electron store items.");
}
};
const refreshData = async () => {
setIsLoading(true);
try {
loadLocalStorageItems();
await loadElectronStoreItems();
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void refreshData();
}, []);
const saveEditedItem = async () => {
if (!editingItem) return;
try {
if (editingItem.isElectronStore) {
if (globalThis.electronStore) {
await globalThis.electronStore.setItem(
editingItem.key,
editingItem.value,
);
// Automatically sync to localStorage and cache (following storage precedence behavior)
localStorage.setItem(editingItem.key, editingItem.value);
storageCache[editingItem.key] = editingItem.value;
toast.success(
"Electron store item updated successfully. localStorage and cache automatically synced.",
);
} else {
toast.error("Electron store bridge unavailable.");
}
} else {
// Update only localStorage for non-electron items
localStorage.setItem(editingItem.key, editingItem.value);
toast.success("localStorage item updated successfully.");
}
setEditingItem(null);
await refreshData();
} catch (error) {
console.error("Failed to save item:", error);
toast.error("Failed to save item.");
}
};
const addNewItem = async () => {
if (!newItem?.key?.trim()) return;
try {
if (newItem.isElectronStore) {
if (globalThis.electronStore) {
await globalThis.electronStore.setItem(newItem.key, newItem.value);
// Automatically sync to localStorage and cache (following storage precedence behavior)
localStorage.setItem(newItem.key, newItem.value);
storageCache[newItem.key] = newItem.value;
toast.success(
"New electron store item added successfully. localStorage and cache automatically synced.",
);
} else {
toast.error("Electron store bridge unavailable.");
}
} else {
localStorage.setItem(newItem.key, newItem.value);
toast.success("New localStorage item added successfully.");
}
setNewItem(null);
await refreshData();
} catch (error) {
console.error("Failed to add item:", error);
toast.error("Failed to add new item.");
}
};
const deleteItem = async (key: string, isElectronStore: boolean) => {
try {
if (isElectronStore) {
if (globalThis.electronStore) {
await globalThis.electronStore.removeItem(key);
// Automatically sync deletion to localStorage and cache (following storage precedence behavior)
localStorage.removeItem(key);
delete storageCache[key];
toast.success(
"Electron store item deleted successfully. localStorage and cache automatically synced.",
);
} else {
toast.error("Electron store bridge unavailable.");
}
} else {
localStorage.removeItem(key);
toast.success("localStorage item deleted successfully.");
}
await refreshData();
} catch (error) {
console.error("Failed to delete item:", error);
toast.error("Failed to delete item.");
}
};
// Export/Import helpers
const exportItems = (items: StorageItem[], filename: string) => {
const blob = new Blob([JSON.stringify(items, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const onImportJson = async (file: File, isElectronStore: boolean) => {
try {
const text = await file.text();
const parsed = tryParseJSON(text);
if (!parsed.ok || !Array.isArray(parsed.data))
throw new Error("Invalid import format: expected array");
type ImportEntry = { key: string; value?: unknown };
let applied = 0;
for (const entry of parsed.data as ImportEntry[]) {
if (!entry || typeof entry.key !== "string") continue;
const value =
typeof entry.value === "string"
? entry.value
: JSON.stringify(entry.value);
if (isElectronStore) {
if (globalThis.electronStore) {
await globalThis.electronStore.setItem(entry.key, value);
// Automatically sync to localStorage and cache (following storage precedence behavior)
localStorage.setItem(entry.key, value);
storageCache[entry.key] = value;
applied++;
}
} else {
localStorage.setItem(entry.key, value);
applied++;
}
}
toast.success(
`Imported ${applied} items${isElectronStore ? ". localStorage and cache automatically synced." : ""}`,
);
await refreshData();
} catch (error) {
const err = error as { message?: string };
toast.error(err?.message || "Import failed");
}
};
// Copy to clipboard helper
const handleCopyValue = (value: string) => {
navigator.clipboard
.writeText(value)
.then(() => toast.success("Copied value"))
.catch(() => toast.error("Failed to copy value"));
};
// Filters
const filterItems = (items: StorageItem[]) => {
const q = searchQuery.trim().toLowerCase();
if (!q) return items;
return items.filter(
(it) =>
it.key.toLowerCase().includes(q) ||
(shouldSearchInValues && it.value.toLowerCase().includes(q)),
);
};
const renderStorageTable = (
items: StorageItem[],
isElectronStore: boolean,
title: string,
icon: React.ReactNode,
) => {
const filtered = filterItems(items);
return (
<Card className="border-border/60 bg-background/90 flex h-full max-h-[40vh] flex-col border pb-6 pt-0 shadow-md backdrop-blur-sm">
<CardHeader className="border-border/60 bg-muted/10 shrink-0 border-b px-5 py-4">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
{icon}
{title}
<Badge variant="outline">
{searchQuery
? `${filtered.length}/${items.length}`
: `${items.length}`}{" "}
items
</Badge>
</CardTitle>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setNewItem({ key: "", value: "", isElectronStore })
}
>
<Plus className="mr-1 h-4 w-4" /> Add
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
exportItems(
items,
`${isElectronStore ? "electron" : "local"}-storage.json`,
)
}
>
<Download className="mr-1 h-4 w-4" /> Export
</Button>
<label className="inline-flex items-center">
<input
type="file"
accept="application/json"
className="hidden"
onChange={(event) =>
event.target.files &&
onImportJson(event.target.files[0], isElectronStore)
}
/>
<Button asChild variant="outline" size="sm">
<span>
<Upload className="mr-1 h-4 w-4" /> Import
</span>
</Button>
</label>
</div>
</div>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
<ScrollArea type="always" className="h-full max-h-[420px]">
<div className="space-y-3 p-4">
{filtered.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
{searchQuery
? "No items match your search"
: "No items found"}
</div>
) : (
filtered.map((item) => (
<div
key={item.key}
className="border-border/50 bg-muted/10 hover:border-primary/40 hover:bg-primary/5 group rounded-xl border transition-all"
>
<div className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<code className="bg-background/80 rounded px-1.5 font-mono text-sm shadow-sm">
{highlight(item.key, searchQuery)}
</code>
<TypeBadge type={item.type} />
<span className="text-muted-foreground text-xs">
{formatSize(item.size)}
</span>
</div>
<div className="text-muted-foreground break-all font-mono text-sm leading-relaxed">
<OverviewValue value={item.value} maxChars={160} />
</div>
</div>
<div className="ml-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyValue(item.value)}
aria-label="Copy value"
title="Copy value"
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
setEditingItem({
key: item.key,
value: item.value,
isElectronStore,
})
}
aria-label="Edit item"
title="Edit item"
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Delete item"
title="Delete item"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete Storage Item
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the key “
{item.key}”? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
deleteItem(item.key, isElectronStore)
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
);
};
const jsonValidity = useMemo(() => {
if (!editingItem) return null;
return tryParseJSON(editingItem.value);
}, [editingItem]);
const editingItemType = useMemo(() => {
if (!editingItem) return "string";
return getValueInfo(editingItem.value).type;
}, [editingItem]);
const storageStats = useMemo(() => {
const localCount = localStorageItems.length;
const electronCount = electronStoreItems.length;
const localSize = localStorageItems.reduce(
(acc, item) => acc + item.size,
0,
);
const electronSize = electronStoreItems.reduce(
(acc, item) => acc + item.size,
0,
);
const uniqueKeys = new Set([
...localStorageItems.map((item) => item.key),
...electronStoreItems.map((item) => item.key),
]);
return {
local: { count: localCount, size: localSize },
electron: { count: electronCount, size: electronSize },
total: {
count: uniqueKeys.size,
size: localSize + electronSize,
},
};
}, [localStorageItems, electronStoreItems]);
const statCards = useMemo(
() => [
{
id: "local",
label: "localStorage",
count: storageStats.local.count,
size: storageStats.local.size,
accent: "from-sky-500/25 via-blue-500/10 to-transparent",
},
{
id: "electron",
label: "Electron Store",
count: storageStats.electron.count,
size: storageStats.electron.size,
accent: "from-purple-500/25 via-fuchsia-500/10 to-transparent",
},
{
id: "total",
label: "Merged footprint",
count: storageStats.total.count,
size: storageStats.total.size,
accent: "from-emerald-500/20 via-teal-500/10 to-transparent",
},
],
[storageStats],
);
return (
<>
<div className="border-border/60 bg-background/95 relative mt-2 overflow-hidden rounded-3xl border shadow-xl backdrop-blur">
<div className="from-primary/10 bg-linear-to-br pointer-events-none absolute inset-0 via-blue-500/10 to-transparent" />
<div className="bg-primary/20 pointer-events-none absolute -right-24 top-1/2 h-96 w-96 -translate-y-1/2 rounded-full blur-[120px]" />
<div className="relative z-10 flex flex-col gap-6 p-6 md:p-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="relative w-full sm:min-w-60">
<Search className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Search by key or value…"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
className="border-border/60 bg-background/80 w-full rounded-full pl-9 pr-4 text-sm shadow-inner"
/>
</div>
<div className="flex items-center justify-end gap-3">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Switch
id="search-values-toggle"
checked={shouldSearchInValues}
onCheckedChange={(checked) =>
setShouldSearchInValues(Boolean(checked))
}
aria-labelledby="search-values-label"
/>
<span id="search-values-label" className="font-medium">
Search values
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={refreshData}
disabled={isLoading}
className="border-primary/40 bg-primary/10 text-primary hover:bg-primary/20 shadow-sm transition-colors"
>
<RefreshCw
className={`mr-1 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{statCards.map((stat) => (
<div
key={stat.id}
className="border-border/60 bg-background/90 relative overflow-hidden rounded-2xl border p-4 shadow-inner"
>
<div
className={`bg-linear-to-br pointer-events-none absolute inset-0 ${stat.accent}`}
/>
<div className="relative z-10 space-y-2">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{stat.label}
</p>
<div className="font-mono text-2xl font-semibold">
{stat.count.toLocaleString()}
<span className="text-muted-foreground ml-2 text-sm font-medium">
keys
</span>
</div>
<p className="text-muted-foreground text-xs">
{formatSize(stat.size)} total footprint
</p>
</div>
</div>
))}
</div>
<div className="flex items-start gap-3 rounded-2xl border border-amber-200/80 bg-amber-50/70 p-4 text-sm shadow-sm dark:border-amber-800/70 dark:bg-amber-950/40">
<div className="mt-1 flex items-center justify-center rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-300">
<svg
className="m-1 h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="text-muted-foreground space-y-2">
<p className="font-medium text-amber-800 dark:text-amber-200">
Storage precedence
</p>
<p>
<strong>Electron Store overrides localStorage.</strong> Edits
sync downstream automatically, so prefer modifying the Electron
layer when possible.
</p>
<p className="text-xs">
📚 Learn more in{" "}
<a
href="https://github.com/RLAlpha49/Anilist-Manga-Updater/blob/master/docs/guides/STORAGE_IMPLEMENTATION.md"
target="_blank"
rel="noopener noreferrer"
className="underline decoration-dotted underline-offset-4 hover:text-amber-600 dark:hover:text-amber-300"
>
STORAGE_IMPLEMENTATION.md
</a>
</p>
</div>
</div>
</div>
</div>
<div className="mt-6 flex min-h-0 flex-1 flex-col">
<Tabs defaultValue="localStorage" className="min-h-0 flex-1 gap-4">
<TabsList className="bg-muted/40 grid w-full grid-cols-2 gap-2 rounded-full p-1 text-sm font-medium">
<TabsTrigger
value="localStorage"
className="data-[state=active]:bg-background rounded-full data-[state=active]:shadow-md"
>
localStorage
</TabsTrigger>
<TabsTrigger
value="electronStore"
className="data-[state=active]:bg-background rounded-full data-[state=active]:shadow-md"
>
Electron Store
</TabsTrigger>
</TabsList>
<TabsContent
value="localStorage"
className="min-h-[480px] flex-1 pb-4"
>
{renderStorageTable(
localStorageItems,
false,
"localStorage",
<HardDrive className="h-4 w-4" />,
)}
</TabsContent>
<TabsContent
value="electronStore"
className="min-h-[480px] flex-1 pb-4"
>
{renderStorageTable(
electronStoreItems,
true,
"Electron Store",
<Database className="h-4 w-4" />,
)}
</TabsContent>
</Tabs>
</div>
{/* Edit Item Dialog */}
{editingItem && (
<Dialog open={!!editingItem} onOpenChange={() => setEditingItem(null)}>
<DialogContent className="max-w-2xl!">
<DialogHeader>
<DialogTitle>Edit Storage Item</DialogTitle>
<DialogDescription>
Modify the value for key:{" "}
<code className="font-mono">{editingItem.key}</code>
{editingItem.isElectronStore && (
<div className="mt-2 text-sm text-blue-600 dark:text-blue-400">
💡 Electron store changes will automatically sync to
localStorage and cache
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-key" className="pb-2">
Key
</Label>
<div>
<Input
id="edit-key"
value={editingItem.key}
disabled
className="font-mono"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<Label htmlFor="edit-value">Value</Label>
<div className="flex items-center gap-3">
{editingItemType === "object" && (
<>
<div className="text-muted-foreground text-xs">
{jsonValidity &&
(jsonValidity.ok ? (
<span className="text-green-600">JSON valid</span>
) : (
<span className="text-red-600">
{jsonValidity.error}
</span>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
if (!editingItem) return;
const formatted = tryFormatJSON(editingItem.value);
if (!formatted) {
toast.error("Value is not valid JSON");
return;
}
setEditingItem({
...editingItem,
value: formatted,
});
toast.success("Formatted JSON");
}}
>
Format JSON
</Button>
</>
)}
</div>
</div>
<div className="pt-2">
<div>
<Textarea
id="edit-value"
value={editingItem.value}
onChange={(event) =>
setEditingItem({
...editingItem,
value: event.target.value,
})
}
className="wrap-break-word h-64 w-full max-w-xl resize-y overflow-auto whitespace-pre-wrap font-mono"
style={{ resize: "none" }}
placeholder="Enter value (JSON or string)..."
/>
</div>
</div>
</div>
<div className="flex w-full justify-end gap-2">
<Button variant="outline" onClick={() => setEditingItem(null)}>
<X className="mr-1 h-4 w-4" /> Cancel
</Button>
<Button onClick={saveEditedItem}>
<Save className="mr-1 h-4 w-4" /> Save
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* Add New Item Dialog */}
{newItem && (
<Dialog open={!!newItem} onOpenChange={() => setNewItem(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Add New Storage Item</DialogTitle>
<DialogDescription>
Add a new item to{" "}
{newItem.isElectronStore ? "Electron Store" : "localStorage"}
{newItem.isElectronStore && (
<div className="mt-2 text-sm text-blue-600 dark:text-blue-400">
💡 Electron store items will automatically sync to
localStorage and cache
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="new-key">Key</Label>
<Input
id="new-key"
value={newItem.key}
onChange={(event) =>
setNewItem({ ...newItem, key: event.target.value })
}
className="font-mono"
placeholder="Enter key..."
/>
</div>
<div>
<Label htmlFor="new-value">Value</Label>
<Textarea
id="new-value"
value={newItem.value}
onChange={(event) =>
setNewItem({ ...newItem, value: event.target.value })
}
className="h-64 resize-y overflow-y-auto font-mono"
style={{ width: "100%", maxWidth: "100%" }}
placeholder="Enter value (JSON or string)..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setNewItem(null)}>
<X className="mr-1 h-4 w-4" /> Cancel
</Button>
<Button onClick={addNewItem} disabled={!newItem.key.trim()}>
<Plus className="mr-1 h-4 w-4" /> Add Item
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
</>
);
}
Storage debugger for viewing and editing localStorage and Electron Store entries. Displays storage statistics, allows search, export, import, and item editing.