PlugSnatcher/src/App.tsx

891 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { appDataDir } from '@tauri-apps/api/path'; // Import for data directory (if needed frontend side)
import "./App.css";
type ServerType =
| 'Paper'
| 'Spigot'
| 'Bukkit'
| 'Vanilla'
| 'Forge'
| 'Fabric'
| 'Velocity'
| 'BungeeCord'
| 'Waterfall'
| 'Unknown';
interface ServerInfo {
server_type: ServerType;
minecraft_version?: string;
plugins_directory: string;
plugins_count: number;
}
interface Plugin {
name: string;
version: string;
latest_version?: string;
description?: string;
authors: string[];
has_update: boolean;
api_version?: string;
main_class?: string;
depend?: string[] | null;
soft_depend?: string[] | null;
load_before?: string[] | null;
commands?: any;
permissions?: any;
file_path: string;
file_hash: string;
website?: string;
changelog?: string;
repository_source?: string; // Add repository source (string for simplicity)
repository_id?: string; // Add repository ID
repository_url?: string; // URL to the plugin's repository page
}
interface ScanResult {
server_info: ServerInfo;
plugins: Plugin[];
}
interface ScanProgress {
processed: number;
total: number;
current_file: string;
}
// --- New Interfaces for Update Events ---
interface BulkUpdateProgressPayload {
processed: number;
total: number;
current_plugin_name: string;
}
interface SingleUpdateResultPayload {
original_file_path: string;
plugin: Plugin | null; // Updated plugin state or null if check failed but wasn't a panic
error: string | null; // Error message if any
}
// Interface for potential plugin matches for ambiguous plugins
interface PotentialPluginMatch {
name: string;
version: string;
repository: string;
repository_id: string;
page_url: string;
description?: string;
minecraft_versions: string[];
download_count?: number;
}
// --- End New Interfaces ---
interface PluginDetailsProps {
plugin: Plugin;
onClose: () => void;
}
// Get server type icon
function getServerTypeIcon(serverType: ServerType): string {
switch (serverType) {
case 'Paper':
return '📄';
case 'Spigot':
return '🔌';
case 'Bukkit':
return '🪣';
case 'Vanilla':
return '🧊';
case 'Forge':
return '🔨';
case 'Fabric':
return '🧵';
case 'Velocity':
return '⚡';
case 'BungeeCord':
return '🔗';
case 'Waterfall':
return '🌊';
default:
return '❓';
}
}
// Get a formatted server type name for display
function getServerTypeName(serverType: ServerType): string {
return serverType === 'Unknown' ? 'Unknown Server' : serverType;
}
function PluginDetails({ plugin, onClose }: PluginDetailsProps) {
return (
<div className="plugin-details-modal">
<div className="plugin-details-content">
<button className="close-button" onClick={onClose}>&times;</button>
<h2>{plugin.name}</h2>
<div className="plugin-version-display">Version: {plugin.version} {plugin.latest_version && plugin.has_update && <span className="update-available-badge">(Update available: {plugin.latest_version})</span>}</div>
{plugin.description && (
<div className="plugin-description">{plugin.description}</div>
)}
{plugin.website && (
<div className="plugin-website">
<div className="section-label">Website:</div>
<a href={plugin.website} target="_blank" rel="noopener noreferrer">
{plugin.website}
</a>
</div>
)}
{plugin.authors && plugin.authors.length > 0 && (
<div className="plugin-authors">
<div className="section-label">Authors:</div>
<div>{plugin.authors.join(", ")}</div>
</div>
)}
{plugin.depend && plugin.depend.length > 0 && (
<div className="plugin-dependencies">
<div className="section-label">Dependencies:</div>
<div>{plugin.depend.join(", ")}</div>
</div>
)}
{plugin.soft_depend && plugin.soft_depend.length > 0 && (
<div className="plugin-soft-dependencies">
<div className="section-label">Soft Dependencies:</div>
<div>{plugin.soft_depend.join(", ")}</div>
</div>
)}
{plugin.changelog && plugin.has_update && (
<div className="plugin-changelog">
<div className="section-label">Changelog:</div>
<div className="changelog-content">{plugin.changelog}</div>
</div>
)}
<div className="plugin-file-info">
<div className="section-label">File Path:</div>
<div className="file-path">{plugin.file_path}</div>
<div className="section-label">File Hash (SHA-256):</div>
<div className="file-hash">{plugin.file_hash}</div>
</div>
{plugin.has_update && plugin.latest_version && (
<div className="update-actions">
<button
className="update-button detail-update-button"
onClick={() => window.dispatchEvent(new CustomEvent('update-plugin', { detail: plugin }))}
>
Update to version {plugin.latest_version}
</button>
</div>
)}
</div>
</div>
);
}
// Add this component after the PluginDetails component and before the App component
function ServerInfoDisplay({ serverInfo }: { serverInfo: ServerInfo | null }) {
if (!serverInfo) return null;
return (
<div className="server-info">
<h2>Server Information</h2>
<div className="server-type">
<span className="server-icon">{getServerTypeIcon(serverInfo.server_type)}</span>
<span className="server-type-name">{getServerTypeName(serverInfo.server_type)}</span>
</div>
<div className="minecraft-version">
<span className="version-label">Minecraft Version</span>
<span className="version-value">{serverInfo.minecraft_version || "Unknown"}</span>
</div>
<div className="plugins-path">
<span className="path-label">Plugins Directory</span>
<span className="path-value">{serverInfo.plugins_directory || "Unknown"}</span>
</div>
<div className="plugins-count">
<b>{serverInfo.plugins_count}</b> plugins found
</div>
</div>
);
}
// Add this new component after PluginDetails
function WarningModal({ message, onClose }: { message: string, onClose: () => void }) {
return (
<div className="modal-backdrop">
<div className="warning-modal">
<h3> Warning</h3>
<p>{message}</p>
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
// Component for selecting the correct plugin from potential matches
function PluginMatchSelector({
plugin,
potentialMatches,
onSelect,
onCancel
}: {
plugin: Plugin,
potentialMatches: PotentialPluginMatch[],
onSelect: (match: PotentialPluginMatch) => void,
onCancel: () => void
}) {
return (
<div className="modal-backdrop">
<div className="plugin-match-modal">
<h3>Multiple Matches Found</h3>
<p>We found several potential matches for <strong>{plugin.name}</strong>. Please select the correct one:</p>
<div className="matches-list">
{potentialMatches.map((match, index) => (
<div key={`${match.repository}-${match.repository_id}`} className="match-item">
<div className="match-details">
<h4>{match.name}</h4>
<p className="match-version">Version: {match.version}</p>
{match.description && <p className="match-description">{match.description}</p>}
<div className="match-meta">
<span className="match-repo">Source: {match.repository}</span>
{match.download_count && <span className="match-downloads">Downloads: {match.download_count.toLocaleString()}</span>}
<span className="match-mc-version">MC: {match.minecraft_versions.join(', ')}</span>
</div>
</div>
<div className="match-actions">
<button className="select-match-button" onClick={() => onSelect(match)}>
Select This Match
</button>
<a
href={match.page_url}
target="_blank"
rel="noopener noreferrer"
className="view-page-link"
>
View Page
</a>
</div>
</div>
))}
</div>
<div className="match-selector-footer">
<button className="cancel-button" onClick={onCancel}>Cancel</button>
</div>
</div>
</div>
);
}
function App() {
const [serverPath, setServerPath] = useState("");
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [isScanning, setIsScanning] = useState(false);
const [scanComplete, setScanComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPlugin, setSelectedPlugin] = useState<Plugin | null>(null);
const [scanProgress, setScanProgress] = useState<ScanProgress | null>(null);
const [isCheckingUpdates, setIsCheckingUpdates] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
// --- New State Variables ---
const [pluginLoadingStates, setPluginLoadingStates] = useState<Record<string, boolean>>({});
const [bulkUpdateProgress, setBulkUpdateProgress] = useState<BulkUpdateProgressPayload | null>(null);
const [warningMessage, setWarningMessage] = useState<string | null>(null);
const [potentialMatches, setPotentialMatches] = useState<PotentialPluginMatch[]>([]);
// --- New state for match selector ---
const [showMatchSelector, setShowMatchSelector] = useState(false);
const [pluginToDisambiguate, setPluginToDisambiguate] = useState<Plugin | null>(null);
// --- End New state for match selector ---
// --- End New State Variables ---
const [serverType, setServerType] = useState<ServerType>('Unknown');
useEffect(() => {
let unlistenScanStarted: UnlistenFn | undefined;
let unlistenScanProgress: UnlistenFn | undefined;
let unlistenScanCompleted: UnlistenFn | undefined;
let unlistenScanError: UnlistenFn | undefined;
let unlistenBulkUpdateStart: UnlistenFn | undefined;
let unlistenUpdateCheckProgress: UnlistenFn | undefined;
let unlistenSingleUpdateCheckStarted: UnlistenFn | undefined;
let unlistenSingleUpdateCheckCompleted: UnlistenFn | undefined;
const setupListeners = async () => {
unlistenScanStarted = await listen("scan_started", () => {
console.log("Scan started event received");
setIsScanning(true);
setScanProgress(null);
setError(null);
});
unlistenScanProgress = await listen<ScanProgress>("scan_progress", (event) => {
console.log("Scan progress event received:", event.payload);
setScanProgress(event.payload);
});
unlistenScanCompleted = await listen<ScanResult>("scan_completed", (event) => {
console.log("Scan completed event received with payload:", event.payload);
try {
console.log("Server info:", event.payload.server_info);
console.log("Plugins count:", event.payload.plugins.length);
// Update state in a specific order to ensure UI updates properly
setIsScanning(false);
setScanComplete(true);
setServerInfo(event.payload.server_info);
setPlugins(event.payload.plugins);
setServerType(event.payload.server_info.server_type);
// Add a slight delay and verify the state was updated
setTimeout(() => {
console.log("Verifying state updates after scan:");
console.log("- scanComplete:", scanComplete);
console.log("- serverInfo:", serverInfo);
console.log("- plugins count:", plugins.length);
// Force a state update if plugins length is still 0 but we got plugins
if (plugins.length === 0 && event.payload.plugins.length > 0) {
console.log("Forcing state update because plugins array is empty");
setPlugins([...event.payload.plugins]);
}
}, 100);
console.log("State updated after scan completion");
} catch (err) {
console.error("Error handling scan completion:", err);
setError(`Error handling scan completion: ${err}`);
setIsScanning(false);
}
});
unlistenScanError = await listen<string>("scan_error", (event) => {
console.log("Scan error event received:", event.payload);
setIsScanning(false);
setError(event.payload);
});
unlistenBulkUpdateStart = await listen<number>("bulk_update_start", (event) => {
console.log("Bulk update start event received, total plugins:", event.payload);
setIsCheckingUpdates(true);
setUpdateError(null);
setBulkUpdateProgress({
processed: 0,
total: event.payload,
current_plugin_name: "Starting update check..."
});
});
unlistenUpdateCheckProgress = await listen<BulkUpdateProgressPayload>("update_check_progress", (event) => {
console.log("Update check progress event received:", event.payload);
setBulkUpdateProgress(event.payload);
});
unlistenSingleUpdateCheckStarted = await listen<string>("single_update_check_started", (event) => {
console.log("Single update check started for:", event.payload);
});
unlistenSingleUpdateCheckCompleted = await listen<SingleUpdateResultPayload>("single_update_check_completed", (event) => {
console.log("Single update check completed, result:", event.payload);
const { original_file_path, plugin, error } = event.payload;
setPluginLoadingStates(prev => ({ ...prev, [original_file_path]: false }));
if (error) {
setUpdateError(`Error checking for updates: ${error}`);
return;
}
if (plugin) {
setPlugins(prevPlugins => prevPlugins.map(p => {
if (p.file_path === original_file_path) {
return plugin;
}
return p;
}));
if (serverPath) {
invoke("save_plugin_data", {
plugins: plugins.map(p => p.file_path === original_file_path ? plugin : p),
serverPath
}).catch(err => {
console.error("Error saving plugin data after single update:", err);
});
}
}
});
window.addEventListener('update-plugin', ((e: CustomEvent<Plugin>) => {
if (e.detail) {
updatePlugin(e.detail);
}
}) as EventListener);
};
setupListeners();
return () => {
unlistenScanStarted?.();
unlistenScanProgress?.();
unlistenScanCompleted?.();
unlistenScanError?.();
unlistenBulkUpdateStart?.();
unlistenUpdateCheckProgress?.();
unlistenSingleUpdateCheckStarted?.();
unlistenSingleUpdateCheckCompleted?.();
window.removeEventListener('update-plugin', (() => {}) as EventListener);
};
}, []);
async function selectDirectory() {
try {
const selected = await open({
directory: true,
multiple: false,
title: "Select Minecraft Server Folder",
});
if (selected && typeof selected === "string") {
console.log(`Directory selected: ${selected}`);
setServerPath(selected);
setServerInfo(null);
setPlugins([]);
setIsScanning(false);
setScanComplete(false);
setError(null);
setScanProgress(null);
setIsCheckingUpdates(false);
setUpdateError(null);
setPluginLoadingStates({});
setBulkUpdateProgress(null);
try {
console.log(`Attempting to load persisted data for: ${selected}`);
const loadedPlugins: Plugin[] = await invoke("load_plugin_data", { serverPath: selected });
if (loadedPlugins && loadedPlugins.length > 0) {
console.log(`Loaded ${loadedPlugins.length} plugins from persistence.`);
setPlugins(loadedPlugins);
setScanComplete(true);
setError(null);
} else {
console.log("No persisted plugin data found for this server.");
}
} catch (loadError) {
console.error("Error loading persisted plugin data:", loadError);
setError(`Failed to load previous plugin data: ${loadError}`);
}
} else {
console.log("Directory selection cancelled.");
}
} catch (err) {
console.error("Error selecting directory:", err);
setError(`Error selecting directory: ${err}`);
}
}
async function scanForPlugins() {
if (!serverPath || isScanning) return;
try {
console.log("Starting scan for plugins in:", serverPath);
setIsScanning(true);
setScanComplete(false);
setPlugins([]);
setServerInfo(null);
setScanProgress(null);
setError(null);
await invoke("scan_server_dir", { path: serverPath });
console.log("Scan server dir command invoked successfully");
} catch (err) {
console.error("Error invoking scan command:", err);
setError(`Failed to start scan: ${err as string}`);
setIsScanning(false);
}
}
async function checkForUpdates() {
if (!plugins.length || isCheckingUpdates) return;
setIsCheckingUpdates(true);
setUpdateError(null);
setBulkUpdateProgress(null);
console.log("Invoking bulk check_plugin_updates...");
try {
const repositoriesToCheck = ['SpigotMC', 'Modrinth', 'GitHub'];
const pluginsToSend = plugins.map(p => ({
name: p.name,
version: p.version,
authors: p.authors || [],
file_path: p.file_path,
file_hash: p.file_hash,
website: p.website,
description: p.description,
api_version: p.api_version,
main_class: p.main_class,
depend: p.depend,
soft_depend: p.soft_depend,
load_before: p.load_before,
commands: p.commands,
permissions: p.permissions,
has_update: p.has_update || false,
repository_source: p.repository_source,
repository_id: p.repository_id,
repository_url: p.repository_url,
}));
console.log("Sending plugin data to backend, count:", pluginsToSend.length);
const updatedPlugins = await invoke<Plugin[]>("check_plugin_updates", {
plugins: pluginsToSend,
repositories: repositoriesToCheck,
});
console.log("Bulk update check completed successfully, updating state.");
setPlugins(updatedPlugins);
if (serverPath) {
try {
console.log("[checkForUpdates] Saving plugin data...");
await invoke("save_plugin_data", { plugins: updatedPlugins, serverPath });
console.log("[checkForUpdates] Plugin data saved successfully.");
} catch (saveError) {
console.error("Error saving plugin data after bulk update:", saveError);
setUpdateError(`Update check complete, but failed to save plugin data: ${saveError}`);
}
}
} catch (err) {
const errorMessage = `Error during bulk update check: ${err instanceof Error ? err.message : String(err)}`;
console.error(errorMessage);
setUpdateError(errorMessage);
} finally {
setIsCheckingUpdates(false);
setBulkUpdateProgress(null);
}
}
async function checkSinglePlugin(plugin: Plugin) {
if (isScanning || isCheckingUpdates || pluginLoadingStates[plugin.file_path]) return;
console.log(`Invoking single check for: ${plugin.name} (${plugin.file_path})`);
setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: true }));
setUpdateError(null);
try {
const repositoriesToCheck = ['SpigotMC', 'Modrinth', 'GitHub'];
const pluginToSend = {
name: plugin.name,
version: plugin.version,
authors: plugin.authors || [],
file_path: plugin.file_path,
file_hash: plugin.file_hash,
website: plugin.website,
description: plugin.description,
api_version: plugin.api_version,
main_class: plugin.main_class,
depend: plugin.depend,
soft_depend: plugin.soft_depend,
load_before: plugin.load_before,
commands: plugin.commands,
permissions: plugin.permissions,
has_update: plugin.has_update,
};
await invoke("check_single_plugin_update_command", {
plugin: pluginToSend,
repositoriesToCheck,
});
} catch (err) {
const errorMessage = `Error invoking single update command for ${plugin.name}: ${err instanceof Error ? err.message : String(err)}`;
console.error(errorMessage);
setUpdateError(errorMessage);
setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: false }));
}
}
async function updatePlugin(plugin: Plugin) {
if (!plugin.has_update || !plugin.latest_version || !plugin.repository_source || !plugin.repository_id) {
setUpdateError(`Cannot update ${plugin.name}: Missing required update information`);
return;
}
setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: true }));
setUpdateError(null);
try {
console.log(`Updating plugin: ${plugin.name} to version ${plugin.latest_version}`);
const newFilePath = await invoke<string>("replace_plugin", {
pluginId: plugin.repository_id,
version: plugin.latest_version,
repository: plugin.repository_source,
currentFilePath: plugin.file_path,
serverInfo: serverInfo
});
console.log(`Update successful for ${plugin.name}, new file path: ${newFilePath}`);
setPlugins(currentPlugins => currentPlugins.map(p => {
if (p.file_path === plugin.file_path) {
return {
...p,
version: p.latest_version || p.version,
has_update: false,
latest_version: p.latest_version,
file_path: newFilePath
};
}
return p;
}));
if (serverPath) {
await invoke("save_plugin_data", {
plugins: plugins.map(p => {
if (p.file_path === plugin.file_path) {
return {
...p,
version: p.latest_version || p.version,
has_update: false,
file_path: newFilePath
};
}
return p;
}),
serverPath
});
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error(`Error updating ${plugin.name}:`, errorMessage);
if (errorMessage.includes("in use") ||
errorMessage.includes("server running") ||
errorMessage.includes("being used by another process")) {
setWarningMessage(`Cannot update ${plugin.name}: The Minecraft server appears to be running. Please stop your server before updating plugins.`);
} else if (errorMessage.includes("download failed")) {
setUpdateError(`Failed to download update for ${plugin.name}. Please check your internet connection and try again.`);
} else if (errorMessage.includes("Critical error")) {
setWarningMessage(`${errorMessage} A backup of your original plugin is available in the backups folder.`);
} else {
setUpdateError(`Error updating ${plugin.name}: ${errorMessage}`);
}
} finally {
setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: false }));
}
}
const showPluginDetails = (plugin: Plugin) => {
setSelectedPlugin(plugin);
};
const closePluginDetails = () => {
setSelectedPlugin(null);
};
const handleSelectMatch = async (selectedMatch: PotentialPluginMatch) => {
if (!pluginToDisambiguate || !serverPath) return;
console.log(`User selected match: ${selectedMatch.name} from ${selectedMatch.repository}`);
setShowMatchSelector(false);
setPluginLoadingStates(prev => ({ ...prev, [pluginToDisambiguate.file_path]: true }));
try {
const updatedPlugin: Plugin = await invoke("set_plugin_repository", {
pluginFilePath: pluginToDisambiguate.file_path,
repository: selectedMatch.repository,
repositoryId: selectedMatch.repository_id,
pageUrl: selectedMatch.page_url,
serverPath: serverPath,
});
setPlugins(currentPlugins =>
currentPlugins.map(p =>
p.file_path === updatedPlugin.file_path ? updatedPlugin : p
)
);
console.log(`Successfully set repository source for ${updatedPlugin.name}`);
} catch (err) {
console.error("Error setting plugin repository source:", err);
setUpdateError(`Failed to set repository source for ${pluginToDisambiguate.name}: ${err}`);
} finally {
setPluginLoadingStates(prev => ({ ...prev, [pluginToDisambiguate.file_path]: false }));
setPluginToDisambiguate(null);
setPotentialMatches([]);
}
};
const handleCancelMatchSelection = () => {
setShowMatchSelector(false);
setPluginToDisambiguate(null);
setPotentialMatches([]);
if (pluginToDisambiguate) {
setPluginLoadingStates(prev => ({ ...prev, [pluginToDisambiguate.file_path]: false }));
}
};
return (
<div className="app-container">
<header className="app-header">
<h1>🔧 PlugSnatcher</h1>
<p>Minecraft Plugin Manager</p>
</header>
<main className="app-content">
<section className="server-selector">
<h2>Select Server Directory</h2>
<div className="input-group">
<input
type="text"
value={serverPath}
onChange={(e) => setServerPath(e.target.value)}
placeholder="Enter server directory path..."
/>
<button onClick={selectDirectory}>Browse</button>
</div>
<button
className="scan-button"
onClick={scanForPlugins}
disabled={isScanning || !serverPath}
>
{isScanning ? "Scanning..." : "Scan for Plugins"}
</button>
{isScanning && scanProgress && (
<div className="scan-progress">
<p>Scanning: {scanProgress.current_file}</p>
<progress value={scanProgress.processed} max={scanProgress.total}></progress>
<span>{scanProgress.processed} / {scanProgress.total}</span>
</div>
)}
{error && (
<div className="error-message">
{error}
</div>
)}
</section>
{serverInfo && (
<ServerInfoDisplay serverInfo={serverInfo} />
)}
{serverInfo && plugins.length > 0 && (
<div className="update-controls">
<button
onClick={checkForUpdates}
disabled={isScanning || isCheckingUpdates || Object.values(pluginLoadingStates).some(isLoading => isLoading) || plugins.length === 0}
className="action-button update-check-button"
>
{isCheckingUpdates ? 'Checking All...' : 'Check All for Updates'}
</button>
{isCheckingUpdates && bulkUpdateProgress && (
<div className="bulk-update-progress">
Checking {bulkUpdateProgress.processed}/{bulkUpdateProgress.total}: {bulkUpdateProgress.current_plugin_name}
<progress value={bulkUpdateProgress.processed} max={bulkUpdateProgress.total}></progress>
</div>
)}
{updateError && (
<div className="error-message update-error">{updateError}</div>
)}
</div>
)}
{plugins.length > 0 && (
<section className="plugins-list">
<h2>Installed Plugins ({plugins.length})</h2>
<div className="plugins-header">
<span>Name</span>
<span>Current Version</span>
<span>Latest Version</span>
<span>Actions</span>
</div>
{plugins.map((plugin) => (
<div key={plugin.file_path} className={`plugin-item ${plugin.has_update ? 'has-update' : ''}`}>
<div className="plugin-name">{plugin.name}</div>
<div className="plugin-version">{plugin.version}</div>
<div className="plugin-latest-version">
{plugin.repository_url && plugin.latest_version ? (
<a href={plugin.repository_url} target="_blank" rel="noopener noreferrer" className="version-link">
{plugin.latest_version}
</a>
) : (
plugin.latest_version || 'N/A'
)}
</div>
<div className="plugin-actions">
<button
className="action-button check-single-button"
onClick={() => checkSinglePlugin(plugin)}
disabled={isScanning || isCheckingUpdates || pluginLoadingStates[plugin.file_path]}
>
{pluginLoadingStates[plugin.file_path] ? 'Checking...' : 'Check'}
</button>
{plugin.has_update && (
<button
className="update-button"
onClick={() => updatePlugin(plugin)}
disabled={isScanning || isCheckingUpdates || pluginLoadingStates[plugin.file_path]}
>
{pluginLoadingStates[plugin.file_path] ? 'Updating...' : 'Update'}
</button>
)}
<button className="info-button" onClick={() => showPluginDetails(plugin)}>Info</button>
</div>
</div>
))}
</section>
)}
{scanComplete && plugins.length === 0 && (
<section className="plugins-list">
<h2>Installed Plugins (0)</h2>
<p>No plugins found in this directory.</p>
</section>
)}
{selectedPlugin && (
<PluginDetails plugin={selectedPlugin} onClose={closePluginDetails} />
)}
{warningMessage && (
<WarningModal
message={warningMessage}
onClose={() => setWarningMessage(null)}
/>
)}
{showMatchSelector && pluginToDisambiguate && (
<PluginMatchSelector
plugin={pluginToDisambiguate}
potentialMatches={potentialMatches}
onSelect={handleSelectMatch}
onCancel={handleCancelMatchSelection}
/>
)}
</main>
<footer className="app-footer">
<p>PlugSnatcher v0.1.0 - Developed with 💻 and </p>
</footer>
</div>
);
}
export default App;