ManageMarketplaces.tsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837
  1. import { c as _c } from "react/compiler-runtime";
  2. import figures from 'figures';
  3. import * as React from 'react';
  4. import { useEffect, useRef, useState } from 'react';
  5. import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js';
  6. import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
  7. import { Byline } from '../../components/design-system/Byline.js';
  8. import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js';
  9. // eslint-disable-next-line custom-rules/prefer-use-keybindings -- useInput needed for marketplace-specific u/r shortcuts and y/n confirmation not in keybinding schema
  10. import { Box, Text, useInput } from '../../ink.js';
  11. import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js';
  12. import type { LoadedPlugin } from '../../types/plugin.js';
  13. import { count } from '../../utils/array.js';
  14. import { shouldSkipPluginAutoupdate } from '../../utils/config.js';
  15. import { errorMessage } from '../../utils/errors.js';
  16. import { clearAllCaches } from '../../utils/plugins/cacheUtils.js';
  17. import { createPluginId, formatMarketplaceLoadingErrors, getMarketplaceSourceDisplay, loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js';
  18. import { loadKnownMarketplacesConfig, refreshMarketplace, removeMarketplaceSource, setMarketplaceAutoUpdate } from '../../utils/plugins/marketplaceManager.js';
  19. import { updatePluginsForMarketplaces } from '../../utils/plugins/pluginAutoupdate.js';
  20. import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js';
  21. import { isMarketplaceAutoUpdate } from '../../utils/plugins/schemas.js';
  22. import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js';
  23. import { plural } from '../../utils/stringUtils.js';
  24. import type { ViewState } from './types.js';
  25. type Props = {
  26. setViewState: (state: ViewState) => void;
  27. error?: string | null;
  28. setError?: (error: string | null) => void;
  29. setResult: (result: string | null) => void;
  30. exitState: {
  31. pending: boolean;
  32. keyName: 'Ctrl-C' | 'Ctrl-D' | null;
  33. };
  34. onManageComplete?: () => void | Promise<void>;
  35. targetMarketplace?: string;
  36. action?: 'update' | 'remove';
  37. };
  38. type MarketplaceState = {
  39. name: string;
  40. source: string;
  41. lastUpdated?: string;
  42. pluginCount?: number;
  43. installedPlugins?: LoadedPlugin[];
  44. pendingUpdate?: boolean;
  45. pendingRemove?: boolean;
  46. autoUpdate?: boolean;
  47. };
  48. type InternalViewState = 'list' | 'details' | 'confirm-remove';
  49. export function ManageMarketplaces({
  50. setViewState,
  51. error,
  52. setError,
  53. setResult,
  54. exitState,
  55. onManageComplete,
  56. targetMarketplace,
  57. action
  58. }: Props): React.ReactNode {
  59. const [marketplaceStates, setMarketplaceStates] = useState<MarketplaceState[]>([]);
  60. const [loading, setLoading] = useState(true);
  61. const [selectedIndex, setSelectedIndex] = useState(0);
  62. const [isProcessing, setIsProcessing] = useState(false);
  63. const [processError, setProcessError] = useState<string | null>(null);
  64. const [successMessage, setSuccessMessage] = useState<string | null>(null);
  65. const [progressMessage, setProgressMessage] = useState<string | null>(null);
  66. const [internalView, setInternalView] = useState<InternalViewState>('list');
  67. const [selectedMarketplace, setSelectedMarketplace] = useState<MarketplaceState | null>(null);
  68. const [detailsMenuIndex, setDetailsMenuIndex] = useState(0);
  69. const hasAttemptedAutoAction = useRef(false);
  70. // Load marketplaces and their installed plugins
  71. useEffect(() => {
  72. async function loadMarketplaces() {
  73. try {
  74. const config = await loadKnownMarketplacesConfig();
  75. const {
  76. enabled,
  77. disabled
  78. } = await loadAllPlugins();
  79. const allPlugins = [...enabled, ...disabled];
  80. // Load marketplaces with graceful degradation
  81. const {
  82. marketplaces,
  83. failures
  84. } = await loadMarketplacesWithGracefulDegradation(config);
  85. const states: MarketplaceState[] = [];
  86. for (const {
  87. name,
  88. config: entry,
  89. data: marketplace
  90. } of marketplaces) {
  91. // Get all plugins installed from this marketplace
  92. const installedFromMarketplace = allPlugins.filter(plugin => plugin.source.endsWith(`@${name}`));
  93. states.push({
  94. name,
  95. source: getMarketplaceSourceDisplay(entry.source),
  96. lastUpdated: entry.lastUpdated,
  97. pluginCount: marketplace?.plugins.length,
  98. installedPlugins: installedFromMarketplace,
  99. pendingUpdate: false,
  100. pendingRemove: false,
  101. autoUpdate: isMarketplaceAutoUpdate(name, entry)
  102. });
  103. }
  104. // Sort: claude-plugin-directory first, then alphabetically
  105. states.sort((a, b) => {
  106. if (a.name === 'claude-plugin-directory') return -1;
  107. if (b.name === 'claude-plugin-directory') return 1;
  108. return a.name.localeCompare(b.name);
  109. });
  110. setMarketplaceStates(states);
  111. // Handle marketplace loading errors/warnings
  112. const successCount = count(marketplaces, m => m.data !== null);
  113. const errorResult = formatMarketplaceLoadingErrors(failures, successCount);
  114. if (errorResult) {
  115. if (errorResult.type === 'warning') {
  116. setProcessError(errorResult.message);
  117. } else {
  118. throw new Error(errorResult.message);
  119. }
  120. }
  121. // Auto-execute if target and action provided
  122. if (targetMarketplace && !hasAttemptedAutoAction.current && !error) {
  123. hasAttemptedAutoAction.current = true;
  124. const targetIndex = states.findIndex(s => s.name === targetMarketplace);
  125. if (targetIndex >= 0) {
  126. const targetState = states[targetIndex];
  127. if (action) {
  128. // Mark the action as pending and execute
  129. setSelectedIndex(targetIndex + 1); // +1 because "Add Marketplace" is at index 0
  130. const newStates = [...states];
  131. if (action === 'update') {
  132. newStates[targetIndex]!.pendingUpdate = true;
  133. } else if (action === 'remove') {
  134. newStates[targetIndex]!.pendingRemove = true;
  135. }
  136. setMarketplaceStates(newStates);
  137. // Apply the change immediately
  138. setTimeout(applyChanges, 100, newStates);
  139. } else if (targetState) {
  140. // No action - just show the details view for this marketplace
  141. setSelectedIndex(targetIndex + 1); // +1 because "Add Marketplace" is at index 0
  142. setSelectedMarketplace(targetState);
  143. setInternalView('details');
  144. }
  145. } else if (setError) {
  146. setError(`Marketplace not found: ${targetMarketplace}`);
  147. }
  148. }
  149. } catch (err) {
  150. if (setError) {
  151. setError(err instanceof Error ? err.message : 'Failed to load marketplaces');
  152. }
  153. setProcessError(err instanceof Error ? err.message : 'Failed to load marketplaces');
  154. } finally {
  155. setLoading(false);
  156. }
  157. }
  158. void loadMarketplaces();
  159. // eslint-disable-next-line react-hooks/exhaustive-deps
  160. // biome-ignore lint/correctness/useExhaustiveDependencies: intentional
  161. }, [targetMarketplace, action, error]);
  162. // Check if there are any pending changes
  163. const hasPendingChanges = () => {
  164. return marketplaceStates.some(state => state.pendingUpdate || state.pendingRemove);
  165. };
  166. // Get count of pending operations
  167. const getPendingCounts = () => {
  168. const updateCount = count(marketplaceStates, s => s.pendingUpdate);
  169. const removeCount = count(marketplaceStates, s => s.pendingRemove);
  170. return {
  171. updateCount,
  172. removeCount
  173. };
  174. };
  175. // Apply all pending changes
  176. const applyChanges = async (states?: MarketplaceState[]) => {
  177. const statesToProcess = states || marketplaceStates;
  178. const wasInDetailsView = internalView === 'details';
  179. setIsProcessing(true);
  180. setProcessError(null);
  181. setSuccessMessage(null);
  182. setProgressMessage(null);
  183. try {
  184. const settings = getSettingsForSource('userSettings');
  185. let updatedCount = 0;
  186. let removedCount = 0;
  187. const refreshedMarketplaces = new Set<string>();
  188. for (const state of statesToProcess) {
  189. // Handle remove
  190. if (state.pendingRemove) {
  191. // First uninstall all plugins from this marketplace
  192. if (state.installedPlugins && state.installedPlugins.length > 0) {
  193. const newEnabledPlugins = {
  194. ...settings?.enabledPlugins
  195. };
  196. for (const plugin of state.installedPlugins) {
  197. const pluginId = createPluginId(plugin.name, state.name);
  198. // Mark as disabled/uninstalled
  199. newEnabledPlugins[pluginId] = false;
  200. }
  201. updateSettingsForSource('userSettings', {
  202. enabledPlugins: newEnabledPlugins
  203. });
  204. }
  205. // Then remove the marketplace
  206. await removeMarketplaceSource(state.name);
  207. removedCount++;
  208. logEvent('tengu_marketplace_removed', {
  209. marketplace_name: state.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  210. plugins_uninstalled: state.installedPlugins?.length || 0
  211. });
  212. continue;
  213. }
  214. // Handle update
  215. if (state.pendingUpdate) {
  216. // Refresh individual marketplace for efficiency with progress reporting
  217. await refreshMarketplace(state.name, (message: string) => {
  218. setProgressMessage(message);
  219. });
  220. updatedCount++;
  221. refreshedMarketplaces.add(state.name.toLowerCase());
  222. logEvent('tengu_marketplace_updated', {
  223. marketplace_name: state.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  224. });
  225. }
  226. }
  227. // After marketplace clones are refreshed, bump installed plugins from
  228. // those marketplaces to the new version. Without this, the loader's
  229. // cache-on-miss (copyPluginToVersionedCache) creates the new version
  230. // dir on the next loadAllPlugins() call, but installed_plugins.json
  231. // stays on the old version — so cleanupOrphanedPluginVersionsInBackground
  232. // stamps the NEW dir with .orphaned_at on the next startup. See #29512.
  233. // updatePluginOp (called inside the helper) is what actually writes
  234. // installed_plugins.json via updateInstallationPathOnDisk.
  235. let updatedPluginCount = 0;
  236. if (refreshedMarketplaces.size > 0) {
  237. const updatedPluginIds = await updatePluginsForMarketplaces(refreshedMarketplaces);
  238. updatedPluginCount = updatedPluginIds.length;
  239. }
  240. // Clear caches after changes
  241. clearAllCaches();
  242. // Call completion callback
  243. if (onManageComplete) {
  244. await onManageComplete();
  245. }
  246. // Reload marketplace data to show updated timestamps
  247. const config = await loadKnownMarketplacesConfig();
  248. const {
  249. enabled,
  250. disabled
  251. } = await loadAllPlugins();
  252. const allPlugins = [...enabled, ...disabled];
  253. const {
  254. marketplaces
  255. } = await loadMarketplacesWithGracefulDegradation(config);
  256. const newStates: MarketplaceState[] = [];
  257. for (const {
  258. name,
  259. config: entry,
  260. data: marketplace
  261. } of marketplaces) {
  262. const installedFromMarketplace = allPlugins.filter(plugin => plugin.source.endsWith(`@${name}`));
  263. newStates.push({
  264. name,
  265. source: getMarketplaceSourceDisplay(entry.source),
  266. lastUpdated: entry.lastUpdated,
  267. pluginCount: marketplace?.plugins.length,
  268. installedPlugins: installedFromMarketplace,
  269. pendingUpdate: false,
  270. pendingRemove: false,
  271. autoUpdate: isMarketplaceAutoUpdate(name, entry)
  272. });
  273. }
  274. // Sort: claude-plugin-directory first, then alphabetically
  275. newStates.sort((a, b) => {
  276. if (a.name === 'claude-plugin-directory') return -1;
  277. if (b.name === 'claude-plugin-directory') return 1;
  278. return a.name.localeCompare(b.name);
  279. });
  280. setMarketplaceStates(newStates);
  281. // Update selected marketplace reference with fresh data
  282. if (wasInDetailsView && selectedMarketplace) {
  283. const updatedMarketplace = newStates.find(s => s.name === selectedMarketplace.name);
  284. if (updatedMarketplace) {
  285. setSelectedMarketplace(updatedMarketplace);
  286. }
  287. }
  288. // Build success message
  289. const actions: string[] = [];
  290. if (updatedCount > 0) {
  291. const pluginPart = updatedPluginCount > 0 ? ` (${updatedPluginCount} ${plural(updatedPluginCount, 'plugin')} bumped)` : '';
  292. actions.push(`Updated ${updatedCount} ${plural(updatedCount, 'marketplace')}${pluginPart}`);
  293. }
  294. if (removedCount > 0) {
  295. actions.push(`Removed ${removedCount} ${plural(removedCount, 'marketplace')}`);
  296. }
  297. if (actions.length > 0) {
  298. const successMsg = `${figures.tick} ${actions.join(', ')}`;
  299. // If we were in details view, stay there and show success
  300. if (wasInDetailsView) {
  301. setSuccessMessage(successMsg);
  302. } else {
  303. // Otherwise show result and exit to menu
  304. setResult(successMsg);
  305. setTimeout(setViewState, 2000, {
  306. type: 'menu' as const
  307. });
  308. }
  309. } else if (!wasInDetailsView) {
  310. setViewState({
  311. type: 'menu'
  312. });
  313. }
  314. } catch (err) {
  315. const errorMsg = errorMessage(err);
  316. setProcessError(errorMsg);
  317. if (setError) {
  318. setError(errorMsg);
  319. }
  320. } finally {
  321. setIsProcessing(false);
  322. setProgressMessage(null);
  323. }
  324. };
  325. // Handle confirming marketplace removal
  326. const confirmRemove = async () => {
  327. if (!selectedMarketplace) return;
  328. // Mark for removal and apply
  329. const newStates = marketplaceStates.map(state => state.name === selectedMarketplace.name ? {
  330. ...state,
  331. pendingRemove: true
  332. } : state);
  333. setMarketplaceStates(newStates);
  334. await applyChanges(newStates);
  335. };
  336. // Build menu options for details view
  337. const buildDetailsMenuOptions = (marketplace: MarketplaceState | null): Array<{
  338. label: string;
  339. secondaryLabel?: string;
  340. value: string;
  341. }> => {
  342. if (!marketplace) return [];
  343. const options: Array<{
  344. label: string;
  345. secondaryLabel?: string;
  346. value: string;
  347. }> = [{
  348. label: `Browse plugins (${marketplace.pluginCount ?? 0})`,
  349. value: 'browse'
  350. }, {
  351. label: 'Update marketplace',
  352. secondaryLabel: marketplace.lastUpdated ? `(last updated ${new Date(marketplace.lastUpdated).toLocaleDateString()})` : undefined,
  353. value: 'update'
  354. }];
  355. // Only show auto-update toggle if auto-updater is not globally disabled
  356. if (!shouldSkipPluginAutoupdate()) {
  357. options.push({
  358. label: marketplace.autoUpdate ? 'Disable auto-update' : 'Enable auto-update',
  359. value: 'toggle-auto-update'
  360. });
  361. }
  362. options.push({
  363. label: 'Remove marketplace',
  364. value: 'remove'
  365. });
  366. return options;
  367. };
  368. // Handle toggling auto-update for a marketplace
  369. const handleToggleAutoUpdate = async (marketplace: MarketplaceState) => {
  370. const newAutoUpdate = !marketplace.autoUpdate;
  371. try {
  372. await setMarketplaceAutoUpdate(marketplace.name, newAutoUpdate);
  373. // Update local state
  374. setMarketplaceStates(prev => prev.map(state => state.name === marketplace.name ? {
  375. ...state,
  376. autoUpdate: newAutoUpdate
  377. } : state));
  378. // Update selected marketplace reference
  379. setSelectedMarketplace(prev => prev ? {
  380. ...prev,
  381. autoUpdate: newAutoUpdate
  382. } : prev);
  383. } catch (err) {
  384. setProcessError(err instanceof Error ? err.message : 'Failed to update setting');
  385. }
  386. };
  387. // Escape in details or confirm-remove view - go back to list
  388. useKeybinding('confirm:no', () => {
  389. setInternalView('list');
  390. setDetailsMenuIndex(0);
  391. }, {
  392. context: 'Confirmation',
  393. isActive: !isProcessing && (internalView === 'details' || internalView === 'confirm-remove')
  394. });
  395. // Escape in list view with pending changes - clear pending changes
  396. useKeybinding('confirm:no', () => {
  397. setMarketplaceStates(prev => prev.map(state => ({
  398. ...state,
  399. pendingUpdate: false,
  400. pendingRemove: false
  401. })));
  402. setSelectedIndex(0);
  403. }, {
  404. context: 'Confirmation',
  405. isActive: !isProcessing && internalView === 'list' && hasPendingChanges()
  406. });
  407. // Escape in list view without pending changes - exit to parent menu
  408. useKeybinding('confirm:no', () => {
  409. setViewState({
  410. type: 'menu'
  411. });
  412. }, {
  413. context: 'Confirmation',
  414. isActive: !isProcessing && internalView === 'list' && !hasPendingChanges()
  415. });
  416. // List view — navigation (up/down/enter via configurable keybindings)
  417. useKeybindings({
  418. 'select:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)),
  419. 'select:next': () => {
  420. const totalItems = marketplaceStates.length + 1;
  421. setSelectedIndex(prev => Math.min(totalItems - 1, prev + 1));
  422. },
  423. 'select:accept': () => {
  424. const marketplaceIndex = selectedIndex - 1;
  425. if (selectedIndex === 0) {
  426. setViewState({
  427. type: 'add-marketplace'
  428. });
  429. } else if (hasPendingChanges()) {
  430. void applyChanges();
  431. } else {
  432. const marketplace = marketplaceStates[marketplaceIndex];
  433. if (marketplace) {
  434. setSelectedMarketplace(marketplace);
  435. setInternalView('details');
  436. setDetailsMenuIndex(0);
  437. }
  438. }
  439. }
  440. }, {
  441. context: 'Select',
  442. isActive: !isProcessing && internalView === 'list'
  443. });
  444. // List view — marketplace-specific actions (u/r shortcuts)
  445. useInput(input => {
  446. const marketplaceIndex = selectedIndex - 1;
  447. if ((input === 'u' || input === 'U') && marketplaceIndex >= 0) {
  448. setMarketplaceStates(prev => prev.map((state, idx) => idx === marketplaceIndex ? {
  449. ...state,
  450. pendingUpdate: !state.pendingUpdate,
  451. pendingRemove: state.pendingUpdate ? state.pendingRemove : false
  452. } : state));
  453. } else if ((input === 'r' || input === 'R') && marketplaceIndex >= 0) {
  454. const marketplace = marketplaceStates[marketplaceIndex];
  455. if (marketplace) {
  456. setSelectedMarketplace(marketplace);
  457. setInternalView('confirm-remove');
  458. }
  459. }
  460. }, {
  461. isActive: !isProcessing && internalView === 'list'
  462. });
  463. // Details view — navigation
  464. useKeybindings({
  465. 'select:previous': () => setDetailsMenuIndex(prev => Math.max(0, prev - 1)),
  466. 'select:next': () => {
  467. const menuOptions = buildDetailsMenuOptions(selectedMarketplace);
  468. setDetailsMenuIndex(prev => Math.min(menuOptions.length - 1, prev + 1));
  469. },
  470. 'select:accept': () => {
  471. if (!selectedMarketplace) return;
  472. const menuOptions = buildDetailsMenuOptions(selectedMarketplace);
  473. const selectedOption = menuOptions[detailsMenuIndex];
  474. if (selectedOption?.value === 'browse') {
  475. setViewState({
  476. type: 'browse-marketplace',
  477. targetMarketplace: selectedMarketplace.name
  478. });
  479. } else if (selectedOption?.value === 'update') {
  480. const newStates = marketplaceStates.map(state => state.name === selectedMarketplace.name ? {
  481. ...state,
  482. pendingUpdate: true
  483. } : state);
  484. setMarketplaceStates(newStates);
  485. void applyChanges(newStates);
  486. } else if (selectedOption?.value === 'toggle-auto-update') {
  487. void handleToggleAutoUpdate(selectedMarketplace);
  488. } else if (selectedOption?.value === 'remove') {
  489. setInternalView('confirm-remove');
  490. }
  491. }
  492. }, {
  493. context: 'Select',
  494. isActive: !isProcessing && internalView === 'details'
  495. });
  496. // Confirm-remove view — y/n input
  497. useInput(input => {
  498. if (input === 'y' || input === 'Y') {
  499. void confirmRemove();
  500. } else if (input === 'n' || input === 'N') {
  501. setInternalView('list');
  502. setSelectedMarketplace(null);
  503. }
  504. }, {
  505. isActive: !isProcessing && internalView === 'confirm-remove'
  506. });
  507. if (loading) {
  508. return <Text>Loading marketplaces…</Text>;
  509. }
  510. if (marketplaceStates.length === 0) {
  511. return <Box flexDirection="column">
  512. <Box marginBottom={1}>
  513. <Text bold>Manage marketplaces</Text>
  514. </Box>
  515. {/* Add Marketplace option */}
  516. <Box flexDirection="row" gap={1}>
  517. <Text color="suggestion">{figures.pointer} +</Text>
  518. <Text bold color="suggestion">
  519. Add Marketplace
  520. </Text>
  521. </Box>
  522. <Box marginLeft={3}>
  523. <Text dimColor italic>
  524. {exitState.pending ? <>Press {exitState.keyName} again to go back</> : <Byline>
  525. <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" />
  526. <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" />
  527. </Byline>}
  528. </Text>
  529. </Box>
  530. </Box>;
  531. }
  532. // Show confirmation dialog
  533. if (internalView === 'confirm-remove' && selectedMarketplace) {
  534. const pluginCount = selectedMarketplace.installedPlugins?.length || 0;
  535. return <Box flexDirection="column">
  536. <Text bold color="warning">
  537. Remove marketplace <Text italic>{selectedMarketplace.name}</Text>?
  538. </Text>
  539. <Box flexDirection="column">
  540. {pluginCount > 0 && <Box marginTop={1}>
  541. <Text color="warning">
  542. This will also uninstall {pluginCount}{' '}
  543. {plural(pluginCount, 'plugin')} from this marketplace:
  544. </Text>
  545. </Box>}
  546. {selectedMarketplace.installedPlugins && selectedMarketplace.installedPlugins.length > 0 && <Box flexDirection="column" marginTop={1} marginLeft={2}>
  547. {selectedMarketplace.installedPlugins.map(plugin => <Text key={plugin.name} dimColor>
  548. • {plugin.name}
  549. </Text>)}
  550. </Box>}
  551. <Box marginTop={1}>
  552. <Text>
  553. Press <Text bold>y</Text> to confirm or <Text bold>n</Text> to
  554. cancel
  555. </Text>
  556. </Box>
  557. </Box>
  558. </Box>;
  559. }
  560. // Show marketplace details
  561. if (internalView === 'details' && selectedMarketplace) {
  562. // Check if this marketplace is currently being processed
  563. // Check pendingUpdate first so we show updating state immediately when user presses Enter
  564. const isUpdating = selectedMarketplace.pendingUpdate || isProcessing;
  565. const menuOptions = buildDetailsMenuOptions(selectedMarketplace);
  566. return <Box flexDirection="column">
  567. <Text bold>{selectedMarketplace.name}</Text>
  568. <Text dimColor>{selectedMarketplace.source}</Text>
  569. <Box marginTop={1}>
  570. <Text>
  571. {selectedMarketplace.pluginCount || 0} available{' '}
  572. {plural(selectedMarketplace.pluginCount || 0, 'plugin')}
  573. </Text>
  574. </Box>
  575. {/* Installed plugins section */}
  576. {selectedMarketplace.installedPlugins && selectedMarketplace.installedPlugins.length > 0 && <Box flexDirection="column" marginTop={1}>
  577. <Text bold>
  578. Installed plugins ({selectedMarketplace.installedPlugins.length}
  579. ):
  580. </Text>
  581. <Box flexDirection="column" marginLeft={1}>
  582. {selectedMarketplace.installedPlugins.map(plugin => <Box key={plugin.name} flexDirection="row" gap={1}>
  583. <Text>{figures.bullet}</Text>
  584. <Box flexDirection="column">
  585. <Text>{plugin.name}</Text>
  586. <Text dimColor>{plugin.manifest.description}</Text>
  587. </Box>
  588. </Box>)}
  589. </Box>
  590. </Box>}
  591. {/* Processing indicator */}
  592. {isUpdating && <Box marginTop={1} flexDirection="column">
  593. <Text color="claude">Updating marketplace…</Text>
  594. {progressMessage && <Text dimColor>{progressMessage}</Text>}
  595. </Box>}
  596. {/* Success message */}
  597. {!isUpdating && successMessage && <Box marginTop={1}>
  598. <Text color="claude">{successMessage}</Text>
  599. </Box>}
  600. {/* Error message */}
  601. {!isUpdating && processError && <Box marginTop={1}>
  602. <Text color="error">{processError}</Text>
  603. </Box>}
  604. {/* Menu options */}
  605. {!isUpdating && <Box flexDirection="column" marginTop={1}>
  606. {menuOptions.map((option, idx) => {
  607. if (!option) return null;
  608. const isSelected = idx === detailsMenuIndex;
  609. return <Box key={option.value}>
  610. <Text color={isSelected ? 'suggestion' : undefined}>
  611. {isSelected ? figures.pointer : ' '} {option.label}
  612. </Text>
  613. {option.secondaryLabel && <Text dimColor> {option.secondaryLabel}</Text>}
  614. </Box>;
  615. })}
  616. </Box>}
  617. {/* Show explanatory text at the bottom when auto-update is enabled */}
  618. {!isUpdating && !shouldSkipPluginAutoupdate() && selectedMarketplace.autoUpdate && <Box marginTop={1}>
  619. <Text dimColor>
  620. Auto-update enabled. Claude Code will automatically update this
  621. marketplace and its installed plugins.
  622. </Text>
  623. </Box>}
  624. <Box marginLeft={3}>
  625. <Text dimColor italic>
  626. {isUpdating ? <>Please wait…</> : <Byline>
  627. <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" />
  628. <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" />
  629. </Byline>}
  630. </Text>
  631. </Box>
  632. </Box>;
  633. }
  634. // Show marketplace list
  635. const {
  636. updateCount,
  637. removeCount
  638. } = getPendingCounts();
  639. return <Box flexDirection="column">
  640. <Box marginBottom={1}>
  641. <Text bold>Manage marketplaces</Text>
  642. </Box>
  643. {/* Add Marketplace option */}
  644. <Box flexDirection="row" gap={1} marginBottom={1}>
  645. <Text color={selectedIndex === 0 ? 'suggestion' : undefined}>
  646. {selectedIndex === 0 ? figures.pointer : ' '} +
  647. </Text>
  648. <Text bold color={selectedIndex === 0 ? 'suggestion' : undefined}>
  649. Add Marketplace
  650. </Text>
  651. </Box>
  652. {/* Marketplace list */}
  653. <Box flexDirection="column">
  654. {marketplaceStates.map((state, idx) => {
  655. const isSelected = idx + 1 === selectedIndex; // +1 because Add Marketplace is at index 0
  656. // Build status indicators
  657. const indicators: string[] = [];
  658. if (state.pendingUpdate) indicators.push('UPDATE');
  659. if (state.pendingRemove) indicators.push('REMOVE');
  660. return <Box key={state.name} flexDirection="row" gap={1} marginBottom={1}>
  661. <Text color={isSelected ? 'suggestion' : undefined}>
  662. {isSelected ? figures.pointer : ' '}{' '}
  663. {state.pendingRemove ? figures.cross : figures.bullet}
  664. </Text>
  665. <Box flexDirection="column" flexGrow={1}>
  666. <Box flexDirection="row" gap={1}>
  667. <Text bold strikethrough={state.pendingRemove} dimColor={state.pendingRemove}>
  668. {state.name === 'claude-plugins-official' && <Text color="claude">✻ </Text>}
  669. {state.name}
  670. {state.name === 'claude-plugins-official' && <Text color="claude"> ✻</Text>}
  671. </Text>
  672. {indicators.length > 0 && <Text color="warning">[{indicators.join(', ')}]</Text>}
  673. </Box>
  674. <Text dimColor>{state.source}</Text>
  675. <Text dimColor>
  676. {state.pluginCount !== undefined && <>{state.pluginCount} available</>}
  677. {state.installedPlugins && state.installedPlugins.length > 0 && <> • {state.installedPlugins.length} installed</>}
  678. {state.lastUpdated && <>
  679. {' '}
  680. • Updated{' '}
  681. {new Date(state.lastUpdated).toLocaleDateString()}
  682. </>}
  683. </Text>
  684. </Box>
  685. </Box>;
  686. })}
  687. </Box>
  688. {/* Pending changes summary */}
  689. {hasPendingChanges() && <Box marginTop={1} flexDirection="column">
  690. <Text>
  691. <Text bold>Pending changes:</Text>{' '}
  692. <Text dimColor>Enter to apply</Text>
  693. </Text>
  694. {updateCount > 0 && <Text>
  695. • Update {updateCount} {plural(updateCount, 'marketplace')}
  696. </Text>}
  697. {removeCount > 0 && <Text color="warning">
  698. • Remove {removeCount} {plural(removeCount, 'marketplace')}
  699. </Text>}
  700. </Box>}
  701. {/* Processing indicator */}
  702. {isProcessing && <Box marginTop={1}>
  703. <Text color="claude">Processing changes…</Text>
  704. </Box>}
  705. {/* Error display */}
  706. {processError && <Box marginTop={1}>
  707. <Text color="error">{processError}</Text>
  708. </Box>}
  709. <ManageMarketplacesKeyHints exitState={exitState} hasPendingActions={hasPendingChanges()} />
  710. </Box>;
  711. }
  712. type ManageMarketplacesKeyHintsProps = {
  713. exitState: Props['exitState'];
  714. hasPendingActions: boolean;
  715. };
  716. function ManageMarketplacesKeyHints(t0) {
  717. const $ = _c(18);
  718. const {
  719. exitState,
  720. hasPendingActions
  721. } = t0;
  722. if (exitState.pending) {
  723. let t1;
  724. if ($[0] !== exitState.keyName) {
  725. t1 = <Box marginTop={1}><Text dimColor={true} italic={true}>Press {exitState.keyName} again to go back</Text></Box>;
  726. $[0] = exitState.keyName;
  727. $[1] = t1;
  728. } else {
  729. t1 = $[1];
  730. }
  731. return t1;
  732. }
  733. let t1;
  734. if ($[2] !== hasPendingActions) {
  735. t1 = hasPendingActions && <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="apply changes" />;
  736. $[2] = hasPendingActions;
  737. $[3] = t1;
  738. } else {
  739. t1 = $[3];
  740. }
  741. let t2;
  742. if ($[4] !== hasPendingActions) {
  743. t2 = !hasPendingActions && <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" />;
  744. $[4] = hasPendingActions;
  745. $[5] = t2;
  746. } else {
  747. t2 = $[5];
  748. }
  749. let t3;
  750. if ($[6] !== hasPendingActions) {
  751. t3 = !hasPendingActions && <KeyboardShortcutHint shortcut="u" action="update" />;
  752. $[6] = hasPendingActions;
  753. $[7] = t3;
  754. } else {
  755. t3 = $[7];
  756. }
  757. let t4;
  758. if ($[8] !== hasPendingActions) {
  759. t4 = !hasPendingActions && <KeyboardShortcutHint shortcut="r" action="remove" />;
  760. $[8] = hasPendingActions;
  761. $[9] = t4;
  762. } else {
  763. t4 = $[9];
  764. }
  765. const t5 = hasPendingActions ? "cancel" : "go back";
  766. let t6;
  767. if ($[10] !== t5) {
  768. t6 = <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description={t5} />;
  769. $[10] = t5;
  770. $[11] = t6;
  771. } else {
  772. t6 = $[11];
  773. }
  774. let t7;
  775. if ($[12] !== t1 || $[13] !== t2 || $[14] !== t3 || $[15] !== t4 || $[16] !== t6) {
  776. t7 = <Box marginTop={1}><Text dimColor={true} italic={true}><Byline>{t1}{t2}{t3}{t4}{t6}</Byline></Text></Box>;
  777. $[12] = t1;
  778. $[13] = t2;
  779. $[14] = t3;
  780. $[15] = t4;
  781. $[16] = t6;
  782. $[17] = t7;
  783. } else {
  784. t7 = $[17];
  785. }
  786. return t7;
  787. }