kv-clearnup/src/components/Uninstaller/AppDetails.tsx

328 lines
16 KiB
TypeScript

import { useState, useEffect } from 'react';
import { ArrowLeft, Trash2, RefreshCw, Eraser, FileText, Settings, Database, Folder, AlertTriangle } from 'lucide-react';
import { API } from '../../api/client';
import type { AppInfo, AppDetails } from '../../api/client';
import { GlassCard } from '../ui/GlassCard';
import { GlassButton } from '../ui/GlassButton';
import { useToast } from '../ui/Toast';
interface Props {
app: AppInfo;
onBack: () => void;
onUninstall: () => void;
}
export function AppDetailsView({ app, onBack, onUninstall }: Props) {
const [details, setDetails] = useState<AppDetails | null>(null);
const [loading, setLoading] = useState(true);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [processing, setProcessing] = useState(false);
const toast = useToast();
useEffect(() => {
loadDetails();
}, [app.path]);
const loadDetails = async () => {
try {
setLoading(true);
const data = await API.getAppDetails(app.path, app.bundleID);
setDetails(data);
// Select all by default
const allFiles = new Set<string>();
allFiles.add(data.path); // Main app bundle
data.associated.forEach(f => allFiles.add(f.path));
setSelectedFiles(allFiles);
} catch (error) {
console.error(error);
toast.addToast({ type: 'error', title: 'Error', message: 'Failed to load app details' });
} finally {
setLoading(false);
}
};
const formatSize = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
const toggleFile = (path: string) => {
const newSet = new Set(selectedFiles);
if (newSet.has(path)) {
newSet.delete(path);
} else {
newSet.add(path);
}
setSelectedFiles(newSet);
};
const getIconForType = (type: string) => {
switch (type) {
case 'config': return <Settings className="w-4 h-4 text-gray-400" />;
case 'cache': return <Database className="w-4 h-4 text-yellow-400" />;
case 'log': return <FileText className="w-4 h-4 text-blue-400" />;
case 'registry': return <Settings className="w-4 h-4 text-purple-400" />;
default: return <Folder className="w-4 h-4 text-gray-400" />;
}
};
const handleAction = async (action: 'uninstall' | 'reset' | 'cache') => {
if (!details) return;
// Special handling for Uninstall with official uninstaller
if (action === 'uninstall' && details.uninstallString) {
const confirmed = await toast.confirm(
`Run Uninstaller?`,
`This will launch the official uninstaller for ${details.name}.`
);
if (!confirmed) return;
try {
setProcessing(true);
await API.uninstallApp(details.uninstallString);
toast.addToast({ type: 'success', title: 'Success', message: 'Uninstaller launched' });
// We don't automatically close the view or reload apps because the uninstaller is external
// But we can go back
onBack();
} catch (error) {
console.error(error);
toast.addToast({ type: 'error', title: 'Error', message: 'Failed to launch uninstaller' });
} finally {
setProcessing(false);
}
return;
}
let filesToDelete: string[] = [];
if (action === 'uninstall') {
filesToDelete = Array.from(selectedFiles);
} else if (action === 'reset') {
// Reset: Config + Data only, keep App
filesToDelete = details.associated
.filter(f => f.type === 'config' || f.type === 'data')
.map(f => f.path);
} else if (action === 'cache') {
// Cache: Caches + Logs
filesToDelete = details.associated
.filter(f => f.type === 'cache' || f.type === 'log')
.map(f => f.path);
}
if (filesToDelete.length === 0) {
toast.addToast({ type: 'info', title: 'Info', message: 'No files selected for this action' });
return;
}
// Confirmation (Simple browser confirm for now, better UI later)
const confirmed = await toast.confirm(
`Delete ${filesToDelete.length} items?`,
'This cannot be undone.'
);
if (!confirmed) return;
try {
setProcessing(true);
await API.deleteAppFiles(filesToDelete);
toast.addToast({ type: 'success', title: 'Success', message: 'Cleaned up successfully' });
if (action === 'uninstall') {
onUninstall();
} else {
loadDetails(); // Reload to show remaining files
}
} catch (error) {
console.error(error);
toast.addToast({ type: 'error', title: 'Error', message: 'Failed to delete files' });
} finally {
setProcessing(false);
}
};
if (loading || !details) {
return (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-4" />
<p>Analyzing application structure...</p>
</div>
);
}
const totalSelectedSize = Array.from(selectedFiles).reduce((acc, path) => {
if (path === details.path) return acc + details.size;
const assoc = details.associated.find(f => f.path === path);
return acc + (assoc ? assoc.size : 0);
}, 0);
return (
<div className="space-y-6 max-w-6xl mx-auto p-6 animate-in fade-in slide-in-from-right-4 duration-300">
<header className="flex items-center gap-4 mb-8">
<button
onClick={onBack}
className="p-2.5 bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white shadow-sm hover:shadow-md"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{details.name}
</h1>
<p className="text-gray-500 dark:text-gray-400 text-sm font-mono mt-1">{details.bundleID}</p>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* File List */}
<div className="lg:col-span-2 space-y-4">
<GlassCard className="overflow-hidden">
<div className="p-5 border-b border-gray-100 dark:border-white/5 flex items-center justify-between bg-gray-50/50 dark:bg-white/5">
<span className="font-semibold text-gray-900 dark:text-gray-200">Application Bundle & Data</span>
<span className="text-sm font-medium px-3 py-1 rounded-full bg-blue-100/50 dark:bg-blue-500/20 text-blue-600 dark:text-blue-300">
{formatSize(totalSelectedSize)} selected
</span>
</div>
<div className="p-3 space-y-1">
{/* Main App */}
<div
className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer border ${selectedFiles.has(details.path)
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/30'
: 'hover:bg-gray-50 dark:hover:bg-white/5 border-transparent'
}`}
onClick={() => toggleFile(details.path)}
>
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${selectedFiles.has(details.path)
? 'bg-blue-500 border-blue-500 text-white'
: 'border-gray-300 dark:border-gray-600'
}`}>
{selectedFiles.has(details.path) && <Folder className="w-3 h-3" />}
</div>
<div className="p-2 rounded-lg bg-gray-100 dark:bg-white/5 text-gray-500 dark:text-gray-400">
<PackageIcon className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">Application Bundle</div>
<div className="text-xs text-gray-500 truncate mt-0.5">{details.path}</div>
</div>
<span className="text-sm font-mono font-medium text-gray-600 dark:text-gray-400">{formatSize(details.size)}</span>
</div>
{/* Associated Files */}
{details.associated.map((file) => (
<div
key={file.path}
className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer border ${selectedFiles.has(file.path)
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/30'
: 'hover:bg-gray-50 dark:hover:bg-white/5 border-transparent'
}`}
onClick={() => toggleFile(file.path)}
>
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${selectedFiles.has(file.path)
? 'bg-blue-500 border-blue-500 text-white'
: 'border-gray-300 dark:border-gray-600'
}`}>
{selectedFiles.has(file.path) && <Folder className="w-3 h-3" />}
</div>
<div className="p-2 rounded-lg bg-gray-100 dark:bg-white/5">
{getIconForType(file.type)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 capitalize">{file.type}</div>
<div className="text-xs text-gray-500 truncate mt-0.5">{file.path}</div>
</div>
<span className="text-sm font-mono font-medium text-gray-600 dark:text-gray-400">{formatSize(file.size)}</span>
</div>
))}
</div>
</GlassCard>
</div>
{/* Actions */}
<div className="space-y-6">
<GlassCard className="p-5 space-y-5">
<h3 className="font-semibold text-gray-900 dark:text-gray-200">Cleanup Actions</h3>
<GlassButton
variant="danger"
className="w-full justify-start gap-4 p-4 h-auto"
onClick={() => handleAction('uninstall')}
disabled={processing}
>
<div className="p-2 bg-white/20 rounded-lg">
<Trash2 className="w-5 h-5" />
</div>
<div className="flex flex-col items-start text-left">
<span className="font-semibold text-base">Uninstall</span>
<span className="text-xs opacity-90 font-normal">Remove {selectedFiles.size} selected items</span>
</div>
</GlassButton>
<div className="h-px bg-gray-100 dark:bg-white/10 my-2" />
<div className="grid grid-cols-1 gap-3">
<button
onClick={() => handleAction('reset')}
disabled={processing}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-white/10 group"
>
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 group-hover:bg-blue-100 dark:group-hover:bg-blue-500/20 transition-colors">
<RefreshCw className="w-4 h-4" />
</div>
<div className="flex flex-col items-start text-left">
<span className="font-medium text-gray-900 dark:text-gray-200 text-sm">Reset Application</span>
<span className="text-xs text-gray-500">Delete config & data only</span>
</div>
</button>
<button
onClick={() => handleAction('cache')}
disabled={processing}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-white/10 group"
>
<div className="p-2 rounded-lg bg-orange-50 dark:bg-orange-500/10 text-orange-600 dark:text-orange-400 group-hover:bg-orange-100 dark:group-hover:bg-orange-500/20 transition-colors">
<Eraser className="w-4 h-4" />
</div>
<div className="flex flex-col items-start text-left">
<span className="font-medium text-gray-900 dark:text-gray-200 text-sm">Clear Cache</span>
<span className="text-xs text-gray-500">Remove temporary files</span>
</div>
</button>
</div>
</GlassCard>
<div className="p-4 rounded-xl bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 flex gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 shrink-0 mt-0.5" />
<p className="text-xs text-yellow-800 dark:text-yellow-200/80 leading-relaxed font-medium">
Deleted files cannot be recovered. Ensure you have backups of important data before uninstalling applications.
</p>
</div>
</div>
</div>
</div>
);
}
function PackageIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="m16.5 9.4-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
)
}