781 lines
28 KiB
TypeScript
781 lines
28 KiB
TypeScript
import { c as _c } from "react/compiler-runtime";
|
|
import figures from 'figures';
|
|
import * as React from 'react';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
|
|
import { Byline } from '../../components/design-system/Byline.js';
|
|
import { SearchBox } from '../../components/SearchBox.js';
|
|
import { useSearchInput } from '../../hooks/useSearchInput.js';
|
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
|
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- useInput needed for raw search mode text input
|
|
import { Box, Text, useInput, useTerminalFocus } from '../../ink.js';
|
|
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 } from '../../utils/plugins/installedPluginsManager.js';
|
|
import { createPluginId, detectEmptyMarketplaceReason, type EmptyMarketplaceReason, formatFailureDetails, formatMarketplaceLoadingErrors, loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js';
|
|
import { 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 } 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>;
|
|
onSearchModeChange?: (isActive: boolean) => void;
|
|
targetPlugin?: string;
|
|
};
|
|
type ViewState = 'plugin-list' | 'plugin-details' | {
|
|
type: 'plugin-options';
|
|
plugin: LoadedPlugin;
|
|
pluginId: string;
|
|
};
|
|
export function DiscoverPlugins({
|
|
error,
|
|
setError,
|
|
result: _result,
|
|
setResult,
|
|
setViewState: setParentViewState,
|
|
onInstallComplete,
|
|
onSearchModeChange,
|
|
targetPlugin
|
|
}: Props): React.ReactNode {
|
|
// View state
|
|
const [viewState, setViewState] = useState<ViewState>('plugin-list');
|
|
const [selectedPlugin, setSelectedPlugin] = useState<InstallablePlugin | null>(null);
|
|
|
|
// Data state
|
|
const [availablePlugins, setAvailablePlugins] = useState<InstallablePlugin[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [installCounts, setInstallCounts] = useState<Map<string, number> | null>(null);
|
|
|
|
// Search state
|
|
const [isSearchMode, setIsSearchModeRaw] = useState(false);
|
|
const setIsSearchMode = useCallback((active: boolean) => {
|
|
setIsSearchModeRaw(active);
|
|
onSearchModeChange?.(active);
|
|
}, [onSearchModeChange]);
|
|
const {
|
|
query: searchQuery,
|
|
setQuery: setSearchQuery,
|
|
cursorOffset: searchCursorOffset
|
|
} = useSearchInput({
|
|
isActive: viewState === 'plugin-list' && isSearchMode && !loading,
|
|
onExit: () => {
|
|
setIsSearchMode(false);
|
|
}
|
|
});
|
|
const isTerminalFocused = useTerminalFocus();
|
|
const {
|
|
columns: terminalWidth
|
|
} = useTerminalSize();
|
|
|
|
// Filter plugins based on search query
|
|
const filteredPlugins = useMemo(() => {
|
|
if (!searchQuery) return availablePlugins;
|
|
const lowerQuery = searchQuery.toLowerCase();
|
|
return availablePlugins.filter(plugin => plugin.entry.name.toLowerCase().includes(lowerQuery) || plugin.entry.description?.toLowerCase().includes(lowerQuery) || plugin.marketplaceName.toLowerCase().includes(lowerQuery));
|
|
}, [availablePlugins, searchQuery]);
|
|
|
|
// 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: filteredPlugins.length,
|
|
selectedIndex
|
|
});
|
|
|
|
// Reset selection when search query changes
|
|
useEffect(() => {
|
|
setSelectedIndex(0);
|
|
}, [searchQuery]);
|
|
|
|
// 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
|
|
const [warning, setWarning] = useState<string | null>(null);
|
|
|
|
// Empty state reason
|
|
const [emptyReason, setEmptyReason] = useState<EmptyMarketplaceReason | null>(null);
|
|
|
|
// Load all plugins from all marketplaces
|
|
useEffect(() => {
|
|
async function loadAllPlugins() {
|
|
try {
|
|
const config = await loadKnownMarketplacesConfig();
|
|
|
|
// Load marketplaces with graceful degradation
|
|
const {
|
|
marketplaces,
|
|
failures
|
|
} = await loadMarketplacesWithGracefulDegradation(config);
|
|
|
|
// Collect all plugins from all marketplaces
|
|
const allPlugins: InstallablePlugin[] = [];
|
|
for (const {
|
|
name,
|
|
data: marketplace
|
|
} of marketplaces) {
|
|
if (marketplace) {
|
|
for (const entry of marketplace.plugins) {
|
|
const pluginId = createPluginId(entry.name, name);
|
|
allPlugins.push({
|
|
entry,
|
|
marketplaceName: name,
|
|
pluginId,
|
|
// Only block when globally installed (user/managed scope).
|
|
// Project/local-scope installs don't block — user may want to
|
|
// promote to user scope so it's available everywhere (gh-29997).
|
|
isInstalled: isPluginGloballyInstalled(pluginId)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter out installed and policy-blocked plugins
|
|
const uninstalledPlugins = allPlugins.filter(p => !p.isInstalled && !isPluginBlockedByPolicy(p.pluginId));
|
|
|
|
// Fetch install counts and sort by popularity
|
|
try {
|
|
const counts = await getInstallCounts();
|
|
setInstallCounts(counts);
|
|
if (counts) {
|
|
// Sort by install count (descending), then alphabetically
|
|
uninstalledPlugins.sort((a_0, b_0) => {
|
|
const countA = counts.get(a_0.pluginId) ?? 0;
|
|
const countB = counts.get(b_0.pluginId) ?? 0;
|
|
if (countA !== countB) return countB - countA;
|
|
return a_0.entry.name.localeCompare(b_0.entry.name);
|
|
});
|
|
} else {
|
|
// No counts available - sort alphabetically
|
|
uninstalledPlugins.sort((a_1, b_1) => a_1.entry.name.localeCompare(b_1.entry.name));
|
|
}
|
|
} catch (error_0) {
|
|
// Log the error, then gracefully degrade to alphabetical sort
|
|
logForDebugging(`Failed to fetch install counts: ${errorMessage(error_0)}`);
|
|
uninstalledPlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
|
|
}
|
|
setAvailablePlugins(uninstalledPlugins);
|
|
|
|
// Detect empty reason if no plugins available
|
|
const configuredCount = Object.keys(config).length;
|
|
if (uninstalledPlugins.length === 0) {
|
|
const reason = await detectEmptyMarketplaceReason({
|
|
configuredMarketplaceCount: configuredCount,
|
|
failedMarketplaceCount: failures.length
|
|
});
|
|
setEmptyReason(reason);
|
|
}
|
|
|
|
// 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 plugins.');
|
|
} else {
|
|
throw new Error(errorResult.message);
|
|
}
|
|
}
|
|
|
|
// Handle targetPlugin - navigate directly to plugin details
|
|
// Search in allPlugins (before filtering) to handle installed plugins gracefully
|
|
if (targetPlugin) {
|
|
const foundPlugin = allPlugins.find(p_0 => p_0.entry.name === targetPlugin);
|
|
if (foundPlugin) {
|
|
if (foundPlugin.isInstalled) {
|
|
setError(`Plugin '${foundPlugin.pluginId}' is already installed. Use '/plugin' to manage existing plugins.`);
|
|
} else {
|
|
setSelectedPlugin(foundPlugin);
|
|
setViewState('plugin-details');
|
|
}
|
|
} else {
|
|
setError(`Plugin "${targetPlugin}" not found in any marketplace`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load plugins');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
void loadAllPlugins();
|
|
}, [setError, targetPlugin]);
|
|
|
|
// Install selected plugins
|
|
const installSelectedPlugins = async () => {
|
|
if (selectedForInstall.size === 0) return;
|
|
const pluginsToInstall = availablePlugins.filter(p_1 => selectedForInstall.has(p_1.pluginId));
|
|
setInstallingPlugins(new Set(pluginsToInstall.map(p_2 => p_2.pluginId)));
|
|
let successCount_0 = 0;
|
|
let failureCount = 0;
|
|
const newFailedPlugins: Array<{
|
|
name: string;
|
|
reason: string;
|
|
}> = [];
|
|
for (const plugin_0 of pluginsToInstall) {
|
|
const result = await installPluginFromMarketplace({
|
|
pluginId: plugin_0.pluginId,
|
|
entry: plugin_0.entry,
|
|
marketplaceName: plugin_0.marketplaceName,
|
|
scope: 'user'
|
|
});
|
|
if (result.success) {
|
|
successCount_0++;
|
|
} else {
|
|
failureCount++;
|
|
newFailedPlugins.push({
|
|
name: plugin_0.entry.name,
|
|
reason: result.error
|
|
});
|
|
}
|
|
}
|
|
setInstallingPlugins(new Set());
|
|
setSelectedForInstall(new Set());
|
|
clearAllCaches();
|
|
|
|
// Handle installation results
|
|
if (failureCount === 0) {
|
|
const message = `✓ Installed ${successCount_0} ${plural(successCount_0, 'plugin')}. ` + `Run /reload-plugins to activate.`;
|
|
setResult(message);
|
|
} else if (successCount_0 === 0) {
|
|
setError(`Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`);
|
|
} else {
|
|
const message_0 = `✓ Installed ${successCount_0} of ${successCount_0 + failureCount} plugins. ` + `Failed: ${formatFailureDetails(newFailedPlugins, false)}. ` + `Run /reload-plugins to activate successfully installed plugins.`;
|
|
setResult(message_0);
|
|
}
|
|
if (successCount_0 > 0) {
|
|
if (onInstallComplete) {
|
|
await onInstallComplete();
|
|
}
|
|
}
|
|
setParentViewState({
|
|
type: 'menu'
|
|
});
|
|
};
|
|
|
|
// Install single plugin from details view
|
|
const handleSinglePluginInstall = async (plugin_1: InstallablePlugin, scope: 'user' | 'project' | 'local' = 'user') => {
|
|
setIsInstalling(true);
|
|
setInstallError(null);
|
|
const result_0 = await installPluginFromMarketplace({
|
|
pluginId: plugin_1.pluginId,
|
|
entry: plugin_1.entry,
|
|
marketplaceName: plugin_1.marketplaceName,
|
|
scope
|
|
});
|
|
if (result_0.success) {
|
|
const loaded = await findPluginOptionsTarget(plugin_1.pluginId);
|
|
if (loaded) {
|
|
setIsInstalling(false);
|
|
setViewState({
|
|
type: 'plugin-options',
|
|
plugin: loaded,
|
|
pluginId: plugin_1.pluginId
|
|
});
|
|
return;
|
|
}
|
|
setResult(result_0.message);
|
|
if (onInstallComplete) {
|
|
await onInstallComplete();
|
|
}
|
|
setParentViewState({
|
|
type: 'menu'
|
|
});
|
|
} else {
|
|
setIsInstalling(false);
|
|
setInstallError(result_0.error);
|
|
}
|
|
};
|
|
|
|
// Handle error state
|
|
useEffect(() => {
|
|
if (error) {
|
|
setResult(error);
|
|
}
|
|
}, [error, setResult]);
|
|
|
|
// Escape in plugin-details view - go back to plugin-list
|
|
useKeybinding('confirm:no', () => {
|
|
setViewState('plugin-list');
|
|
setSelectedPlugin(null);
|
|
}, {
|
|
context: 'Confirmation',
|
|
isActive: viewState === 'plugin-details'
|
|
});
|
|
|
|
// Escape in plugin-list view (not search mode) - exit to parent menu
|
|
useKeybinding('confirm:no', () => {
|
|
setParentViewState({
|
|
type: 'menu'
|
|
});
|
|
}, {
|
|
context: 'Confirmation',
|
|
isActive: viewState === 'plugin-list' && !isSearchMode
|
|
});
|
|
|
|
// Handle entering search mode (non-escape keys)
|
|
useInput((input, _key) => {
|
|
const keyIsNotCtrlOrMeta = !_key.ctrl && !_key.meta;
|
|
if (!isSearchMode) {
|
|
// Enter search mode with '/' or any printable character
|
|
if (input === '/' && keyIsNotCtrlOrMeta) {
|
|
setIsSearchMode(true);
|
|
setSearchQuery('');
|
|
} else if (keyIsNotCtrlOrMeta && input.length > 0 && !/^\s+$/.test(input) &&
|
|
// Don't enter search mode for navigation keys
|
|
input !== 'j' && input !== 'k' && input !== 'i') {
|
|
setIsSearchMode(true);
|
|
setSearchQuery(input);
|
|
}
|
|
}
|
|
}, {
|
|
isActive: viewState === 'plugin-list' && !loading
|
|
});
|
|
|
|
// Plugin-list navigation (non-search mode)
|
|
useKeybindings({
|
|
'select:previous': () => {
|
|
if (selectedIndex === 0) {
|
|
setIsSearchMode(true);
|
|
} else {
|
|
pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex);
|
|
}
|
|
},
|
|
'select:next': () => {
|
|
if (selectedIndex < filteredPlugins.length - 1) {
|
|
pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex);
|
|
}
|
|
},
|
|
'select:accept': () => {
|
|
if (selectedIndex === filteredPlugins.length && selectedForInstall.size > 0) {
|
|
void installSelectedPlugins();
|
|
} else if (selectedIndex < filteredPlugins.length) {
|
|
const plugin_2 = filteredPlugins[selectedIndex];
|
|
if (plugin_2) {
|
|
if (plugin_2.isInstalled) {
|
|
setParentViewState({
|
|
type: 'manage-plugins',
|
|
targetPlugin: plugin_2.entry.name,
|
|
targetMarketplace: plugin_2.marketplaceName
|
|
});
|
|
} else {
|
|
setSelectedPlugin(plugin_2);
|
|
setViewState('plugin-details');
|
|
setDetailsMenuIndex(0);
|
|
setInstallError(null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, {
|
|
context: 'Select',
|
|
isActive: viewState === 'plugin-list' && !isSearchMode
|
|
});
|
|
useKeybindings({
|
|
'plugin:toggle': () => {
|
|
if (selectedIndex < filteredPlugins.length) {
|
|
const plugin_3 = filteredPlugins[selectedIndex];
|
|
if (plugin_3 && !plugin_3.isInstalled) {
|
|
const newSelection = new Set(selectedForInstall);
|
|
if (newSelection.has(plugin_3.pluginId)) {
|
|
newSelection.delete(plugin_3.pluginId);
|
|
} else {
|
|
newSelection.add(plugin_3.pluginId);
|
|
}
|
|
setSelectedForInstall(newSelection);
|
|
}
|
|
}
|
|
},
|
|
'plugin:install': () => {
|
|
if (selectedForInstall.size > 0) {
|
|
void installSelectedPlugins();
|
|
}
|
|
}
|
|
}, {
|
|
context: 'Plugin',
|
|
isActive: viewState === 'plugin-list' && !isSearchMode
|
|
});
|
|
|
|
// 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_0 = selectedPlugin.entry.homepage;
|
|
const githubRepo_0 = 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_0) {
|
|
void openBrowser(hasHomepage_0);
|
|
} else if (action === 'github' && githubRepo_0) {
|
|
void openBrowser(`https://github.com/${githubRepo_0}`);
|
|
} 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: plugin_4,
|
|
pluginId: pluginId_0
|
|
} = viewState;
|
|
function finish(msg: string): void {
|
|
setResult(msg);
|
|
if (onInstallComplete) {
|
|
void onInstallComplete();
|
|
}
|
|
setParentViewState({
|
|
type: 'menu'
|
|
});
|
|
}
|
|
return <PluginOptionsFlow plugin={plugin_4} pluginId={pluginId_0} onDone={(outcome, detail) => {
|
|
switch (outcome) {
|
|
case 'configured':
|
|
finish(`✓ Installed and configured ${plugin_4.name}. Run /reload-plugins to apply.`);
|
|
break;
|
|
case 'skipped':
|
|
finish(`✓ Installed ${plugin_4.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>;
|
|
}
|
|
|
|
// Plugin details view
|
|
if (viewState === 'plugin-details' && selectedPlugin) {
|
|
const hasHomepage_1 = selectedPlugin.entry.homepage;
|
|
const githubRepo_1 = extractGitHubRepo(selectedPlugin);
|
|
const menuOptions = buildPluginDetailsMenuOptions(hasHomepage_1, githubRepo_1);
|
|
return <Box flexDirection="column">
|
|
<Box marginBottom={1}>
|
|
<Text bold>Plugin details</Text>
|
|
</Box>
|
|
|
|
<Box flexDirection="column" marginBottom={1}>
|
|
<Text bold>{selectedPlugin.entry.name}</Text>
|
|
<Text dimColor>from {selectedPlugin.marketplaceName}</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>
|
|
|
|
<PluginTrustWarning />
|
|
|
|
{installError && <Box marginBottom={1}>
|
|
<Text color="error">Error: {installError}</Text>
|
|
</Box>}
|
|
|
|
<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.startsWith('install-') ? 'Installing…' : option.label}
|
|
</Text>
|
|
</Box>)}
|
|
</Box>
|
|
|
|
<Box marginTop={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>;
|
|
}
|
|
|
|
// Empty state
|
|
if (availablePlugins.length === 0) {
|
|
return <Box flexDirection="column">
|
|
<Box marginBottom={1}>
|
|
<Text bold>Discover plugins</Text>
|
|
</Box>
|
|
<EmptyStateMessage reason={emptyReason} />
|
|
<Box marginTop={1}>
|
|
<Text dimColor italic>
|
|
Esc to go back
|
|
</Text>
|
|
</Box>
|
|
</Box>;
|
|
}
|
|
|
|
// Get visible plugins from pagination
|
|
const visiblePlugins = pagination.getVisibleItems(filteredPlugins);
|
|
return <Box flexDirection="column">
|
|
<Box>
|
|
<Text bold>Discover plugins</Text>
|
|
{pagination.needsPagination && <Text dimColor>
|
|
{' '}
|
|
({pagination.scrollPosition.current}/
|
|
{pagination.scrollPosition.total})
|
|
</Text>}
|
|
</Box>
|
|
|
|
{/* Search box */}
|
|
<Box marginBottom={1}>
|
|
<SearchBox query={searchQuery} isFocused={isSearchMode} isTerminalFocused={isTerminalFocused} width={terminalWidth - 4} cursorOffset={searchCursorOffset} />
|
|
</Box>
|
|
|
|
{/* Warning banner */}
|
|
{warning && <Box marginBottom={1}>
|
|
<Text color="warning">
|
|
{figures.warning} {warning}
|
|
</Text>
|
|
</Box>}
|
|
|
|
{/* No search results */}
|
|
{filteredPlugins.length === 0 && searchQuery && <Box marginBottom={1}>
|
|
<Text dimColor>No plugins match "{searchQuery}"</Text>
|
|
</Box>}
|
|
|
|
{/* Scroll up indicator */}
|
|
{pagination.scrollPosition.canScrollUp && <Box>
|
|
<Text dimColor> {figures.arrowUp} more above</Text>
|
|
</Box>}
|
|
|
|
{/* Plugin list - use startIndex in key to force re-render on scroll */}
|
|
{visiblePlugins.map((plugin_5, visibleIndex) => {
|
|
const actualIndex = pagination.toActualIndex(visibleIndex);
|
|
const isSelected = selectedIndex === actualIndex;
|
|
const isSelectedForInstall = selectedForInstall.has(plugin_5.pluginId);
|
|
const isInstallingThis = installingPlugins.has(plugin_5.pluginId);
|
|
const isLast = visibleIndex === visiblePlugins.length - 1;
|
|
return <Box key={`${pagination.startIndex}-${plugin_5.pluginId}`} flexDirection="column" marginBottom={isLast && !error ? 0 : 1}>
|
|
<Box>
|
|
<Text color={isSelected && !isSearchMode ? 'suggestion' : undefined}>
|
|
{isSelected && !isSearchMode ? figures.pointer : ' '}{' '}
|
|
</Text>
|
|
<Text>
|
|
{isInstallingThis ? figures.ellipsis : isSelectedForInstall ? figures.radioOn : figures.radioOff}{' '}
|
|
{plugin_5.entry.name}
|
|
<Text dimColor> · {plugin_5.marketplaceName}</Text>
|
|
{plugin_5.entry.tags?.includes('community-managed') && <Text dimColor> [Community Managed]</Text>}
|
|
{installCounts && plugin_5.marketplaceName === OFFICIAL_MARKETPLACE_NAME && <Text dimColor>
|
|
{' · '}
|
|
{formatInstallCount(installCounts.get(plugin_5.pluginId) ?? 0)}{' '}
|
|
installs
|
|
</Text>}
|
|
</Text>
|
|
</Box>
|
|
{plugin_5.entry.description && <Box marginLeft={4}>
|
|
<Text dimColor>
|
|
{truncateToWidth(plugin_5.entry.description, 60)}
|
|
</Text>
|
|
</Box>}
|
|
</Box>;
|
|
})}
|
|
|
|
{/* Scroll down indicator */}
|
|
{pagination.scrollPosition.canScrollDown && <Box>
|
|
<Text dimColor> {figures.arrowDown} more below</Text>
|
|
</Box>}
|
|
|
|
{/* Error messages */}
|
|
{error && <Box marginTop={1}>
|
|
<Text color="error">
|
|
{figures.cross} {error}
|
|
</Text>
|
|
</Box>}
|
|
|
|
<DiscoverPluginsKeyHint hasSelection={selectedForInstall.size > 0} canToggle={selectedIndex < filteredPlugins.length && !filteredPlugins[selectedIndex]?.isInstalled} />
|
|
</Box>;
|
|
}
|
|
function DiscoverPluginsKeyHint(t0) {
|
|
const $ = _c(10);
|
|
const {
|
|
hasSelection,
|
|
canToggle
|
|
} = t0;
|
|
let t1;
|
|
if ($[0] !== hasSelection) {
|
|
t1 = hasSelection && <ConfigurableShortcutHint action="plugin:install" context="Plugin" fallback="i" description="install" bold={true} />;
|
|
$[0] = hasSelection;
|
|
$[1] = t1;
|
|
} else {
|
|
t1 = $[1];
|
|
}
|
|
let t2;
|
|
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
|
|
t2 = <Text>type to search</Text>;
|
|
$[2] = t2;
|
|
} else {
|
|
t2 = $[2];
|
|
}
|
|
let t3;
|
|
if ($[3] !== canToggle) {
|
|
t3 = canToggle && <ConfigurableShortcutHint action="plugin:toggle" context="Plugin" fallback="Space" description="toggle" />;
|
|
$[3] = canToggle;
|
|
$[4] = t3;
|
|
} else {
|
|
t3 = $[4];
|
|
}
|
|
let t4;
|
|
let t5;
|
|
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
|
|
t4 = <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="details" />;
|
|
t5 = <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />;
|
|
$[5] = t4;
|
|
$[6] = t5;
|
|
} else {
|
|
t4 = $[5];
|
|
t5 = $[6];
|
|
}
|
|
let t6;
|
|
if ($[7] !== t1 || $[8] !== t3) {
|
|
t6 = <Box marginTop={1}><Text dimColor={true} italic={true}><Byline>{t1}{t2}{t3}{t4}{t5}</Byline></Text></Box>;
|
|
$[7] = t1;
|
|
$[8] = t3;
|
|
$[9] = t6;
|
|
} else {
|
|
t6 = $[9];
|
|
}
|
|
return t6;
|
|
}
|
|
|
|
/**
|
|
* Context-aware empty state message for the Discover screen
|
|
*/
|
|
function EmptyStateMessage(t0) {
|
|
const $ = _c(6);
|
|
const {
|
|
reason
|
|
} = t0;
|
|
switch (reason) {
|
|
case "git-not-installed":
|
|
{
|
|
let t1;
|
|
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
|
t1 = <><Text dimColor={true}>Git is required to install marketplaces.</Text><Text dimColor={true}>Please install git and restart Claude Code.</Text></>;
|
|
$[0] = t1;
|
|
} else {
|
|
t1 = $[0];
|
|
}
|
|
return t1;
|
|
}
|
|
case "all-blocked-by-policy":
|
|
{
|
|
let t1;
|
|
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
|
|
t1 = <><Text dimColor={true}>Your organization policy does not allow any external marketplaces.</Text><Text dimColor={true}>Contact your administrator.</Text></>;
|
|
$[1] = t1;
|
|
} else {
|
|
t1 = $[1];
|
|
}
|
|
return t1;
|
|
}
|
|
case "policy-restricts-sources":
|
|
{
|
|
let t1;
|
|
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
|
|
t1 = <><Text dimColor={true}>Your organization restricts which marketplaces can be added.</Text><Text dimColor={true}>Switch to the Marketplaces tab to view allowed sources.</Text></>;
|
|
$[2] = t1;
|
|
} else {
|
|
t1 = $[2];
|
|
}
|
|
return t1;
|
|
}
|
|
case "all-marketplaces-failed":
|
|
{
|
|
let t1;
|
|
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
|
|
t1 = <><Text dimColor={true}>Failed to load marketplace data.</Text><Text dimColor={true}>Check your network connection.</Text></>;
|
|
$[3] = t1;
|
|
} else {
|
|
t1 = $[3];
|
|
}
|
|
return t1;
|
|
}
|
|
case "all-plugins-installed":
|
|
{
|
|
let t1;
|
|
if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
|
|
t1 = <><Text dimColor={true}>All available plugins are already installed.</Text><Text dimColor={true}>Check for new plugins later or add more marketplaces.</Text></>;
|
|
$[4] = t1;
|
|
} else {
|
|
t1 = $[4];
|
|
}
|
|
return t1;
|
|
}
|
|
case "no-marketplaces-configured":
|
|
default:
|
|
{
|
|
let t1;
|
|
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
|
|
t1 = <><Text dimColor={true}>No plugins available.</Text><Text dimColor={true}>Add a marketplace first using the Marketplaces tab.</Text></>;
|
|
$[5] = t1;
|
|
} else {
|
|
t1 = $[5];
|
|
}
|
|
return t1;
|
|
}
|
|
}
|
|
}
|