DiscoverPlugins.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780
  1. import { c as _c } from "react/compiler-runtime";
  2. import figures from 'figures';
  3. import * as React from 'react';
  4. import { useCallback, useEffect, useMemo, useState } from 'react';
  5. import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
  6. import { Byline } from '../../components/design-system/Byline.js';
  7. import { SearchBox } from '../../components/SearchBox.js';
  8. import { useSearchInput } from '../../hooks/useSearchInput.js';
  9. import { useTerminalSize } from '../../hooks/useTerminalSize.js';
  10. // eslint-disable-next-line custom-rules/prefer-use-keybindings -- useInput needed for raw search mode text input
  11. import { Box, Text, useInput, useTerminalFocus } from '../../ink.js';
  12. import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js';
  13. import type { LoadedPlugin } from '../../types/plugin.js';
  14. import { count } from '../../utils/array.js';
  15. import { openBrowser } from '../../utils/browser.js';
  16. import { logForDebugging } from '../../utils/debug.js';
  17. import { errorMessage } from '../../utils/errors.js';
  18. import { clearAllCaches } from '../../utils/plugins/cacheUtils.js';
  19. import { formatInstallCount, getInstallCounts } from '../../utils/plugins/installCounts.js';
  20. import { isPluginGloballyInstalled } from '../../utils/plugins/installedPluginsManager.js';
  21. import { createPluginId, detectEmptyMarketplaceReason, type EmptyMarketplaceReason, formatFailureDetails, formatMarketplaceLoadingErrors, loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js';
  22. import { loadKnownMarketplacesConfig } from '../../utils/plugins/marketplaceManager.js';
  23. import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js';
  24. import { installPluginFromMarketplace } from '../../utils/plugins/pluginInstallationHelpers.js';
  25. import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js';
  26. import { plural } from '../../utils/stringUtils.js';
  27. import { truncateToWidth } from '../../utils/truncate.js';
  28. import { findPluginOptionsTarget, PluginOptionsFlow } from './PluginOptionsFlow.js';
  29. import { PluginTrustWarning } from './PluginTrustWarning.js';
  30. import { buildPluginDetailsMenuOptions, extractGitHubRepo, type InstallablePlugin } from './pluginDetailsHelpers.js';
  31. import type { ViewState as ParentViewState } from './types.js';
  32. import { usePagination } from './usePagination.js';
  33. type Props = {
  34. error: string | null;
  35. setError: (error: string | null) => void;
  36. result: string | null;
  37. setResult: (result: string | null) => void;
  38. setViewState: (state: ParentViewState) => void;
  39. onInstallComplete?: () => void | Promise<void>;
  40. onSearchModeChange?: (isActive: boolean) => void;
  41. targetPlugin?: string;
  42. };
  43. type ViewState = 'plugin-list' | 'plugin-details' | {
  44. type: 'plugin-options';
  45. plugin: LoadedPlugin;
  46. pluginId: string;
  47. };
  48. export function DiscoverPlugins({
  49. error,
  50. setError,
  51. result: _result,
  52. setResult,
  53. setViewState: setParentViewState,
  54. onInstallComplete,
  55. onSearchModeChange,
  56. targetPlugin
  57. }: Props): React.ReactNode {
  58. // View state
  59. const [viewState, setViewState] = useState<ViewState>('plugin-list');
  60. const [selectedPlugin, setSelectedPlugin] = useState<InstallablePlugin | null>(null);
  61. // Data state
  62. const [availablePlugins, setAvailablePlugins] = useState<InstallablePlugin[]>([]);
  63. const [loading, setLoading] = useState(true);
  64. const [installCounts, setInstallCounts] = useState<Map<string, number> | null>(null);
  65. // Search state
  66. const [isSearchMode, setIsSearchModeRaw] = useState(false);
  67. const setIsSearchMode = useCallback((active: boolean) => {
  68. setIsSearchModeRaw(active);
  69. onSearchModeChange?.(active);
  70. }, [onSearchModeChange]);
  71. const {
  72. query: searchQuery,
  73. setQuery: setSearchQuery,
  74. cursorOffset: searchCursorOffset
  75. } = useSearchInput({
  76. isActive: viewState === 'plugin-list' && isSearchMode && !loading,
  77. onExit: () => {
  78. setIsSearchMode(false);
  79. }
  80. });
  81. const isTerminalFocused = useTerminalFocus();
  82. const {
  83. columns: terminalWidth
  84. } = useTerminalSize();
  85. // Filter plugins based on search query
  86. const filteredPlugins = useMemo(() => {
  87. if (!searchQuery) return availablePlugins;
  88. const lowerQuery = searchQuery.toLowerCase();
  89. return availablePlugins.filter(plugin => plugin.entry.name.toLowerCase().includes(lowerQuery) || plugin.entry.description?.toLowerCase().includes(lowerQuery) || plugin.marketplaceName.toLowerCase().includes(lowerQuery));
  90. }, [availablePlugins, searchQuery]);
  91. // Selection state
  92. const [selectedIndex, setSelectedIndex] = useState(0);
  93. const [selectedForInstall, setSelectedForInstall] = useState<Set<string>>(new Set());
  94. const [installingPlugins, setInstallingPlugins] = useState<Set<string>>(new Set());
  95. // Pagination for plugin list (continuous scrolling)
  96. const pagination = usePagination<InstallablePlugin>({
  97. totalItems: filteredPlugins.length,
  98. selectedIndex
  99. });
  100. // Reset selection when search query changes
  101. useEffect(() => {
  102. setSelectedIndex(0);
  103. }, [searchQuery]);
  104. // Details view state
  105. const [detailsMenuIndex, setDetailsMenuIndex] = useState(0);
  106. const [isInstalling, setIsInstalling] = useState(false);
  107. const [installError, setInstallError] = useState<string | null>(null);
  108. // Warning state for non-critical errors
  109. const [warning, setWarning] = useState<string | null>(null);
  110. // Empty state reason
  111. const [emptyReason, setEmptyReason] = useState<EmptyMarketplaceReason | null>(null);
  112. // Load all plugins from all marketplaces
  113. useEffect(() => {
  114. async function loadAllPlugins() {
  115. try {
  116. const config = await loadKnownMarketplacesConfig();
  117. // Load marketplaces with graceful degradation
  118. const {
  119. marketplaces,
  120. failures
  121. } = await loadMarketplacesWithGracefulDegradation(config);
  122. // Collect all plugins from all marketplaces
  123. const allPlugins: InstallablePlugin[] = [];
  124. for (const {
  125. name,
  126. data: marketplace
  127. } of marketplaces) {
  128. if (marketplace) {
  129. for (const entry of marketplace.plugins) {
  130. const pluginId = createPluginId(entry.name, name);
  131. allPlugins.push({
  132. entry,
  133. marketplaceName: name,
  134. pluginId,
  135. // Only block when globally installed (user/managed scope).
  136. // Project/local-scope installs don't block — user may want to
  137. // promote to user scope so it's available everywhere (gh-29997).
  138. isInstalled: isPluginGloballyInstalled(pluginId)
  139. });
  140. }
  141. }
  142. }
  143. // Filter out installed and policy-blocked plugins
  144. const uninstalledPlugins = allPlugins.filter(p => !p.isInstalled && !isPluginBlockedByPolicy(p.pluginId));
  145. // Fetch install counts and sort by popularity
  146. try {
  147. const counts = await getInstallCounts();
  148. setInstallCounts(counts);
  149. if (counts) {
  150. // Sort by install count (descending), then alphabetically
  151. uninstalledPlugins.sort((a_0, b_0) => {
  152. const countA = counts.get(a_0.pluginId) ?? 0;
  153. const countB = counts.get(b_0.pluginId) ?? 0;
  154. if (countA !== countB) return countB - countA;
  155. return a_0.entry.name.localeCompare(b_0.entry.name);
  156. });
  157. } else {
  158. // No counts available - sort alphabetically
  159. uninstalledPlugins.sort((a_1, b_1) => a_1.entry.name.localeCompare(b_1.entry.name));
  160. }
  161. } catch (error_0) {
  162. // Log the error, then gracefully degrade to alphabetical sort
  163. logForDebugging(`Failed to fetch install counts: ${errorMessage(error_0)}`);
  164. uninstalledPlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
  165. }
  166. setAvailablePlugins(uninstalledPlugins);
  167. // Detect empty reason if no plugins available
  168. const configuredCount = Object.keys(config).length;
  169. if (uninstalledPlugins.length === 0) {
  170. const reason = await detectEmptyMarketplaceReason({
  171. configuredMarketplaceCount: configuredCount,
  172. failedMarketplaceCount: failures.length
  173. });
  174. setEmptyReason(reason);
  175. }
  176. // Handle marketplace loading errors/warnings
  177. const successCount = count(marketplaces, m => m.data !== null);
  178. const errorResult = formatMarketplaceLoadingErrors(failures, successCount);
  179. if (errorResult) {
  180. if (errorResult.type === 'warning') {
  181. setWarning(errorResult.message + '. Showing available plugins.');
  182. } else {
  183. throw new Error(errorResult.message);
  184. }
  185. }
  186. // Handle targetPlugin - navigate directly to plugin details
  187. // Search in allPlugins (before filtering) to handle installed plugins gracefully
  188. if (targetPlugin) {
  189. const foundPlugin = allPlugins.find(p_0 => p_0.entry.name === targetPlugin);
  190. if (foundPlugin) {
  191. if (foundPlugin.isInstalled) {
  192. setError(`Plugin '${foundPlugin.pluginId}' is already installed. Use '/plugin' to manage existing plugins.`);
  193. } else {
  194. setSelectedPlugin(foundPlugin);
  195. setViewState('plugin-details');
  196. }
  197. } else {
  198. setError(`Plugin "${targetPlugin}" not found in any marketplace`);
  199. }
  200. }
  201. } catch (err) {
  202. setError(err instanceof Error ? err.message : 'Failed to load plugins');
  203. } finally {
  204. setLoading(false);
  205. }
  206. }
  207. void loadAllPlugins();
  208. }, [setError, targetPlugin]);
  209. // Install selected plugins
  210. const installSelectedPlugins = async () => {
  211. if (selectedForInstall.size === 0) return;
  212. const pluginsToInstall = availablePlugins.filter(p_1 => selectedForInstall.has(p_1.pluginId));
  213. setInstallingPlugins(new Set(pluginsToInstall.map(p_2 => p_2.pluginId)));
  214. let successCount_0 = 0;
  215. let failureCount = 0;
  216. const newFailedPlugins: Array<{
  217. name: string;
  218. reason: string;
  219. }> = [];
  220. for (const plugin_0 of pluginsToInstall) {
  221. const result = await installPluginFromMarketplace({
  222. pluginId: plugin_0.pluginId,
  223. entry: plugin_0.entry,
  224. marketplaceName: plugin_0.marketplaceName,
  225. scope: 'user'
  226. });
  227. if (result.success) {
  228. successCount_0++;
  229. } else {
  230. failureCount++;
  231. newFailedPlugins.push({
  232. name: plugin_0.entry.name,
  233. reason: (result as { success: false; error: string }).error
  234. });
  235. }
  236. }
  237. setInstallingPlugins(new Set());
  238. setSelectedForInstall(new Set());
  239. clearAllCaches();
  240. // Handle installation results
  241. if (failureCount === 0) {
  242. const message = `✓ Installed ${successCount_0} ${plural(successCount_0, 'plugin')}. ` + `Run /reload-plugins to activate.`;
  243. setResult(message);
  244. } else if (successCount_0 === 0) {
  245. setError(`Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`);
  246. } else {
  247. const message_0 = `✓ Installed ${successCount_0} of ${successCount_0 + failureCount} plugins. ` + `Failed: ${formatFailureDetails(newFailedPlugins, false)}. ` + `Run /reload-plugins to activate successfully installed plugins.`;
  248. setResult(message_0);
  249. }
  250. if (successCount_0 > 0) {
  251. if (onInstallComplete) {
  252. await onInstallComplete();
  253. }
  254. }
  255. setParentViewState({
  256. type: 'menu'
  257. });
  258. };
  259. // Install single plugin from details view
  260. const handleSinglePluginInstall = async (plugin_1: InstallablePlugin, scope: 'user' | 'project' | 'local' = 'user') => {
  261. setIsInstalling(true);
  262. setInstallError(null);
  263. const result_0 = await installPluginFromMarketplace({
  264. pluginId: plugin_1.pluginId,
  265. entry: plugin_1.entry,
  266. marketplaceName: plugin_1.marketplaceName,
  267. scope
  268. });
  269. if (result_0.success) {
  270. const loaded = await findPluginOptionsTarget(plugin_1.pluginId);
  271. if (loaded) {
  272. setIsInstalling(false);
  273. setViewState({
  274. type: 'plugin-options',
  275. plugin: loaded,
  276. pluginId: plugin_1.pluginId
  277. });
  278. return;
  279. }
  280. setResult(result_0.message);
  281. if (onInstallComplete) {
  282. await onInstallComplete();
  283. }
  284. setParentViewState({
  285. type: 'menu'
  286. });
  287. } else {
  288. setIsInstalling(false);
  289. setInstallError((result_0 as { success: false; error: string }).error);
  290. }
  291. };
  292. // Handle error state
  293. useEffect(() => {
  294. if (error) {
  295. setResult(error);
  296. }
  297. }, [error, setResult]);
  298. // Escape in plugin-details view - go back to plugin-list
  299. useKeybinding('confirm:no', () => {
  300. setViewState('plugin-list');
  301. setSelectedPlugin(null);
  302. }, {
  303. context: 'Confirmation',
  304. isActive: viewState === 'plugin-details'
  305. });
  306. // Escape in plugin-list view (not search mode) - exit to parent menu
  307. useKeybinding('confirm:no', () => {
  308. setParentViewState({
  309. type: 'menu'
  310. });
  311. }, {
  312. context: 'Confirmation',
  313. isActive: viewState === 'plugin-list' && !isSearchMode
  314. });
  315. // Handle entering search mode (non-escape keys)
  316. useInput((input, _key) => {
  317. const keyIsNotCtrlOrMeta = !_key.ctrl && !_key.meta;
  318. if (!isSearchMode) {
  319. // Enter search mode with '/' or any printable character
  320. if (input === '/' && keyIsNotCtrlOrMeta) {
  321. setIsSearchMode(true);
  322. setSearchQuery('');
  323. } else if (keyIsNotCtrlOrMeta && input.length > 0 && !/^\s+$/.test(input) &&
  324. // Don't enter search mode for navigation keys
  325. input !== 'j' && input !== 'k' && input !== 'i') {
  326. setIsSearchMode(true);
  327. setSearchQuery(input);
  328. }
  329. }
  330. }, {
  331. isActive: viewState === 'plugin-list' && !loading
  332. });
  333. // Plugin-list navigation (non-search mode)
  334. useKeybindings({
  335. 'select:previous': () => {
  336. if (selectedIndex === 0) {
  337. setIsSearchMode(true);
  338. } else {
  339. pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex);
  340. }
  341. },
  342. 'select:next': () => {
  343. if (selectedIndex < filteredPlugins.length - 1) {
  344. pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex);
  345. }
  346. },
  347. 'select:accept': () => {
  348. if (selectedIndex === filteredPlugins.length && selectedForInstall.size > 0) {
  349. void installSelectedPlugins();
  350. } else if (selectedIndex < filteredPlugins.length) {
  351. const plugin_2 = filteredPlugins[selectedIndex];
  352. if (plugin_2) {
  353. if (plugin_2.isInstalled) {
  354. setParentViewState({
  355. type: 'manage-plugins',
  356. targetPlugin: plugin_2.entry.name,
  357. targetMarketplace: plugin_2.marketplaceName
  358. });
  359. } else {
  360. setSelectedPlugin(plugin_2);
  361. setViewState('plugin-details');
  362. setDetailsMenuIndex(0);
  363. setInstallError(null);
  364. }
  365. }
  366. }
  367. }
  368. }, {
  369. context: 'Select',
  370. isActive: viewState === 'plugin-list' && !isSearchMode
  371. });
  372. useKeybindings({
  373. 'plugin:toggle': () => {
  374. if (selectedIndex < filteredPlugins.length) {
  375. const plugin_3 = filteredPlugins[selectedIndex];
  376. if (plugin_3 && !plugin_3.isInstalled) {
  377. const newSelection = new Set(selectedForInstall);
  378. if (newSelection.has(plugin_3.pluginId)) {
  379. newSelection.delete(plugin_3.pluginId);
  380. } else {
  381. newSelection.add(plugin_3.pluginId);
  382. }
  383. setSelectedForInstall(newSelection);
  384. }
  385. }
  386. },
  387. 'plugin:install': () => {
  388. if (selectedForInstall.size > 0) {
  389. void installSelectedPlugins();
  390. }
  391. }
  392. }, {
  393. context: 'Plugin',
  394. isActive: viewState === 'plugin-list' && !isSearchMode
  395. });
  396. // Plugin-details navigation
  397. const detailsMenuOptions = React.useMemo(() => {
  398. if (!selectedPlugin) return [];
  399. const hasHomepage = selectedPlugin.entry.homepage;
  400. const githubRepo = extractGitHubRepo(selectedPlugin);
  401. return buildPluginDetailsMenuOptions(hasHomepage, githubRepo);
  402. }, [selectedPlugin]);
  403. useKeybindings({
  404. 'select:previous': () => {
  405. if (detailsMenuIndex > 0) {
  406. setDetailsMenuIndex(detailsMenuIndex - 1);
  407. }
  408. },
  409. 'select:next': () => {
  410. if (detailsMenuIndex < detailsMenuOptions.length - 1) {
  411. setDetailsMenuIndex(detailsMenuIndex + 1);
  412. }
  413. },
  414. 'select:accept': () => {
  415. if (!selectedPlugin) return;
  416. const action = detailsMenuOptions[detailsMenuIndex]?.action;
  417. const hasHomepage_0 = selectedPlugin.entry.homepage;
  418. const githubRepo_0 = extractGitHubRepo(selectedPlugin);
  419. if (action === 'install-user') {
  420. void handleSinglePluginInstall(selectedPlugin, 'user');
  421. } else if (action === 'install-project') {
  422. void handleSinglePluginInstall(selectedPlugin, 'project');
  423. } else if (action === 'install-local') {
  424. void handleSinglePluginInstall(selectedPlugin, 'local');
  425. } else if (action === 'homepage' && hasHomepage_0) {
  426. void openBrowser(hasHomepage_0);
  427. } else if (action === 'github' && githubRepo_0) {
  428. void openBrowser(`https://github.com/${githubRepo_0}`);
  429. } else if (action === 'back') {
  430. setViewState('plugin-list');
  431. setSelectedPlugin(null);
  432. }
  433. }
  434. }, {
  435. context: 'Select',
  436. isActive: viewState === 'plugin-details' && !!selectedPlugin
  437. });
  438. if (typeof viewState === 'object' && viewState.type === 'plugin-options') {
  439. const {
  440. plugin: plugin_4,
  441. pluginId: pluginId_0
  442. } = viewState;
  443. function finish(msg: string): void {
  444. setResult(msg);
  445. if (onInstallComplete) {
  446. void onInstallComplete();
  447. }
  448. setParentViewState({
  449. type: 'menu'
  450. });
  451. }
  452. return <PluginOptionsFlow plugin={plugin_4} pluginId={pluginId_0} onDone={(outcome, detail) => {
  453. switch (outcome) {
  454. case 'configured':
  455. finish(`✓ Installed and configured ${plugin_4.name}. Run /reload-plugins to apply.`);
  456. break;
  457. case 'skipped':
  458. finish(`✓ Installed ${plugin_4.name}. Run /reload-plugins to apply.`);
  459. break;
  460. case 'error':
  461. finish(`Installed but failed to save config: ${detail}`);
  462. break;
  463. }
  464. }} />;
  465. }
  466. // Loading state
  467. if (loading) {
  468. return <Text>Loading…</Text>;
  469. }
  470. // Error state
  471. if (error) {
  472. return <Text color="error">{error}</Text>;
  473. }
  474. // Plugin details view
  475. if (viewState === 'plugin-details' && selectedPlugin) {
  476. const hasHomepage_1 = selectedPlugin.entry.homepage;
  477. const githubRepo_1 = extractGitHubRepo(selectedPlugin);
  478. const menuOptions = buildPluginDetailsMenuOptions(hasHomepage_1, githubRepo_1);
  479. return <Box flexDirection="column">
  480. <Box marginBottom={1}>
  481. <Text bold>Plugin details</Text>
  482. </Box>
  483. <Box flexDirection="column" marginBottom={1}>
  484. <Text bold>{selectedPlugin.entry.name}</Text>
  485. <Text dimColor>from {selectedPlugin.marketplaceName}</Text>
  486. {selectedPlugin.entry.version && <Text dimColor>Version: {selectedPlugin.entry.version}</Text>}
  487. {selectedPlugin.entry.description && <Box marginTop={1}>
  488. <Text>{selectedPlugin.entry.description}</Text>
  489. </Box>}
  490. {selectedPlugin.entry.author && <Box marginTop={1}>
  491. <Text dimColor>
  492. By:{' '}
  493. {typeof selectedPlugin.entry.author === 'string' ? selectedPlugin.entry.author : selectedPlugin.entry.author.name}
  494. </Text>
  495. </Box>}
  496. </Box>
  497. <PluginTrustWarning />
  498. {installError && <Box marginBottom={1}>
  499. <Text color="error">Error: {installError}</Text>
  500. </Box>}
  501. <Box flexDirection="column">
  502. {menuOptions.map((option, index) => <Box key={option.action}>
  503. {detailsMenuIndex === index && <Text>{'> '}</Text>}
  504. {detailsMenuIndex !== index && <Text>{' '}</Text>}
  505. <Text bold={detailsMenuIndex === index}>
  506. {isInstalling && option.action.startsWith('install-') ? 'Installing…' : option.label}
  507. </Text>
  508. </Box>)}
  509. </Box>
  510. <Box marginTop={1}>
  511. <Text dimColor>
  512. <Byline>
  513. <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" />
  514. <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />
  515. </Byline>
  516. </Text>
  517. </Box>
  518. </Box>;
  519. }
  520. // Empty state
  521. if (availablePlugins.length === 0) {
  522. return <Box flexDirection="column">
  523. <Box marginBottom={1}>
  524. <Text bold>Discover plugins</Text>
  525. </Box>
  526. <EmptyStateMessage reason={emptyReason} />
  527. <Box marginTop={1}>
  528. <Text dimColor italic>
  529. Esc to go back
  530. </Text>
  531. </Box>
  532. </Box>;
  533. }
  534. // Get visible plugins from pagination
  535. const visiblePlugins = pagination.getVisibleItems(filteredPlugins);
  536. return <Box flexDirection="column">
  537. <Box>
  538. <Text bold>Discover plugins</Text>
  539. {pagination.needsPagination && <Text dimColor>
  540. {' '}
  541. ({pagination.scrollPosition.current}/
  542. {pagination.scrollPosition.total})
  543. </Text>}
  544. </Box>
  545. {/* Search box */}
  546. <Box marginBottom={1}>
  547. <SearchBox query={searchQuery} isFocused={isSearchMode} isTerminalFocused={isTerminalFocused} width={terminalWidth - 4} cursorOffset={searchCursorOffset} />
  548. </Box>
  549. {/* Warning banner */}
  550. {warning && <Box marginBottom={1}>
  551. <Text color="warning">
  552. {figures.warning} {warning}
  553. </Text>
  554. </Box>}
  555. {/* No search results */}
  556. {filteredPlugins.length === 0 && searchQuery && <Box marginBottom={1}>
  557. <Text dimColor>No plugins match &quot;{searchQuery}&quot;</Text>
  558. </Box>}
  559. {/* Scroll up indicator */}
  560. {pagination.scrollPosition.canScrollUp && <Box>
  561. <Text dimColor> {figures.arrowUp} more above</Text>
  562. </Box>}
  563. {/* Plugin list - use startIndex in key to force re-render on scroll */}
  564. {visiblePlugins.map((plugin_5, visibleIndex) => {
  565. const actualIndex = pagination.toActualIndex(visibleIndex);
  566. const isSelected = selectedIndex === actualIndex;
  567. const isSelectedForInstall = selectedForInstall.has(plugin_5.pluginId);
  568. const isInstallingThis = installingPlugins.has(plugin_5.pluginId);
  569. const isLast = visibleIndex === visiblePlugins.length - 1;
  570. return <Box key={`${pagination.startIndex}-${plugin_5.pluginId}`} flexDirection="column" marginBottom={isLast && !error ? 0 : 1}>
  571. <Box>
  572. <Text color={isSelected && !isSearchMode ? 'suggestion' : undefined}>
  573. {isSelected && !isSearchMode ? figures.pointer : ' '}{' '}
  574. </Text>
  575. <Text>
  576. {isInstallingThis ? figures.ellipsis : isSelectedForInstall ? figures.radioOn : figures.radioOff}{' '}
  577. {plugin_5.entry.name}
  578. <Text dimColor> · {plugin_5.marketplaceName}</Text>
  579. {plugin_5.entry.tags?.includes('community-managed') && <Text dimColor> [Community Managed]</Text>}
  580. {installCounts && plugin_5.marketplaceName === OFFICIAL_MARKETPLACE_NAME && <Text dimColor>
  581. {' · '}
  582. {formatInstallCount(installCounts.get(plugin_5.pluginId) ?? 0)}{' '}
  583. installs
  584. </Text>}
  585. </Text>
  586. </Box>
  587. {plugin_5.entry.description && <Box marginLeft={4}>
  588. <Text dimColor>
  589. {truncateToWidth(plugin_5.entry.description, 60)}
  590. </Text>
  591. </Box>}
  592. </Box>;
  593. })}
  594. {/* Scroll down indicator */}
  595. {pagination.scrollPosition.canScrollDown && <Box>
  596. <Text dimColor> {figures.arrowDown} more below</Text>
  597. </Box>}
  598. {/* Error messages */}
  599. {error && <Box marginTop={1}>
  600. <Text color="error">
  601. {figures.cross} {error}
  602. </Text>
  603. </Box>}
  604. <DiscoverPluginsKeyHint hasSelection={selectedForInstall.size > 0} canToggle={selectedIndex < filteredPlugins.length && !filteredPlugins[selectedIndex]?.isInstalled} />
  605. </Box>;
  606. }
  607. function DiscoverPluginsKeyHint(t0) {
  608. const $ = _c(10);
  609. const {
  610. hasSelection,
  611. canToggle
  612. } = t0;
  613. let t1;
  614. if ($[0] !== hasSelection) {
  615. t1 = hasSelection && <ConfigurableShortcutHint action="plugin:install" context="Plugin" fallback="i" description="install" bold={true} />;
  616. $[0] = hasSelection;
  617. $[1] = t1;
  618. } else {
  619. t1 = $[1];
  620. }
  621. let t2;
  622. if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
  623. t2 = <Text>type to search</Text>;
  624. $[2] = t2;
  625. } else {
  626. t2 = $[2];
  627. }
  628. let t3;
  629. if ($[3] !== canToggle) {
  630. t3 = canToggle && <ConfigurableShortcutHint action="plugin:toggle" context="Plugin" fallback="Space" description="toggle" />;
  631. $[3] = canToggle;
  632. $[4] = t3;
  633. } else {
  634. t3 = $[4];
  635. }
  636. let t4;
  637. let t5;
  638. if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
  639. t4 = <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="details" />;
  640. t5 = <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />;
  641. $[5] = t4;
  642. $[6] = t5;
  643. } else {
  644. t4 = $[5];
  645. t5 = $[6];
  646. }
  647. let t6;
  648. if ($[7] !== t1 || $[8] !== t3) {
  649. t6 = <Box marginTop={1}><Text dimColor={true} italic={true}><Byline>{t1}{t2}{t3}{t4}{t5}</Byline></Text></Box>;
  650. $[7] = t1;
  651. $[8] = t3;
  652. $[9] = t6;
  653. } else {
  654. t6 = $[9];
  655. }
  656. return t6;
  657. }
  658. /**
  659. * Context-aware empty state message for the Discover screen
  660. */
  661. function EmptyStateMessage(t0) {
  662. const $ = _c(6);
  663. const {
  664. reason
  665. } = t0;
  666. switch (reason) {
  667. case "git-not-installed":
  668. {
  669. let t1;
  670. if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
  671. t1 = <><Text dimColor={true}>Git is required to install marketplaces.</Text><Text dimColor={true}>Please install git and restart Claude Code.</Text></>;
  672. $[0] = t1;
  673. } else {
  674. t1 = $[0];
  675. }
  676. return t1;
  677. }
  678. case "all-blocked-by-policy":
  679. {
  680. let t1;
  681. if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
  682. t1 = <><Text dimColor={true}>Your organization policy does not allow any external marketplaces.</Text><Text dimColor={true}>Contact your administrator.</Text></>;
  683. $[1] = t1;
  684. } else {
  685. t1 = $[1];
  686. }
  687. return t1;
  688. }
  689. case "policy-restricts-sources":
  690. {
  691. let t1;
  692. if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
  693. 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></>;
  694. $[2] = t1;
  695. } else {
  696. t1 = $[2];
  697. }
  698. return t1;
  699. }
  700. case "all-marketplaces-failed":
  701. {
  702. let t1;
  703. if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
  704. t1 = <><Text dimColor={true}>Failed to load marketplace data.</Text><Text dimColor={true}>Check your network connection.</Text></>;
  705. $[3] = t1;
  706. } else {
  707. t1 = $[3];
  708. }
  709. return t1;
  710. }
  711. case "all-plugins-installed":
  712. {
  713. let t1;
  714. if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
  715. t1 = <><Text dimColor={true}>All available plugins are already installed.</Text><Text dimColor={true}>Check for new plugins later or add more marketplaces.</Text></>;
  716. $[4] = t1;
  717. } else {
  718. t1 = $[4];
  719. }
  720. return t1;
  721. }
  722. case "no-marketplaces-configured":
  723. default:
  724. {
  725. let t1;
  726. if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
  727. t1 = <><Text dimColor={true}>No plugins available.</Text><Text dimColor={true}>Add a marketplace first using the Marketplaces tab.</Text></>;
  728. $[5] = t1;
  729. } else {
  730. t1 = $[5];
  731. }
  732. return t1;
  733. }
  734. }
  735. }