mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-07 02:58:15 +00:00
package details visible in expandable section
This commit is contained in:
parent
965a7ae90c
commit
81304a22ea
19 changed files with 2027 additions and 261 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
75
src/services/package-manager/__tests__/searchUtils.test.ts
Normal file
75
src/services/package-manager/__tests__/searchUtils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
83
webview-ui/src/components/package-manager/selectors.ts
Normal file
83
webview-ui/src/components/package-manager/selectors.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
76
webview-ui/src/components/package-manager/utils/grouping.ts
Normal file
76
webview-ui/src/components/package-manager/utils/grouping.ts
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue