From 81304a22ea6453e7497c16d61820a57f48299951 Mon Sep 17 00:00:00 2001 From: Smartsheet-JB-Brown Date: Mon, 14 Apr 2025 06:54:17 -0700 Subject: [PATCH] package details visible in expandable section --- .../webview/packageManagerMessageHandler.ts | 29 +- src/core/webview/webviewMessageHandler.ts | 105 +++--- .../PackageManagerIntegration.test.ts | 229 +++++++++++++ .../__tests__/PackageManagerRealData.test.ts | 207 +++++++++++ .../__tests__/searchUtils.test.ts | 75 ++++ src/services/package-manager/types.ts | 35 +- src/shared/WebviewMessage.ts | 2 + .../package-manager/PackageManagerView.tsx | 289 +++++----------- .../__tests__/PackageManagerView.test.tsx | 323 ++++++++++++++++++ .../__tests__/selectors.test.ts | 143 ++++++++ .../components/ExpandableSection.tsx | 47 +++ .../components/PackageManagerItemCard.tsx | 161 +++++++++ .../package-manager/components/TypeGroup.tsx | 48 +++ .../__tests__/ExpandableSection.test.tsx | 91 +++++ .../__tests__/PackageManagerItemCard.test.tsx | 142 ++++++++ .../components/__tests__/TypeGroup.test.tsx | 83 +++++ .../components/package-manager/selectors.ts | 83 +++++ .../utils/__tests__/grouping.test.ts | 120 +++++++ .../package-manager/utils/grouping.ts | 76 +++++ 19 files changed, 2027 insertions(+), 261 deletions(-) create mode 100644 src/services/package-manager/__tests__/PackageManagerIntegration.test.ts create mode 100644 src/services/package-manager/__tests__/PackageManagerRealData.test.ts create mode 100644 src/services/package-manager/__tests__/searchUtils.test.ts create mode 100644 webview-ui/src/components/package-manager/__tests__/PackageManagerView.test.tsx create mode 100644 webview-ui/src/components/package-manager/__tests__/selectors.test.ts create mode 100644 webview-ui/src/components/package-manager/components/ExpandableSection.tsx create mode 100644 webview-ui/src/components/package-manager/components/PackageManagerItemCard.tsx create mode 100644 webview-ui/src/components/package-manager/components/TypeGroup.tsx create mode 100644 webview-ui/src/components/package-manager/components/__tests__/ExpandableSection.test.tsx create mode 100644 webview-ui/src/components/package-manager/components/__tests__/PackageManagerItemCard.test.tsx create mode 100644 webview-ui/src/components/package-manager/components/__tests__/TypeGroup.test.tsx create mode 100644 webview-ui/src/components/package-manager/selectors.ts create mode 100644 webview-ui/src/components/package-manager/utils/__tests__/grouping.test.ts create mode 100644 webview-ui/src/components/package-manager/utils/grouping.ts diff --git a/src/core/webview/packageManagerMessageHandler.ts b/src/core/webview/packageManagerMessageHandler.ts index b6902f8528..aca8ddf8b3 100644 --- a/src/core/webview/packageManagerMessageHandler.ts +++ b/src/core/webview/packageManagerMessageHandler.ts @@ -3,7 +3,7 @@ import { ClineProvider } from "./ClineProvider" import { WebviewMessage } from "../../shared/WebviewMessage" import { ExtensionMessage } from "../../shared/ExtensionMessage" import { PackageManagerManager } from "../../services/package-manager" -import { PackageManagerItem, PackageManagerSource } from "../../services/package-manager/types" +import { ComponentType, PackageManagerItem, PackageManagerSource } from "../../services/package-manager/types" import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../services/package-manager/constants" import { validateSources } from "../../services/package-manager/validation" import { GlobalState } from "../../schemas" @@ -222,6 +222,33 @@ export async function handlePackageManagerMessages( return true } + case "filterPackageManagerItems": { + if (message.filters) { + try { + // Get current items from the manager + const items = packageManagerManager.getCurrentItems() + + // Apply filters using the manager's filtering logic + const filteredItems = packageManagerManager.filterItems(items, { + type: message.filters.type as ComponentType | undefined, + search: message.filters.search, + tags: message.filters.tags, + }) + + // Get current state and merge with filtered items + const currentState = await provider.getStateToPostToWebview() + await provider.postMessageToWebview({ + type: "state", + state: { ...currentState, packageManagerItems: filteredItems }, + }) + } catch (error) { + console.error("Package Manager: Error filtering items:", error) + vscode.window.showErrorMessage("Failed to filter package manager items") + } + } + return true + } + case "refreshPackageManagerSource": { if (message.url) { try { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 049fe2c46b..2719806951 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -46,20 +46,18 @@ import { PackageManagerManager } from "../../services/package-manager" import { handlePackageManagerMessages } from "./packageManagerMessageHandler" // Track if package manager data has been loaded -let packageManagerDataLoaded = false; +let packageManagerDataLoaded = false export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, - packageManagerManager?: PackageManagerManager + packageManagerManager?: PackageManagerManager, ) => { // Utility functions provided for concise get/update of global state via contextProxy API. const getGlobalState = (key: K) => provider.contextProxy.getValue(key) const updateGlobalState = async (key: K, value: GlobalState[K]) => await provider.contextProxy.setValue(key, value) - - switch (message.type) { case "webviewDidLaunch": // Load custom modes first @@ -68,38 +66,39 @@ export const webviewMessageHandler = async ( // Don't handle package manager messages in webviewDidLaunch // They will be handled by the fetchPackageManagerItems case - console.log(`DEBUG: webviewDidLaunch - skipping package manager handling, will be triggered by explicit fetchPackageManagerItems`); + console.log( + `DEBUG: webviewDidLaunch - skipping package manager handling, will be triggered by explicit fetchPackageManagerItems`, + ) + + console.log(`DEBUG: About to call postStateToWebview`) + await provider.postStateToWebview() + console.log(`DEBUG: After calling postStateToWebview`) + + console.log(`DEBUG: About to initialize workspace tracker file paths`) + provider.workspaceTracker?.initializeFilePaths() // don't await + console.log(`DEBUG: After initializing workspace tracker file paths`) - console.log(`DEBUG: About to call postStateToWebview`); - await provider.postStateToWebview(); - console.log(`DEBUG: After calling postStateToWebview`); - - console.log(`DEBUG: About to initialize workspace tracker file paths`); - provider.workspaceTracker?.initializeFilePaths(); // don't await - console.log(`DEBUG: After initializing workspace tracker file paths`); - // Continue with the rest of the webviewDidLaunch case - console.log(`DEBUG: Continuing with webviewDidLaunch case`); + console.log(`DEBUG: Continuing with webviewDidLaunch case`) getTheme().then((theme) => { - console.log(`DEBUG: Got theme, posting to webview`); - provider.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) }); - }); + console.log(`DEBUG: Got theme, posting to webview`) + provider.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) }) + }) - // If MCP Hub is already initialized, update the webview with current server list - console.log(`DEBUG: Getting MCP Hub`); - const mcpHub = provider.getMcpHub(); + console.log(`DEBUG: Getting MCP Hub`) + const mcpHub = provider.getMcpHub() if (mcpHub) { - console.log(`DEBUG: MCP Hub exists, getting servers`); - const servers = mcpHub!.getAllServers(); - console.log(`DEBUG: Got servers, posting to webview`); + console.log(`DEBUG: MCP Hub exists, getting servers`) + const servers = mcpHub!.getAllServers() + console.log(`DEBUG: Got servers, posting to webview`) provider.postMessageToWebview({ type: "mcpServers", mcpServers: servers, - }); - console.log(`DEBUG: Posted MCP servers to webview`); + }) + console.log(`DEBUG: Posted MCP servers to webview`) } else { - console.log(`DEBUG: MCP Hub is undefined, skipping server list update`); + console.log(`DEBUG: MCP Hub is undefined, skipping server list update`) } // Post last cached models in case the call to endpoint fails. @@ -261,23 +260,25 @@ export const webviewMessageHandler = async ( }) provider.isViewLaunched = true - break; + break case "fetchPackageManagerItems": if (packageManagerManager) { - console.log(`DEBUG: Handling explicit fetchPackageManagerItems message`); + console.log(`DEBUG: Handling explicit fetchPackageManagerItems message`) try { // Use non-null assertion to tell TypeScript that packageManagerManager is definitely not undefined here - console.log(`DEBUG: Before calling handlePackageManagerMessages for fetchPackageManagerItems`); - const result = await handlePackageManagerMessages(provider, message, packageManagerManager!); - console.log(`DEBUG: After calling handlePackageManagerMessages for fetchPackageManagerItems, result: ${result}`); - console.log(`DEBUG: Package manager message handled successfully: ${message.type}`); + console.log(`DEBUG: Before calling handlePackageManagerMessages for fetchPackageManagerItems`) + const result = await handlePackageManagerMessages(provider, message, packageManagerManager!) + console.log( + `DEBUG: After calling handlePackageManagerMessages for fetchPackageManagerItems, result: ${result}`, + ) + console.log(`DEBUG: Package manager message handled successfully: ${message.type}`) } catch (error) { - console.error(`DEBUG: Error handling package manager message: ${error}`); + console.error(`DEBUG: Error handling package manager message: ${error}`) } } else { - console.log(`DEBUG: packageManagerManager is undefined, skipping package manager message handling`); + console.log(`DEBUG: packageManagerManager is undefined, skipping package manager message handling`) } - break; + break case "newTask": // Code that should run in response to the hello message command //vscode.window.showInformationMessage(message.text!) @@ -1363,23 +1364,25 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() break } - -} - -// Handle package manager related messages -if (packageManagerManager && - (message.type === "packageManagerSources" || - message.type === "openExternal" || - message.type === "refreshPackageManagerSource")) { - try { - console.log(`DEBUG: Routing ${message.type} message to packageManagerMessageHandler`); - const result = await handlePackageManagerMessages(provider, message, packageManagerManager); - console.log(`DEBUG: Package manager message handled successfully: ${message.type}, result: ${result}`); - } catch (error) { - console.error(`DEBUG: Error handling package manager message: ${error}`); - } -} + } + // Handle package manager related messages + // Handle package manager messages + if ( + packageManagerManager && + (message.type === "packageManagerSources" || + message.type === "openExternal" || + message.type === "refreshPackageManagerSource" || + message.type === "filterPackageManagerItems") + ) { + try { + console.log(`DEBUG: Routing ${message.type} message to packageManagerMessageHandler`) + const result = await handlePackageManagerMessages(provider, message, packageManagerManager) + console.log(`DEBUG: Package manager message handled successfully: ${message.type}, result: ${result}`) + } catch (error) { + console.error(`DEBUG: Error handling package manager message: ${error}`) + } + } } const generateSystemPrompt = async (provider: ClineProvider, message: WebviewMessage) => { const { diff --git a/src/services/package-manager/__tests__/PackageManagerIntegration.test.ts b/src/services/package-manager/__tests__/PackageManagerIntegration.test.ts new file mode 100644 index 0000000000..a498e6974b --- /dev/null +++ b/src/services/package-manager/__tests__/PackageManagerIntegration.test.ts @@ -0,0 +1,229 @@ +import * as path from "path" +import * as vscode from "vscode" +import { PackageManagerManager } from "../PackageManagerManager" +import { MetadataScanner } from "../MetadataScanner" +import { handlePackageManagerMessages } from "../../../core/webview/packageManagerMessageHandler" +import { ClineProvider } from "../../../core/webview/ClineProvider" +import { WebviewMessage } from "../../../shared/WebviewMessage" +import { PackageManagerItem } from "../types" + +// Mock vscode +jest.mock("vscode") + +describe("Package Manager Integration", () => { + let manager: PackageManagerManager + let metadataScanner: MetadataScanner + let provider: ClineProvider + let postedMessages: any[] = [] + let templateItems: PackageManagerItem[] + + beforeAll(async () => { + // Load real data from template once + metadataScanner = new MetadataScanner() + const templatePath = path.resolve(__dirname, "../../../../package-manager-template") + templateItems = await metadataScanner.scanDirectory(templatePath, "https://example.com") + + // Debug log the loaded data + console.log("Loaded template items:", JSON.stringify(templateItems, null, 2)) + }) + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks() + postedMessages = [] + + // Create a real context-like object + const context = { + extensionPath: path.resolve(__dirname, "../../../../"), + globalStorageUri: { fsPath: path.resolve(__dirname, "../../../../mock/settings/path") }, + } as vscode.ExtensionContext + + // Create real instances + manager = new PackageManagerManager(context) + + // Set up manager with template data + manager["currentItems"] = [...templateItems] + + // Create a minimal provider mock that tracks posted messages + provider = { + postMessageToWebview: jest.fn((message) => { + postedMessages.push(message) + return Promise.resolve() + }), + postStateToWebview: jest.fn(() => Promise.resolve()), + getStateToPostToWebview: jest.fn(() => Promise.resolve({})), + contextProxy: { + getValue: jest.fn(), + setValue: jest.fn(), + }, + } as unknown as ClineProvider + }) + + describe("search functionality", () => { + it("should find exact match for 'data validator'", async () => { + // Search for exact match "data validator" + await handlePackageManagerMessages( + provider, + { + type: "filterPackageManagerItems", + filters: { + search: "data validator", + }, + } as WebviewMessage, + manager, + ) + + // Verify the filtered results in the state update + const stateUpdate = postedMessages.find( + (msg) => msg.type === "state" && msg.state?.packageManagerItems !== undefined, + ) + expect(stateUpdate).toBeDefined() + + const filteredItems = stateUpdate.state.packageManagerItems as PackageManagerItem[] + expect(filteredItems).toBeDefined() + + // Should only find the package containing "Data Validator" + expect(filteredItems.length).toBe(1) + expect(filteredItems[0].name).toBe("Data Platform Package") + + // Should not find other items containing just "data" or just "validator" + const otherDataItems = filteredItems.filter( + (item) => + item.name !== "Data Platform Package" && + (item.name.toLowerCase().includes("data") || item.description.toLowerCase().includes("data")), + ) + expect(otherDataItems).toHaveLength(0) + + // Verify the data validator component is present + const dataValidator = filteredItems[0].items?.find( + (item) => item.type === "mcp server" && item.metadata?.name === "Data Validator", + ) + expect(dataValidator).toBeDefined() + expect(dataValidator?.metadata?.description).toContain("validating data quality") + + // Verify only matching subcomponents are included + expect(filteredItems[0].items?.length).toBe(1) + expect(filteredItems[0].items?.[0].metadata?.name).toBe("Data Validator") + }) + + it("should handle partial matches", async () => { + // Test partial match "validator" + await handlePackageManagerMessages( + provider, + { + type: "filterPackageManagerItems", + filters: { + search: "validator", + }, + } as WebviewMessage, + manager, + ) + + const stateUpdate = postedMessages.find( + (msg) => msg.type === "state" && msg.state?.packageManagerItems !== undefined, + ) + const filteredItems = stateUpdate.state.packageManagerItems as PackageManagerItem[] + + expect(filteredItems.length).toBe(1) + expect(filteredItems[0].name).toBe("Data Platform Package") + }) + + it("should handle type filtering with search", async () => { + // Search with type filter + await handlePackageManagerMessages( + provider, + { + type: "filterPackageManagerItems", + filters: { + search: "data", + type: "mcp server", + }, + } as WebviewMessage, + manager, + ) + + const stateUpdate = postedMessages.find( + (msg) => msg.type === "state" && msg.state?.packageManagerItems !== undefined, + ) + const filteredItems = stateUpdate.state.packageManagerItems as PackageManagerItem[] + + // Should find: + // 1. Data Processor (standalone MCP server) + // 2. Data Platform Package (contains Data Validator MCP server) + expect(filteredItems.length).toBe(2) + + // Verify standalone MCP server + const standaloneServer = filteredItems.find((item) => item.type === "mcp server") + expect(standaloneServer).toBeDefined() + expect(standaloneServer?.name).toBe("Data Processor") + + // Verify package with MCP server + const packageWithServer = filteredItems.find((item) => item.type === "package") + expect(packageWithServer).toBeDefined() + expect(packageWithServer?.name).toBe("Data Platform Package") + expect(packageWithServer?.items?.length).toBe(1) + expect(packageWithServer?.items?.[0].metadata?.name).toBe("Data Validator") + + // Verify excluded items + const allItems = [...templateItems] + const excludedItems = allItems.filter( + (item) => !filteredItems.some((filtered) => filtered.name === item.name), + ) + + // Example MCP Server - right type but no "data" match + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Example MCP Server" })) + // File Analyzer - right type but no "data" match + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "File Analyzer MCP Server" })) + // Data Engineer - has "data" but wrong type + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Data Engineer" })) + }) + + it("should handle no matches", async () => { + // Search for non-existent term + await handlePackageManagerMessages( + provider, + { + type: "filterPackageManagerItems", + filters: { + search: "nonexistent", + }, + } as WebviewMessage, + manager, + ) + + const stateUpdate = postedMessages.find( + (msg) => msg.type === "state" && msg.state?.packageManagerItems !== undefined, + ) + const filteredItems = stateUpdate.state.packageManagerItems as PackageManagerItem[] + + expect(filteredItems).toHaveLength(0) + }) + + it("should be case insensitive", async () => { + // Test different cases + const searchTerms = ["DATA VALIDATOR", "data validator", "Data Validator", "dAtA vAlIdAtOr"] + + for (const term of searchTerms) { + postedMessages = [] // Reset for each test + await handlePackageManagerMessages( + provider, + { + type: "filterPackageManagerItems", + filters: { + search: term, + }, + } as WebviewMessage, + manager, + ) + + const stateUpdate = postedMessages.find( + (msg) => msg.type === "state" && msg.state?.packageManagerItems !== undefined, + ) + const filteredItems = stateUpdate.state.packageManagerItems as PackageManagerItem[] + + expect(filteredItems.length).toBe(1) + expect(filteredItems[0].name).toBe("Data Platform Package") + } + }) + }) +}) diff --git a/src/services/package-manager/__tests__/PackageManagerRealData.test.ts b/src/services/package-manager/__tests__/PackageManagerRealData.test.ts new file mode 100644 index 0000000000..c445c91305 --- /dev/null +++ b/src/services/package-manager/__tests__/PackageManagerRealData.test.ts @@ -0,0 +1,207 @@ +import * as path from "path" +import { PackageManagerManager } from "../PackageManagerManager" +import { MetadataScanner } from "../MetadataScanner" +import { PackageManagerItem } from "../types" + +describe("Package Manager with Real Data", () => { + let manager: PackageManagerManager + let templateItems: PackageManagerItem[] + + beforeAll(async () => { + // Load real data from template + const metadataScanner = new MetadataScanner() + const templatePath = path.resolve(__dirname, "../../../../package-manager-template") + templateItems = await metadataScanner.scanDirectory(templatePath, "https://example.com") + }) + + beforeEach(() => { + // Create manager with template data + manager = new PackageManagerManager({ + extensionPath: path.resolve(__dirname, "../../../../"), + globalStorageUri: { fsPath: path.resolve(__dirname, "../../../../mock/settings/path") }, + } as any) + manager["currentItems"] = [...templateItems] + }) + + describe("search functionality with real data", () => { + it("should match case-insensitive and whitespace-insensitive substrings", () => { + const searchTerms = [ + "Data Valid", // Should match "Data Validator" + "DATA VALID", // Should match "Data Validator" + "data valid", // Should match "Data Validator" + "validator", // Should match "Data Validator" + ] + + for (const term of searchTerms) { + const filteredItems = manager.filterItems(templateItems, { search: term }) + + // Should find Data Platform Package containing Data Validator + expect(filteredItems.length).toBe(1) + expect(filteredItems[0].name).toBe("Data Platform Package") + expect(filteredItems[0].items?.length).toBe(1) + expect(filteredItems[0].items?.[0].metadata?.name).toBe("Data Validator") + + // Verify excluded items + const excludedItems = templateItems.filter( + (item) => !filteredItems.some((filtered) => filtered.name === item.name), + ) + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Data Processor" })) + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Data Engineer" })) + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Example MCP Server" })) + + // Verify excluded subcomponents + const excludedSubcomponents = templateItems + .find((item) => item.name === "Data Platform Package") + ?.items?.filter( + (subItem) => + !filteredItems[0].items?.some( + (filtered) => filtered.metadata?.name === subItem.metadata?.name, + ), + ) + expect(excludedSubcomponents).toContainEqual( + expect.objectContaining({ + metadata: expect.objectContaining({ + name: "Data Platform Administrator", + }), + }), + ) + } + }) + + it("should find partial matches in standalone components", () => { + const searchTerms = [ + "data proc", // Should match "Data Processor" + "DATA PROC", // Should match "Data Processor" + "processor", // Should match "Data Processor" + ] + + for (const term of searchTerms) { + const filteredItems = manager.filterItems(templateItems, { search: term }) + + // Should find Data Processor as standalone component + expect(filteredItems.length).toBe(1) + expect(filteredItems[0].name).toBe("Data Processor") + expect(filteredItems[0].type).toBe("mcp server") + + // Verify excluded items + const excludedItems = templateItems.filter( + (item) => !filteredItems.some((filtered) => filtered.name === item.name), + ) + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Data Platform Package" })) + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Example MCP Server" })) + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Data Engineer" })) + } + }) + + it("should handle type filtering with search correctly", () => { + // Test with broad search term "data" and type "mcp server" + const filteredItems = manager.filterItems(templateItems, { + search: "data", + type: "mcp server", + }) + + // Should find two items because: + // 1. Data Processor - matches "data" and is an MCP server + // 2. Data Platform Package - contains Data Validator which is an MCP server and matches "data" + expect(filteredItems.length).toBe(2) + + // Verify Data Processor (standalone MCP server) + const standaloneServer = filteredItems.find((item) => item.type === "mcp server") + expect(standaloneServer).toBeDefined() + expect(standaloneServer?.name).toBe("Data Processor") + + // Verify Data Platform Package (contains matching MCP server) + const packageWithServer = filteredItems.find((item) => item.type === "package") + expect(packageWithServer).toBeDefined() + expect(packageWithServer?.name).toBe("Data Platform Package") + expect(packageWithServer?.items?.length).toBe(1) + expect(packageWithServer?.items?.[0].metadata?.name).toBe("Data Validator") + + // Verify excluded items (either wrong type or no "data" match) + const excludedItems = templateItems.filter( + (item) => !filteredItems.some((filtered) => filtered.name === item.name), + ) + // Example MCP Server - right type but no "data" match + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Example MCP Server" })) + // File Analyzer - right type but no "data" match + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "File Analyzer MCP Server" })) + // Data Engineer - has "data" but wrong type + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Data Engineer" })) + + // Verify excluded subcomponents (either wrong type or no "data" match) + const excludedSubcomponents = templateItems + .find((item) => item.name === "Data Platform Package") + ?.items?.filter( + (subItem) => + !filteredItems + .find((item) => item.name === "Data Platform Package") + ?.items?.some((filtered) => filtered.metadata?.name === subItem.metadata?.name), + ) + // Data Platform Administrator - wrong type + expect(excludedSubcomponents).toContainEqual( + expect.objectContaining({ + metadata: expect.objectContaining({ + name: "Data Platform Administrator", + }), + }), + ) + }) + + it("should handle specific search with type filtering", () => { + // Test with specific search "valid" and type "mcp server" + const filteredItems = manager.filterItems(templateItems, { + search: "valid", + type: "mcp server", + }) + + // Should only find Data Platform Package containing Data Validator + expect(filteredItems.length).toBe(1) + expect(filteredItems[0].name).toBe("Data Platform Package") + expect(filteredItems[0].items?.length).toBe(1) + expect(filteredItems[0].items?.[0].metadata?.name).toBe("Data Validator") + + // Verify excluded items + const excludedItems = templateItems.filter( + (item) => !filteredItems.some((filtered) => filtered.name === item.name), + ) + // Data Processor - right type but no "valid" match + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Data Processor" })) + // Example MCP Server - right type but no "valid" match + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Example MCP Server" })) + // Data Engineer - no "valid" match and wrong type + expect(excludedItems).toContainEqual(expect.objectContaining({ name: "Data Engineer" })) + }) + + it("should handle no matches by excluding everything", () => { + const filteredItems = manager.filterItems(templateItems, { search: "nonexistent" }) + expect(filteredItems).toHaveLength(0) + + // Verify all items were excluded + const excludedItems = templateItems.filter( + (item) => !filteredItems.some((filtered) => filtered.name === item.name), + ) + expect(excludedItems.length).toBe(templateItems.length) + }) + + it("should exclude non-matching types", () => { + const filteredItems = manager.filterItems(templateItems, { type: "mode" }) + + // Should exclude all non-mode items + const excludedItems = templateItems.filter( + (item) => !filteredItems.some((filtered) => filtered.name === item.name), + ) + expect(excludedItems).toContainEqual( + expect.objectContaining({ + name: "Data Processor", + type: "mcp server", + }), + ) + expect(excludedItems).toContainEqual( + expect.objectContaining({ + name: "Data Platform Package", + type: "package", + }), + ) + }) + }) +}) diff --git a/src/services/package-manager/__tests__/searchUtils.test.ts b/src/services/package-manager/__tests__/searchUtils.test.ts new file mode 100644 index 0000000000..5a1c5ec1cc --- /dev/null +++ b/src/services/package-manager/__tests__/searchUtils.test.ts @@ -0,0 +1,75 @@ +import { PackageManagerManager } from "../PackageManagerManager" +import * as vscode from "vscode" + +describe("containsSearchTerm", () => { + let manager: PackageManagerManager + + beforeEach(() => { + const context = { + globalStorageUri: { fsPath: "" }, + } as vscode.ExtensionContext + manager = new PackageManagerManager(context) + }) + + // Helper function to access the private containsSearchTerm function + const testSearch = (searchTerm: string | undefined, text: string | undefined): boolean => { + if (!text) return false + const normalizeText = (text: string) => text.toLowerCase().replace(/\s+/g, " ").trim() + const normalizedSearchTerm = searchTerm ? normalizeText(searchTerm) : "" + return normalizedSearchTerm === "" || normalizeText(text).includes(normalizedSearchTerm) + } + + describe("basic matching", () => { + it("should match exact strings", () => { + expect(testSearch("data validator", "Data Validator")).toBe(true) + expect(testSearch("DATA VALIDATOR", "Data Validator")).toBe(true) + expect(testSearch("Data Validator", "Data Validator")).toBe(true) + }) + + it("should match partial strings", () => { + expect(testSearch("valid", "Data Validator")).toBe(true) + expect(testSearch("validator", "Data Validator")).toBe(true) + expect(testSearch("data valid", "Data Validator")).toBe(true) + }) + + it("should not match words in wrong order", () => { + expect(testSearch("validator data", "Data Validator")).toBe(false) + expect(testSearch("validating data", "Data Validator")).toBe(false) + }) + }) + + describe("whitespace handling", () => { + it("should handle extra spaces", () => { + expect(testSearch("data validator", "Data Validator")).toBe(true) + expect(testSearch(" data validator ", "Data Validator")).toBe(true) + }) + + it("should handle different types of whitespace", () => { + expect(testSearch("data\tvalidator", "Data Validator")).toBe(true) + expect(testSearch("data\nvalidator", "Data Validator")).toBe(true) + }) + }) + + describe("case sensitivity", () => { + it("should be case insensitive", () => { + expect(testSearch("DATA VALIDATOR", "data validator")).toBe(true) + expect(testSearch("data validator", "DATA VALIDATOR")).toBe(true) + expect(testSearch("DaTa VaLiDaToR", "dAtA vAlIdAtOr")).toBe(true) + }) + }) + + describe("empty values", () => { + it("should handle empty search term", () => { + expect(testSearch("", "Data Validator")).toBe(true) + }) + + it("should handle empty text", () => { + expect(testSearch("data validator", "")).toBe(false) + }) + + it("should handle undefined values", () => { + expect(testSearch(undefined as any, "Data Validator")).toBe(true) + expect(testSearch("data validator", undefined as any)).toBe(false) + }) + }) +}) diff --git a/src/services/package-manager/types.ts b/src/services/package-manager/types.ts index fadedcfdb1..86c0d11a72 100644 --- a/src/services/package-manager/types.ts +++ b/src/services/package-manager/types.ts @@ -1,3 +1,16 @@ +/** + * Information about why an item matched search/filter criteria + */ +export interface MatchInfo { + matched: boolean + matchReason?: { + nameMatch?: boolean + descriptionMatch?: boolean + tagMatch?: boolean + hasMatchingSubcomponents?: boolean + } +} + /** * Supported component types */ @@ -26,16 +39,27 @@ export interface ComponentMetadata extends BaseMetadata { } /** - * Package metadata with optional external items + * Package metadata with optional subcomponents */ export interface PackageMetadata extends ComponentMetadata { type: "package" items?: { type: ComponentType path: string + metadata?: ComponentMetadata }[] } +/** + * Subcomponent metadata with parent reference + */ +export interface SubcomponentMetadata extends ComponentMetadata { + parentPackage: { + name: string + path: string + } +} + /** * Represents an individual package manager item */ @@ -51,7 +75,14 @@ export interface PackageManagerItem { version?: string lastUpdated?: string sourceUrl?: string - items?: { type: ComponentType; path: string }[] + items?: { + type: ComponentType + path: string + metadata?: ComponentMetadata + lastUpdated?: string + matchInfo?: MatchInfo // Add match information for subcomponents + }[] + matchInfo?: MatchInfo // Add match information for the package itself } /** diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 889e695a85..0ae299addd 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -124,6 +124,7 @@ export interface WebviewMessage { | "toggleApiConfigPin" | "packageManagerSources" | "fetchPackageManagerItems" + | "filterPackageManagerItems" | "packageManagerButtonClicked" | "refreshPackageManagerSource" | "repositoryRefreshComplete" @@ -154,6 +155,7 @@ export interface WebviewMessage { requestId?: string ids?: string[] sources?: PackageManagerSource[] + filters?: { type?: string; search?: string; tags?: string[] } url?: string // For openExternal } diff --git a/webview-ui/src/components/package-manager/PackageManagerView.tsx b/webview-ui/src/components/package-manager/PackageManagerView.tsx index d03f90fb3f..32c333846a 100644 --- a/webview-ui/src/components/package-manager/PackageManagerView.tsx +++ b/webview-ui/src/components/package-manager/PackageManagerView.tsx @@ -6,169 +6,72 @@ import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" import { PackageManagerItem, PackageManagerSource } from "../../../../src/services/package-manager/types" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk" +import { isFilterActive as checkFilterActive, getDisplayedItems as filterAndSortItems } from "./selectors" +import { PackageManagerItemCard } from "./components/PackageManagerItemCard" interface PackageManagerViewProps { onDone?: () => void } -interface PackageManagerItemCardProps { - item: PackageManagerItem - filters: { type: string; search: string; tags: string[] } - setFilters: React.Dispatch> - activeTab: "browse" | "sources" - setActiveTab: React.Dispatch> -} - -const PackageManagerItemCard: React.FC = ({ - item, - filters, - setFilters, - activeTab, - setActiveTab, -}) => { - const isValidUrl = (urlString: string): boolean => { - try { - new URL(urlString) - return true - } catch (e) { - return false - } - } - - const getTypeLabel = (type: string) => { - switch (type) { - case "mode": - return "Mode" - case "mcp server": - return "MCP Server" - case "prompt": - return "Prompt" - case "package": - return "Package" - default: - return "Other" - } - } - - const getTypeColor = (type: string) => { - switch (type) { - case "mode": - return "bg-blue-600" - case "mcp server": - return "bg-green-600" - case "prompt": - return "bg-purple-600" - case "package": - return "bg-orange-600" - default: - return "bg-gray-600" - } - } - - const handleOpenUrl = () => { - const urlToOpen = item.sourceUrl && isValidUrl(item.sourceUrl) ? item.sourceUrl : item.repoUrl - vscode.postMessage({ - type: "openExternal", - url: urlToOpen, - }) - } - - 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", - })} - - )} -
- - -
-
- ) -} - const PackageManagerView: React.FC = ({ onDone }) => { const { packageManagerSources, setPackageManagerSources } = useExtensionState() - const [items, setItems] = useState([]) + + // Core state + const [allItems, setAllItems] = useState([]) const [activeTab, setActiveTab] = useState<"browse" | "sources">("browse") const [refreshingUrls, setRefreshingUrls] = useState([]) - // Clear items when switching to sources tab - useEffect(() => { - if (activeTab === "sources") { - setItems([]) - } - }, [activeTab]) + // Filter and sort state const [filters, setFilters] = useState({ type: "", search: "", tags: [] as string[] }) + const [sortConfig, setSortConfig] = useState({ by: "name", order: "asc" as "asc" | "desc" }) const [tagSearch, setTagSearch] = useState("") const [isTagInputActive, setIsTagInputActive] = useState(false) - const [sortBy, setSortBy] = useState("name") - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc") + + // Loading state const [isFetching, setIsFetching] = useState(false) const fetchTimeoutRef = useRef() + // Compute displayed items + const displayedItems = useMemo( + () => filterAndSortItems(allItems, filters, sortConfig), + [allItems, filters, sortConfig], + ) + + // Sort items + const sortedItems = useMemo( + () => + [...displayedItems].sort((a, b) => { + let comparison = 0 + + switch (sortConfig.by) { + 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 sortConfig.order === "asc" ? comparison : -comparison + }), + [displayedItems, sortConfig], + ) + + const allTags = useMemo(() => { + const tagSet = new Set() + allItems.forEach((item) => { + if (item.tags) { + item.tags.forEach((tag) => tagSet.add(tag)) + } + }) + return Array.from(tagSet).sort() + }, [allItems]) + const fetchPackageManagerItems = useCallback(() => { // Clear any pending fetch timeout if (fetchTimeoutRef.current) { @@ -206,6 +109,7 @@ const PackageManagerView: React.FC = ({ onDone }) => { fetchPackageManagerItems() } }, [packageManagerSources, fetchPackageManagerItems, activeTab]) + useEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data @@ -217,11 +121,11 @@ const PackageManagerView: React.FC = ({ onDone }) => { } setIsFetching(false) - // Only update items if they're present in the state + // Update items when we receive filtered items from the backend if (message.state?.packageManagerItems !== undefined) { const receivedItems = message.state.packageManagerItems || [] console.log("Received package manager items:", receivedItems.length) - setItems([...receivedItems]) + setAllItems(receivedItems) } } @@ -252,62 +156,27 @@ const PackageManagerView: React.FC = ({ onDone }) => { clearTimeout(fetchTimeoutRef.current) } } - }, [fetchPackageManagerItems]) // Include fetchPackageManagerItems in dependencies + }, [fetchPackageManagerItems]) - const filteredItems = items.filter((item) => { - if (filters.type && item.type !== filters.type) { - return false + // Debounce filter requests + useEffect(() => { + if (!checkFilterActive(filters)) { + return } - 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) + const debounceTimeout = setTimeout(() => { + vscode.postMessage({ + type: "filterPackageManagerItems", + filters: { + type: filters.type || undefined, + search: filters.search || undefined, + tags: filters.tags.length > 0 ? filters.tags : undefined, + }, + }) + }, 300) // 300ms debounce delay - if (!nameMatch && !descMatch && !authorMatch) { - return false - } - } - - if (filters.tags.length > 0) { - if (!item.tags || !item.tags.some((tag) => filters.tags.includes(tag))) { - return false - } - } - - return true - }) - - 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 - }) - - const allTags = useMemo(() => { - const tagSet = new Set() - items.forEach((item) => { - if (item.tags) { - item.tags.forEach((tag) => tagSet.add(tag)) - } - }) - return Array.from(tagSet).sort() - }, [items]) + return () => clearTimeout(debounceTimeout) + }, [filters]) return ( @@ -367,17 +236,22 @@ const PackageManagerView: React.FC = ({ onDone }) => {
@@ -482,7 +356,9 @@ const PackageManagerView: React.FC = ({ onDone }) => {

- {`${sortedItems.length} items found`} + {checkFilterActive(filters) + ? `${sortedItems.length} items found (filtered)` + : `${sortedItems.length} items total`}

+
+
{children}
+
+
+ ) +} diff --git a/webview-ui/src/components/package-manager/components/PackageManagerItemCard.tsx b/webview-ui/src/components/package-manager/components/PackageManagerItemCard.tsx new file mode 100644 index 0000000000..4bb066f886 --- /dev/null +++ b/webview-ui/src/components/package-manager/components/PackageManagerItemCard.tsx @@ -0,0 +1,161 @@ +import React, { useMemo } from "react" +import { Button } from "@/components/ui/button" +import { PackageManagerItem } from "../../../../../src/services/package-manager/types" +import { vscode } from "@/utils/vscode" +import { groupItemsByType, GroupedItems } from "../utils/grouping" +import { ExpandableSection } from "./ExpandableSection" +import { TypeGroup } from "./TypeGroup" + +interface PackageManagerItemCardProps { + item: PackageManagerItem + filters: { type: string; search: string; tags: string[] } + setFilters: React.Dispatch> + activeTab: "browse" | "sources" + setActiveTab: React.Dispatch> +} + +export const PackageManagerItemCard: React.FC = ({ + item, + filters, + setFilters, + activeTab, + setActiveTab, +}) => { + const isValidUrl = (urlString: string): boolean => { + try { + new URL(urlString) + return true + } catch (e) { + return false + } + } + + const getTypeLabel = (type: string) => { + switch (type) { + case "mode": + return "Mode" + case "mcp server": + return "MCP Server" + case "prompt": + return "Prompt" + case "package": + return "Package" + default: + return "Other" + } + } + + const getTypeColor = (type: string) => { + switch (type) { + case "mode": + return "bg-blue-600" + case "mcp server": + return "bg-green-600" + case "prompt": + return "bg-purple-600" + case "package": + return "bg-orange-600" + default: + return "bg-gray-600" + } + } + + const handleOpenUrl = () => { + const urlToOpen = item.sourceUrl && isValidUrl(item.sourceUrl) ? item.sourceUrl : item.repoUrl + vscode.postMessage({ + type: "openExternal", + url: urlToOpen, + }) + } + + // Group items by type + const groupedItems = useMemo(() => { + if (!item.items?.length) { + return null + } + return groupItemsByType(item.items) + }, [item.items]) as GroupedItems | null + + 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", + })} + + )} +
+ + +
+ + {groupedItems && ( + + {Object.entries(groupedItems).map(([type, group]) => ( + + ))} + + )} +
+ ) +} diff --git a/webview-ui/src/components/package-manager/components/TypeGroup.tsx b/webview-ui/src/components/package-manager/components/TypeGroup.tsx new file mode 100644 index 0000000000..a8bd3e2d9e --- /dev/null +++ b/webview-ui/src/components/package-manager/components/TypeGroup.tsx @@ -0,0 +1,48 @@ +import React from "react" +import { cn } from "@/lib/utils" +import { formatItemText } from "../utils/grouping" + +interface TypeGroupProps { + type: string + items: Array<{ + name: string + description?: string + metadata?: any + path?: string + }> + className?: string +} + +export const TypeGroup: React.FC = ({ type, items, className }) => { + const getTypeLabel = (type: string) => { + switch (type) { + case "mode": + return "Modes" + case "mcp server": + return "MCP Servers" + case "prompt": + return "Prompts" + case "package": + return "Packages" + default: + return `${type.charAt(0).toUpperCase()}${type.slice(1)}s` + } + } + + if (!items?.length) { + return null + } + + return ( +
+

{getTypeLabel(type)}

+
    + {items.map((item, index) => ( +
  1. + {formatItemText(item)} +
  2. + ))} +
+
+ ) +} diff --git a/webview-ui/src/components/package-manager/components/__tests__/ExpandableSection.test.tsx b/webview-ui/src/components/package-manager/components/__tests__/ExpandableSection.test.tsx new file mode 100644 index 0000000000..2c49f206b8 --- /dev/null +++ b/webview-ui/src/components/package-manager/components/__tests__/ExpandableSection.test.tsx @@ -0,0 +1,91 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { ExpandableSection } from "../ExpandableSection" + +describe("ExpandableSection", () => { + const defaultProps = { + title: "Test Section", + children:
Test Content
, + } + + it("should render with default state", () => { + render() + + expect(screen.getByText("Test Section")).toBeInTheDocument() + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false") + + const content = screen.getByRole("region") + expect(content).toHaveClass("max-h-0") + expect(content).toHaveClass("opacity-0") + }) + + it("should expand when clicked", () => { + render() + + const button = screen.getByRole("button") + fireEvent.click(button) + + expect(button).toHaveAttribute("aria-expanded", "true") + + const content = screen.getByRole("region") + expect(content).toHaveClass("max-h-[500px]") + expect(content).toHaveClass("opacity-100") + }) + + it("should render expanded by default when defaultExpanded is true", () => { + render() + + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true") + + const content = screen.getByRole("region") + expect(content).toHaveClass("max-h-[500px]") + expect(content).toHaveClass("opacity-100") + }) + + it("should toggle expansion state on button click", () => { + render() + + const button = screen.getByRole("button") + + // Initial state + expect(button).toHaveAttribute("aria-expanded", "false") + + // First click - expand + fireEvent.click(button) + expect(button).toHaveAttribute("aria-expanded", "true") + + // Second click - collapse + fireEvent.click(button) + expect(button).toHaveAttribute("aria-expanded", "false") + }) + + it("should apply custom className", () => { + const customClass = "custom-test-class" + render() + + const section = screen.getByRole("region").parentElement + expect(section).toHaveClass(customClass) + }) + + it("should have proper accessibility attributes", () => { + render() + + const button = screen.getByRole("button") + const region = screen.getByRole("region") + + expect(button).toHaveAttribute("aria-expanded") + expect(button).toHaveAttribute("aria-controls", "details-content") + expect(region).toHaveAttribute("aria-labelledby", "details-button") + }) + + it("should render children content", () => { + const testContent = "Special test content" + render( + +
{testContent}
+
, + ) + + expect(screen.getByText(testContent)).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/package-manager/components/__tests__/PackageManagerItemCard.test.tsx b/webview-ui/src/components/package-manager/components/__tests__/PackageManagerItemCard.test.tsx new file mode 100644 index 0000000000..087b13b0a7 --- /dev/null +++ b/webview-ui/src/components/package-manager/components/__tests__/PackageManagerItemCard.test.tsx @@ -0,0 +1,142 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { PackageManagerItemCard } from "../PackageManagerItemCard" +import { PackageManagerItem } from "../../../../../../src/services/package-manager/types" + +// Mock vscode API +const mockPostMessage = jest.fn() +jest.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: (msg: any) => mockPostMessage(msg), + }, +})) + +describe("PackageManagerItemCard", () => { + const mockItem: PackageManagerItem = { + name: "Test Package", + description: "A test package", + type: "package", + repoUrl: "test-url", + url: "test-url", + tags: ["test", "mock"], + items: [ + { + type: "mcp server", + path: "test/path", + metadata: { + name: "Test Server", + description: "A test server", + version: "1.0.0", + type: "mcp server", + }, + }, + { + type: "mode", + path: "test/path2", + metadata: { + name: "Test Mode", + description: "A test mode", + version: "2.0.0", + type: "mode", + }, + }, + ], + version: "1.0.0", + author: "Test Author", + lastUpdated: "2025-04-13", + } + + const defaultProps = { + item: mockItem, + filters: { type: "", search: "", tags: [] }, + setFilters: jest.fn(), + activeTab: "browse" as const, + setActiveTab: jest.fn(), + } + + beforeEach(() => { + mockPostMessage.mockClear() + }) + + it("should render basic item information", () => { + render() + + expect(screen.getByText("Test Package")).toBeInTheDocument() + expect(screen.getByText("A test package")).toBeInTheDocument() + expect(screen.getByText("by Test Author")).toBeInTheDocument() + expect(screen.getByText("Package")).toBeInTheDocument() + }) + + it("should render tags", () => { + render() + + expect(screen.getByText("test")).toBeInTheDocument() + expect(screen.getByText("mock")).toBeInTheDocument() + }) + + it("should handle tag clicks", () => { + const setFilters = jest.fn() + render() + + fireEvent.click(screen.getByText("test")) + expect(setFilters).toHaveBeenCalledWith( + expect.objectContaining({ + tags: ["test"], + }), + ) + }) + + it("should render version and date information", () => { + render() + + expect(screen.getByText("1.0.0")).toBeInTheDocument() + // Use a regex to match the date since it depends on the timezone + expect(screen.getByText(/Apr \d{1,2}, 2025/)).toBeInTheDocument() + }) + + it("should handle source URL click", () => { + render() + + fireEvent.click(screen.getByText("Source")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: "test-url", + }) + }) + + describe("Details section", () => { + it("should render expandable details section when item has subcomponents", () => { + render() + + expect(screen.getByText("Details")).toBeInTheDocument() + }) + + it("should not render details section when item has no subcomponents", () => { + const itemWithoutItems = { ...mockItem, items: [] } + render() + + expect(screen.queryByText("Details")).not.toBeInTheDocument() + }) + + it("should show grouped items when expanded", () => { + render() + + fireEvent.click(screen.getByText("Details")) + + expect(screen.getByText("MCP Servers")).toBeInTheDocument() + expect(screen.getByText("Modes")).toBeInTheDocument() + expect(screen.getByText("Test Server - A test server")).toBeInTheDocument() + expect(screen.getByText("Test Mode - A test mode")).toBeInTheDocument() + }) + + it("should maintain proper order of items within groups", () => { + render() + + fireEvent.click(screen.getByText("Details")) + + const items = screen.getAllByRole("listitem") + expect(items[0]).toHaveTextContent("Test Server") + expect(items[1]).toHaveTextContent("Test Mode") + }) + }) +}) diff --git a/webview-ui/src/components/package-manager/components/__tests__/TypeGroup.test.tsx b/webview-ui/src/components/package-manager/components/__tests__/TypeGroup.test.tsx new file mode 100644 index 0000000000..57d5ac23a6 --- /dev/null +++ b/webview-ui/src/components/package-manager/components/__tests__/TypeGroup.test.tsx @@ -0,0 +1,83 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import { TypeGroup } from "../TypeGroup" + +describe("TypeGroup", () => { + const mockItems = [ + { + name: "Test Item 1", + description: "Description 1", + path: "test/path/1", + }, + { + name: "Test Item 2", + description: "Description 2", + path: "test/path/2", + }, + ] + + it("should render type header and items", () => { + render() + + expect(screen.getByText("MCP Servers")).toBeInTheDocument() + expect(screen.getByText("Test Item 1 - Description 1")).toBeInTheDocument() + expect(screen.getByText("Test Item 2 - Description 2")).toBeInTheDocument() + }) + + it("should format different types correctly", () => { + const types = [ + { input: "mode", expected: "Modes" }, + { input: "mcp server", expected: "MCP Servers" }, + { input: "prompt", expected: "Prompts" }, + { input: "package", expected: "Packages" }, + { input: "custom", expected: "Customs" }, + ] + + types.forEach(({ input, expected }) => { + const { unmount } = render() + expect(screen.getByText(expected)).toBeInTheDocument() + unmount() + }) + }) + + it("should handle items without descriptions", () => { + const itemsWithoutDesc = [{ name: "Test Item", path: "test/path" }] + + render() + expect(screen.getByText("Test Item")).toBeInTheDocument() + }) + + it("should not render when items array is empty", () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it("should not render when items is undefined", () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it("should apply custom className", () => { + const customClass = "custom-test-class" + render() + + const container = screen.getByRole("heading").parentElement + expect(container).toHaveClass(customClass) + }) + + it("should render items in a numbered list", () => { + render() + + const list = screen.getByRole("list") + expect(list).toHaveClass("list-decimal") + expect(list.children).toHaveLength(2) + }) + + it("should show path as title attribute", () => { + render() + + const items = screen.getAllByRole("listitem") + expect(items[0]).toHaveAttribute("title", "test/path/1") + expect(items[1]).toHaveAttribute("title", "test/path/2") + }) +}) diff --git a/webview-ui/src/components/package-manager/selectors.ts b/webview-ui/src/components/package-manager/selectors.ts new file mode 100644 index 0000000000..13ce2aaf86 --- /dev/null +++ b/webview-ui/src/components/package-manager/selectors.ts @@ -0,0 +1,83 @@ +import { PackageManagerItem } from "../../../../src/services/package-manager/types" + +interface Filters { + type: string + search: string + tags: string[] +} + +interface SortConfig { + by: string + order: "asc" | "desc" +} + +export const isFilterActive = (filters: Filters): boolean => { + return !!(filters.type || filters.search || filters.tags.length > 0) +} + +export const filterItems = (items: PackageManagerItem[], filters: Filters): PackageManagerItem[] => { + if (!isFilterActive(filters)) { + return items + } + + return items.filter((item) => { + // Type filter + if (filters.type && item.type !== filters.type) { + return false + } + + // Search filter + if (filters.search) { + const searchTerm = filters.search.toLowerCase() + const matchesSearch = + item.name.toLowerCase().includes(searchTerm) || + (item.description || "").toLowerCase().includes(searchTerm) || + (item.author || "").toLowerCase().includes(searchTerm) + + if (!matchesSearch) { + return false + } + } + + // Tags filter + if (filters.tags.length > 0) { + const hasMatchingTag = item.tags?.some((tag) => filters.tags.includes(tag)) + if (!hasMatchingTag) { + return false + } + } + + return true + }) +} + +export const sortItems = (items: PackageManagerItem[], config: SortConfig): PackageManagerItem[] => { + return [...items].sort((a, b) => { + let comparison = 0 + + switch (config.by) { + 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 config.order === "asc" ? comparison : -comparison + }) +} + +export const getDisplayedItems = ( + items: PackageManagerItem[], + filters: Filters, + sortConfig: SortConfig, +): PackageManagerItem[] => { + const filteredItems = filterItems(items, filters) + return sortItems(filteredItems, sortConfig) +} diff --git a/webview-ui/src/components/package-manager/utils/__tests__/grouping.test.ts b/webview-ui/src/components/package-manager/utils/__tests__/grouping.test.ts new file mode 100644 index 0000000000..fe025bc6d9 --- /dev/null +++ b/webview-ui/src/components/package-manager/utils/__tests__/grouping.test.ts @@ -0,0 +1,120 @@ +import { groupItemsByType, formatItemText, getTotalItemCount, getUniqueTypes } from "../grouping" +import { PackageManagerItem } from "../../../../../../src/services/package-manager/types" + +describe("grouping utilities", () => { + const mockItems = [ + { + type: "mcp server", + path: "servers/test-server", + metadata: { + name: "Test Server", + description: "A test server", + version: "1.0.0", + }, + }, + { + type: "mode", + path: "modes/test-mode", + metadata: { + name: "Test Mode", + description: "A test mode", + version: "2.0.0", + }, + }, + { + type: "mcp server", + path: "servers/another-server", + metadata: { + name: "Another Server", + description: "Another test server", + version: "1.1.0", + }, + }, + ] as PackageManagerItem["items"] + + describe("groupItemsByType", () => { + it("should group items by type correctly", () => { + const result = groupItemsByType(mockItems) + + expect(Object.keys(result)).toHaveLength(2) + expect(result["mcp server"].items).toHaveLength(2) + expect(result["mode"].items).toHaveLength(1) + + expect(result["mcp server"].items[0].name).toBe("Test Server") + expect(result["mode"].items[0].name).toBe("Test Mode") + }) + + it("should handle empty items array", () => { + expect(groupItemsByType([])).toEqual({}) + expect(groupItemsByType(undefined)).toEqual({}) + }) + + it("should handle items with missing metadata", () => { + const itemsWithMissingData = [ + { + type: "mcp server", + path: "test/path", + }, + ] as PackageManagerItem["items"] + + const result = groupItemsByType(itemsWithMissingData) + expect(result["mcp server"].items[0].name).toBe("Unnamed item") + }) + + it("should preserve item order within groups", () => { + const result = groupItemsByType(mockItems) + const servers = result["mcp server"].items + + expect(servers[0].name).toBe("Test Server") + expect(servers[1].name).toBe("Another Server") + }) + + it("should skip items without type", () => { + const itemsWithoutType = [ + { + path: "test/path", + metadata: { name: "Test" }, + }, + ] as PackageManagerItem["items"] + + const result = groupItemsByType(itemsWithoutType) + expect(Object.keys(result)).toHaveLength(0) + }) + }) + + describe("formatItemText", () => { + it("should format item with name and description", () => { + const item = { name: "Test", description: "Description" } + expect(formatItemText(item)).toBe("Test - Description") + }) + + it("should handle items without description", () => { + const item = { name: "Test" } + expect(formatItemText(item)).toBe("Test") + }) + }) + + describe("getTotalItemCount", () => { + it("should count total items across all groups", () => { + const groups = groupItemsByType(mockItems) + expect(getTotalItemCount(groups)).toBe(3) + }) + + it("should handle empty groups", () => { + expect(getTotalItemCount({})).toBe(0) + }) + }) + + describe("getUniqueTypes", () => { + it("should return sorted array of unique types", () => { + const groups = groupItemsByType(mockItems) + const types = getUniqueTypes(groups) + + expect(types).toEqual(["mcp server", "mode"]) + }) + + it("should handle empty groups", () => { + expect(getUniqueTypes({})).toEqual([]) + }) + }) +}) diff --git a/webview-ui/src/components/package-manager/utils/grouping.ts b/webview-ui/src/components/package-manager/utils/grouping.ts new file mode 100644 index 0000000000..cd8cf0219d --- /dev/null +++ b/webview-ui/src/components/package-manager/utils/grouping.ts @@ -0,0 +1,76 @@ +import { PackageManagerItem } from "../../../../../src/services/package-manager/types" + +export interface GroupedItems { + [type: string]: { + type: string + items: Array<{ + name: string + description?: string + metadata?: any + path?: string + }> + } +} + +/** + * Groups package items by their type + * @param items Array of items to group + * @returns Object with items grouped by type + */ +export function groupItemsByType(items: PackageManagerItem["items"] = []): GroupedItems { + if (!items?.length) { + return {} + } + + return items.reduce((groups: GroupedItems, item) => { + if (!item.type) { + return groups + } + + if (!groups[item.type]) { + groups[item.type] = { + type: item.type, + items: [], + } + } + + groups[item.type].items.push({ + name: item.metadata?.name || "Unnamed item", + description: item.metadata?.description, + metadata: item.metadata, + path: item.path, + }) + + return groups + }, {}) +} + +/** + * Gets a formatted string representation of an item + * @param item The item to format + * @returns Formatted string with name and description + */ +export function formatItemText(item: { name: string; description?: string }): string { + if (!item.description) { + return item.name + } + return `${item.name} - ${item.description}` +} + +/** + * Gets the total number of items across all groups + * @param groups Grouped items object + * @returns Total number of items + */ +export function getTotalItemCount(groups: GroupedItems): number { + return Object.values(groups).reduce((total, group) => total + group.items.length, 0) +} + +/** + * Gets an array of unique types from the grouped items + * @param groups Grouped items object + * @returns Array of type strings + */ +export function getUniqueTypes(groups: GroupedItems): string[] { + return Object.keys(groups).sort() +}