diff --git a/src/core/webview/packageManagerMessageHandler.ts b/src/core/webview/packageManagerMessageHandler.ts index ce69282276..afae6588f6 100644 --- a/src/core/webview/packageManagerMessageHandler.ts +++ b/src/core/webview/packageManagerMessageHandler.ts @@ -12,248 +12,272 @@ import { GlobalState } from "../../schemas" * Handle package manager-related messages from the webview */ export async function handlePackageManagerMessages( - provider: ClineProvider, - message: WebviewMessage, - packageManagerManager: PackageManagerManager + provider: ClineProvider, + message: WebviewMessage, + packageManagerManager: PackageManagerManager, ): Promise { - // Utility function for updating global state - const updateGlobalState = async (key: K, value: GlobalState[K]) => - await provider.contextProxy.setValue(key, value) + // Utility function for updating global state + const updateGlobalState = async (key: K, value: GlobalState[K]) => + await provider.contextProxy.setValue(key, value) - switch (message.type) { - case "webviewDidLaunch": { - // For webviewDidLaunch, we don't do anything - package manager items will be loaded by explicit fetchPackageManagerItems - console.log("Package Manager: webviewDidLaunch received, but skipping fetch (will be triggered by explicit fetchPackageManagerItems)"); - return true; - } - case "fetchPackageManagerItems": { - // Check if we need to force refresh using type assertion - const forceRefresh = (message as any).forceRefresh === true; - console.log(`Package Manager: Fetch requested with forceRefresh=${forceRefresh}`); - try { - console.log("Package Manager: Received request to fetch package manager items") - console.log("DEBUG: Processing package manager request") - - // Wrap the entire initialization in a try-catch block - try { - // Initialize default sources if none exist - let sources = await provider.contextProxy.getValue("packageManagerSources") as PackageManagerSource[] || [] - - if (!sources || sources.length === 0) { - console.log("Package Manager: No sources found, initializing default sources") - sources = [DEFAULT_PACKAGE_MANAGER_SOURCE]; - - // Save the default sources - await provider.contextProxy.setValue("packageManagerSources", sources) - console.log("Package Manager: Default sources initialized") - } - - console.log(`Package Manager: Fetching items from ${sources.length} sources`) - console.log(`DEBUG: PackageManagerManager instance: ${packageManagerManager ? "exists" : "null"}`) - - // Add timing information - const startTime = Date.now() - - // Simplify the initialization by limiting the number of items and adding more error handling - let items: PackageManagerItem[] = []; - - try { - console.log("DEBUG: Starting to fetch items from sources"); - // Only fetch from the first enabled source to reduce complexity - const enabledSources = sources.filter(s => s.enabled); - if (enabledSources.length > 0) { - const firstSource = enabledSources[0]; - console.log(`Package Manager: Fetching items from first source: ${firstSource.url}`); - - // Get items from the first source only - const sourceItems = await packageManagerManager.getPackageManagerItems([firstSource]); - items = sourceItems; - console.log("DEBUG: Successfully fetched items:", items.length); - } else { - console.log("DEBUG: No enabled sources found"); - } - } catch (fetchError) { - console.error("Failed to fetch package manager items:", fetchError); - // Continue with empty items array - items = []; - } - - console.log("DEBUG: Fetch completed, preparing to send items to webview"); - const endTime = Date.now() - - console.log(`Package Manager: Found ${items.length} items in ${endTime - startTime}ms`) - console.log(`Package Manager: First item:`, items.length > 0 ? items[0] : 'No items') - - // Send the items to the webview - console.log("DEBUG: Creating message to send items to webview"); - - // Get the current state to include apiConfiguration to prevent welcome screen from showing - const currentState = await provider.getState(); - - const message = { - type: "state", - state: { - // Include the current apiConfiguration to prevent welcome screen from showing - // This is critical because ExtensionStateContext checks apiConfiguration to determine if welcome screen should be shown - apiConfiguration: currentState.apiConfiguration, - packageManagerItems: items - } - } as ExtensionMessage; - - console.log(`Package Manager: Sending message to webview:`, message); - console.log("DEBUG: About to call postMessageToWebview with apiConfiguration:", - currentState.apiConfiguration ? "present" : "missing"); - provider.postMessageToWebview(message); - console.log("DEBUG: Called postMessageToWebview"); - console.log(`Package Manager: Message sent to webview`); - - } catch (initError) { - console.error("Error in package manager initialization:", initError); - // Send an empty items array to the webview to prevent the spinner from spinning forever - // Get the current state to include apiConfiguration to prevent welcome screen from showing - const currentState = await provider.getState(); - - provider.postMessageToWebview({ - type: "state", - state: { - // Include the current apiConfiguration to prevent welcome screen from showing - // This is critical because ExtensionStateContext checks apiConfiguration to determine if welcome screen should be shown - apiConfiguration: currentState.apiConfiguration, - packageManagerItems: [] - } - } as any); // Use type assertion to bypass TypeScript checking - vscode.window.showErrorMessage(`Package manager initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`); - } - } catch (error) { - console.error("Failed to fetch package manager items:", error); - vscode.window.showErrorMessage(`Failed to fetch package manager items: ${error instanceof Error ? error.message : String(error)}`) - } - return true - } - case "packageManagerSources": { - if (message.sources) { - // Enforce maximum of 10 sources - const MAX_SOURCES = 10; - let updatedSources: PackageManagerSource[]; - - if (message.sources.length > MAX_SOURCES) { - // Truncate to maximum allowed and show warning - updatedSources = message.sources.slice(0, MAX_SOURCES); - vscode.window.showWarningMessage(`Maximum of ${MAX_SOURCES} package manager sources allowed. Additional sources have been removed.`); - } else { - updatedSources = message.sources; - } - - // Validate sources using the validation utility - const validationErrors = validateSources(updatedSources); + switch (message.type) { + case "webviewDidLaunch": { + // For webviewDidLaunch, we don't do anything - package manager items will be loaded by explicit fetchPackageManagerItems + console.log( + "Package Manager: webviewDidLaunch received, but skipping fetch (will be triggered by explicit fetchPackageManagerItems)", + ) + return true + } + case "fetchPackageManagerItems": { + // Check if we need to force refresh using type assertion + const forceRefresh = (message as any).forceRefresh === true + console.log(`Package Manager: Fetch requested with forceRefresh=${forceRefresh}`) + try { + console.log("Package Manager: Received request to fetch package manager items") + console.log("DEBUG: Processing package manager request") - // Filter out invalid sources - if (validationErrors.length > 0) { - console.log("Package Manager: Validation errors found in sources", validationErrors); + // Wrap the entire initialization in a try-catch block + try { + // Initialize default sources if none exist + let sources = + ((await provider.contextProxy.getValue("packageManagerSources")) as PackageManagerSource[]) || + [] - // Create a map of invalid indices - const invalidIndices = new Set(); - validationErrors.forEach(error => { - // Extract index from error message (Source #X: ...) - const match = error.message.match(/Source #(\d+):/); - if (match && match[1]) { - const index = parseInt(match[1], 10) - 1; // Convert to 0-based index - if (index >= 0 && index < updatedSources.length) { - invalidIndices.add(index); - } - } - }); + if (!sources || sources.length === 0) { + console.log("Package Manager: No sources found, initializing default sources") + sources = [DEFAULT_PACKAGE_MANAGER_SOURCE] - // Filter out invalid sources - updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index)); + // Save the default sources + await provider.contextProxy.setValue("packageManagerSources", sources) + console.log("Package Manager: Default sources initialized") + } - // Show validation errors - const errorMessage = `Package manager sources validation failed:\n${validationErrors.map(e => e.message).join('\n')}`; - console.error(errorMessage); - vscode.window.showErrorMessage(errorMessage); - } + console.log(`Package Manager: Fetching items from ${sources.length} sources`) + console.log(`DEBUG: PackageManagerManager instance: ${packageManagerManager ? "exists" : "null"}`) - // Update the global state with the validated sources - await updateGlobalState("packageManagerSources", updatedSources); - - // Clean up cache directories for repositories that are no longer in the sources list - try { - console.log("Package Manager: Cleaning up cache directories for removed sources"); - await packageManagerManager.cleanupCacheDirectories(updatedSources); - console.log("Package Manager: Cache cleanup completed"); - } catch (error) { - console.error("Package Manager: Error during cache cleanup:", error); - } - - // Update the webview with the new state - await provider.postStateToWebview(); - } - return true; - } - case "openExternal": { - if (message.url) { - console.log(`Package Manager: Opening external URL: ${message.url}`); - try { - vscode.env.openExternal(vscode.Uri.parse(message.url)); - console.log(`Package Manager: Successfully opened URL: ${message.url}`); - } catch (error) { - console.error(`Package Manager: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`); - vscode.window.showErrorMessage(`Failed to open URL: ${error instanceof Error ? error.message : String(error)}`); - } - } else { - console.error("Package Manager: openExternal called without a URL"); - } - return true; - } - - case "refreshPackageManagerSource": { - if (message.url) { - try { - console.log(`Package Manager: Received request to refresh source ${message.url}`); - - // Get the current sources - const sources = await provider.contextProxy.getValue("packageManagerSources") as PackageManagerSource[] || []; - - // Find the source with the matching URL - const source = sources.find(s => s.url === message.url); - - if (source) { - try { - // Refresh the repository with the source name - await packageManagerManager.refreshRepository(message.url, source.name); - vscode.window.showInformationMessage(`Successfully refreshed package manager source: ${source.name || message.url}`); - - // Trigger a fetch to update the UI with the refreshed data - const currentState = await provider.getState(); - provider.postMessageToWebview({ - type: "state", - state: { - apiConfiguration: currentState.apiConfiguration, - packageManagerItems: await packageManagerManager.getPackageManagerItems(sources.filter(s => s.enabled)) - } - } as ExtensionMessage); - } finally { - // Always notify the webview that the refresh is complete, even if it failed - console.log(`Package Manager: Sending repositoryRefreshComplete message for ${message.url}`); - provider.postMessageToWebview({ - type: "repositoryRefreshComplete", - url: message.url - }); - } - } else { - console.error(`Package Manager: Source URL not found: ${message.url}`); - vscode.window.showErrorMessage(`Source URL not found: ${message.url}`); - } - } catch (error) { - console.error(`Package Manager: Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`); - vscode.window.showErrorMessage(`Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`); - } - } - return true; - } - - - default: - return false - } -} \ No newline at end of file + // Add timing information + const startTime = Date.now() + + // Simplify the initialization by limiting the number of items and adding more error handling + let items: PackageManagerItem[] = [] + + try { + console.log("DEBUG: Starting to fetch items from sources") + // Only fetch from the first enabled source to reduce complexity + const enabledSources = sources.filter((s) => s.enabled) + if (enabledSources.length > 0) { + const firstSource = enabledSources[0] + console.log(`Package Manager: Fetching items from first source: ${firstSource.url}`) + + // Get items from the first source only + const sourceItems = await packageManagerManager.getPackageManagerItems([firstSource]) + items = sourceItems + console.log("DEBUG: Successfully fetched items:", items.length) + } else { + console.log("DEBUG: No enabled sources found") + } + } catch (fetchError) { + console.error("Failed to fetch package manager items:", fetchError) + // Continue with empty items array + items = [] + } + + console.log("DEBUG: Fetch completed, preparing to send items to webview") + const endTime = Date.now() + + console.log(`Package Manager: Found ${items.length} items in ${endTime - startTime}ms`) + console.log(`Package Manager: First item:`, items.length > 0 ? items[0] : "No items") + + // Send the items to the webview + console.log("DEBUG: Creating message to send items to webview") + + // Get the current state to include apiConfiguration to prevent welcome screen from showing + const currentState = await provider.getState() + + const message = { + type: "state", + state: { + // Include the current apiConfiguration to prevent welcome screen from showing + // This is critical because ExtensionStateContext checks apiConfiguration to determine if welcome screen should be shown + apiConfiguration: currentState.apiConfiguration, + packageManagerItems: items, + }, + } as ExtensionMessage + + console.log(`Package Manager: Sending message to webview:`, message) + console.log( + "DEBUG: About to call postMessageToWebview with apiConfiguration:", + currentState.apiConfiguration ? "present" : "missing", + ) + provider.postMessageToWebview(message) + console.log("DEBUG: Called postMessageToWebview") + console.log(`Package Manager: Message sent to webview`) + } catch (initError) { + console.error("Error in package manager initialization:", initError) + // Send an empty items array to the webview to prevent the spinner from spinning forever + // Get the current state to include apiConfiguration to prevent welcome screen from showing + const currentState = await provider.getState() + + provider.postMessageToWebview({ + type: "state", + state: { + // Include the current apiConfiguration to prevent welcome screen from showing + // This is critical because ExtensionStateContext checks apiConfiguration to determine if welcome screen should be shown + apiConfiguration: currentState.apiConfiguration, + packageManagerItems: [], + }, + } as any) // Use type assertion to bypass TypeScript checking + vscode.window.showErrorMessage( + `Package manager initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`, + ) + } + } catch (error) { + console.error("Failed to fetch package manager items:", error) + vscode.window.showErrorMessage( + `Failed to fetch package manager items: ${error instanceof Error ? error.message : String(error)}`, + ) + } + return true + } + case "packageManagerSources": { + if (message.sources) { + // Enforce maximum of 10 sources + const MAX_SOURCES = 10 + let updatedSources: PackageManagerSource[] + + if (message.sources.length > MAX_SOURCES) { + // Truncate to maximum allowed and show warning + updatedSources = message.sources.slice(0, MAX_SOURCES) + vscode.window.showWarningMessage( + `Maximum of ${MAX_SOURCES} package manager sources allowed. Additional sources have been removed.`, + ) + } else { + updatedSources = message.sources + } + + // Validate sources using the validation utility + const validationErrors = validateSources(updatedSources) + + // Filter out invalid sources + if (validationErrors.length > 0) { + console.log("Package Manager: Validation errors found in sources", validationErrors) + + // Create a map of invalid indices + const invalidIndices = new Set() + validationErrors.forEach((error) => { + // Extract index from error message (Source #X: ...) + const match = error.message.match(/Source #(\d+):/) + if (match && match[1]) { + const index = parseInt(match[1], 10) - 1 // Convert to 0-based index + if (index >= 0 && index < updatedSources.length) { + invalidIndices.add(index) + } + } + }) + + // Filter out invalid sources + updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index)) + + // Show validation errors + const errorMessage = `Package manager sources validation failed:\n${validationErrors.map((e) => e.message).join("\n")}` + console.error(errorMessage) + vscode.window.showErrorMessage(errorMessage) + } + + // Update the global state with the validated sources + await updateGlobalState("packageManagerSources", updatedSources) + + // Clean up cache directories for repositories that are no longer in the sources list + try { + console.log("Package Manager: Cleaning up cache directories for removed sources") + await packageManagerManager.cleanupCacheDirectories(updatedSources) + console.log("Package Manager: Cache cleanup completed") + } catch (error) { + console.error("Package Manager: Error during cache cleanup:", error) + } + + // Update the webview with the new state + await provider.postStateToWebview() + } + return true + } + case "openExternal": { + if (message.url) { + console.log(`Package Manager: Opening external URL: ${message.url}`) + try { + vscode.env.openExternal(vscode.Uri.parse(message.url)) + console.log(`Package Manager: Successfully opened URL: ${message.url}`) + } catch (error) { + console.error( + `Package Manager: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`, + ) + vscode.window.showErrorMessage( + `Failed to open URL: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } else { + console.error("Package Manager: openExternal called without a URL") + } + return true + } + + case "refreshPackageManagerSource": { + if (message.url) { + try { + console.log(`Package Manager: Received request to refresh source ${message.url}`) + + // Get the current sources + const sources = + ((await provider.contextProxy.getValue("packageManagerSources")) as PackageManagerSource[]) || + [] + + // Find the source with the matching URL + const source = sources.find((s) => s.url === message.url) + + if (source) { + try { + // Refresh the repository with the source name + await packageManagerManager.refreshRepository(message.url, source.name) + vscode.window.showInformationMessage( + `Successfully refreshed package manager source: ${source.name || message.url}`, + ) + + // Trigger a fetch to update the UI with the refreshed data + const currentState = await provider.getState() + provider.postMessageToWebview({ + type: "state", + state: { + apiConfiguration: currentState.apiConfiguration, + packageManagerItems: await packageManagerManager.getPackageManagerItems( + sources.filter((s) => s.enabled), + ), + }, + } as ExtensionMessage) + } finally { + // Always notify the webview that the refresh is complete, even if it failed + console.log(`Package Manager: Sending repositoryRefreshComplete message for ${message.url}`) + provider.postMessageToWebview({ + type: "repositoryRefreshComplete", + url: message.url, + }) + } + } else { + console.error(`Package Manager: Source URL not found: ${message.url}`) + vscode.window.showErrorMessage(`Source URL not found: ${message.url}`) + } + } catch (error) { + console.error( + `Package Manager: Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`, + ) + vscode.window.showErrorMessage( + `Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + return true + } + + default: + return false + } +} diff --git a/src/services/package-manager/PackageManagerManager.ts b/src/services/package-manager/PackageManagerManager.ts index 2ba01e958d..30a2766434 100644 --- a/src/services/package-manager/PackageManagerManager.ts +++ b/src/services/package-manager/PackageManagerManager.ts @@ -1,286 +1,287 @@ -import * as vscode from "vscode"; -import * as path from "path"; -import * as fs from "fs/promises"; -import { GitFetcher } from "./GitFetcher"; -import { PackageManagerItem, PackageManagerRepository, PackageManagerSource } from "./types"; +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import { GitFetcher } from "./GitFetcher" +import { PackageManagerItem, PackageManagerRepository, PackageManagerSource } from "./types" /** * Service for managing package manager data */ export class PackageManagerManager { - // Cache expiry time in milliseconds (set to a low value for testing) - private static readonly CACHE_EXPIRY_MS = 10 * 1000; // 10 seconds (normally 3600000 = 1 hour) - - private gitFetcher: GitFetcher; - private cache: Map = new Map(); - - constructor(private readonly context: vscode.ExtensionContext) { - this.gitFetcher = new GitFetcher(context); - } - - /** - * Gets package manager items from all enabled sources - * @param sources The package manager sources - * @returns An array of PackageManagerItem objects - */ - async getPackageManagerItems(sources: PackageManagerSource[]): Promise { - console.log(`PackageManagerManager: Getting items from ${sources.length} sources`); - const items: PackageManagerItem[] = []; - const errors: Error[] = []; - - // Filter enabled sources - const enabledSources = sources.filter(s => s.enabled); - console.log(`PackageManagerManager: ${enabledSources.length} enabled sources`); - - // Process sources sequentially to avoid overwhelming the system - for (const source of enabledSources) { - try { - console.log(`PackageManagerManager: Processing source ${source.url}`); - // Pass the source name to getRepositoryData - const repo = await this.getRepositoryData(source.url, false, source.name); - - if (repo.items && repo.items.length > 0) { - console.log(`PackageManagerManager: Found ${repo.items.length} items in ${source.url}`); - items.push(...repo.items); - } else { - console.log(`PackageManagerManager: No items found in ${source.url}`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`PackageManagerManager: Failed to fetch data from ${source.url}:`, error); - errors.push(new Error(`Source ${source.url}: ${errorMessage}`)); - } - } - - // Show a single error message with all failures - if (errors.length > 0) { - const errorMessage = `Failed to fetch from ${errors.length} sources: ${errors.map(e => e.message).join("; ")}`; - console.error(`PackageManagerManager: ${errorMessage}`); - vscode.window.showErrorMessage(errorMessage); - } - - console.log(`PackageManagerManager: Returning ${items.length} total items`); - return items; - } - - /** - * Gets repository data from a URL, using cache if available - * @param url The repository URL - * @param forceRefresh Whether to bypass the cache and force a refresh - * @param sourceName The name of the source - * @returns A PackageManagerRepository object - */ - async getRepositoryData(url: string, forceRefresh: boolean = false, sourceName?: string): Promise { - try { - console.log(`PackageManagerManager: Getting repository data for ${url}`); - - // Check cache first (unless force refresh is requested) - const cached = this.cache.get(url); - - if (!forceRefresh && cached && (Date.now() - cached.timestamp) < PackageManagerManager.CACHE_EXPIRY_MS) { - console.log(`PackageManagerManager: Using cached data for ${url} (age: ${Date.now() - cached.timestamp}ms)`); - return cached.data; - } - - if (forceRefresh) { - console.log(`PackageManagerManager: Force refresh requested for ${url}, bypassing cache`); - } - - console.log(`PackageManagerManager: Cache miss or expired for ${url}, fetching fresh data`); - - // Fetch fresh data with timeout protection - const fetchPromise = this.gitFetcher.fetchRepository(url, sourceName); - - // Create a timeout promise - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error(`Repository fetch timed out after 30 seconds: ${url}`)); - }, 30000); // 30 second timeout - }); - - // Race the fetch against the timeout - const data = await Promise.race([fetchPromise, timeoutPromise]); - - // Cache the result - this.cache.set(url, { data, timestamp: Date.now() }); - console.log(`PackageManagerManager: Successfully fetched and cached data for ${url}`); - - return data; - } catch (error) { - console.error(`PackageManagerManager: Error fetching repository data for ${url}:`, error); - - // Return empty repository data instead of throwing - return { - metadata: {}, - items: [], - url - }; - } - } - - /** - * Refreshes a specific repository, bypassing the cache - * @param url The repository URL to refresh - * @param sourceName Optional name of the source - * @returns The refreshed repository data - */ - async refreshRepository(url: string, sourceName?: string): Promise { - console.log(`PackageManagerManager: Refreshing repository ${url}`); - - try { - // Force a refresh by bypassing the cache - const data = await this.getRepositoryData(url, true, sourceName); - console.log(`PackageManagerManager: Repository ${url} refreshed successfully`); - return data; - } catch (error) { - console.error(`PackageManagerManager: Failed to refresh repository ${url}:`, error); - throw error; - } - } - - /** - * Clears the in-memory cache - */ - clearCache(): void { - this.cache.clear(); - } - - /** - * Cleans up cache directories for repositories that are no longer in the configured sources - * @param currentSources The current list of package manager sources - */ - async cleanupCacheDirectories(currentSources: PackageManagerSource[]): Promise { - try { - // Get the cache directory path - const cacheDir = path.join(this.context.globalStorageUri.fsPath, "package-manager-cache"); - - // Check if cache directory exists - try { - await fs.stat(cacheDir); - } catch (error) { - console.log("PackageManagerManager: Cache directory doesn't exist yet, nothing to clean up"); - return; - } - - // Get all subdirectories in the cache directory - const entries = await fs.readdir(cacheDir, { withFileTypes: true }); - const cachedRepoDirs = entries - .filter(entry => entry.isDirectory()) - .map(entry => entry.name); - - console.log(`PackageManagerManager: Found ${cachedRepoDirs.length} cached repositories`); - - // Get the list of repository names from current sources - const currentRepoNames = currentSources.map(source => this.getRepoNameFromUrl(source.url)); - - // Find directories to delete - const dirsToDelete = cachedRepoDirs.filter(dir => !currentRepoNames.includes(dir)); - - console.log(`PackageManagerManager: Found ${dirsToDelete.length} repositories to delete`); - - // Delete each directory that's no longer in the sources - for (const dirName of dirsToDelete) { - try { - const dirPath = path.join(cacheDir, dirName); - console.log(`PackageManagerManager: Deleting cache directory ${dirPath}`); - await fs.rm(dirPath, { recursive: true, force: true }); - console.log(`PackageManagerManager: Successfully deleted ${dirPath}`); - } catch (error) { - console.error(`PackageManagerManager: Failed to delete directory ${dirName}:`, error); - } - } - - console.log(`PackageManagerManager: Cache cleanup completed, deleted ${dirsToDelete.length} directories`); - } catch (error) { - console.error("PackageManagerManager: Error cleaning up cache directories:", error); - } - } - - /** - * Extracts a safe directory name from a Git URL - * @param url The Git repository URL - * @returns A sanitized directory name - */ - private getRepoNameFromUrl(url: string): string { - // Extract repo name from URL and sanitize it - const urlParts = url.split("/").filter(part => part !== ""); - const repoName = urlParts[urlParts.length - 1].replace(/\.git$/, ""); - return repoName.replace(/[^a-zA-Z0-9-_]/g, "-"); - } - - /** - * Filters package manager items based on criteria - * @param items The items to filter - * @param filters The filter criteria - * @returns Filtered items - */ - filterItems(items: PackageManagerItem[], filters: { type?: string, search?: string, tags?: string[] }): PackageManagerItem[] { - return items.filter(item => { - // Filter by type - if (filters.type && item.type !== filters.type) { - return false; - } - - // Filter by search term - if (filters.search) { - const searchTerm = filters.search.toLowerCase(); - const nameMatch = item.name.toLowerCase().includes(searchTerm); - const descMatch = item.description.toLowerCase().includes(searchTerm); - const authorMatch = item.author?.toLowerCase().includes(searchTerm); - - if (!nameMatch && !descMatch && !authorMatch) { - return false; - } - } - - // Filter by tags - if (filters.tags && filters.tags.length > 0) { - if (!item.tags || item.tags.length === 0) { - return false; - } - - const hasMatchingTag = filters.tags.some(tag => item.tags!.includes(tag)); - if (!hasMatchingTag) { - return false; - } - } - - return true; - }); - } - - /** - * Sorts package manager items - * @param items The items to sort - * @param sortBy The field to sort by - * @param sortOrder The sort order - * @returns Sorted items - */ - sortItems(items: PackageManagerItem[], sortBy: string, sortOrder: "asc" | "desc"): PackageManagerItem[] { - return [...items].sort((a, b) => { - let comparison = 0; - - switch (sortBy) { - case "name": - comparison = a.name.localeCompare(b.name); - break; - case "author": - comparison = (a.author || "").localeCompare(b.author || ""); - break; - case "lastUpdated": - comparison = (a.lastUpdated || "").localeCompare(b.lastUpdated || ""); - break; - case "stars": - comparison = (a.stars || 0) - (b.stars || 0); - break; - case "downloads": - comparison = (a.downloads || 0) - (b.downloads || 0); - break; - default: - comparison = a.name.localeCompare(b.name); - } - - return sortOrder === "asc" ? comparison : -comparison; - }); - } -} \ No newline at end of file + // Cache expiry time in milliseconds (set to a low value for testing) + private static readonly CACHE_EXPIRY_MS = 10 * 1000 // 10 seconds (normally 3600000 = 1 hour) + + private gitFetcher: GitFetcher + private cache: Map = new Map() + + constructor(private readonly context: vscode.ExtensionContext) { + this.gitFetcher = new GitFetcher(context) + } + + /** + * Gets package manager items from all enabled sources + * @param sources The package manager sources + * @returns An array of PackageManagerItem objects + */ + async getPackageManagerItems(sources: PackageManagerSource[]): Promise { + console.log(`PackageManagerManager: Getting items from ${sources.length} sources`) + const items: PackageManagerItem[] = [] + const errors: Error[] = [] + + // Filter enabled sources + const enabledSources = sources.filter((s) => s.enabled) + console.log(`PackageManagerManager: ${enabledSources.length} enabled sources`) + + // Process sources sequentially to avoid overwhelming the system + for (const source of enabledSources) { + try { + console.log(`PackageManagerManager: Processing source ${source.url}`) + // Pass the source name to getRepositoryData + const repo = await this.getRepositoryData(source.url, false, source.name) + + if (repo.items && repo.items.length > 0) { + console.log(`PackageManagerManager: Found ${repo.items.length} items in ${source.url}`) + items.push(...repo.items) + } else { + console.log(`PackageManagerManager: No items found in ${source.url}`) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`PackageManagerManager: Failed to fetch data from ${source.url}:`, error) + errors.push(new Error(`Source ${source.url}: ${errorMessage}`)) + } + } + + // Show a single error message with all failures + if (errors.length > 0) { + const errorMessage = `Failed to fetch from ${errors.length} sources: ${errors.map((e) => e.message).join("; ")}` + console.error(`PackageManagerManager: ${errorMessage}`) + vscode.window.showErrorMessage(errorMessage) + } + + console.log(`PackageManagerManager: Returning ${items.length} total items`) + return items + } + + /** + * Gets repository data from a URL, using cache if available + * @param url The repository URL + * @param forceRefresh Whether to bypass the cache and force a refresh + * @param sourceName The name of the source + * @returns A PackageManagerRepository object + */ + async getRepositoryData( + url: string, + forceRefresh: boolean = false, + sourceName?: string, + ): Promise { + try { + console.log(`PackageManagerManager: Getting repository data for ${url}`) + + // Check cache first (unless force refresh is requested) + const cached = this.cache.get(url) + + if (!forceRefresh && cached && Date.now() - cached.timestamp < PackageManagerManager.CACHE_EXPIRY_MS) { + console.log( + `PackageManagerManager: Using cached data for ${url} (age: ${Date.now() - cached.timestamp}ms)`, + ) + return cached.data + } + + if (forceRefresh) { + console.log(`PackageManagerManager: Force refresh requested for ${url}, bypassing cache`) + } + + console.log(`PackageManagerManager: Cache miss or expired for ${url}, fetching fresh data`) + + // Fetch fresh data with timeout protection + const fetchPromise = this.gitFetcher.fetchRepository(url, sourceName) + + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Repository fetch timed out after 30 seconds: ${url}`)) + }, 30000) // 30 second timeout + }) + + // Race the fetch against the timeout + const data = await Promise.race([fetchPromise, timeoutPromise]) + + // Cache the result + this.cache.set(url, { data, timestamp: Date.now() }) + console.log(`PackageManagerManager: Successfully fetched and cached data for ${url}`) + + return data + } catch (error) { + console.error(`PackageManagerManager: Error fetching repository data for ${url}:`, error) + + // Return empty repository data instead of throwing + return { + metadata: {}, + items: [], + url, + } + } + } + + /** + * Refreshes a specific repository, bypassing the cache + * @param url The repository URL to refresh + * @param sourceName Optional name of the source + * @returns The refreshed repository data + */ + async refreshRepository(url: string, sourceName?: string): Promise { + console.log(`PackageManagerManager: Refreshing repository ${url}`) + + try { + // Force a refresh by bypassing the cache + const data = await this.getRepositoryData(url, true, sourceName) + console.log(`PackageManagerManager: Repository ${url} refreshed successfully`) + return data + } catch (error) { + console.error(`PackageManagerManager: Failed to refresh repository ${url}:`, error) + throw error + } + } + + /** + * Clears the in-memory cache + */ + clearCache(): void { + this.cache.clear() + } + + /** + * Cleans up cache directories for repositories that are no longer in the configured sources + * @param currentSources The current list of package manager sources + */ + async cleanupCacheDirectories(currentSources: PackageManagerSource[]): Promise { + try { + // Get the cache directory path + const cacheDir = path.join(this.context.globalStorageUri.fsPath, "package-manager-cache") + + // Check if cache directory exists + try { + await fs.stat(cacheDir) + } catch (error) { + console.log("PackageManagerManager: Cache directory doesn't exist yet, nothing to clean up") + return + } + + // Get all subdirectories in the cache directory + const entries = await fs.readdir(cacheDir, { withFileTypes: true }) + const cachedRepoDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + + console.log(`PackageManagerManager: Found ${cachedRepoDirs.length} cached repositories`) + + // Get the list of repository names from current sources + const currentRepoNames = currentSources.map((source) => this.getRepoNameFromUrl(source.url)) + + // Find directories to delete + const dirsToDelete = cachedRepoDirs.filter((dir) => !currentRepoNames.includes(dir)) + + console.log(`PackageManagerManager: Found ${dirsToDelete.length} repositories to delete`) + + // Delete each directory that's no longer in the sources + for (const dirName of dirsToDelete) { + try { + const dirPath = path.join(cacheDir, dirName) + console.log(`PackageManagerManager: Deleting cache directory ${dirPath}`) + await fs.rm(dirPath, { recursive: true, force: true }) + console.log(`PackageManagerManager: Successfully deleted ${dirPath}`) + } catch (error) { + console.error(`PackageManagerManager: Failed to delete directory ${dirName}:`, error) + } + } + + console.log(`PackageManagerManager: Cache cleanup completed, deleted ${dirsToDelete.length} directories`) + } catch (error) { + console.error("PackageManagerManager: Error cleaning up cache directories:", error) + } + } + + /** + * Extracts a safe directory name from a Git URL + * @param url The Git repository URL + * @returns A sanitized directory name + */ + private getRepoNameFromUrl(url: string): string { + // Extract repo name from URL and sanitize it + const urlParts = url.split("/").filter((part) => part !== "") + const repoName = urlParts[urlParts.length - 1].replace(/\.git$/, "") + return repoName.replace(/[^a-zA-Z0-9-_]/g, "-") + } + + /** + * Filters package manager items based on criteria + * @param items The items to filter + * @param filters The filter criteria + * @returns Filtered items + */ + filterItems( + items: PackageManagerItem[], + filters: { type?: string; search?: string; tags?: string[] }, + ): PackageManagerItem[] { + return items.filter((item) => { + // Filter by type + if (filters.type && item.type !== filters.type) { + return false + } + + // Filter by search term + if (filters.search) { + const searchTerm = filters.search.toLowerCase() + const nameMatch = item.name.toLowerCase().includes(searchTerm) + const descMatch = item.description.toLowerCase().includes(searchTerm) + const authorMatch = item.author?.toLowerCase().includes(searchTerm) + + if (!nameMatch && !descMatch && !authorMatch) { + return false + } + } + + // Filter by tags + if (filters.tags && filters.tags.length > 0) { + if (!item.tags || item.tags.length === 0) { + return false + } + + const hasMatchingTag = filters.tags.some((tag) => item.tags!.includes(tag)) + if (!hasMatchingTag) { + return false + } + } + + return true + }) + } + + /** + * Sorts package manager items + * @param items The items to sort + * @param sortBy The field to sort by + * @param sortOrder The sort order + * @returns Sorted items + */ + sortItems(items: PackageManagerItem[], sortBy: string, sortOrder: "asc" | "desc"): PackageManagerItem[] { + return [...items].sort((a, b) => { + let comparison = 0 + + switch (sortBy) { + case "name": + comparison = a.name.localeCompare(b.name) + break + case "author": + comparison = (a.author || "").localeCompare(b.author || "") + break + case "lastUpdated": + comparison = (a.lastUpdated || "").localeCompare(b.lastUpdated || "") + break + default: + comparison = a.name.localeCompare(b.name) + } + + return sortOrder === "asc" ? comparison : -comparison + }) + } +} diff --git a/src/services/package-manager/validation.ts b/src/services/package-manager/validation.ts index 188345ef11..37b9e73689 100644 --- a/src/services/package-manager/validation.ts +++ b/src/services/package-manager/validation.ts @@ -1,14 +1,14 @@ /** * Validation utilities for package manager sources */ -import { PackageManagerSource } from "./types"; +import { PackageManagerSource } from "./types" /** * Error type for package manager source validation */ export interface ValidationError { - field: string; - message: string; + field: string + message: string } /** @@ -22,72 +22,74 @@ export interface ValidationError { * @returns True if the URL is a valid Git repository URL, false otherwise */ export function isValidGitRepositoryUrl(url: string): boolean { - // Trim the URL to remove any leading/trailing whitespace - const trimmedUrl = url.trim(); + // Trim the URL to remove any leading/trailing whitespace + const trimmedUrl = url.trim() - // HTTPS pattern (GitHub, GitLab, Bitbucket, etc.) - // Examples: - // - https://github.com/username/repo - // - https://github.com/username/repo.git - // - https://gitlab.com/username/repo - // - https://bitbucket.org/username/repo - const httpsPattern = /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\/.+)*(\.git)?$/; + // HTTPS pattern (GitHub, GitLab, Bitbucket, etc.) + // Examples: + // - https://github.com/username/repo + // - https://github.com/username/repo.git + // - https://gitlab.com/username/repo + // - https://bitbucket.org/username/repo + const httpsPattern = + /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\/.+)*(\.git)?$/ - // SSH pattern - // Examples: - // - git@github.com:username/repo.git - // - git@gitlab.com:username/repo.git - const sshPattern = /^git@(github\.com|gitlab\.com|bitbucket\.org):([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(\.git)?$/; + // SSH pattern + // Examples: + // - git@github.com:username/repo.git + // - git@gitlab.com:username/repo.git + const sshPattern = /^git@(github\.com|gitlab\.com|bitbucket\.org):([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(\.git)?$/ - // Git protocol pattern - // Examples: - // - git://github.com/username/repo.git - const gitProtocolPattern = /^git:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\.git)?$/; + // Git protocol pattern + // Examples: + // - git://github.com/username/repo.git + const gitProtocolPattern = + /^git:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\.git)?$/ - return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl); + return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl) } export function validateSourceUrl(url: string): ValidationError[] { - const errors: ValidationError[] = []; + const errors: ValidationError[] = [] - // Check if URL is empty - if (!url) { - errors.push({ - field: "url", - message: "URL cannot be empty" - }); - return errors; // Return early if URL is empty - } + // Check if URL is empty + if (!url) { + errors.push({ + field: "url", + message: "URL cannot be empty", + }) + return errors // Return early if URL is empty + } - // Check if URL is valid format - try { - new URL(url); - } catch (e) { - errors.push({ - field: "url", - message: "Invalid URL format" - }); - return errors; // Return early if URL is not valid - } + // Check if URL is valid format + try { + new URL(url) + } catch (e) { + errors.push({ + field: "url", + message: "Invalid URL format", + }) + return errors // Return early if URL is not valid + } - // Check for non-visible characters (except spaces) - const nonVisibleCharRegex = /[^\S ]/; - if (nonVisibleCharRegex.test(url)) { - errors.push({ - field: "url", - message: "URL contains non-visible characters other than spaces" - }); - } + // Check for non-visible characters (except spaces) + const nonVisibleCharRegex = /[^\S ]/ + if (nonVisibleCharRegex.test(url)) { + errors.push({ + field: "url", + message: "URL contains non-visible characters other than spaces", + }) + } - // Check if URL is a valid Git repository URL - if (!isValidGitRepositoryUrl(url)) { - errors.push({ - field: "url", - message: "URL must be a valid Git repository URL (e.g., https://github.com/username/repo)" - }); - } + // Check if URL is a valid Git repository URL + if (!isValidGitRepositoryUrl(url)) { + errors.push({ + field: "url", + message: "URL must be a valid Git repository URL (e.g., https://github.com/username/repo)", + }) + } - return errors; + return errors } /** @@ -96,31 +98,31 @@ export function validateSourceUrl(url: string): ValidationError[] { * @returns An array of validation errors, empty if valid */ export function validateSourceName(name?: string): ValidationError[] { - const errors: ValidationError[] = []; + const errors: ValidationError[] = [] - // Skip validation if name is not provided - if (!name) { - return errors; - } + // Skip validation if name is not provided + if (!name) { + return errors + } - // Check name length - if (name.length > 20) { - errors.push({ - field: "name", - message: "Name must be 20 characters or less" - }); - } + // Check name length + if (name.length > 20) { + errors.push({ + field: "name", + message: "Name must be 20 characters or less", + }) + } - // Check for non-visible characters (except spaces) - const nonVisibleCharRegex = /[^\S ]/; - if (nonVisibleCharRegex.test(name)) { - errors.push({ - field: "name", - message: "Name contains non-visible characters other than spaces" - }); - } + // Check for non-visible characters (except spaces) + const nonVisibleCharRegex = /[^\S ]/ + if (nonVisibleCharRegex.test(name)) { + errors.push({ + field: "name", + message: "Name contains non-visible characters other than spaces", + }) + } - return errors; + return errors } /** @@ -130,83 +132,81 @@ export function validateSourceName(name?: string): ValidationError[] { * @returns An array of validation errors, empty if valid */ export function validateSourceDuplicates( - sources: PackageManagerSource[], - newSource?: PackageManagerSource + sources: PackageManagerSource[], + newSource?: PackageManagerSource, ): ValidationError[] { - const errors: ValidationError[] = []; - const normalizedUrls: { url: string; index: number }[] = []; - const normalizedNames: { name: string; index: number }[] = []; + const errors: ValidationError[] = [] + const normalizedUrls: { url: string; index: number }[] = [] + const normalizedNames: { name: string; index: number }[] = [] - // Process existing sources - sources.forEach((source, index) => { - // Normalize URL (case and whitespace insensitive) - const normalizedUrl = source.url.toLowerCase().replace(/\s+/g, ''); - normalizedUrls.push({ url: normalizedUrl, index }); + // Process existing sources + sources.forEach((source, index) => { + // Normalize URL (case and whitespace insensitive) + const normalizedUrl = source.url.toLowerCase().replace(/\s+/g, "") + normalizedUrls.push({ url: normalizedUrl, index }) - // Normalize name if it exists (case and whitespace insensitive) - if (source.name) { - const normalizedName = source.name.toLowerCase().replace(/\s+/g, ''); - normalizedNames.push({ name: normalizedName, index }); - } - }); + // Normalize name if it exists (case and whitespace insensitive) + if (source.name) { + const normalizedName = source.name.toLowerCase().replace(/\s+/g, "") + normalizedNames.push({ name: normalizedName, index }) + } + }) - // Check for duplicates within the existing sources - normalizedUrls.forEach((item, index) => { - const duplicates = normalizedUrls.filter( - (other, otherIndex) => other.url === item.url && otherIndex !== index - ); + // Check for duplicates within the existing sources + normalizedUrls.forEach((item, index) => { + const duplicates = normalizedUrls.filter((other, otherIndex) => other.url === item.url && otherIndex !== index) - if (duplicates.length > 0) { - errors.push({ - field: "url", - message: `Source #${item.index + 1} has a duplicate URL with Source #${duplicates[0].index + 1} (case and whitespace insensitive match)` - }); - } - }); + if (duplicates.length > 0) { + errors.push({ + field: "url", + message: `Source #${item.index + 1} has a duplicate URL with Source #${duplicates[0].index + 1} (case and whitespace insensitive match)`, + }) + } + }) - normalizedNames.forEach((item, index) => { - const duplicates = normalizedNames.filter( - (other, otherIndex) => other.name === item.name && otherIndex !== index - ); + normalizedNames.forEach((item, index) => { + const duplicates = normalizedNames.filter( + (other, otherIndex) => other.name === item.name && otherIndex !== index, + ) - if (duplicates.length > 0) { - errors.push({ - field: "name", - message: `Source #${item.index + 1} has a duplicate name with Source #${duplicates[0].index + 1} (case and whitespace insensitive match)` - }); - } - }); + if (duplicates.length > 0) { + errors.push({ + field: "name", + message: `Source #${item.index + 1} has a duplicate name with Source #${duplicates[0].index + 1} (case and whitespace insensitive match)`, + }) + } + }) - // Check new source against existing sources if provided - if (newSource) { - // Validate URL - if (newSource.url) { - const normalizedNewUrl = newSource.url.toLowerCase().replace(/\s+/g, ''); - const duplicateUrl = normalizedUrls.find(item => item.url === normalizedNewUrl); + // Check new source against existing sources if provided + if (newSource) { + // Validate URL + if (newSource.url) { + const normalizedNewUrl = newSource.url.toLowerCase().replace(/\s+/g, "") + const duplicateUrl = normalizedUrls.find((item) => item.url === normalizedNewUrl) - if (duplicateUrl) { - errors.push({ - field: "url", - message: `URL is a duplicate of Source #${duplicateUrl.index + 1} (case and whitespace insensitive match)` - }); - } - } + if (duplicateUrl) { + errors.push({ + field: "url", + message: `URL is a duplicate of Source #${duplicateUrl.index + 1} (case and whitespace insensitive match)`, + }) + } + } - // Validate name - if (newSource.name) { - const normalizedNewName = newSource.name.toLowerCase().replace(/\s+/g, ''); - const duplicateName = normalizedNames.find(item => item.name === normalizedNewName); + // Validate name + if (newSource.name) { + const normalizedNewName = newSource.name.toLowerCase().replace(/\s+/g, "") + const duplicateName = normalizedNames.find((item) => item.name === normalizedNewName) - if (duplicateName) { - errors.push({ - field: "name", - message: `Name is a duplicate of Source #${duplicateName.index + 1} (case and whitespace insensitive match)` - }); - } - } - } + if (duplicateName) { + errors.push({ + field: "name", + message: `Name is a duplicate of Source #${duplicateName.index + 1} (case and whitespace insensitive match)`, + }) + } + } + } - return errors; + return errors } /** @@ -216,15 +216,15 @@ export function validateSourceDuplicates( * @returns An array of validation errors, empty if valid */ export function validateSource( - source: PackageManagerSource, - existingSources: PackageManagerSource[] = [] + source: PackageManagerSource, + existingSources: PackageManagerSource[] = [], ): ValidationError[] { - // Combine all validation errors - return [ - ...validateSourceUrl(source.url), - ...validateSourceName(source.name), - ...validateSourceDuplicates(existingSources, source) - ]; + // Combine all validation errors + return [ + ...validateSourceUrl(source.url), + ...validateSourceName(source.name), + ...validateSourceDuplicates(existingSources, source), + ] } /** @@ -233,27 +233,24 @@ export function validateSource( * @returns An array of validation errors, empty if valid */ export function validateSources(sources: PackageManagerSource[]): ValidationError[] { - const errors: ValidationError[] = []; + const errors: ValidationError[] = [] - // Validate each source individually - sources.forEach((source, index) => { - const sourceErrors = [ - ...validateSourceUrl(source.url), - ...validateSourceName(source.name) - ]; + // Validate each source individually + sources.forEach((source, index) => { + const sourceErrors = [...validateSourceUrl(source.url), ...validateSourceName(source.name)] - // Add index to error messages - sourceErrors.forEach(error => { - errors.push({ - field: error.field, - message: `Source #${index + 1}: ${error.message}` - }); - }); - }); + // Add index to error messages + sourceErrors.forEach((error) => { + errors.push({ + field: error.field, + message: `Source #${index + 1}: ${error.message}`, + }) + }) + }) - // Check for duplicates across all sources - const duplicateErrors = validateSourceDuplicates(sources); - errors.push(...duplicateErrors); + // Check for duplicates across all sources + const duplicateErrors = validateSourceDuplicates(sources) + errors.push(...duplicateErrors) - return errors; -} \ No newline at end of file + return errors +} diff --git a/webview-ui/src/components/package-manager/PackageManagerView.tsx b/webview-ui/src/components/package-manager/PackageManagerView.tsx index 66df314626..0bb17b2aa5 100644 --- a/webview-ui/src/components/package-manager/PackageManagerView.tsx +++ b/webview-ui/src/components/package-manager/PackageManagerView.tsx @@ -1,766 +1,658 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { Button } from "@/components/ui/button"; -import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"; -import { useExtensionState } from "../../context/ExtensionStateContext"; -import { useAppTranslation } from "../../i18n/TranslationContext"; -import { Tab, TabContent, TabHeader } from "../common/Tab"; -import { vscode } from "@/utils/vscode"; -import { PackageManagerItem, PackageManagerSource } from "../../../../src/services/package-manager/types"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react" +import { Button } from "@/components/ui/button" +import { useExtensionState } from "../../context/ExtensionStateContext" +import { useAppTranslation } from "../../i18n/TranslationContext" +import { Tab, TabContent, TabHeader } from "../common/Tab" +import { vscode } from "@/utils/vscode" +import { PackageManagerItem, PackageManagerSource } from "../../../../src/services/package-manager/types" +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk" -type PackageManagerViewProps = {}; +type PackageManagerViewProps = {} +const PackageManagerView = (_props: PackageManagerViewProps) => { + const { packageManagerSources, setPackageManagerSources } = useExtensionState() + console.log("DEBUG: PackageManagerView initialized with sources:", packageManagerSources) + useAppTranslation() // Keep the hook but don't destructure unused 't' + const [items, setItems] = useState([]) + const [activeTab, setActiveTab] = useState<"browse" | "sources">("browse") + const [refreshingUrls, setRefreshingUrls] = useState([]) -const PackageManagerView = ({}: PackageManagerViewProps) => { - const { packageManagerSources, setPackageManagerSources } = useExtensionState(); - console.log("DEBUG: PackageManagerView initialized with sources:", packageManagerSources); - const { t } = useAppTranslation(); - const [items, setItems] = useState([]); - const [activeTab, setActiveTab] = useState<"browse" | "sources">("browse"); - const [refreshingUrls, setRefreshingUrls] = useState([]); - - // Track activeTab changes - useEffect(() => { - console.log("DEBUG: activeTab changed to", activeTab); - }, [activeTab]); - const [filters, setFilters] = useState({ type: "", search: "", tags: [] as string[] }); - const [tagSearch, setTagSearch] = useState(""); - const [isTagInputActive, setIsTagInputActive] = useState(false); - const [sortBy, setSortBy] = useState("name"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - - // Debug state changes - useEffect(() => { - console.log("DEBUG: items state changed", { - itemsLength: items.length, - isFetching - }); - }, [items]); - - // Track if we're currently fetching items to prevent duplicate requests - const [isFetching, setIsFetching] = useState(false); - // Track if the fetch was manually triggered by a refresh button - const isManualRefresh = useRef(false); - - // Use a ref to track if we've already fetched items - const hasInitialFetch = useRef(false); - // Track the last sources we fetched to avoid duplicate fetches - const lastSourcesKey = useRef(null); + // Track activeTab changes + useEffect(() => { + console.log("DEBUG: activeTab changed to", activeTab) + }, [activeTab]) + const [filters, setFilters] = useState({ type: "", search: "", tags: [] as string[] }) + const [tagSearch, setTagSearch] = useState("") + const [isTagInputActive, setIsTagInputActive] = useState(false) + const [sortBy, setSortBy] = useState("name") + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc") - // Fetch function without debounce for immediate execution - const fetchPackageManagerItems = useCallback(() => { - console.log("DEBUG: fetchPackageManagerItems called"); - // Only send fetch request if we're not already fetching - if (!isFetching) { - setIsFetching(true); - try { - // Request items from extension with explicit fetch - vscode.postMessage({ - type: "fetchPackageManagerItems", - forceRefresh: true // Add a flag to force refresh - } as any); - console.log("Explicitly fetching package manager items with force refresh..."); - } catch (error) { - console.error("Failed to fetch package manager items:", error); - setIsFetching(false); - } - } else { - console.log("DEBUG: Skipping fetch because already in progress"); - } - }, [isFetching]); + // Track if we're currently fetching items to prevent duplicate requests + const [isFetching, setIsFetching] = useState(false) - // Always fetch items when component mounts, regardless of other conditions - useEffect(() => { - console.log("DEBUG: PackageManagerView mount effect triggered"); - - // Force fetch on mount, ignoring all conditions - setTimeout(() => { - console.log("DEBUG: Forcing fetch on component mount"); - setIsFetching(false); // Reset fetching state first - fetchPackageManagerItems(); - hasInitialFetch.current = true; - }, 500); // Small delay to ensure component is fully mounted - - - }, []); // Empty dependency array means this runs once on mount - - // Additional effect for when packageManagerSources changes - useEffect(() => { - console.log("DEBUG: PackageManagerView packageManagerSources effect triggered", { - hasInitialFetch: hasInitialFetch.current, - packageManagerSources, - isFetching, - itemsLength: items.length - }); - - // Only fetch if packageManagerSources changes, we're not already fetching, and this isn't the initial render - if (packageManagerSources && hasInitialFetch.current && !isFetching && packageManagerSources.length > 0) { - // Generate a key based on the current sources - const sourcesKey = JSON.stringify(packageManagerSources.map(s => s.url)); + // Debug state changes + useEffect(() => { + console.log("DEBUG: items state changed", { + itemsLength: items.length, + isFetching, + }) + }, [items, isFetching]) - // Only fetch if the sources have changed and it's not a manual refresh - if (sourcesKey !== lastSourcesKey.current && !isManualRefresh.current) { - console.log("DEBUG: Calling fetchPackageManagerItems due to sources change"); - lastSourcesKey.current = sourcesKey; - fetchPackageManagerItems(); - } else { - console.log("DEBUG: Skipping fetch because sources haven't changed or manual refresh is in progress"); - } - } - }, [packageManagerSources, fetchPackageManagerItems, isFetching]); + // Track if the fetch was manually triggered by a refresh button + const isManualRefresh = useRef(false) - // Handle message from extension - useEffect(() => { - console.log("DEBUG: Setting up message handler"); - - const handleMessage = (event: MessageEvent) => { - console.log("DEBUG: Message received in PackageManagerView", event.data); - console.log("DEBUG: Message type:", event.data.type); - console.log("DEBUG: Message state:", event.data.state ? "exists" : "undefined"); - const message = event.data; - - // Handle action messages - specifically for packageManagerButtonClicked - if (message.type === "action" && message.action === "packageManagerButtonClicked") { - console.log("DEBUG: Received packageManagerButtonClicked action, triggering fetch"); - // Directly trigger a fetch when the package manager tab is clicked - setTimeout(() => { - vscode.postMessage({ - type: "fetchPackageManagerItems", - forceRefresh: true - } as any); - }, 100); - } - // Handle repository refresh completion - if (message.type === "repositoryRefreshComplete" && message.url) { - console.log(`DEBUG: Repository refresh complete for ${message.url}`); - console.log(`DEBUG: Current refreshingUrls before update:`, refreshingUrls); - setRefreshingUrls(prev => { - const updated = prev.filter(url => url !== message.url); - console.log(`DEBUG: Updated refreshingUrls:`, updated); - return updated; - }); - } - - // Handle state messages with packageManagerItems - if (message.type === "state" && message.state) { - console.log("DEBUG: Received state message", message.state); - console.log("DEBUG: State has packageManagerItems:", message.state.packageManagerItems ? "yes" : "no"); - if (message.state.packageManagerItems) { - console.log("DEBUG: packageManagerItems length:", message.state.packageManagerItems.length); - } - - // Check for packageManagerItems - if (message.state.packageManagerItems) { - const receivedItems = message.state.packageManagerItems || []; - console.log("DEBUG: Received packageManagerItems", receivedItems.length); - console.log("DEBUG: Full message state:", message.state); - - if (receivedItems.length > 0) { - console.log("DEBUG: First item:", receivedItems[0]); - console.log("DEBUG: All items:", JSON.stringify(receivedItems)); - - // Force a new array reference to ensure React detects the change - setItems([...receivedItems]); + // Use a ref to track if we've already fetched items + const hasInitialFetch = useRef(false) + // Track the last sources we fetched to avoid duplicate fetches + const lastSourcesKey = useRef(null) - // Update the fetching state in a separate call to avoid triggering another fetch - setTimeout(() => { - setIsFetching(false); - isManualRefresh.current = false; // Reset the manual refresh flag - console.log("DEBUG: States updated - items:", receivedItems.length, "isFetching: false, isManualRefresh: false"); - }, 0); - } else { - console.log("DEBUG: Received empty items array"); - setItems([]); + // Fetch function without debounce for immediate execution + const fetchPackageManagerItems = useCallback(() => { + console.log("DEBUG: fetchPackageManagerItems called") + // Only send fetch request if we're not already fetching + if (!isFetching) { + setIsFetching(true) + try { + // Request items from extension with explicit fetch + vscode.postMessage({ + type: "fetchPackageManagerItems", + forceRefresh: true, // Add a flag to force refresh + } as any) + console.log("Explicitly fetching package manager items with force refresh...") + } catch (error) { + console.error("Failed to fetch package manager items:", error) + setIsFetching(false) + } + } else { + console.log("DEBUG: Skipping fetch because already in progress") + } + }, [isFetching]) - // Update the fetching state in a separate call to avoid triggering another fetch - setTimeout(() => { - setIsFetching(false); - isManualRefresh.current = false; // Reset the manual refresh flag - console.log("DEBUG: States updated - items: 0, isFetching: false, isManualRefresh: false"); - }, 0); - } - } - } - }; + // Always fetch items when component mounts, regardless of other conditions + useEffect(() => { + console.log("DEBUG: PackageManagerView mount effect triggered") - window.addEventListener("message", handleMessage); - return () => window.removeEventListener("message", handleMessage); - }, []); + // Force fetch on mount, ignoring all conditions + setTimeout(() => { + console.log("DEBUG: Forcing fetch on component mount") + setIsFetching(false) // Reset fetching state first + fetchPackageManagerItems() + hasInitialFetch.current = true + }, 500) // Small delay to ensure component is fully mounted + }, [fetchPackageManagerItems]) // Add fetchPackageManagerItems as dependency - // Filter items based on filters - console.log("DEBUG: Filtering items", { itemsCount: items.length, filters }); - console.log("DEBUG: Items before filtering:", items.map(item => ({ name: item.name, type: item.type }))); - const filteredItems = items.filter(item => { - // Filter by type - if (filters.type && item.type !== filters.type) { - return false; - } - - // Filter by search term - if (filters.search) { - const searchTerm = filters.search.toLowerCase(); - const nameMatch = item.name.toLowerCase().includes(searchTerm); - const descMatch = item.description.toLowerCase().includes(searchTerm); - const authorMatch = item.author?.toLowerCase().includes(searchTerm); - - if (!nameMatch && !descMatch && !authorMatch) { - return false; - } - } - - // Filter by tags (OR logic - item passes if it has ANY of the selected tags) - if (filters.tags.length > 0) { - // If the item has no tags, it doesn't match when tag filtering is active - if (!item.tags || item.tags.length === 0) { - return false; - } + // Additional effect for when packageManagerSources changes + useEffect(() => { + console.log("DEBUG: PackageManagerView packageManagerSources effect triggered", { + hasInitialFetch: hasInitialFetch.current, + packageManagerSources, + isFetching, + itemsLength: items.length, + }) - // Check if any of the item's tags match any of the selected tags - const hasMatchingTag = item.tags.some(tag => filters.tags.includes(tag)); - if (!hasMatchingTag) { - return false; - } - } + // Only fetch if packageManagerSources changes, we're not already fetching, and this isn't the initial render + if (packageManagerSources && hasInitialFetch.current && !isFetching && packageManagerSources.length > 0) { + // Generate a key based on the current sources + const sourcesKey = JSON.stringify(packageManagerSources.map((s) => s.url)) - return true; - }); - console.log("DEBUG: After filtering", { filteredItemsCount: filteredItems.length }); - - // Sort items - console.log("DEBUG: Sorting items", { filteredItemsCount: filteredItems.length, sortBy, sortOrder }); - const sortedItems = [...filteredItems].sort((a, b) => { - let comparison = 0; - - switch (sortBy) { - case "name": - comparison = a.name.localeCompare(b.name); - break; - case "author": - comparison = (a.author || "").localeCompare(b.author || ""); - break; - case "lastUpdated": - comparison = (a.lastUpdated || "").localeCompare(b.lastUpdated || ""); - break; - default: - comparison = a.name.localeCompare(b.name); - } - - return sortOrder === "asc" ? comparison : -comparison; - }); - console.log("DEBUG: Final sorted items", { - sortedItemsCount: sortedItems.length, - firstItem: sortedItems.length > 0 ? sortedItems[0].name : 'none' - }); + // Only fetch if the sources have changed and it's not a manual refresh + if (sourcesKey !== lastSourcesKey.current && !isManualRefresh.current) { + console.log("DEBUG: Calling fetchPackageManagerItems due to sources change") + lastSourcesKey.current = sourcesKey + fetchPackageManagerItems() + } else { + console.log("DEBUG: Skipping fetch because sources haven't changed or manual refresh is in progress") + } + // Reset refreshingUrls when items length changes + setRefreshingUrls([]) + } + }, [packageManagerSources, fetchPackageManagerItems, isFetching, items.length]) - // Collect all unique tags from items - const allTags = useMemo(() => { - const tagSet = new Set(); - items.forEach(item => { - if (item.tags && item.tags.length > 0) { - item.tags.forEach(tag => tagSet.add(tag)); - } - }); - return Array.from(tagSet).sort(); - }, [items]); + // Handle message from extension + useEffect(() => { + console.log("DEBUG: Setting up message handler") - // Add debug logging right before rendering - useEffect(() => { - console.log("DEBUG: Rendering with", { - sortedItemsCount: sortedItems.length, - firstItem: sortedItems.length > 0 ? `${sortedItems[0].name} (${sortedItems[0].type})` : 'none', - availableTags: allTags.length - }); - }, [sortedItems, allTags]); - - // Log right before rendering - console.log("DEBUG: About to render with", { - itemsLength: items.length, - filteredItemsLength: filteredItems.length, - sortedItemsLength: sortedItems.length, - activeTab - }); - - return ( - - -
-

Package Manager

-
-
- - -
-
+ const handleMessage = (event: MessageEvent) => { + console.log("DEBUG: Message received in PackageManagerView", event.data) + console.log("DEBUG: Message type:", event.data.type) + console.log("DEBUG: Message state:", event.data.state ? "exists" : "undefined") + const message = event.data - - {activeTab === "browse" ? ( - <> -
- setFilters({ ...filters, search: e.target.value })} - className="w-full p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded" - /> -
-
-
- - -
+ // Handle action messages - specifically for packageManagerButtonClicked + if (message.type === "action" && message.action === "packageManagerButtonClicked") { + console.log("DEBUG: Received packageManagerButtonClicked action, triggering fetch") + // Directly trigger a fetch when the package manager tab is clicked + setTimeout(() => { + vscode.postMessage({ + type: "fetchPackageManagerItems", + forceRefresh: true, + } as any) + }, 100) + } + // Handle repository refresh completion + if (message.type === "repositoryRefreshComplete" && message.url) { + console.log(`DEBUG: Repository refresh complete for ${message.url}`) + console.log(`DEBUG: Current refreshingUrls before update:`, refreshingUrls) + setRefreshingUrls((prev) => { + const updated = prev.filter((url) => url !== message.url) + console.log(`DEBUG: Updated refreshingUrls:`, updated) + return updated + }) + } -
- - - -
-
+ // Handle state messages with packageManagerItems + if (message.type === "state" && message.state) { + console.log("DEBUG: Received state message", message.state) + console.log("DEBUG: State has packageManagerItems:", message.state.packageManagerItems ? "yes" : "no") + if (message.state.packageManagerItems) { + console.log("DEBUG: packageManagerItems length:", message.state.packageManagerItems.length) + } - {allTags.length > 0 && ( -
-
-
- - - ({allTags.length} available) - -
- {filters.tags.length > 0 && ( - - )} -
- - setIsTagInputActive(true)} - onBlur={(e) => { - // Only hide if not clicking within the command list - if (!e.relatedTarget?.closest('[cmdk-list]')) { - setIsTagInputActive(false); - } - }} - className="w-full p-1 bg-vscode-input-background text-vscode-input-foreground border-b border-vscode-dropdown-border" - /> - {(isTagInputActive || tagSearch) && ( - - - No matching tags found - - - {allTags - .filter(tag => tag.toLowerCase().includes(tagSearch.toLowerCase())) - .map(tag => ( - { - const isSelected = filters.tags.includes(tag); - if (isSelected) { - setFilters({ - ...filters, - tags: filters.tags.filter(t => t !== tag) - }); - } else { - setFilters({ - ...filters, - tags: [...filters.tags, tag] - }); - } - }} - className={`flex items-center gap-2 p-1 cursor-pointer text-sm hover:bg-vscode-button-secondaryBackground ${ - filters.tags.includes(tag) - ? 'bg-vscode-button-background text-vscode-button-foreground' - : 'text-vscode-dropdown-foreground' - }`} - onMouseDown={(e) => { - // Prevent blur event when clicking items - e.preventDefault(); - }} - > - - {tag} - - ))} - - - )} - -
- {filters.tags.length > 0 - ? `Showing items with any of the selected tags (${filters.tags.length} selected)` - : 'Click tags to filter items'} -
-
- )} -
-
- - {console.log("DEBUG: Rendering condition", { - sortedItemsLength: sortedItems.length, - condition: sortedItems.length === 0 ? "empty" : "has items" - })} - - {sortedItems.length === 0 ? ( -
-

No package manager items found

- -
- ) : ( -
-
-

- {`${sortedItems.length} items found`} -

- -
-
- {sortedItems.map((item) => ( - - ))} -
-
- )} - - ) : ( - { - setPackageManagerSources(sources); - vscode.postMessage({ type: "packageManagerSources", sources }); - }} - /> - )} -
-
- ); -}; + // Check for packageManagerItems + if (message.state.packageManagerItems) { + const receivedItems = message.state.packageManagerItems || [] + console.log("DEBUG: Received packageManagerItems", receivedItems.length) + console.log("DEBUG: Full message state:", message.state) -const PackageManagerItemCard = ({ - item, - filters, - setFilters, - activeTab, - setActiveTab -}: { - item: PackageManagerItem; - filters: { type: string; search: string; tags: string[] }; - setFilters: React.Dispatch>; - activeTab: "browse" | "sources"; - setActiveTab: React.Dispatch>; -}) => { - const { t } = useAppTranslation(); - - // Helper function to validate URL - const isValidUrl = (urlString: string): boolean => { - try { - new URL(urlString); - return true; - } catch (e) { - return false; - } - }; + if (receivedItems.length > 0) { + console.log("DEBUG: First item:", receivedItems[0]) + console.log("DEBUG: All items:", JSON.stringify(receivedItems)) - const getTypeLabel = (type: string) => { - switch (type) { - case "role": - return "Role"; - case "mcp-server": - return "MCP Server"; - case "storage": - return "Storage"; - default: - return "Other"; - } - }; - - const getTypeColor = (type: string) => { - switch (type) { - case "role": - return "bg-blue-600"; - case "mcp-server": - return "bg-green-600"; - case "storage": - return "bg-purple-600"; - default: - return "bg-gray-600"; - } - }; - - const handleOpenUrl = () => { - // Use sourceUrl if it exists and is a valid URL, otherwise fall back to url - const urlToOpen = item.sourceUrl && isValidUrl(item.sourceUrl) ? item.sourceUrl : item.url; - console.log(`PackageManagerItemCard: Opening URL: ${urlToOpen}`); - vscode.postMessage({ - type: "openExternal", - url: urlToOpen - }); - console.log(`PackageManagerItemCard: Sent openExternal message with URL: ${urlToOpen}`); - }; + // Force a new array reference to ensure React detects the change + setItems([...receivedItems]) - return ( -
-
-
-

{item.name}

- {item.author && ( -

- {`by ${item.author}`} -

- )} -
- - {getTypeLabel(item.type)} - -
- -

{item.description}

- - {item.tags && item.tags.length > 0 && ( -
- {item.tags.map(tag => ( - - ))} -
- )} - -
-
- {item.version && ( - - - {item.version} - - )} - {item.lastUpdated && ( - - - {new Date(item.lastUpdated).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric' - })} - - )} -
- - -
-
- ); -}; + // Update the fetching state in a separate call to avoid triggering another fetch + setTimeout(() => { + setIsFetching(false) + isManualRefresh.current = false // Reset the manual refresh flag + console.log( + "DEBUG: States updated - items:", + receivedItems.length, + "isFetching: false, isManualRefresh: false", + ) + }, 0) + } else { + console.log("DEBUG: Received empty items array") + setItems([]) -// Validation utilities for the frontend -interface ValidationError { - field: string; - message: string; + // Update the fetching state in a separate call to avoid triggering another fetch + setTimeout(() => { + setIsFetching(false) + isManualRefresh.current = false // Reset the manual refresh flag + console.log("DEBUG: States updated - items: 0, isFetching: false, isManualRefresh: false") + }, 0) + } + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [refreshingUrls]) // Add refreshingUrls as dependency + + // Filter items based on filters + console.log("DEBUG: Filtering items", { itemsCount: items.length, filters }) + console.log( + "DEBUG: Items before filtering:", + items.map((item) => ({ name: item.name, type: item.type })), + ) + const filteredItems = items.filter((item) => { + // Filter by type + if (filters.type && item.type !== filters.type) { + return false + } + + // Filter by search term + if (filters.search) { + const searchTerm = filters.search.toLowerCase() + const nameMatch = item.name.toLowerCase().includes(searchTerm) + const descMatch = item.description.toLowerCase().includes(searchTerm) + const authorMatch = item.author?.toLowerCase().includes(searchTerm) + + if (!nameMatch && !descMatch && !authorMatch) { + return false + } + } + + // Filter by tags (OR logic - item passes if it has ANY of the selected tags) + if (filters.tags.length > 0) { + // If the item has no tags, it doesn't match when tag filtering is active + if (!item.tags || item.tags.length === 0) { + return false + } + + // Check if any of the item's tags match any of the selected tags + const hasMatchingTag = item.tags.some((tag) => filters.tags.includes(tag)) + if (!hasMatchingTag) { + return false + } + } + + return true + }) + console.log("DEBUG: After filtering", { filteredItemsCount: filteredItems.length }) + + // Sort items + console.log("DEBUG: Sorting items", { filteredItemsCount: filteredItems.length, sortBy, sortOrder }) + const sortedItems = [...filteredItems].sort((a, b) => { + let comparison = 0 + + switch (sortBy) { + case "name": + comparison = a.name.localeCompare(b.name) + break + case "author": + comparison = (a.author || "").localeCompare(b.author || "") + break + case "lastUpdated": + comparison = (a.lastUpdated || "").localeCompare(b.lastUpdated || "") + break + default: + comparison = a.name.localeCompare(b.name) + } + + return sortOrder === "asc" ? comparison : -comparison + }) + console.log("DEBUG: Final sorted items", { + sortedItemsCount: sortedItems.length, + firstItem: sortedItems.length > 0 ? sortedItems[0].name : "none", + }) + + // Collect all unique tags from items + const allTags = useMemo(() => { + const tagSet = new Set() + items.forEach((item) => { + if (item.tags && item.tags.length > 0) { + item.tags.forEach((tag) => tagSet.add(tag)) + } + }) + return Array.from(tagSet).sort() + }, [items]) + + // Add debug logging right before rendering + useEffect(() => { + console.log("DEBUG: Rendering with", { + sortedItemsCount: sortedItems.length, + firstItem: sortedItems.length > 0 ? `${sortedItems[0].name} (${sortedItems[0].type})` : "none", + availableTags: allTags.length, + }) + }, [sortedItems, allTags]) + + // Log right before rendering + console.log("DEBUG: About to render with", { + itemsLength: items.length, + filteredItemsLength: filteredItems.length, + sortedItemsLength: sortedItems.length, + activeTab, + }) + + return ( + + +
+

Package Manager

+
+
+ + +
+
+ + + {activeTab === "browse" ? ( + <> +
+ setFilters({ ...filters, search: e.target.value })} + className="w-full p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded" + /> +
+
+
+ + +
+ +
+ + + +
+
+ + {allTags.length > 0 && ( +
+
+
+ + + ({allTags.length} available) + +
+ {filters.tags.length > 0 && ( + + )} +
+ + setIsTagInputActive(true)} + onBlur={(e) => { + // Only hide if not clicking within the command list + if (!e.relatedTarget?.closest("[cmdk-list]")) { + setIsTagInputActive(false) + } + }} + className="w-full p-1 bg-vscode-input-background text-vscode-input-foreground border-b border-vscode-dropdown-border" + /> + {(isTagInputActive || tagSearch) && ( + + + No matching tags found + + + {allTags + .filter((tag) => + tag.toLowerCase().includes(tagSearch.toLowerCase()), + ) + .map((tag) => ( + { + const isSelected = filters.tags.includes(tag) + if (isSelected) { + setFilters({ + ...filters, + tags: filters.tags.filter( + (t) => t !== tag, + ), + }) + } else { + setFilters({ + ...filters, + tags: [...filters.tags, tag], + }) + } + }} + className={`flex items-center gap-2 p-1 cursor-pointer text-sm hover:bg-vscode-button-secondaryBackground ${ + filters.tags.includes(tag) + ? "bg-vscode-button-background text-vscode-button-foreground" + : "text-vscode-dropdown-foreground" + }`} + onMouseDown={(e) => { + // Prevent blur event when clicking items + e.preventDefault() + }}> + + {tag} + + ))} + + + )} + +
+ {filters.tags.length > 0 + ? `Showing items with any of the selected tags (${filters.tags.length} selected)` + : "Click tags to filter items"} +
+
+ )} +
+
+ + {console.log("DEBUG: Rendering condition", { + sortedItemsLength: sortedItems.length, + condition: sortedItems.length === 0 ? "empty" : "has items", + })} + + {sortedItems.length === 0 ? ( +
+

No package manager items found

+ +
+ ) : ( +
+
+

+ {`${sortedItems.length} items found`} +

+ +
+
+ {sortedItems.map((item) => ( + + ))} +
+
+ )} + + ) : ( + { + setPackageManagerSources(sources) + vscode.postMessage({ type: "packageManagerSources", sources }) + }} + /> + )} +
+
+ ) } -const validateSourceUrl = (url: string): ValidationError[] => { - const errors: ValidationError[] = []; +const PackageManagerItemCard = ({ + item, + filters, + setFilters, + activeTab, + setActiveTab, +}: { + item: PackageManagerItem + filters: { type: string; search: string; tags: string[] } + setFilters: React.Dispatch> + activeTab: "browse" | "sources" + setActiveTab: React.Dispatch> +}) => { + useAppTranslation() // Keep the hook but don't destructure unused 't' - // Check if URL is empty - if (!url) { - errors.push({ - field: "url", - message: "URL cannot be empty" - }); - return errors; - } + // Helper function to validate URL + const isValidUrl = (urlString: string): boolean => { + try { + new URL(urlString) + return true + } catch (e) { + return false + } + } - // Check if URL is valid format - try { - new URL(url); - } catch (e) { - errors.push({ - field: "url", - message: "Invalid URL format" - }); - } + const getTypeLabel = (type: string) => { + switch (type) { + case "role": + return "Role" + case "mcp-server": + return "MCP Server" + case "storage": + return "Storage" + default: + return "Other" + } + } - // Check for non-visible characters (except spaces) - const nonVisibleCharRegex = /[^\S ]/; - if (nonVisibleCharRegex.test(url)) { - errors.push({ - field: "url", - message: "URL contains non-visible characters other than spaces" - }); - } + const getTypeColor = (type: string) => { + switch (type) { + case "role": + return "bg-blue-600" + case "mcp-server": + return "bg-green-600" + case "storage": + return "bg-purple-600" + default: + return "bg-gray-600" + } + } - return errors; -}; + const handleOpenUrl = () => { + // Use sourceUrl if it exists and is a valid URL, otherwise fall back to url + const urlToOpen = item.sourceUrl && isValidUrl(item.sourceUrl) ? item.sourceUrl : item.url + console.log(`PackageManagerItemCard: Opening URL: ${urlToOpen}`) + vscode.postMessage({ + type: "openExternal", + url: urlToOpen, + }) + console.log(`PackageManagerItemCard: Sent openExternal message with URL: ${urlToOpen}`) + } -const validateSourceName = (name?: string): ValidationError[] => { - const errors: ValidationError[] = []; + return ( +
+
+
+

{item.name}

+ {item.author &&

{`by ${item.author}`}

} +
+ + {getTypeLabel(item.type)} + +
- // Skip validation if name is not provided - if (!name) { - return errors; - } +

{item.description}

- // Check name length - if (name.length > 20) { - errors.push({ - field: "name", - message: "Name must be 20 characters or less" - }); - } + {item.tags && item.tags.length > 0 && ( +
+ {item.tags.map((tag) => ( + + ))} +
+ )} - // Check for non-visible characters (except spaces) - const nonVisibleCharRegex = /[^\S ]/; - if (nonVisibleCharRegex.test(name)) { - errors.push({ - field: "name", - message: "Name contains non-visible characters other than spaces" - }); - } +
+
+ {item.version && ( + + + {item.version} + + )} + {item.lastUpdated && ( + + + {new Date(item.lastUpdated).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + )} +
- return errors; -}; - -const validateSourceDuplicates = ( - sources: PackageManagerSource[], - newUrl: string, - newName?: string -): ValidationError[] => { - const errors: ValidationError[] = []; - - if (newUrl) { - // Check for duplicate URLs (case and whitespace insensitive) - const normalizedNewUrl = newUrl.toLowerCase().replace(/\s+/g, ''); - const duplicateUrl = sources.some(source => - source.url.toLowerCase().replace(/\s+/g, '') === normalizedNewUrl - ); - - if (duplicateUrl) { - errors.push({ - field: "url", - message: "This URL is already in the list (case and whitespace insensitive match)" - }); - } - } - - if (newName) { - // Check for duplicate names (case and whitespace insensitive) - const normalizedNewName = newName.toLowerCase().replace(/\s+/g, ''); - const duplicateName = sources.some(source => - source.name && source.name.toLowerCase().replace(/\s+/g, '') === normalizedNewName - ); - - if (duplicateName) { - errors.push({ - field: "name", - message: "This name is already in use (case and whitespace insensitive match)" - }); - } - } - - return errors; -}; + +
+
+ ) +} /** * Checks if a URL is a valid Git repository URL @@ -768,245 +660,252 @@ const validateSourceDuplicates = ( * @returns True if the URL is a valid Git repository URL, false otherwise */ const isValidGitRepositoryUrl = (url: string): boolean => { - // Trim the URL to remove any leading/trailing whitespace - const trimmedUrl = url.trim(); + // Trim the URL to remove any leading/trailing whitespace + const trimmedUrl = url.trim() - // HTTPS pattern (GitHub, GitLab, Bitbucket, etc.) - // Examples: - // - https://github.com/username/repo - // - https://github.com/username/repo.git - // - https://gitlab.com/username/repo - // - https://bitbucket.org/username/repo - const httpsPattern = /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\/.+)*(\.git)?$/; + // HTTPS pattern (GitHub, GitLab, Bitbucket, etc.) + // Examples: + // - https://github.com/username/repo + // - https://github.com/username/repo.git + // - https://gitlab.com/username/repo + // - https://bitbucket.org/username/repo + const httpsPattern = + /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\/.+)*(\.git)?$/ - // SSH pattern - // Examples: - // - git@github.com:username/repo.git - // - git@gitlab.com:username/repo.git - const sshPattern = /^git@(github\.com|gitlab\.com|bitbucket\.org):([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(\.git)?$/; + // SSH pattern + // Examples: + // - git@github.com:username/repo.git + // - git@gitlab.com:username/repo.git + const sshPattern = /^git@(github\.com|gitlab\.com|bitbucket\.org):([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(\.git)?$/ - // Git protocol pattern - // Examples: - // - git://github.com/username/repo.git - const gitProtocolPattern = /^git:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\.git)?$/; + // Git protocol pattern + // Examples: + // - git://github.com/username/repo.git + const gitProtocolPattern = + /^git:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\.git)?$/ - return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl); -}; + return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl) +} const PackageManagerSourcesConfig = ({ - sources, - refreshingUrls, - setRefreshingUrls, - onSourcesChange + sources, + refreshingUrls, + setRefreshingUrls, + onSourcesChange, }: { - sources: PackageManagerSource[]; - refreshingUrls: string[]; - setRefreshingUrls: React.Dispatch>; - onSourcesChange: (sources: PackageManagerSource[]) => void; + sources: PackageManagerSource[] + refreshingUrls: string[] + setRefreshingUrls: React.Dispatch> + onSourcesChange: (sources: PackageManagerSource[]) => void }) => { - const { t } = useAppTranslation(); - const [newSourceUrl, setNewSourceUrl] = useState(""); - const [newSourceName, setNewSourceName] = useState(""); - const [error, setError] = useState(""); + useAppTranslation() // Keep the hook but don't destructure unused 't' + const [newSourceUrl, setNewSourceUrl] = useState("") + const [newSourceName, setNewSourceName] = useState("") + const [error, setError] = useState("") - const handleAddSource = () => { - // Validate URL - if (!newSourceUrl) { - setError("URL cannot be empty"); - return; - } + const handleAddSource = () => { + // Validate URL + if (!newSourceUrl) { + setError("URL cannot be empty") + return + } - try { - new URL(newSourceUrl); - } catch (e) { - setError("Invalid URL format"); - return; - } + try { + new URL(newSourceUrl) + } catch (e) { + setError("Invalid URL format") + return + } - // Check for non-visible characters in URL (except spaces) - const nonVisibleCharRegex = /[^\S ]/; - if (nonVisibleCharRegex.test(newSourceUrl)) { - setError("URL contains non-visible characters other than spaces"); - return; - } + // Check for non-visible characters in URL (except spaces) + const nonVisibleCharRegex = /[^\S ]/ + if (nonVisibleCharRegex.test(newSourceUrl)) { + setError("URL contains non-visible characters other than spaces") + return + } - // Check if URL is a valid Git repository URL - if (!isValidGitRepositoryUrl(newSourceUrl)) { - setError("URL must be a valid Git repository URL (e.g., https://github.com/username/repo)"); - return; - } + // Check if URL is a valid Git repository URL + if (!isValidGitRepositoryUrl(newSourceUrl)) { + setError("URL must be a valid Git repository URL (e.g., https://github.com/username/repo)") + return + } - // Check if URL already exists (case and whitespace insensitive) - const normalizedNewUrl = newSourceUrl.toLowerCase().replace(/\s+/g, ''); - if (sources.some(source => source.url.toLowerCase().replace(/\s+/g, '') === normalizedNewUrl)) { - setError("This URL is already in the list (case and whitespace insensitive match)"); - return; - } + // Check if URL already exists (case and whitespace insensitive) + const normalizedNewUrl = newSourceUrl.toLowerCase().replace(/\s+/g, "") + if (sources.some((source) => source.url.toLowerCase().replace(/\s+/g, "") === normalizedNewUrl)) { + setError("This URL is already in the list (case and whitespace insensitive match)") + return + } - // Validate name if provided - if (newSourceName) { - // Check name length - if (newSourceName.length > 20) { - setError("Name must be 20 characters or less"); - return; - } + // Validate name if provided + if (newSourceName) { + // Check name length + if (newSourceName.length > 20) { + setError("Name must be 20 characters or less") + return + } - // Check for non-visible characters in name (except spaces) - if (nonVisibleCharRegex.test(newSourceName)) { - setError("Name contains non-visible characters other than spaces"); - return; - } + // Check for non-visible characters in name (except spaces) + if (nonVisibleCharRegex.test(newSourceName)) { + setError("Name contains non-visible characters other than spaces") + return + } - // Check if name already exists (case and whitespace insensitive) - const normalizedNewName = newSourceName.toLowerCase().replace(/\s+/g, ''); - if (sources.some(source => - source.name && source.name.toLowerCase().replace(/\s+/g, '') === normalizedNewName - )) { - setError("This name is already in use (case and whitespace insensitive match)"); - return; - } - } + // Check if name already exists (case and whitespace insensitive) + const normalizedNewName = newSourceName.toLowerCase().replace(/\s+/g, "") + if ( + sources.some( + (source) => source.name && source.name.toLowerCase().replace(/\s+/g, "") === normalizedNewName, + ) + ) { + setError("This name is already in use (case and whitespace insensitive match)") + return + } + } - // Check if maximum number of sources has been reached - const MAX_SOURCES = 10; - if (sources.length >= MAX_SOURCES) { - setError(`Maximum of ${MAX_SOURCES} sources allowed`); - return; - } + // Check if maximum number of sources has been reached + const MAX_SOURCES = 10 + if (sources.length >= MAX_SOURCES) { + setError(`Maximum of ${MAX_SOURCES} sources allowed`) + return + } - // Add new source - const newSource: PackageManagerSource = { - url: newSourceUrl, - name: newSourceName || undefined, - enabled: true - }; + // Add new source + const newSource: PackageManagerSource = { + url: newSourceUrl, + name: newSourceName || undefined, + enabled: true, + } - onSourcesChange([...sources, newSource]); - - // Reset form - setNewSourceUrl(""); - setNewSourceName(""); - setError(""); - }; + onSourcesChange([...sources, newSource]) - const handleToggleSource = (index: number) => { - const updatedSources = [...sources]; - updatedSources[index].enabled = !updatedSources[index].enabled; - onSourcesChange(updatedSources); - }; + // Reset form + setNewSourceUrl("") + setNewSourceName("") + setError("") + } - const handleRemoveSource = (index: number) => { - const updatedSources = sources.filter((_, i) => i !== index); - onSourcesChange(updatedSources); - }; - - const handleRefreshSource = (url: string) => { - // Add URL to refreshing list - setRefreshingUrls(prev => [...prev, url]); - - // Send message to refresh this specific source - vscode.postMessage({ - type: "refreshPackageManagerSource", - url - }); - }; + const handleToggleSource = (index: number) => { + const updatedSources = [...sources] + updatedSources[index].enabled = !updatedSources[index].enabled + onSourcesChange(updatedSources) + } - return ( -
-

Configure Package Manager Sources

-

- Add Git repositories that contain package manager items. These repositories will be fetched when browsing the package manager. -

- -
-
Add New Source
-
- { - setNewSourceUrl(e.target.value); - setError(""); - }} - className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded" - /> -

- Supported formats: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), or Git protocol (git://github.com/username/repo.git) -

- { - // Limit input to 20 characters - setNewSourceName(e.target.value.slice(0, 20)); - setError(""); - }} - maxLength={20} // HTML attribute to limit input length - className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded" - /> -
- {error &&

{error}

} - -
-
- Current Sources ({sources.length}/10 max) -
- {sources.length === 0 ? ( -

- No sources configured. Add a source to get started. -

- ) : ( -
- {sources.map((source, index) => ( -
-
-
- handleToggleSource(index)} - className="mr-2" - /> -
-

{source.name || source.url}

- {source.name &&

{source.url}

} -
-
-
-
- - -
-
- ))} -
- )} -
- ); -}; + const handleRemoveSource = (index: number) => { + const updatedSources = sources.filter((_, i) => i !== index) + onSourcesChange(updatedSources) + } -export default PackageManagerView; \ No newline at end of file + const handleRefreshSource = (url: string) => { + // Add URL to refreshing list + setRefreshingUrls((prev) => [...prev, url]) + + // Send message to refresh this specific source + vscode.postMessage({ + type: "refreshPackageManagerSource", + url, + }) + } + + return ( +
+

Configure Package Manager Sources

+

+ Add Git repositories that contain package manager items. These repositories will be fetched when + browsing the package manager. +

+ +
+
Add New Source
+
+ { + setNewSourceUrl(e.target.value) + setError("") + }} + className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded" + /> +

+ Supported formats: HTTPS (https://github.com/username/repo), SSH + (git@github.com:username/repo.git), or Git protocol (git://github.com/username/repo.git) +

+ { + // Limit input to 20 characters + setNewSourceName(e.target.value.slice(0, 20)) + setError("") + }} + maxLength={20} // HTML attribute to limit input length + className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded" + /> +
+ {error &&

{error}

} + +
+
+ Current Sources{" "} + ({sources.length}/10 max) +
+ {sources.length === 0 ? ( +

No sources configured. Add a source to get started.

+ ) : ( +
+ {sources.map((source, index) => ( +
+
+
+ handleToggleSource(index)} + className="mr-2" + /> +
+

+ {source.name || source.url} +

+ {source.name && ( +

{source.url}

+ )} +
+
+
+
+ + +
+
+ ))} +
+ )} +
+ ) +} + +export default PackageManagerView