claude-code-best/src/commands/plugin/BrowseMarketplace.tsx
2026-05-01 21:39:30 +08:00

901 lines
32 KiB
TypeScript

import figures from 'figures';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
import { Box, Byline, Text } from '@anthropic/ink';
import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js';
import type { LoadedPlugin } from '../../types/plugin.js';
import { count } from '../../utils/array.js';
import { openBrowser } from '../../utils/browser.js';
import { logForDebugging } from '../../utils/debug.js';
import { errorMessage } from '../../utils/errors.js';
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js';
import { formatInstallCount, getInstallCounts } from '../../utils/plugins/installCounts.js';
import { isPluginGloballyInstalled, isPluginInstalled } from '../../utils/plugins/installedPluginsManager.js';
import {
createPluginId,
formatFailureDetails,
formatMarketplaceLoadingErrors,
getMarketplaceSourceDisplay,
loadMarketplacesWithGracefulDegradation,
} from '../../utils/plugins/marketplaceHelpers.js';
import { getMarketplace, loadKnownMarketplacesConfig } from '../../utils/plugins/marketplaceManager.js';
import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js';
import { installPluginFromMarketplace } from '../../utils/plugins/pluginInstallationHelpers.js';
import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js';
import { plural } from '../../utils/stringUtils.js';
import { truncateToWidth } from '../../utils/truncate.js';
import { findPluginOptionsTarget, PluginOptionsFlow } from './PluginOptionsFlow.js';
import { PluginTrustWarning } from './PluginTrustWarning.js';
import {
buildPluginDetailsMenuOptions,
extractGitHubRepo,
type InstallablePlugin,
PluginSelectionKeyHint,
} from './pluginDetailsHelpers.js';
import type { ViewState as ParentViewState } from './types.js';
import { usePagination } from './usePagination.js';
type Props = {
error: string | null;
setError: (error: string | null) => void;
result: string | null;
setResult: (result: string | null) => void;
setViewState: (state: ParentViewState) => void;
onInstallComplete?: () => void | Promise<void>;
targetMarketplace?: string;
targetPlugin?: string;
};
type ViewState =
| 'marketplace-list'
| 'plugin-list'
| 'plugin-details'
| { type: 'plugin-options'; plugin: LoadedPlugin; pluginId: string };
type MarketplaceInfo = {
name: string;
totalPlugins: number;
installedCount: number;
source?: string;
};
export function BrowseMarketplace({
error,
setError,
result: _result,
setResult,
setViewState: setParentViewState,
onInstallComplete,
targetMarketplace,
targetPlugin,
}: Props): React.ReactNode {
// View state
const [viewState, setViewState] = useState<ViewState>('marketplace-list');
const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null);
const [selectedPlugin, setSelectedPlugin] = useState<InstallablePlugin | null>(null);
// Data state
const [marketplaces, setMarketplaces] = useState<MarketplaceInfo[]>([]);
const [availablePlugins, setAvailablePlugins] = useState<InstallablePlugin[]>([]);
const [loading, setLoading] = useState(true);
const [installCounts, setInstallCounts] = useState<Map<string, number> | null>(null);
// Selection state
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedForInstall, setSelectedForInstall] = useState<Set<string>>(new Set());
const [installingPlugins, setInstallingPlugins] = useState<Set<string>>(new Set());
// Pagination for plugin list (continuous scrolling)
const pagination = usePagination<InstallablePlugin>({
totalItems: availablePlugins.length,
selectedIndex,
});
// Details view state
const [detailsMenuIndex, setDetailsMenuIndex] = useState(0);
const [isInstalling, setIsInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
// Warning state for non-critical errors (e.g., some marketplaces failed to load)
const [warning, setWarning] = useState<string | null>(null);
// Handle escape to go back - viewState-dependent navigation
const handleBack = React.useCallback(() => {
if (viewState === 'plugin-list') {
// If navigated directly to a specific marketplace via targetMarketplace,
// go back to manage-marketplaces showing that marketplace's details
if (targetMarketplace) {
setParentViewState({
type: 'manage-marketplaces',
targetMarketplace,
});
} else if (marketplaces.length === 1) {
// If there's only one marketplace, skip the marketplace-list view
// since we auto-navigated past it on load
setParentViewState({ type: 'menu' });
} else {
setViewState('marketplace-list');
setSelectedMarketplace(null);
setSelectedForInstall(new Set());
}
} else if (viewState === 'plugin-details') {
setViewState('plugin-list');
setSelectedPlugin(null);
} else {
// At root level (marketplace-list), exit the plugin menu
setParentViewState({ type: 'menu' });
}
}, [viewState, targetMarketplace, setParentViewState, marketplaces.length]);
useKeybinding('confirm:no', handleBack, { context: 'Confirmation' });
// Load marketplaces and count installed plugins
useEffect(() => {
async function loadMarketplaceData() {
try {
const config = await loadKnownMarketplacesConfig();
// Load marketplaces with graceful degradation
const { marketplaces, failures } = await loadMarketplacesWithGracefulDegradation(config);
const marketplaceInfos: MarketplaceInfo[] = [];
for (const { name, config: marketplaceConfig, data: marketplace } of marketplaces) {
if (marketplace) {
// Count how many plugins from this marketplace are installed
const installedFromThisMarketplace = count(marketplace.plugins, plugin =>
isPluginInstalled(createPluginId((plugin as { name: string }).name, name)),
);
marketplaceInfos.push({
name,
totalPlugins: marketplace.plugins.length,
installedCount: installedFromThisMarketplace,
source: getMarketplaceSourceDisplay(marketplaceConfig.source),
});
}
}
// Sort so claude-plugin-directory is always first
marketplaceInfos.sort((a, b) => {
if (a.name === 'claude-plugin-directory') return -1;
if (b.name === 'claude-plugin-directory') return 1;
return 0;
});
setMarketplaces(marketplaceInfos);
// Handle marketplace loading errors/warnings
const successCount = count(marketplaces, m => m.data !== null);
const errorResult = formatMarketplaceLoadingErrors(failures, successCount);
if (errorResult) {
if (errorResult.type === 'warning') {
setWarning(errorResult.message + '. Showing available marketplaces.');
} else {
throw new Error(errorResult.message);
}
}
// Skip marketplace selection if there's only one marketplace
if (marketplaceInfos.length === 1 && !targetMarketplace && !targetPlugin) {
const singleMarketplace = marketplaceInfos[0];
if (singleMarketplace) {
setSelectedMarketplace(singleMarketplace.name);
setViewState('plugin-list');
}
}
// Handle targetMarketplace and targetPlugin after marketplaces are loaded
if (targetPlugin) {
// Search for the plugin across all marketplaces
let foundPlugin: InstallablePlugin | null = null;
let foundMarketplace: string | null = null;
for (const [name] of Object.entries(config)) {
const marketplace = await getMarketplace(name);
if (marketplace) {
const plugin = marketplace.plugins.find(p => p.name === targetPlugin);
if (plugin) {
const pluginId = createPluginId(plugin.name, name);
foundPlugin = {
entry: plugin,
marketplaceName: name,
pluginId,
// isPluginGloballyInstalled: only block when user/managed scope
// exists (nothing to add). Project/local-scope installs don't
// block — user may want to promote to user scope (gh-29997).
isInstalled: isPluginGloballyInstalled(pluginId),
};
foundMarketplace = name;
break;
}
}
}
if (foundPlugin && foundMarketplace) {
// Block only on global (user/managed) install — project/local scope
// means the user might still want to add a user-scope entry so the
// plugin is available in other projects (gh-29997, gh-29240, gh-29392).
// The plugin-details view offers all three scope options; the backend
// (installPluginOp → addInstalledPlugin) already supports multiple
// scope entries per plugin.
const pluginId = foundPlugin.pluginId;
const globallyInstalled = isPluginGloballyInstalled(pluginId);
if (globallyInstalled) {
setError(`Plugin '${pluginId}' is already installed globally. Use '/plugin' to manage existing plugins.`);
} else {
// Navigate to the plugin details view
setSelectedMarketplace(foundMarketplace);
setSelectedPlugin(foundPlugin);
setViewState('plugin-details');
}
} else {
setError(`Plugin "${targetPlugin}" not found in any marketplace`);
}
} else if (targetMarketplace) {
// Navigate directly to the specified marketplace
const marketplaceExists = marketplaceInfos.some(m => m.name === targetMarketplace);
if (marketplaceExists) {
setSelectedMarketplace(targetMarketplace);
setViewState('plugin-list');
} else {
setError(`Marketplace "${targetMarketplace}" not found`);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load marketplaces');
} finally {
setLoading(false);
}
}
void loadMarketplaceData();
}, [setError, targetMarketplace, targetPlugin]);
// Load plugins when a marketplace is selected
useEffect(() => {
if (!selectedMarketplace) return;
let cancelled = false;
async function loadPluginsForMarketplace(marketplaceName: string) {
setLoading(true);
try {
const marketplace = await getMarketplace(marketplaceName);
if (cancelled) return;
if (!marketplace) {
throw new Error(`Failed to load marketplace: ${marketplaceName}`);
}
// Filter out already installed plugins
const installablePlugins: InstallablePlugin[] = [];
for (const entry of marketplace.plugins) {
const pluginId = createPluginId(entry.name, marketplaceName);
if (isPluginBlockedByPolicy(pluginId)) continue;
installablePlugins.push({
entry,
marketplaceName: marketplaceName,
pluginId,
// Only mark as "installed" when globally scoped (user/managed).
// Project/local installs don't block — user can add user scope
// via the plugin-details view (gh-29997).
isInstalled: isPluginGloballyInstalled(pluginId),
});
}
// Fetch install counts and sort by popularity
try {
const counts = await getInstallCounts();
if (cancelled) return;
setInstallCounts(counts);
if (counts) {
// Sort by install count (descending), then alphabetically
installablePlugins.sort((a, b) => {
const countA = counts.get(a.pluginId) ?? 0;
const countB = counts.get(b.pluginId) ?? 0;
if (countA !== countB) return countB - countA;
return a.entry.name.localeCompare(b.entry.name);
});
} else {
// No counts available - sort alphabetically
installablePlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
}
} catch (error) {
if (cancelled) return;
// Log the error, then gracefully degrade to alphabetical sort
logForDebugging(`Failed to fetch install counts: ${errorMessage(error)}`);
installablePlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
}
setAvailablePlugins(installablePlugins);
setSelectedIndex(0);
setSelectedForInstall(new Set());
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'Failed to load plugins');
} finally {
setLoading(false);
}
}
void loadPluginsForMarketplace(selectedMarketplace);
return () => {
cancelled = true;
};
}, [selectedMarketplace, setError]);
// Install selected plugins
const installSelectedPlugins = async () => {
if (selectedForInstall.size === 0) return;
const pluginsToInstall = availablePlugins.filter(p => selectedForInstall.has(p.pluginId));
setInstallingPlugins(new Set(pluginsToInstall.map(p => p.pluginId)));
let successCount = 0;
let failureCount = 0;
const newFailedPlugins: Array<{ name: string; reason: string }> = [];
for (const plugin of pluginsToInstall) {
const result = await installPluginFromMarketplace({
pluginId: plugin.pluginId,
entry: plugin.entry,
marketplaceName: plugin.marketplaceName,
scope: 'user',
});
if (result.success) {
successCount++;
} else {
failureCount++;
newFailedPlugins.push({
name: plugin.entry.name,
reason: (result as { success: false; error: string }).error,
});
}
}
setInstallingPlugins(new Set());
setSelectedForInstall(new Set());
clearAllCaches();
// Handle installation results
if (failureCount === 0) {
// All succeeded
const message =
`✓ Installed ${successCount} ${plural(successCount, 'plugin')}. ` + `Run /reload-plugins to activate.`;
setResult(message);
} else if (successCount === 0) {
// All failed - show error with reasons
setError(`Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`);
} else {
// Mixed results - show partial success
const message =
`✓ Installed ${successCount} of ${successCount + failureCount} plugins. ` +
`Failed: ${formatFailureDetails(newFailedPlugins, false)}. ` +
`Run /reload-plugins to activate successfully installed plugins.`;
setResult(message);
}
// Handle completion callback and navigation
if (successCount > 0) {
if (onInstallComplete) {
await onInstallComplete();
}
}
setParentViewState({ type: 'menu' });
};
// Install single plugin from details view
const handleSinglePluginInstall = async (plugin: InstallablePlugin, scope: 'user' | 'project' | 'local' = 'user') => {
setIsInstalling(true);
setInstallError(null);
const result = await installPluginFromMarketplace({
pluginId: plugin.pluginId,
entry: plugin.entry,
marketplaceName: plugin.marketplaceName,
scope,
});
if (result.success) {
const loaded = await findPluginOptionsTarget(plugin.pluginId);
if (loaded) {
setIsInstalling(false);
setViewState({
type: 'plugin-options',
plugin: loaded,
pluginId: plugin.pluginId,
});
return;
}
setResult(result.message);
if (onInstallComplete) {
await onInstallComplete();
}
setParentViewState({ type: 'menu' });
} else {
setIsInstalling(false);
setInstallError((result as { success: false; error: string }).error);
}
};
// Handle error state
useEffect(() => {
if (error) {
setResult(error);
}
}, [error, setResult]);
// Marketplace-list navigation
useKeybindings(
{
'select:previous': () => {
if (selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
},
'select:next': () => {
if (selectedIndex < marketplaces.length - 1) {
setSelectedIndex(selectedIndex + 1);
}
},
'select:accept': () => {
const marketplace = marketplaces[selectedIndex];
if (marketplace) {
setSelectedMarketplace(marketplace.name);
setViewState('plugin-list');
}
},
},
{ context: 'Select', isActive: viewState === 'marketplace-list' },
);
// Plugin-list navigation
useKeybindings(
{
'select:previous': () => {
if (selectedIndex > 0) {
pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex);
}
},
'select:next': () => {
if (selectedIndex < availablePlugins.length - 1) {
pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex);
}
},
'select:accept': () => {
if (selectedIndex === availablePlugins.length && selectedForInstall.size > 0) {
void installSelectedPlugins();
} else if (selectedIndex < availablePlugins.length) {
const plugin = availablePlugins[selectedIndex];
if (plugin) {
if (plugin.isInstalled) {
setParentViewState({
type: 'manage-plugins',
targetPlugin: plugin.entry.name,
targetMarketplace: plugin.marketplaceName,
});
} else {
setSelectedPlugin(plugin);
setViewState('plugin-details');
setDetailsMenuIndex(0);
setInstallError(null);
}
}
}
},
},
{ context: 'Select', isActive: viewState === 'plugin-list' },
);
useKeybindings(
{
'plugin:toggle': () => {
if (selectedIndex < availablePlugins.length) {
const plugin = availablePlugins[selectedIndex];
if (plugin && !plugin.isInstalled) {
const newSelection = new Set(selectedForInstall);
if (newSelection.has(plugin.pluginId)) {
newSelection.delete(plugin.pluginId);
} else {
newSelection.add(plugin.pluginId);
}
setSelectedForInstall(newSelection);
}
}
},
'plugin:install': () => {
if (selectedForInstall.size > 0) {
void installSelectedPlugins();
}
},
},
{ context: 'Plugin', isActive: viewState === 'plugin-list' },
);
// Plugin-details navigation
const detailsMenuOptions = React.useMemo(() => {
if (!selectedPlugin) return [];
const hasHomepage = selectedPlugin.entry.homepage;
const githubRepo = extractGitHubRepo(selectedPlugin);
return buildPluginDetailsMenuOptions(hasHomepage, githubRepo);
}, [selectedPlugin]);
useKeybindings(
{
'select:previous': () => {
if (detailsMenuIndex > 0) {
setDetailsMenuIndex(detailsMenuIndex - 1);
}
},
'select:next': () => {
if (detailsMenuIndex < detailsMenuOptions.length - 1) {
setDetailsMenuIndex(detailsMenuIndex + 1);
}
},
'select:accept': () => {
if (!selectedPlugin) return;
const action = detailsMenuOptions[detailsMenuIndex]?.action;
const hasHomepage = selectedPlugin.entry.homepage;
const githubRepo = extractGitHubRepo(selectedPlugin);
if (action === 'install-user') {
void handleSinglePluginInstall(selectedPlugin, 'user');
} else if (action === 'install-project') {
void handleSinglePluginInstall(selectedPlugin, 'project');
} else if (action === 'install-local') {
void handleSinglePluginInstall(selectedPlugin, 'local');
} else if (action === 'homepage' && hasHomepage) {
void openBrowser(hasHomepage);
} else if (action === 'github' && githubRepo) {
void openBrowser(`https://github.com/${githubRepo}`);
} else if (action === 'back') {
setViewState('plugin-list');
setSelectedPlugin(null);
}
},
},
{
context: 'Select',
isActive: viewState === 'plugin-details' && !!selectedPlugin,
},
);
if (typeof viewState === 'object' && viewState.type === 'plugin-options') {
const { plugin, pluginId } = viewState;
function finish(msg: string): void {
setResult(msg);
if (onInstallComplete) {
void onInstallComplete();
}
setParentViewState({ type: 'menu' });
}
return (
<PluginOptionsFlow
plugin={plugin}
pluginId={pluginId}
onDone={(outcome, detail) => {
switch (outcome) {
case 'configured':
finish(`✓ Installed and configured ${plugin.name}. Run /reload-plugins to apply.`);
break;
case 'skipped':
finish(`✓ Installed ${plugin.name}. Run /reload-plugins to apply.`);
break;
case 'error':
finish(`Installed but failed to save config: ${detail}`);
break;
}
}}
/>
);
}
// Loading state
if (loading) {
return <Text>Loading</Text>;
}
// Error state
if (error) {
return <Text color="error">{error}</Text>;
}
// Marketplace selection view
if (viewState === 'marketplace-list') {
if (marketplaces.length === 0) {
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Select marketplace</Text>
</Box>
<Text>No marketplaces configured.</Text>
<Text dimColor>Add a marketplace first using {"'Add marketplace'"}.</Text>
<Box marginTop={1} paddingLeft={1}>
<Text dimColor>
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="go back"
/>
</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Select marketplace</Text>
</Box>
{/* Warning banner for marketplace load failures */}
{warning && (
<Box marginBottom={1} flexDirection="column">
<Text color="warning">
{figures.warning} {warning}
</Text>
</Box>
)}
{marketplaces.map((marketplace, index) => (
<Box key={marketplace.name} flexDirection="column" marginBottom={index < marketplaces.length - 1 ? 1 : 0}>
<Box>
<Text color={selectedIndex === index ? 'suggestion' : undefined}>
{selectedIndex === index ? figures.pointer : ' '} {marketplace.name}
</Text>
</Box>
<Box marginLeft={2}>
<Text dimColor>
{marketplace.totalPlugins} {plural(marketplace.totalPlugins, 'plugin')} available
{marketplace.installedCount > 0 && ` · ${marketplace.installedCount} already installed`}
{marketplace.source && ` · ${marketplace.source}`}
</Text>
</Box>
</Box>
))}
<Box marginTop={1}>
<Text dimColor italic>
<Byline>
<ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" />
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="go back"
/>
</Byline>
</Text>
</Box>
</Box>
);
}
// Plugin details view
if (viewState === 'plugin-details' && selectedPlugin) {
const hasHomepage = selectedPlugin.entry.homepage;
const githubRepo = extractGitHubRepo(selectedPlugin);
const menuOptions = buildPluginDetailsMenuOptions(hasHomepage, githubRepo);
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Plugin Details</Text>
</Box>
{/* Plugin metadata */}
<Box flexDirection="column" marginBottom={1}>
<Text bold>{selectedPlugin.entry.name}</Text>
{selectedPlugin.entry.version && <Text dimColor>Version: {selectedPlugin.entry.version}</Text>}
{selectedPlugin.entry.description && (
<Box marginTop={1}>
<Text>{selectedPlugin.entry.description}</Text>
</Box>
)}
{selectedPlugin.entry.author && (
<Box marginTop={1}>
<Text dimColor>
By:{' '}
{typeof selectedPlugin.entry.author === 'string'
? selectedPlugin.entry.author
: selectedPlugin.entry.author.name}
</Text>
</Box>
)}
</Box>
{/* What will be installed */}
<Box flexDirection="column" marginBottom={1}>
<Text bold>Will install:</Text>
{selectedPlugin.entry.commands && (
<Text dimColor>
· Commands:{' '}
{Array.isArray(selectedPlugin.entry.commands)
? selectedPlugin.entry.commands.join(', ')
: Object.keys(selectedPlugin.entry.commands).join(', ')}
</Text>
)}
{selectedPlugin.entry.agents && (
<Text dimColor>
· Agents:{' '}
{Array.isArray(selectedPlugin.entry.agents)
? selectedPlugin.entry.agents.join(', ')
: Object.keys(selectedPlugin.entry.agents).join(', ')}
</Text>
)}
{selectedPlugin.entry.hooks && (
<Text dimColor>· Hooks: {Object.keys(selectedPlugin.entry.hooks).join(', ')}</Text>
)}
{selectedPlugin.entry.mcpServers && (
<Text dimColor>
· MCP Servers:{' '}
{Array.isArray(selectedPlugin.entry.mcpServers)
? selectedPlugin.entry.mcpServers.join(', ')
: typeof selectedPlugin.entry.mcpServers === 'object'
? Object.keys(selectedPlugin.entry.mcpServers).join(', ')
: 'configured'}
</Text>
)}
{!selectedPlugin.entry.commands &&
!selectedPlugin.entry.agents &&
!selectedPlugin.entry.hooks &&
!selectedPlugin.entry.mcpServers && (
<>
{typeof selectedPlugin.entry.source === 'object' &&
'source' in selectedPlugin.entry.source &&
(selectedPlugin.entry.source.source === 'github' ||
selectedPlugin.entry.source.source === 'url' ||
selectedPlugin.entry.source.source === 'npm' ||
selectedPlugin.entry.source.source === 'pip') ? (
<Text dimColor>· Component summary not available for remote plugin</Text>
) : (
// TODO: Actually scan local plugin directories to show real components
// This would require accessing the filesystem to check for:
// - commands/ directory and list files
// - agents/ directory and list files
// - hooks/ directory and list files
// - .mcp.json or mcp-servers.json files
<Text dimColor>· Components will be discovered at installation</Text>
)}
</>
)}
</Box>
<PluginTrustWarning />
{/* Error message */}
{installError && (
<Box marginBottom={1}>
<Text color="error">Error: {installError}</Text>
</Box>
)}
{/* Menu options */}
<Box flexDirection="column">
{menuOptions.map((option, index) => (
<Box key={option.action}>
{detailsMenuIndex === index && <Text>{'> '}</Text>}
{detailsMenuIndex !== index && <Text>{' '}</Text>}
<Text bold={detailsMenuIndex === index}>
{isInstalling && option.action === 'install' ? 'Installing…' : option.label}
</Text>
</Box>
))}
</Box>
<Box marginTop={1} paddingLeft={1}>
<Text dimColor>
<Byline>
<ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" />
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />
</Byline>
</Text>
</Box>
</Box>
);
}
// Plugin installation view
if (availablePlugins.length === 0) {
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Install plugins</Text>
</Box>
<Text dimColor>No new plugins available to install.</Text>
<Text dimColor>All plugins from this marketplace are already installed.</Text>
<Box marginLeft={3}>
<Text dimColor italic>
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" />
</Text>
</Box>
</Box>
);
}
// Get visible plugins from pagination
const visiblePlugins = pagination.getVisibleItems(availablePlugins);
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Install Plugins</Text>
</Box>
{/* Scroll up indicator */}
{pagination.scrollPosition.canScrollUp && (
<Box>
<Text dimColor> {figures.arrowUp} more above</Text>
</Box>
)}
{/* Plugin list */}
{visiblePlugins.map((plugin, visibleIndex) => {
const actualIndex = pagination.toActualIndex(visibleIndex);
const isSelected = selectedIndex === actualIndex;
const isSelectedForInstall = selectedForInstall.has(plugin.pluginId);
const isInstalling = installingPlugins.has(plugin.pluginId);
const isLast = visibleIndex === visiblePlugins.length - 1;
return (
<Box key={plugin.pluginId} flexDirection="column" marginBottom={isLast && !error ? 0 : 1}>
<Box>
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? figures.pointer : ' '} </Text>
<Text color={plugin.isInstalled ? 'success' : undefined}>
{plugin.isInstalled
? figures.tick
: isInstalling
? figures.ellipsis
: isSelectedForInstall
? figures.radioOn
: figures.radioOff}{' '}
{plugin.entry.name}
{plugin.entry.category && <Text dimColor> [{plugin.entry.category}]</Text>}
{plugin.entry.tags?.includes('community-managed') && <Text dimColor> [Community Managed]</Text>}
{plugin.isInstalled && <Text dimColor> (installed)</Text>}
{installCounts && selectedMarketplace === OFFICIAL_MARKETPLACE_NAME && (
<Text dimColor>
{' · '}
{formatInstallCount(installCounts.get(plugin.pluginId) ?? 0)} installs
</Text>
)}
</Text>
</Box>
{plugin.entry.description && (
<Box marginLeft={4}>
<Text dimColor>{truncateToWidth(plugin.entry.description, 60)}</Text>
{plugin.entry.version && <Text dimColor> · v{plugin.entry.version}</Text>}
</Box>
)}
</Box>
);
})}
{/* Scroll down indicator */}
{pagination.scrollPosition.canScrollDown && (
<Box>
<Text dimColor> {figures.arrowDown} more below</Text>
</Box>
)}
{/* Error messages shown in the UI */}
{error && (
<Box marginTop={1}>
<Text color="error">
{figures.cross} {error}
</Text>
</Box>
)}
<PluginSelectionKeyHint hasSelection={selectedForInstall.size > 0} />
</Box>
);
}