package details visible in expandable section

This commit is contained in:
Smartsheet-JB-Brown 2025-04-14 06:54:17 -07:00
parent 965a7ae90c
commit 81304a22ea
19 changed files with 2027 additions and 261 deletions

View file

@ -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 {

View file

@ -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 = <K extends keyof GlobalState>(key: K) => provider.contextProxy.getValue(key)
const updateGlobalState = async <K extends keyof GlobalState>(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 {

View file

@ -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")
}
})
})
})

View file

@ -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",
}),
)
})
})
})

View file

@ -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)
})
})
})

View file

@ -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
}
/**

View file

@ -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
}

View file

@ -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<React.SetStateAction<{ type: string; search: string; tags: string[] }>>
activeTab: "browse" | "sources"
setActiveTab: React.Dispatch<React.SetStateAction<"browse" | "sources">>
}
const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
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 (
<div className="border border-vscode-panel-border rounded-md p-4 bg-vscode-panel-background">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold text-vscode-foreground">{item.name}</h3>
{item.author && <p className="text-sm text-vscode-descriptionForeground">{`by ${item.author}`}</p>}
</div>
<span className={`px-2 py-1 text-xs text-white rounded-full ${getTypeColor(item.type)}`}>
{getTypeLabel(item.type)}
</span>
</div>
<p className="my-2 text-vscode-foreground">{item.description}</p>
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1 my-2">
{item.tags.map((tag) => (
<button
key={tag}
className={`px-2 py-1 text-xs rounded-full hover:bg-vscode-button-secondaryBackground ${
filters.tags.includes(tag)
? "bg-vscode-button-background text-vscode-button-foreground"
: "bg-vscode-badge-background text-vscode-badge-foreground"
}`}
onClick={() => {
if (filters.tags.includes(tag)) {
setFilters({
...filters,
tags: filters.tags.filter((t) => t !== tag),
})
} else {
setFilters({
...filters,
tags: [...filters.tags, tag],
})
if (activeTab !== "browse") {
setActiveTab("browse")
}
}
}}
title={filters.tags.includes(tag) ? `Remove tag filter: ${tag}` : `Filter by tag: ${tag}`}>
{tag}
</button>
))}
</div>
)}
<div className="flex justify-between items-center mt-4">
<div className="flex items-center gap-4 text-sm text-vscode-descriptionForeground">
{item.version && (
<span className="flex items-center">
<span className="codicon codicon-tag mr-1"></span>
{item.version}
</span>
)}
{item.lastUpdated && (
<span className="flex items-center">
<span className="codicon codicon-calendar mr-1"></span>
{new Date(item.lastUpdated).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
)}
</div>
<Button onClick={handleOpenUrl}>
<span className="codicon codicon-link-external mr-2"></span>
{item.sourceUrl ? "View" : item.sourceName || "Source"}
</Button>
</div>
</div>
)
}
const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
const { packageManagerSources, setPackageManagerSources } = useExtensionState()
const [items, setItems] = useState<PackageManagerItem[]>([])
// Core state
const [allItems, setAllItems] = useState<PackageManagerItem[]>([])
const [activeTab, setActiveTab] = useState<"browse" | "sources">("browse")
const [refreshingUrls, setRefreshingUrls] = useState<string[]>([])
// 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<NodeJS.Timeout>()
// 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<string>()
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<PackageManagerViewProps> = ({ onDone }) => {
fetchPackageManagerItems()
}
}, [packageManagerSources, fetchPackageManagerItems, activeTab])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
@ -217,11 +121,11 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ 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<PackageManagerViewProps> = ({ 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<string>()
items.forEach((item) => {
if (item.tags) {
item.tags.forEach((tag) => tagSet.add(tag))
}
})
return Array.from(tagSet).sort()
}, [items])
return () => clearTimeout(debounceTimeout)
}, [filters])
return (
<Tab>
@ -367,17 +236,22 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
<div className="whitespace-nowrap">
<label className="mr-2">Sort by:</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
value={sortConfig.by}
onChange={(e) => setSortConfig({ ...sortConfig, by: e.target.value })}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded mr-2">
<option value="name">Name</option>
<option value="author">Author</option>
<option value="lastUpdated">Last Updated</option>
</select>
<button
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
onClick={() =>
setSortConfig({
...sortConfig,
order: sortConfig.order === "asc" ? "desc" : "asc",
})
}
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded">
{sortOrder === "asc" ? "↑" : "↓"}
{sortConfig.order === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
@ -482,7 +356,9 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
<div>
<div className="flex justify-between mb-4">
<p className="text-vscode-descriptionForeground">
{`${sortedItems.length} items found`}
{checkFilterActive(filters)
? `${sortedItems.length} items found (filtered)`
: `${sortedItems.length} items total`}
</p>
<Button onClick={fetchPackageManagerItems} size="sm" disabled={isFetching}>
<span
@ -512,7 +388,6 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
setRefreshingUrls={setRefreshingUrls}
onSourcesChange={(sources) => {
setPackageManagerSources(sources)
setItems([]) // Clear items when sources change
vscode.postMessage({ type: "packageManagerSources", sources })
}}
/>

View file

@ -0,0 +1,323 @@
import { render, screen, fireEvent, act } from "@testing-library/react"
import PackageManagerView from "../PackageManagerView"
import { ComponentMetadata, PackageManagerItem } from "../../../../../src/services/package-manager/types"
// Mock vscode API
const mockPostMessage = jest.fn()
jest.mock("@/utils/vscode", () => ({
vscode: {
postMessage: (msg: any) => mockPostMessage(msg),
},
}))
// Mock ExtensionStateContext
jest.mock("../../../context/ExtensionStateContext", () => ({
useExtensionState: () => ({
packageManagerSources: [{ url: "test-url", enabled: true }],
setPackageManagerSources: jest.fn(),
}),
}))
const mockMetadata: ComponentMetadata = {
name: "Test Server",
description: "A test server",
type: "mcp server",
version: "1.0.0",
}
describe("PackageManagerView", () => {
const mockItems: 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: mockMetadata,
},
],
},
{
name: "Another Package",
description: "Another test package",
type: "package",
repoUrl: "test-url-2",
url: "test-url-2",
tags: ["test", "another"],
},
]
beforeEach(() => {
mockPostMessage.mockClear()
// Reset window event listeners
window.removeEventListener = jest.fn()
window.addEventListener = jest.fn()
})
it("should initialize with empty states", () => {
render(<PackageManagerView />)
// Should show empty state message
expect(screen.getByText("No package manager items found")).toBeInTheDocument()
// Should have sent initial fetch request
expect(mockPostMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
forceRefresh: true,
})
})
it("should handle state updates correctly", async () => {
render(<PackageManagerView />)
// Initial fetch request sets isFetching to true
expect(mockPostMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
forceRefresh: true,
})
// Simulate receiving items from backend during initial fetch
act(() => {
// First simulate the fetch request
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
mockEventListener({
data: {
type: "packageManagerButtonClicked",
},
})
// Then simulate receiving items
mockEventListener({
data: {
type: "state",
state: { packageManagerItems: mockItems },
},
})
})
// Wait for items to appear
await screen.findByText(/2 items/)
// Should show both items
await screen.findByText("Test Package")
await screen.findByText("Another Package")
})
it("should handle filter state transitions", async () => {
render(<PackageManagerView />)
// Load initial items
act(() => {
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
mockEventListener({
data: {
type: "state",
state: { packageManagerItems: mockItems },
},
})
})
// Apply search filter
const searchInput = screen.getByPlaceholderText("Search package manager items...")
fireEvent.change(searchInput, { target: { value: "test" } })
// Wait for debounce
await new Promise((resolve) => setTimeout(resolve, 400))
// Should have sent filter request
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "filterPackageManagerItems",
filters: {
type: undefined,
search: "test",
tags: undefined,
},
}),
)
// Simulate receiving filtered results
act(() => {
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
mockEventListener({
data: {
type: "state",
state: { packageManagerItems: [mockItems[0]] },
},
})
})
// Should show filtered count
await screen.findByText(/1 item.*found.*filtered/i)
})
it("should handle tab switching correctly", async () => {
render(<PackageManagerView />)
// Initial fetch request sets isFetching to true
expect(mockPostMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
forceRefresh: true,
})
// Load initial items during fetch
act(() => {
// First simulate the fetch request
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
mockEventListener({
data: {
type: "packageManagerButtonClicked",
},
})
// Then simulate receiving items
mockEventListener({
data: {
type: "state",
state: { packageManagerItems: mockItems },
},
})
})
// Wait for initial items to load
await screen.findByText(/2 items/)
// Switch to sources tab
const sourcesTab = screen.getByText("Sources")
fireEvent.click(sourcesTab)
// Should show sources view
await screen.findByText("Configure Package Manager Sources")
// Switch back to browse tab
const browseTab = screen.getByText("Browse")
fireEvent.click(browseTab)
// Simulate receiving items again
act(() => {
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
mockEventListener({
data: {
type: "state",
state: { packageManagerItems: mockItems },
},
})
})
// Should restore items
await screen.findByText(/2 items/i)
await screen.findByText("Test Package")
await screen.findByText("Another Package")
})
it("should handle source changes correctly", () => {
render(<PackageManagerView />)
// Switch to sources tab
const sourcesTab = screen.getByText("Sources")
fireEvent.click(sourcesTab)
// Add new source
const urlInput = screen.getByPlaceholderText(/Git repository URL/)
fireEvent.change(urlInput, { target: { value: "https://github.com/test/repo" } })
const addButton = screen.getByText("Add Source")
fireEvent.click(addButton)
// Should have sent sources update
expect(mockPostMessage).toHaveBeenCalledWith({
type: "packageManagerSources",
sources: expect.any(Array),
})
// Switch back to browse tab
const browseTab = screen.getByText("Browse")
fireEvent.click(browseTab)
// Should have sent fetch request
expect(mockPostMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
forceRefresh: true,
})
})
it("should preserve filter state during tab switches", async () => {
render(<PackageManagerView />)
// Initial fetch request sets isFetching to true
expect(mockPostMessage).toHaveBeenCalledWith({
type: "fetchPackageManagerItems",
forceRefresh: true,
})
// Load initial items during fetch
act(() => {
// First simulate the fetch request
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
mockEventListener({
data: {
type: "packageManagerButtonClicked",
},
})
// Then simulate receiving items
mockEventListener({
data: {
type: "state",
state: { packageManagerItems: mockItems },
},
})
})
// Wait for initial items to load
await screen.findByText(/2 items/)
// Apply search filter
const searchInput = screen.getByPlaceholderText("Search package manager items...")
fireEvent.change(searchInput, { target: { value: "test" } })
// Wait for debounce
await new Promise((resolve) => setTimeout(resolve, 400))
// Simulate receiving filtered results
act(() => {
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
mockEventListener({
data: {
type: "state",
state: { packageManagerItems: [mockItems[0]] },
},
})
})
// Switch to sources tab and back
const sourcesTab = screen.getByText("Sources")
fireEvent.click(sourcesTab)
const browseTab = screen.getByText("Browse")
fireEvent.click(browseTab)
// Wait for filter operation to complete
await new Promise((resolve) => setTimeout(resolve, 400))
// Simulate receiving filtered results
act(() => {
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
mockEventListener({
data: {
type: "state",
state: { packageManagerItems: [mockItems[0]] },
},
})
})
// Should still show filtered results
await screen.findByText(/1 item.*found.*filtered/i)
await screen.findByText("Test Package")
expect(screen.queryByText("Another Package")).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,143 @@
import { isFilterActive, filterItems, sortItems, getDisplayedItems } from "../selectors"
import { PackageManagerItem } from "../../../../../src/services/package-manager/types"
describe("Package Manager Selectors", () => {
const mockItems: PackageManagerItem[] = [
{
name: "Test Package",
description: "A test package",
type: "package",
repoUrl: "test-url",
url: "test-url",
tags: ["test", "mock"],
},
{
name: "Another Package",
description: "Another test package",
type: "package",
repoUrl: "test-url-2",
url: "test-url-2",
tags: ["test", "another"],
author: "Test Author",
},
{
name: "Test Mode",
description: "A test mode",
type: "mode",
repoUrl: "test-url-3",
url: "test-url-3",
tags: ["mode"],
lastUpdated: "2025-04-13",
},
]
describe("isFilterActive", () => {
it("should return false when no filters are active", () => {
expect(isFilterActive({ type: "", search: "", tags: [] })).toBe(false)
})
it("should return true when type filter is active", () => {
expect(isFilterActive({ type: "package", search: "", tags: [] })).toBe(true)
})
it("should return true when search filter is active", () => {
expect(isFilterActive({ type: "", search: "test", tags: [] })).toBe(true)
})
it("should return true when tags filter is active", () => {
expect(isFilterActive({ type: "", search: "", tags: ["test"] })).toBe(true)
})
})
describe("filterItems", () => {
it("should return all items when no filters are active", () => {
const result = filterItems(mockItems, { type: "", search: "", tags: [] })
expect(result).toEqual(mockItems)
})
it("should filter by type", () => {
const result = filterItems(mockItems, { type: "package", search: "", tags: [] })
expect(result).toHaveLength(2)
expect(result.every((item) => item.type === "package")).toBe(true)
})
it("should filter by search term in name", () => {
const result = filterItems(mockItems, { type: "", search: "another", tags: [] })
expect(result).toHaveLength(1)
expect(result[0].name).toBe("Another Package")
})
it("should filter by search term in description", () => {
const result = filterItems(mockItems, { type: "", search: "test package", tags: [] })
expect(result).toHaveLength(2)
})
it("should filter by search term in author", () => {
const result = filterItems(mockItems, { type: "", search: "test author", tags: [] })
expect(result).toHaveLength(1)
expect(result[0].author).toBe("Test Author")
})
it("should filter by tags", () => {
const result = filterItems(mockItems, { type: "", search: "", tags: ["mock"] })
expect(result).toHaveLength(1)
expect(result[0].name).toBe("Test Package")
})
it("should combine multiple filters", () => {
const result = filterItems(mockItems, { type: "package", search: "test", tags: ["mock"] })
expect(result).toHaveLength(1)
expect(result[0].name).toBe("Test Package")
})
})
describe("sortItems", () => {
it("should sort by name ascending", () => {
const result = sortItems(mockItems, { by: "name", order: "asc" })
expect(result[0].name).toBe("Another Package")
expect(result[2].name).toBe("Test Package")
})
it("should sort by name descending", () => {
const result = sortItems(mockItems, { by: "name", order: "desc" })
expect(result[0].name).toBe("Test Package")
expect(result[2].name).toBe("Another Package")
})
it("should sort by author", () => {
const result = sortItems(mockItems, { by: "author", order: "asc" })
// Items without author should come first
expect(result[0].author).toBeUndefined()
expect(result[2].author).toBe("Test Author")
})
it("should sort by lastUpdated", () => {
const result = sortItems(mockItems, { by: "lastUpdated", order: "asc" })
// Items without lastUpdated should come first
expect(result[0].lastUpdated).toBeUndefined()
expect(result[2].lastUpdated).toBe("2025-04-13")
})
})
describe("getDisplayedItems", () => {
it("should filter and sort items", () => {
const result = getDisplayedItems(
mockItems,
{ type: "package", search: "", tags: [] },
{ by: "name", order: "asc" },
)
expect(result).toHaveLength(2)
expect(result[0].name).toBe("Another Package")
expect(result[1].name).toBe("Test Package")
})
it("should handle empty results", () => {
const result = getDisplayedItems(
mockItems,
{ type: "prompt", search: "", tags: [] },
{ by: "name", order: "asc" },
)
expect(result).toHaveLength(0)
})
})
})

View file

@ -0,0 +1,47 @@
import React, { useState } from "react"
import { cn } from "@/lib/utils"
interface ExpandableSectionProps {
title: string
children: React.ReactNode
className?: string
defaultExpanded?: boolean
}
export const ExpandableSection: React.FC<ExpandableSectionProps> = ({
title,
children,
className,
defaultExpanded = false,
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
return (
<div className={cn("border-t border-vscode-panel-border mt-4", className)}>
<button
className="w-full flex items-center justify-between py-2 text-sm text-vscode-foreground hover:text-vscode-textLink"
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
aria-controls="details-content">
<span className="font-medium">{title}</span>
<span
className={cn(
"codicon",
isExpanded ? "codicon-chevron-down" : "codicon-chevron-right",
"transition-transform duration-200",
)}
/>
</button>
<div
id="details-content"
className={cn(
"overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out",
isExpanded ? "max-h-[500px] opacity-100" : "max-h-0 opacity-0",
)}
role="region"
aria-labelledby="details-button">
<div className="py-2">{children}</div>
</div>
</div>
)
}

View file

@ -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<React.SetStateAction<{ type: string; search: string; tags: string[] }>>
activeTab: "browse" | "sources"
setActiveTab: React.Dispatch<React.SetStateAction<"browse" | "sources">>
}
export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
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 (
<div className="border border-vscode-panel-border rounded-md p-4 bg-vscode-panel-background">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold text-vscode-foreground">{item.name}</h3>
{item.author && <p className="text-sm text-vscode-descriptionForeground">{`by ${item.author}`}</p>}
</div>
<span className={`px-2 py-1 text-xs text-white rounded-full ${getTypeColor(item.type)}`}>
{getTypeLabel(item.type)}
</span>
</div>
<p className="my-2 text-vscode-foreground">{item.description}</p>
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1 my-2">
{item.tags.map((tag) => (
<button
key={tag}
className={`px-2 py-1 text-xs rounded-full hover:bg-vscode-button-secondaryBackground ${
filters.tags.includes(tag)
? "bg-vscode-button-background text-vscode-button-foreground"
: "bg-vscode-badge-background text-vscode-badge-foreground"
}`}
onClick={() => {
if (filters.tags.includes(tag)) {
setFilters({
...filters,
tags: filters.tags.filter((t) => t !== tag),
})
} else {
setFilters({
...filters,
tags: [...filters.tags, tag],
})
if (activeTab !== "browse") {
setActiveTab("browse")
}
}
}}
title={filters.tags.includes(tag) ? `Remove tag filter: ${tag}` : `Filter by tag: ${tag}`}>
{tag}
</button>
))}
</div>
)}
<div className="flex justify-between items-center mt-4">
<div className="flex items-center gap-4 text-sm text-vscode-descriptionForeground">
{item.version && (
<span className="flex items-center">
<span className="codicon codicon-tag mr-1"></span>
{item.version}
</span>
)}
{item.lastUpdated && (
<span className="flex items-center">
<span className="codicon codicon-calendar mr-1"></span>
{new Date(item.lastUpdated).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
)}
</div>
<Button onClick={handleOpenUrl}>
<span className="codicon codicon-link-external mr-2"></span>
{item.sourceUrl ? "View" : item.sourceName || "Source"}
</Button>
</div>
{groupedItems && (
<ExpandableSection title="Details">
{Object.entries(groupedItems).map(([type, group]) => (
<TypeGroup key={type} type={type} items={group.items} />
))}
</ExpandableSection>
)}
</div>
)
}

View file

@ -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<TypeGroupProps> = ({ 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 (
<div className={cn("mb-4", className)}>
<h4 className="text-sm font-medium text-vscode-foreground mb-2">{getTypeLabel(type)}</h4>
<ol className="list-decimal list-inside space-y-1">
{items.map((item, index) => (
<li key={`${item.path || index}`} className="text-sm text-vscode-foreground pl-1" title={item.path}>
<span className="text-vscode-descriptionForeground">{formatItemText(item)}</span>
</li>
))}
</ol>
</div>
)
}

View file

@ -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: <div>Test Content</div>,
}
it("should render with default state", () => {
render(<ExpandableSection {...defaultProps} />)
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(<ExpandableSection {...defaultProps} />)
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(<ExpandableSection {...defaultProps} defaultExpanded />)
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(<ExpandableSection {...defaultProps} />)
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(<ExpandableSection {...defaultProps} className={customClass} />)
const section = screen.getByRole("region").parentElement
expect(section).toHaveClass(customClass)
})
it("should have proper accessibility attributes", () => {
render(<ExpandableSection {...defaultProps} />)
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(
<ExpandableSection title="Test">
<div>{testContent}</div>
</ExpandableSection>,
)
expect(screen.getByText(testContent)).toBeInTheDocument()
})
})

View file

@ -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(<PackageManagerItemCard {...defaultProps} />)
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(<PackageManagerItemCard {...defaultProps} />)
expect(screen.getByText("test")).toBeInTheDocument()
expect(screen.getByText("mock")).toBeInTheDocument()
})
it("should handle tag clicks", () => {
const setFilters = jest.fn()
render(<PackageManagerItemCard {...defaultProps} setFilters={setFilters} />)
fireEvent.click(screen.getByText("test"))
expect(setFilters).toHaveBeenCalledWith(
expect.objectContaining({
tags: ["test"],
}),
)
})
it("should render version and date information", () => {
render(<PackageManagerItemCard {...defaultProps} />)
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(<PackageManagerItemCard {...defaultProps} />)
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(<PackageManagerItemCard {...defaultProps} />)
expect(screen.getByText("Details")).toBeInTheDocument()
})
it("should not render details section when item has no subcomponents", () => {
const itemWithoutItems = { ...mockItem, items: [] }
render(<PackageManagerItemCard {...defaultProps} item={itemWithoutItems} />)
expect(screen.queryByText("Details")).not.toBeInTheDocument()
})
it("should show grouped items when expanded", () => {
render(<PackageManagerItemCard {...defaultProps} />)
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(<PackageManagerItemCard {...defaultProps} />)
fireEvent.click(screen.getByText("Details"))
const items = screen.getAllByRole("listitem")
expect(items[0]).toHaveTextContent("Test Server")
expect(items[1]).toHaveTextContent("Test Mode")
})
})
})

View file

@ -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(<TypeGroup type="mcp server" items={mockItems} />)
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(<TypeGroup type={input} items={mockItems} />)
expect(screen.getByText(expected)).toBeInTheDocument()
unmount()
})
})
it("should handle items without descriptions", () => {
const itemsWithoutDesc = [{ name: "Test Item", path: "test/path" }]
render(<TypeGroup type="test" items={itemsWithoutDesc} />)
expect(screen.getByText("Test Item")).toBeInTheDocument()
})
it("should not render when items array is empty", () => {
const { container } = render(<TypeGroup type="test" items={[]} />)
expect(container).toBeEmptyDOMElement()
})
it("should not render when items is undefined", () => {
const { container } = render(<TypeGroup type="test" items={undefined as any} />)
expect(container).toBeEmptyDOMElement()
})
it("should apply custom className", () => {
const customClass = "custom-test-class"
render(<TypeGroup type="test" items={mockItems} className={customClass} />)
const container = screen.getByRole("heading").parentElement
expect(container).toHaveClass(customClass)
})
it("should render items in a numbered list", () => {
render(<TypeGroup type="test" items={mockItems} />)
const list = screen.getByRole("list")
expect(list).toHaveClass("list-decimal")
expect(list.children).toHaveLength(2)
})
it("should show path as title attribute", () => {
render(<TypeGroup type="test" items={mockItems} />)
const items = screen.getAllByRole("listitem")
expect(items[0]).toHaveAttribute("title", "test/path/1")
expect(items[1]).toHaveAttribute("title", "test/path/2")
})
})

View file

@ -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)
}

View file

@ -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([])
})
})
})

View file

@ -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()
}