328 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|