diff --git a/cline_docs/marketplace/implementation/01-architecture.md b/cline_docs/marketplace/implementation/01-architecture.md index 5ffcce10b4..e74433c1b5 100644 --- a/cline_docs/marketplace/implementation/01-architecture.md +++ b/cline_docs/marketplace/implementation/01-architecture.md @@ -249,7 +249,7 @@ classDiagram - Coordinates repository operations - Provides filtering and sorting -4. **packageManagerMessageHandler** +4. **marketplaceMessageHandler** - Routes messages between UI and backend - Processes commands from the UI - Returns data and status updates diff --git a/cline_docs/marketplace/implementation/02-core-components.md b/cline_docs/marketplace/implementation/02-core-components.md index 8ce55b4650..e12a05043a 100644 --- a/cline_docs/marketplace/implementation/02-core-components.md +++ b/cline_docs/marketplace/implementation/02-core-components.md @@ -159,7 +159,7 @@ class PackageManagerManager { } /** - * Get package manager items from sources + * Get marketplace items from sources */ public async getPackageManagerItems( sources: PackageManagerSource[], @@ -251,14 +251,14 @@ The PackageManagerSourceValidation component handles validation of marketplace s ```typescript export class PackageManagerSourceValidation { /** - * Validates a package manager source URL + * Validates a marketplace source URL */ public static validateSourceUrl(url: string): ValidationError[] { // Implementation details } /** - * Validates a package manager source name + * Validates a marketplace source name */ public static validateSourceName(name?: string): ValidationError[] { // Implementation details diff --git a/cline_docs/marketplace/implementation/03-data-structures.md b/cline_docs/marketplace/implementation/03-data-structures.md index 220e0843c4..8e103e6ddf 100644 --- a/cline_docs/marketplace/implementation/03-data-structures.md +++ b/cline_docs/marketplace/implementation/03-data-structures.md @@ -51,7 +51,7 @@ This interface represents a complete repository: ```typescript /** - * Represents an individual package manager item + * Represents an individual marketplace item */ export interface PackageManagerItem { name: string @@ -113,7 +113,7 @@ Enhanced match tracking: ```typescript /** - * Error type for package manager source validation + * Error type for marketplace source validation */ export interface ValidationError { field: string @@ -154,7 +154,7 @@ Manages UI state: - **isFetching**: Loading state indicator - **activeTab**: Current view tab - **refreshingUrls**: Sources being refreshed -- **sources**: Package manager sources +- **sources**: Marketplace sources - **filters**: Active filters - **sortConfig**: Sort configuration diff --git a/cline_docs/marketplace/implementation/05-ui-components.md b/cline_docs/marketplace/implementation/05-ui-components.md index 683d58ab43..fad89ae3f0 100644 --- a/cline_docs/marketplace/implementation/05-ui-components.md +++ b/cline_docs/marketplace/implementation/05-ui-components.md @@ -23,7 +23,7 @@ const PackageManagerView: React.FC = ({ onDone }) => {
-

Package Manager

+

Marketplace

@@ -74,7 +75,7 @@ const PackageManagerView: React.FC = ({ onDone, stateMa
manager.transition({ @@ -88,7 +89,7 @@ const PackageManagerView: React.FC = ({ onDone, stateMa
- + @@ -173,7 +172,7 @@ const PackageManagerView: React.FC = ({ onDone, stateMa
setIsTagInputActive(true)} @@ -187,7 +186,7 @@ const PackageManagerView: React.FC = ({ onDone, stateMa {(isTagInputActive || tagSearch) && ( - {t("package-manager:filters.tags.noResults")} + {t("marketplace:filters.tags.noResults")} {filteredTags.map((tag: string) => ( @@ -237,10 +236,10 @@ const PackageManagerView: React.FC = ({ onDone, stateMa
{state.filters.tags.length > 0 - ? t("package-manager:filters.tags.selected", { + ? t("marketplace:filters.tags.selected", { count: state.filters.tags.length, }) - : t("package-manager:filters.tags.clickToFilter")} + : t("marketplace:filters.tags.clickToFilter")}
)} @@ -251,16 +250,12 @@ const PackageManagerView: React.FC = ({ onDone, stateMa // Use items directly from backend const items = state.displayItems || [] const isEmpty = items.length === 0 - const isLoading = state.isFetching - // Show loading state if fetching and not filtering - // Only show loading state if we're fetching and not filtering - if ( - isLoading && - !(state.filters.type || state.filters.search || state.filters.tags.length > 0) - ) { + + // Only show loading state if we're fetching and have no items to display + if (state.isFetching && isEmpty) { return (
-

{t("package-manager:items.refresh.refreshing")}

+

{t("marketplace:items.refresh.refreshing")}

) } @@ -269,7 +264,7 @@ const PackageManagerView: React.FC = ({ onDone, stateMa if (isEmpty) { return (
-

{t("package-manager:items.empty.noItems")}

+

{t("marketplace:items.empty.noItems")}

) } @@ -278,11 +273,11 @@ const PackageManagerView: React.FC = ({ onDone, stateMa return (

- {t("package-manager:items.count", { count: items.length })} + {t("marketplace:items.count", { count: items.length })}

{items.map((item) => ( - = ({ onDone, stateMa })()} ) : ( - manager.transition({ type: "REFRESH_SOURCE", payload: { url } })} @@ -315,14 +310,14 @@ const PackageManagerView: React.FC = ({ onDone, stateMa ) } -interface PackageManagerSourcesConfigProps { - sources: PackageManagerSource[] +export interface MarketplaceSourcesConfigProps { + sources: MarketplaceSource[] refreshingUrls: string[] onRefreshSource: (url: string) => void - onSourcesChange: (sources: PackageManagerSource[]) => void + onSourcesChange: (sources: MarketplaceSource[]) => void } -const PackageManagerSourcesConfig: React.FC = ({ +export const MarketplaceSourcesConfig: React.FC = ({ sources, refreshingUrls, onRefreshSource, @@ -334,71 +329,46 @@ const PackageManagerSourcesConfig: React.FC = const [error, setError] = useState("") const handleAddSource = () => { - if (!newSourceUrl) { - setError(t("package-manager:sources.errors.emptyUrl")) - return - } - - try { - new URL(newSourceUrl) - } catch (e) { - setError(t("package-manager:sources.errors.invalidUrl")) - return - } - - const nonVisibleCharRegex = /[^\S ]/ - if (nonVisibleCharRegex.test(newSourceUrl)) { - setError(t("package-manager:sources.errors.nonVisibleChars")) - return - } - - if (!isValidGitRepositoryUrl(newSourceUrl)) { - setError(t("package-manager:sources.errors.invalidGitUrl")) - return - } - - const normalizedNewUrl = newSourceUrl.toLowerCase().replace(/\s+/g, "") - if (sources.some((source) => source.url.toLowerCase().replace(/\s+/g, "") === normalizedNewUrl)) { - setError(t("package-manager:sources.errors.duplicateUrl")) - return - } - - if (newSourceName) { - if (newSourceName.length > 20) { - setError(t("package-manager:sources.errors.nameTooLong")) - return - } - - if (nonVisibleCharRegex.test(newSourceName)) { - setError(t("package-manager:sources.errors.nonVisibleCharsName")) - return - } - - const normalizedNewName = newSourceName.toLowerCase().replace(/\s+/g, "") - if ( - sources.some( - (source) => source.name && source.name.toLowerCase().replace(/\s+/g, "") === normalizedNewName, - ) - ) { - setError(t("package-manager:sources.errors.duplicateName")) - return - } - } - + // Check max sources limit first const MAX_SOURCES = 10 if (sources.length >= MAX_SOURCES) { - setError(t("package-manager:sources.errors.maxSources", { max: MAX_SOURCES })) + setError(t("marketplace:sources.errors.maxSources", { max: MAX_SOURCES })) return } - const newSource: PackageManagerSource = { + // Create source object for validation + const sourceToValidate: MarketplaceSource = { url: newSourceUrl, name: newSourceName || undefined, enabled: true, } - onSourcesChange([...sources, newSource]) + // Validate using shared validation + const validationErrors = validateSource(sourceToValidate, sources) + if (validationErrors.length > 0) { + // Map validation errors to UI error messages + const errorMessages: Record = { + "url:empty": "marketplace:sources.errors.emptyUrl", + "url:nonvisible": "marketplace:sources.errors.nonVisibleChars", + "url:invalid": "marketplace:sources.errors.invalidGitUrl", + "url:duplicate": "marketplace:sources.errors.duplicateUrl", + "name:length": "marketplace:sources.errors.nameTooLong", + "name:nonvisible": "marketplace:sources.errors.nonVisibleCharsName", + "name:duplicate": "marketplace:sources.errors.duplicateName", + } + const error = validationErrors[0] + const errorKey = `${error.field}:${error.message.toLowerCase().split(" ")[0]}` + setError(t(errorMessages[errorKey] || "marketplace:sources.errors.invalidGitUrl")) + return + } + + // Add the validated source + onSourcesChange([...sources, sourceToValidate]) + + onSourcesChange([...sources, sourceToValidate]) + + // Reset form state setNewSourceUrl("") setNewSourceName("") setError("") @@ -422,15 +392,15 @@ const PackageManagerSourcesConfig: React.FC = return (
-

{t("package-manager:sources.title")}

-

{t("package-manager:sources.description")}

+

{t("marketplace:sources.title")}

+

{t("marketplace:sources.description")}

-
{t("package-manager:sources.add.title")}
+
{t("marketplace:sources.add.title")}
{ setNewSourceUrl(e.target.value) @@ -439,11 +409,11 @@ const PackageManagerSourcesConfig: React.FC = className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded" />

- {t("package-manager:sources.add.urlFormats")} + {t("marketplace:sources.add.urlFormats")}

{ setNewSourceName(e.target.value.slice(0, 20)) @@ -456,17 +426,17 @@ const PackageManagerSourcesConfig: React.FC = {error &&

{error}

}
- {t("package-manager:sources.current.title")}{" "} + {t("marketplace:sources.current.title")}{" "} - {t("package-manager:sources.current.count", { current: sources.length, max: 10 })} + {t("marketplace:sources.current.count", { current: sources.length, max: 10 })}
{sources.length === 0 ? ( -

{t("package-manager:sources.current.empty")}

+

{t("marketplace:sources.current.empty")}

) : (
{sources.map((source, index) => ( @@ -496,7 +466,7 @@ const PackageManagerSourcesConfig: React.FC = variant="ghost" size="icon" onClick={() => onRefreshSource(source.url)} - title={t("package-manager:sources.current.refresh")} + title={t("marketplace:sources.current.refresh")} className="text-vscode-foreground" disabled={refreshingUrls.includes(source.url)}> = variant="ghost" size="icon" onClick={() => handleRemoveSource(index)} - title={t("package-manager:sources.current.remove")} + title={t("marketplace:sources.current.remove")} className="text-red-500"> @@ -519,16 +489,4 @@ const PackageManagerSourcesConfig: React.FC = ) } -const isValidGitRepositoryUrl = (url: string): boolean => { - const trimmedUrl = url.trim() - - const httpsPattern = - /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\/.+)*(\.git)?$/ - const sshPattern = /^git@(github\.com|gitlab\.com|bitbucket\.org):([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(\.git)?$/ - const gitProtocolPattern = - /^git:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\.git)?$/ - - return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl) -} - -export default PackageManagerView +export default MarketplaceView diff --git a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts new file mode 100644 index 0000000000..9bb6ef6421 --- /dev/null +++ b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts @@ -0,0 +1,586 @@ +import { MarketplaceItem, MarketplaceSource, MatchInfo } from "../../../../src/services/marketplace/types" +import { vscode } from "../../utils/vscode" +import { WebviewMessage } from "../../../../src/shared/WebviewMessage" +import { DEFAULT_MARKETPLACE_SOURCE } from "../../../../src/services/marketplace/constants" + +export interface ViewState { + allItems: MarketplaceItem[] + displayItems?: MarketplaceItem[] // Items currently being displayed (filtered or all) + isFetching: boolean + activeTab: "browse" | "sources" + refreshingUrls: string[] + sources: MarketplaceSource[] + filters: { + type: string + search: string + tags: string[] + } + sortConfig: { + by: "name" | "author" | "lastUpdated" + order: "asc" | "desc" + } +} + +type TransitionPayloads = { + FETCH_ITEMS: undefined + FETCH_COMPLETE: { items: MarketplaceItem[] } + FETCH_ERROR: undefined + SET_ACTIVE_TAB: { tab: ViewState["activeTab"] } + UPDATE_FILTERS: { filters: Partial } + UPDATE_SORT: { sortConfig: Partial } + REFRESH_SOURCE: { url: string } + REFRESH_SOURCE_COMPLETE: { url: string } + UPDATE_SOURCES: { sources: MarketplaceSource[] } +} + +export interface ViewStateTransition { + type: keyof TransitionPayloads + payload?: TransitionPayloads[keyof TransitionPayloads] +} + +export type StateChangeHandler = (state: ViewState) => void + +export class MarketplaceViewStateManager { + private state: ViewState = this.loadInitialState() + + private loadInitialState(): ViewState { + // Try to restore state from sessionStorage if available + if (typeof sessionStorage !== "undefined") { + const savedState = sessionStorage.getItem("marketplaceState") + if (savedState) { + try { + return JSON.parse(savedState) + } catch { + return this.getDefaultState() + } + } + } + return this.getDefaultState() + } + + private getDefaultState(): ViewState { + return { + allItems: [], + displayItems: [] as MarketplaceItem[], + isFetching: false, + activeTab: "browse", + refreshingUrls: [], + sources: [DEFAULT_MARKETPLACE_SOURCE], + filters: { + type: "", + search: "", + tags: [], + }, + sortConfig: { + by: "name", + order: "asc", + }, + } + } + private fetchTimeoutId?: NodeJS.Timeout + private readonly FETCH_TIMEOUT = 30000 // 30 seconds + private stateChangeHandlers: Set = new Set() + private sourcesModified = false // Track if sources have been modified + + // Empty constructor is required for test initialization + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor() { + // Initialize is now handled by the loadInitialState call in the property initialization + } + + public initialize(): void { + // Set initial state + this.state = this.getDefaultState() + + // Send initial sources to extension + vscode.postMessage({ + type: "marketplaceSources", + sources: [DEFAULT_MARKETPLACE_SOURCE], + } as WebviewMessage) + } + + public onStateChange(handler: StateChangeHandler): () => void { + this.stateChangeHandlers.add(handler) + return () => this.stateChangeHandlers.delete(handler) + } + + public cleanup(): void { + // Clear any pending timeouts + if (this.fetchTimeoutId) { + clearTimeout(this.fetchTimeoutId) + this.fetchTimeoutId = undefined + } + + // Reset fetching state + if (this.state.isFetching) { + this.state.isFetching = false + this.notifyStateChange() + } + + // Clear handlers but preserve state + this.stateChangeHandlers.clear() + } + + public getState(): ViewState { + // Only create new arrays if they exist and have items + const displayItems = this.state.displayItems?.length ? [...this.state.displayItems] : this.state.displayItems + const refreshingUrls = this.state.refreshingUrls.length ? [...this.state.refreshingUrls] : [] + const tags = this.state.filters.tags.length ? [...this.state.filters.tags] : [] + + // Create minimal new state object + return { + ...this.state, + allItems: this.state.allItems.length ? [...this.state.allItems] : [], + displayItems, + refreshingUrls, + sources: this.state.sources.length ? [...this.state.sources] : [DEFAULT_MARKETPLACE_SOURCE], + filters: { + ...this.state.filters, + tags, + }, + } + } + + private notifyStateChange(): void { + const newState = this.getState() // Use getState to ensure proper copying + this.stateChangeHandlers.forEach((handler) => { + handler(newState) + }) + + // Save state to sessionStorage if available + if (typeof sessionStorage !== "undefined") { + try { + sessionStorage.setItem("marketplaceState", JSON.stringify(this.state)) + } catch (error) { + console.warn("Failed to save marketplace state:", error) + } + } + } + + public async transition(transition: ViewStateTransition): Promise { + switch (transition.type) { + case "FETCH_ITEMS": { + // Don't start a new fetch if one is in progress + if (this.state.isFetching) { + return + } + + // Clear any existing timeout + this.clearFetchTimeout() + + // Send fetch request + vscode.postMessage({ + type: "fetchMarketplaceItems", + bool: true, + } as WebviewMessage) + + // Store current items before updating state + const currentItems = [...(this.state.allItems || [])] + + // Update state after sending request + this.state = { + ...this.state, + isFetching: true, + allItems: currentItems, + displayItems: currentItems, + } + this.notifyStateChange() + + // Set timeout to reset state if fetch takes too long + this.fetchTimeoutId = setTimeout(() => { + this.clearFetchTimeout() + // On timeout, preserve items if we have them + if (currentItems.length > 0) { + this.state = { + ...this.state, + isFetching: false, + allItems: currentItems, + displayItems: currentItems, + } + } else { + this.state = { + ...this.getDefaultState(), + sources: [...this.state.sources], + activeTab: this.state.activeTab, + } + } + this.notifyStateChange() + }, this.FETCH_TIMEOUT) + + break + } + + case "FETCH_COMPLETE": { + const { items } = transition.payload as TransitionPayloads["FETCH_COMPLETE"] + // Clear any existing timeout + this.clearFetchTimeout() + + // Always update allItems as source of truth + const sortedItems = this.sortItems([...items]) + this.state = { + ...this.state, + allItems: sortedItems, + displayItems: this.isFilterActive() ? this.filterItems(sortedItems) : sortedItems, + isFetching: false, + } + + // Notify state change + this.notifyStateChange() + break + } + + case "FETCH_ERROR": { + this.clearFetchTimeout() + + // Preserve current filters and sources + const { filters, sources, activeTab } = this.state + + // Reset state but preserve filters and sources + this.state = { + ...this.getDefaultState(), + filters, + sources, + activeTab, + isFetching: false, + } + this.notifyStateChange() + break + } + + case "SET_ACTIVE_TAB": { + const { tab } = transition.payload as TransitionPayloads["SET_ACTIVE_TAB"] + + // Update tab state + this.state = { + ...this.state, + activeTab: tab, + allItems: this.state.allItems || [], + displayItems: this.state.displayItems || [], + } + + // If switching to browse tab with no items or modified sources, trigger fetch + if (tab === "browse" && (this.state.allItems.length === 0 || this.sourcesModified)) { + this.state.isFetching = true + this.sourcesModified = false + + vscode.postMessage({ + type: "fetchMarketplaceItems", + bool: true, + } as WebviewMessage) + } + // Update display items if needed + else if (tab === "browse" && this.state.allItems.length > 0) { + this.state.displayItems = this.isFilterActive() + ? this.filterItems(this.state.allItems) + : [...this.state.allItems] + } + + this.notifyStateChange() + break + } + + case "UPDATE_FILTERS": { + const { filters = {} } = (transition.payload as TransitionPayloads["UPDATE_FILTERS"]) || {} + + // Create new filters object preserving existing values for undefined fields + const updatedFilters = { + type: filters.type !== undefined ? filters.type : this.state.filters.type, + search: filters.search !== undefined ? filters.search : this.state.filters.search, + tags: filters.tags !== undefined ? filters.tags : this.state.filters.tags, + } + + // Update state + this.state = { + ...this.state, + filters: updatedFilters, + } + + // Send filter message + vscode.postMessage({ + type: "filterMarketplaceItems", + filters: updatedFilters, + } as WebviewMessage) + + this.notifyStateChange() + + break + } + + case "UPDATE_SORT": { + const { sortConfig } = transition.payload as TransitionPayloads["UPDATE_SORT"] + // Create new state with updated sort config + this.state = { + ...this.state, + sortConfig: { + ...this.state.sortConfig, + ...sortConfig, + }, + } + // Apply sorting to both allItems and displayItems + // Sort items immutably + // Create new sorted arrays + const sortedAllItems = this.sortItems([...this.state.allItems]) + const sortedDisplayItems = this.state.displayItems?.length + ? this.sortItems([...this.state.displayItems]) + : this.state.displayItems + + this.state = { + ...this.state, + allItems: sortedAllItems, + displayItems: sortedDisplayItems, + } + this.notifyStateChange() + break + } + + case "REFRESH_SOURCE": { + const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE"] + if (!this.state.refreshingUrls.includes(url)) { + this.state = { + ...this.state, + refreshingUrls: [...this.state.refreshingUrls, url], + } + this.notifyStateChange() + vscode.postMessage({ + type: "refreshMarketplaceSource", + url, + } as WebviewMessage) + } + break + } + + case "REFRESH_SOURCE_COMPLETE": { + const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE_COMPLETE"] + this.state = { + ...this.state, + refreshingUrls: this.state.refreshingUrls.filter((existingUrl) => existingUrl !== url), + } + this.notifyStateChange() + break + } + + case "UPDATE_SOURCES": { + const { sources } = transition.payload as TransitionPayloads["UPDATE_SOURCES"] + // If all sources are removed, add the default source + const updatedSources = sources.length === 0 ? [DEFAULT_MARKETPLACE_SOURCE] : [...sources] + + // Mark sources as modified + this.sourcesModified = true + + this.state = { + ...this.state, + sources: updatedSources, + isFetching: false, // Reset fetching state + } + + this.notifyStateChange() + + // Send sources update to extension + vscode.postMessage({ + type: "marketplaceSources", + sources: updatedSources, + } as WebviewMessage) + + // If we're on the browse tab, trigger a fetch + if (this.state.activeTab === "browse") { + this.state.isFetching = true + this.notifyStateChange() + + vscode.postMessage({ + type: "fetchMarketplaceItems", + bool: true, + } as WebviewMessage) + } + break + } + } + } + + private clearFetchTimeout(): void { + // Clear fetch timeout + if (this.fetchTimeoutId) { + clearTimeout(this.fetchTimeoutId) + this.fetchTimeoutId = undefined + } + } + + public isFilterActive(): boolean { + return !!(this.state.filters.type || this.state.filters.search || this.state.filters.tags.length > 0) + } + + public filterItems(items: MarketplaceItem[]): MarketplaceItem[] { + const { type, search, tags } = this.state.filters + + return items + .map((item) => { + // Create a copy of the item to modify + const itemCopy = { ...item } + + // Check specific match conditions for the main item + const typeMatch = !type || item.type === type + const nameMatch = search ? item.name.toLowerCase().includes(search.toLowerCase()) : false + const descriptionMatch = search + ? (item.description || "").toLowerCase().includes(search.toLowerCase()) + : false + const tagMatch = tags.length > 0 ? item.tags?.some((tag) => tags.includes(tag)) : false + + // Determine if the main item matches all filters + const mainItemMatches = + typeMatch && (!search || nameMatch || descriptionMatch) && (!tags.length || tagMatch) + + // For packages, check and mark matching subcomponents + if (item.type === "package" && item.items?.length) { + itemCopy.items = item.items.map((subItem) => { + // Check specific match conditions for subitem + const subTypeMatch = !type || subItem.type === type + const subNameMatch = + search && subItem.metadata + ? subItem.metadata.name.toLowerCase().includes(search.toLowerCase()) + : false + const subDescriptionMatch = + search && subItem.metadata + ? subItem.metadata.description.toLowerCase().includes(search.toLowerCase()) + : false + const subTagMatch = + tags.length > 0 ? Boolean(subItem.metadata?.tags?.some((tag) => tags.includes(tag))) : false + + const subItemMatches = + subTypeMatch && + (!search || subNameMatch || subDescriptionMatch) && + (!tags.length || subTagMatch) + + // Ensure all match properties are booleans + const matchInfo: MatchInfo = { + matched: Boolean(subItemMatches), + matchReason: subItemMatches + ? { + typeMatch: Boolean(subTypeMatch), + nameMatch: Boolean(subNameMatch), + descriptionMatch: Boolean(subDescriptionMatch), + tagMatch: Boolean(subTagMatch), + } + : undefined, + } + + return { + ...subItem, + matchInfo, + } + }) + } + + const hasMatchingSubcomponents = itemCopy.items?.some((subItem) => subItem.matchInfo?.matched) + + // Set match info on the main item + itemCopy.matchInfo = { + matched: mainItemMatches || Boolean(hasMatchingSubcomponents), + matchReason: { + typeMatch, + nameMatch, + descriptionMatch, + tagMatch, + hasMatchingSubcomponents: Boolean(hasMatchingSubcomponents), + }, + } + + // Return the item if it matches or has matching subcomponents + if (itemCopy.matchInfo.matched) { + return itemCopy + } + + return null + }) + .filter((item): item is MarketplaceItem => item !== null) + } + + private sortItems(items: MarketplaceItem[]): MarketplaceItem[] { + const { by, order } = this.state.sortConfig + const itemsCopy = [...items] + + return itemsCopy.sort((a, b) => { + const aValue = by === "lastUpdated" ? a[by] || "1970-01-01T00:00:00Z" : a[by] || "" + const bValue = by === "lastUpdated" ? b[by] || "1970-01-01T00:00:00Z" : b[by] || "" + + return order === "asc" ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue) + }) + } + + public async handleMessage(message: any): Promise { + // Handle empty or invalid message + if (!message || !message.type || message.type === "invalidType") { + const { sources } = this.state + this.state = { + ...this.getDefaultState(), + sources: [...sources], + } + this.notifyStateChange() + return + } + + // Handle state updates + if (message.type === "state") { + // Handle empty state + if (!message.state) { + const { sources } = this.state + this.state = { + ...this.getDefaultState(), + sources: [...sources], + } + this.notifyStateChange() + return + } + + // Update sources if present + if (message.state.sources || message.state.marketplaceSources) { + const sources = message.state.marketplaceSources || message.state.sources + this.state = { + ...this.state, + sources: sources?.length > 0 ? [...sources] : [DEFAULT_MARKETPLACE_SOURCE], + } + this.notifyStateChange() + } + + // Handle state updates for marketplace items + if (message.state.marketplaceItems !== undefined) { + const newItems = message.state.marketplaceItems + const currentItems = this.state.allItems || [] + const hasNewItems = newItems.length > 0 + const hasCurrentItems = currentItems.length > 0 + const isOnBrowseTab = this.state.activeTab === "browse" + + // Determine which items to use + const itemsToUse = hasNewItems ? newItems : isOnBrowseTab && hasCurrentItems ? currentItems : [] + const sortedItems = this.sortItems([...itemsToUse]) + const newDisplayItems = this.isFilterActive() ? this.filterItems(sortedItems) : sortedItems + + // Update state in a single operation + this.state = { + ...this.state, + isFetching: false, + allItems: sortedItems, + displayItems: newDisplayItems, + } + this.notifyStateChange() + } + } + + // Handle repository refresh completion + if (message.type === "repositoryRefreshComplete" && message.url) { + void this.transition({ + type: "REFRESH_SOURCE_COMPLETE", + payload: { url: message.url }, + }) + } + + // Handle marketplace button clicks + if (message.type === "marketplaceButtonClicked") { + if (message.text) { + // Error case + void this.transition({ type: "FETCH_ERROR" }) + } else { + // Refresh request + void this.transition({ type: "FETCH_ITEMS" }) + } + } + } +} diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx new file mode 100644 index 0000000000..f669e48eb0 --- /dev/null +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx @@ -0,0 +1,53 @@ +import React from "react" +import { render, fireEvent, screen } from "@testing-library/react" +import { MarketplaceSourcesConfig } from "../MarketplaceView" + +// Mock the translation hook +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, // Return the key as-is for testing + }), +})) + +describe("MarketplaceSourcesConfig", () => { + const mockOnSourcesChange = jest.fn() + const mockOnRefreshSource = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + test("should accept multi-part corporate git URLs", () => { + render( + , + ) + + // Get the URL input + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + + // Type a multi-part corporate git URL + const gitUrl = "git@git.lab.company.com:team-core/project-name.git" + fireEvent.change(urlInput, { target: { value: gitUrl } }) + + // Click the add button + const addButton = screen.getByText("marketplace:sources.add.button") + fireEvent.click(addButton) + + // Verify the source was added without validation errors + expect(mockOnSourcesChange).toHaveBeenCalledWith([ + expect.objectContaining({ + url: gitUrl, + enabled: true, + }), + ]) + + // Verify no error message is shown + const errorElement = screen.queryByText("marketplace:sources.errors.invalidUrl") + expect(errorElement).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/package-manager/__tests__/PackageManagerViewStateManager.test.ts b/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts similarity index 79% rename from webview-ui/src/components/package-manager/__tests__/PackageManagerViewStateManager.test.ts rename to webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts index cb63900285..b3f02b459d 100644 --- a/webview-ui/src/components/package-manager/__tests__/PackageManagerViewStateManager.test.ts +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts @@ -1,13 +1,9 @@ -import { PackageManagerViewStateManager } from "../PackageManagerViewStateManager" +import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager" import { vscode } from "../../../utils/vscode" -import { - ComponentType, - PackageManagerItem, - PackageManagerSource, -} from "../../../../../src/services/package-manager/types" -import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../../../../src/services/package-manager/constants" +import { ComponentType, MarketplaceItem, MarketplaceSource } from "../../../../../src/services/marketplace/types" +import { DEFAULT_MARKETPLACE_SOURCE } from "../../../../../src/services/marketplace/constants" -const createTestItem = (overrides = {}): PackageManagerItem => ({ +const createTestItem = (overrides = {}): MarketplaceItem => ({ name: "test", type: "mode" as ComponentType, description: "Test mode", @@ -20,7 +16,7 @@ const createTestItem = (overrides = {}): PackageManagerItem => ({ ...overrides, }) -const createTestSources = (): PackageManagerSource[] => [ +const createTestSources = (): MarketplaceSource[] => [ { url: "https://github.com/test/repo1", enabled: true }, { url: "https://github.com/test/repo2", enabled: true }, { url: "https://github.com/test/repo3", enabled: true }, @@ -33,13 +29,13 @@ jest.mock("../../../utils/vscode", () => ({ }, })) -describe("PackageManagerViewStateManager", () => { - let manager: PackageManagerViewStateManager +describe("MarketplaceViewStateManager", () => { + let manager: MarketplaceViewStateManager beforeEach(() => { jest.clearAllMocks() jest.useFakeTimers() - manager = new PackageManagerViewStateManager() + manager = new MarketplaceViewStateManager() manager.initialize() // Send initial sources }) @@ -57,7 +53,7 @@ describe("PackageManagerViewStateManager", () => { isFetching: false, activeTab: "browse", refreshingUrls: [], - sources: [DEFAULT_PACKAGE_MANAGER_SOURCE], + sources: [DEFAULT_MARKETPLACE_SOURCE], filters: { type: "", search: "", @@ -73,13 +69,13 @@ describe("PackageManagerViewStateManager", () => { it("should send initial sources when initialized", () => { manager.initialize() expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "packageManagerSources", - sources: [DEFAULT_PACKAGE_MANAGER_SOURCE], + type: "marketplaceSources", + sources: [DEFAULT_MARKETPLACE_SOURCE], }) }) it("should initialize with default source", () => { - const manager = new PackageManagerViewStateManager() + const manager = new MarketplaceViewStateManager() // Initial state should include default source const state = manager.getState() @@ -93,7 +89,7 @@ describe("PackageManagerViewStateManager", () => { // Verify initial message was sent to update sources expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "packageManagerSources", + type: "marketplaceSources", sources: [ { url: "https://github.com/RooVetGit/Roo-Code-Marketplace", @@ -111,7 +107,7 @@ describe("PackageManagerViewStateManager", () => { await manager.transition({ type: "FETCH_ITEMS" }) expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "fetchPackageManagerItems", + type: "fetchMarketplaceItems", bool: true, }) @@ -155,34 +151,123 @@ describe("PackageManagerViewStateManager", () => { }) describe("Race Conditions", () => { - it("should handle rapid tab switching during initial load", async () => { - // Start initial load - await manager.transition({ type: "FETCH_ITEMS" }) + it("should maintain items state when repeatedly switching tabs", async () => { + // Start with initial items + const initialItems = [createTestItem({ name: "Initial Item" })] + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: initialItems }, + }) - // Quickly switch to sources tab + // First switch to sources await manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "sources" }, }) - // Switch back to browse before load completes + // Switch back to browse await manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "browse" }, }) - // Complete the initial load + // Verify items are preserved after first switch + let state = manager.getState() + expect(state.displayItems).toEqual(initialItems) + expect(state.allItems).toEqual(initialItems) + + // Simulate receiving empty response during fetch await manager.handleMessage({ type: "state", - state: { packageManagerItems: [createTestItem()] }, + state: { marketplaceItems: [] }, }) - const state = manager.getState() - expect(state.activeTab).toBe("browse") - expect(state.allItems).toHaveLength(1) + // Verify items are still preserved + state = manager.getState() + expect(state.displayItems).toEqual(initialItems) + expect(state.allItems).toEqual(initialItems) + + // Switch to sources again + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + // Switch back to browse again + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + // Verify items are still preserved after second switch + state = manager.getState() + expect(state.displayItems).toEqual(initialItems) + expect(state.allItems).toEqual(initialItems) + + // Simulate another empty response + await manager.handleMessage({ + type: "state", + state: { marketplaceItems: [] }, + }) + + // Final verification that items are still preserved + state = manager.getState() + expect(state.displayItems).toEqual(initialItems) + expect(state.allItems).toEqual(initialItems) + }) + + it("should preserve items when receiving empty response", async () => { + // Start with initial items + const initialItems = [createTestItem({ name: "Initial Item" })] + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: initialItems }, + }) + + // Verify initial state + let state = manager.getState() + expect(state.allItems).toEqual(initialItems) + expect(state.displayItems).toEqual(initialItems) + + // Simulate receiving an empty response + await manager.handleMessage({ + type: "state", + state: { marketplaceItems: [] }, + }) + + // Verify items are preserved + state = manager.getState() + expect(state.allItems).toEqual(initialItems) + expect(state.displayItems).toEqual(initialItems) expect(state.isFetching).toBe(false) }) + it("should preserve items when switching tabs", async () => { + // Start with initial items + const initialItems = [createTestItem({ name: "Initial Item" })] + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: initialItems }, + }) + + // Switch to sources tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + // Switch back to browse + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + // Verify that items are preserved + const state = manager.getState() + expect(state.displayItems).toEqual(initialItems) + expect(state.allItems).toEqual(initialItems) + }) + it("should handle rapid filtering during initial load", async () => { // Start initial load await manager.transition({ type: "FETCH_ITEMS" }) @@ -196,7 +281,7 @@ describe("PackageManagerViewStateManager", () => { // Complete the initial load await manager.handleMessage({ type: "state", - state: { packageManagerItems: [createTestItem()] }, + state: { marketplaceItems: [createTestItem()] }, }) // Fast-forward past debounce time @@ -208,7 +293,7 @@ describe("PackageManagerViewStateManager", () => { expect(state.displayItems).toBeDefined() expect(vscode.postMessage).toHaveBeenCalledWith( expect.objectContaining({ - type: "filterPackageManagerItems", + type: "filterMarketplaceItems", filters: expect.objectContaining({ type: "mode" }), }), ) @@ -233,7 +318,7 @@ describe("PackageManagerViewStateManager", () => { // Each filter update should be sent immediately expect(vscode.postMessage).toHaveBeenCalledTimes(2) expect(vscode.postMessage).toHaveBeenLastCalledWith({ - type: "filterPackageManagerItems", + type: "filterMarketplaceItems", filters: { search: "test", type: "mode", @@ -266,21 +351,27 @@ describe("PackageManagerViewStateManager", () => { // Get all calls to postMessage const calls = (vscode.postMessage as jest.Mock).mock.calls - const sourcesMessages = calls.filter((call) => call[0].type === "packageManagerSources") + const sourcesMessages = calls.filter((call) => call[0].type === "marketplaceSources") const lastSourcesMessage = sourcesMessages[sourcesMessages.length - 1] // Verify state has default source const state = manager.getState() - expect(state.sources).toEqual([DEFAULT_PACKAGE_MANAGER_SOURCE]) + expect(state.sources).toEqual([DEFAULT_MARKETPLACE_SOURCE]) // Verify the last sources message was sent with default source expect(lastSourcesMessage[0]).toEqual({ - type: "packageManagerSources", - sources: [DEFAULT_PACKAGE_MANAGER_SOURCE], + type: "marketplaceSources", + sources: [DEFAULT_MARKETPLACE_SOURCE], }) }) - it("should handle rapid source operations during fetch", async () => { + it("should handle rapid source operations during fetch when in browse tab", async () => { + // Switch to browse tab first + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + // Start a fetch await manager.transition({ type: "FETCH_ITEMS" }) @@ -295,7 +386,7 @@ describe("PackageManagerViewStateManager", () => { // Complete the fetch await manager.handleMessage({ type: "state", - state: { packageManagerItems: [createTestItem()] }, + state: { marketplaceItems: [createTestItem()] }, }) const state = manager.getState() @@ -303,38 +394,6 @@ describe("PackageManagerViewStateManager", () => { expect(state.allItems).toHaveLength(1) expect(state.isFetching).toBe(false) }) - - it("should trigger fetch after adding a new source and switching to browse", async () => { - // Reset mock before test - ;(vscode.postMessage as jest.Mock).mockClear() - - // Add a new source - const newSource = { url: "https://github.com/test/repo1", enabled: true } - await manager.transition({ - type: "UPDATE_SOURCES", - payload: { sources: [DEFAULT_PACKAGE_MANAGER_SOURCE, newSource] }, - }) - - // Switch to browse tab - await manager.transition({ - type: "SET_ACTIVE_TAB", - payload: { tab: "browse" }, - }) - - // Run any pending timers - jest.runAllTimers() - - // Verify that a fetch was triggered - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "fetchPackageManagerItems", - bool: true, - }) - - // Verify state - const state = manager.getState() - expect(state.isFetching).toBe(true) - expect(state.activeTab).toBe("browse") - }) }) describe("Error Handling", () => { @@ -399,7 +458,7 @@ describe("PackageManagerViewStateManager", () => { // Should send all updates immediately expect(vscode.postMessage).toHaveBeenCalledTimes(3) expect(vscode.postMessage).toHaveBeenLastCalledWith({ - type: "filterPackageManagerItems", + type: "filterMarketplaceItems", filters: { type: "", search: "test3", @@ -437,7 +496,7 @@ describe("PackageManagerViewStateManager", () => { // Should send filter message with empty filters immediately expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "filterPackageManagerItems", + type: "filterMarketplaceItems", filters: { type: "", search: "", @@ -476,7 +535,7 @@ describe("PackageManagerViewStateManager", () => { // Should maintain type filter when search is cleared expect(vscode.postMessage).toHaveBeenLastCalledWith({ - type: "filterPackageManagerItems", + type: "filterMarketplaceItems", filters: { type: "mode", search: "", @@ -513,9 +572,9 @@ describe("PackageManagerViewStateManager", () => { expect(state.refreshingUrls).not.toContain(url) }) - it("should handle package manager button click with error", () => { + it("should handle marketplace button click with error", () => { manager.handleMessage({ - type: "packageManagerButtonClicked", + type: "marketplaceButtonClicked", text: "error", }) @@ -523,15 +582,15 @@ describe("PackageManagerViewStateManager", () => { expect(state.isFetching).toBe(false) }) - it("should handle package manager button click for refresh", () => { + it("should handle marketplace button click for refresh", () => { manager.handleMessage({ - type: "packageManagerButtonClicked", + type: "marketplaceButtonClicked", }) const state = manager.getState() expect(state.isFetching).toBe(true) expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "fetchPackageManagerItems", + type: "fetchMarketplaceItems", bool: true, }) }) @@ -548,20 +607,55 @@ describe("PackageManagerViewStateManager", () => { expect(state.activeTab).toBe("sources") }) - it("should trigger fetch when switching to browse tab with no items", async () => { + it("should trigger initial fetch when switching to browse with no items", async () => { jest.clearAllMocks() // Clear mock to ignore initialize() call + + // Start in sources tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + // Switch to browse tab await manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "browse" }, }) expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "fetchPackageManagerItems", + type: "fetchMarketplaceItems", bool: true, }) }) - it("should not trigger fetch when switching to browse tab with existing items", async () => { + it("should not trigger fetch when switching to browse with existing items", async () => { + jest.clearAllMocks() // Clear mock to ignore initialize() call + + // Add some items first + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: [createTestItem()] }, + }) + + // Switch to sources tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + // Switch back to browse tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + expect(vscode.postMessage).not.toHaveBeenCalledWith({ + type: "fetchMarketplaceItems", + bool: true, + }) + }) + + it("should automatically fetch when sources are modified and viewing browse tab", async () => { jest.clearAllMocks() // Clear mock to ignore initialize() call // Add some items first @@ -576,36 +670,15 @@ describe("PackageManagerViewStateManager", () => { payload: { tab: "browse" }, }) - expect(vscode.postMessage).not.toHaveBeenCalledWith({ - type: "fetchPackageManagerItems", - bool: true, - }) - }) - - it("should trigger fetch when switching to browse tab after source modification", async () => { - jest.clearAllMocks() // Clear mock to ignore initialize() call - - // Add some items first - await manager.transition({ - type: "FETCH_COMPLETE", - payload: { items: [createTestItem()] }, - }) - // Modify sources await manager.transition({ type: "UPDATE_SOURCES", payload: { sources: [{ url: "https://github.com/test/repo1", enabled: true }] }, }) - // Switch to browse tab - await manager.transition({ - type: "SET_ACTIVE_TAB", - payload: { tab: "browse" }, - }) - // Should trigger fetch due to source modification expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "fetchPackageManagerItems", + type: "fetchMarketplaceItems", bool: true, }) }) @@ -617,7 +690,7 @@ describe("PackageManagerViewStateManager", () => { }) expect(vscode.postMessage).not.toHaveBeenCalledWith({ - type: "fetchPackageManagerItems", + type: "fetchMarketplaceItems", bool: true, }) }) @@ -679,13 +752,19 @@ describe("PackageManagerViewStateManager", () => { jest.useRealTimers() }) - it("should trigger fetch for remaining source after source deletion", async () => { + it("should trigger fetch for remaining source after source deletion when in browse tab", async () => { // Start with two sources const sources = [ { url: "https://github.com/test/repo1", enabled: true }, { url: "https://github.com/test/repo2", enabled: true }, ] + // Switch to browse tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + await manager.transition({ type: "UPDATE_SOURCES", payload: { sources }, @@ -702,7 +781,7 @@ describe("PackageManagerViewStateManager", () => { // Verify that a fetch was triggered for the remaining source expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "fetchPackageManagerItems", + type: "fetchMarketplaceItems", bool: true, }) @@ -737,11 +816,11 @@ describe("PackageManagerViewStateManager", () => { // Get all calls to postMessage const calls = (vscode.postMessage as jest.Mock).mock.calls - const sourcesMessage = calls.find((call) => call[0].type === "packageManagerSources") + const sourcesMessage = calls.find((call) => call[0].type === "marketplaceSources") // Verify that the sources message was sent with default source expect(sourcesMessage[0]).toEqual({ - type: "packageManagerSources", + type: "marketplaceSources", sources: [ { url: "https://github.com/RooVetGit/Roo-Code-Marketplace", @@ -766,7 +845,7 @@ describe("PackageManagerViewStateManager", () => { const state = manager.getState() expect(state.sources).toEqual(sources) expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "packageManagerSources", + type: "marketplaceSources", sources, }) }) @@ -782,7 +861,7 @@ describe("PackageManagerViewStateManager", () => { const state = manager.getState() expect(state.refreshingUrls).toContain(url) expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "refreshPackageManagerSource", + type: "refreshMarketplaceSource", url, }) }) @@ -833,7 +912,7 @@ describe("PackageManagerViewStateManager", () => { manager.handleMessage({ type: "state", state: { - packageManagerItems: [initialItems[0]], // Only Item 1 + marketplaceItems: [initialItems[0]], // Only Item 1 }, }) @@ -861,7 +940,7 @@ describe("PackageManagerViewStateManager", () => { jest.advanceTimersByTime(300) expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "filterPackageManagerItems", + type: "filterMarketplaceItems", filters: { type: "mode", search: "test", @@ -973,7 +1052,7 @@ describe("PackageManagerViewStateManager", () => { }) describe("Message Handling", () => { - it("should restore sources from packageManagerSources on webview launch", () => { + it("should restore sources from marketplaceSources on webview launch", () => { const savedSources = [ { url: "https://github.com/RooVetGit/Roo-Code-Marketplace", @@ -990,7 +1069,7 @@ describe("PackageManagerViewStateManager", () => { // Simulate VS Code restart by sending initial state with saved sources manager.handleMessage({ type: "state", - state: { packageManagerSources: savedSources }, + state: { marketplaceSources: savedSources }, }) const state = manager.getState() @@ -1000,11 +1079,11 @@ describe("PackageManagerViewStateManager", () => { it("should use default source when state message has no sources", () => { manager.handleMessage({ type: "state", - state: { packageManagerItems: [] }, + state: { marketplaceItems: [] }, }) const state = manager.getState() - expect(state.sources).toEqual([DEFAULT_PACKAGE_MANAGER_SOURCE]) + expect(state.sources).toEqual([DEFAULT_MARKETPLACE_SOURCE]) }) it("should update sources when receiving state message", () => { @@ -1030,13 +1109,13 @@ describe("PackageManagerViewStateManager", () => { expect(state.sources).toEqual(customSources) }) - it("should handle state message with package manager items", () => { + it("should handle state message with marketplace items", () => { const testItems = [createTestItem()] // We need to use any here since we're testing the raw message handling manager.handleMessage({ type: "state", - state: { packageManagerItems: testItems }, + state: { marketplaceItems: testItems }, } as any) const state = manager.getState() @@ -1062,9 +1141,9 @@ describe("PackageManagerViewStateManager", () => { expect(state.refreshingUrls).not.toContain(url) }) - it("should handle packageManagerButtonClicked message with error", () => { + it("should handle marketplaceButtonClicked message with error", () => { manager.handleMessage({ - type: "packageManagerButtonClicked", + type: "marketplaceButtonClicked", text: "error", }) @@ -1072,9 +1151,9 @@ describe("PackageManagerViewStateManager", () => { expect(state.isFetching).toBe(false) }) - it("should handle packageManagerButtonClicked message for refresh", () => { + it("should handle marketplaceButtonClicked message for refresh", () => { manager.handleMessage({ - type: "packageManagerButtonClicked", + type: "marketplaceButtonClicked", }) const state = manager.getState() diff --git a/webview-ui/src/components/package-manager/components/ExpandableSection.tsx b/webview-ui/src/components/marketplace/components/ExpandableSection.tsx similarity index 100% rename from webview-ui/src/components/package-manager/components/ExpandableSection.tsx rename to webview-ui/src/components/marketplace/components/ExpandableSection.tsx diff --git a/webview-ui/src/components/package-manager/components/PackageManagerItemCard.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx similarity index 75% rename from webview-ui/src/components/package-manager/components/PackageManagerItemCard.tsx rename to webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx index f3ee454a6e..b7d2c20f09 100644 --- a/webview-ui/src/components/package-manager/components/PackageManagerItemCard.tsx +++ b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx @@ -1,22 +1,22 @@ import React, { useMemo, useCallback } from "react" import { Button } from "@/components/ui/button" -import { PackageManagerItem } from "../../../../../src/services/package-manager/types" +import { MarketplaceItem } from "../../../../../src/services/marketplace/types" import { vscode } from "@/utils/vscode" import { groupItemsByType, GroupedItems } from "../utils/grouping" import { ExpandableSection } from "./ExpandableSection" import { TypeGroup } from "./TypeGroup" -import { ViewState } from "../PackageManagerViewStateManager" +import { ViewState } from "../MarketplaceViewStateManager" import { useAppTranslation } from "@/i18n/TranslationContext" -interface PackageManagerItemCardProps { - item: PackageManagerItem +interface MarketplaceItemCardProps { + item: MarketplaceItem filters: ViewState["filters"] setFilters: (filters: Partial) => void activeTab: ViewState["activeTab"] setActiveTab: (tab: ViewState["activeTab"]) => void } -export const PackageManagerItemCard: React.FC = ({ +export const MarketplaceItemCard: React.FC = ({ item, filters, setFilters, @@ -36,15 +36,15 @@ export const PackageManagerItemCard: React.FC = ({ const typeLabel = useMemo(() => { switch (item.type) { case "mode": - return t("package-manager:filters.type.mode") + return t("marketplace:filters.type.mode") case "mcp server": - return t("package-manager:filters.type.mcp server") + return t("marketplace:filters.type.mcp server") case "prompt": - return t("package-manager:filters.type.prompt") + return t("marketplace:filters.type.prompt") case "package": - return t("package-manager:filters.type.package") + return t("marketplace:filters.type.package") default: - return t("package-manager:filters.type.all") + return t("marketplace:filters.type.all") } }, [item.type, t]) @@ -112,7 +112,7 @@ export const PackageManagerItemCard: React.FC = ({ url: item.authorUrl, }) }}> - {t("package-manager:items.card.by", { author: item.author })} + {t("marketplace:items.card.by", { author: item.author })} ) : ( )}

) : item.author ? (

- {t("package-manager:items.card.by", { author: item.author })} + {t("marketplace:items.card.by", { author: item.author })}

) : null}
@@ -165,8 +165,8 @@ export const PackageManagerItemCard: React.FC = ({ }} title={ filters.tags.includes(tag) - ? t("package-manager:filters.tags.clear", { count: tag }) - : t("package-manager:filters.tags.clickToFilter") + ? t("marketplace:filters.tags.clear", { count: tag }) + : t("marketplace:filters.tags.clickToFilter") }> {tag} @@ -199,31 +199,33 @@ export const PackageManagerItemCard: React.FC = ({ aria-label={ item.sourceUrl && isValidUrl(item.sourceUrl) ? "" - : item.sourceName || t("package-manager:items.card.viewSource") + : item.sourceName || t("marketplace:items.card.viewSource") }> {(!item.sourceUrl || !isValidUrl(item.sourceUrl)) && - (item.sourceName || t("package-manager:items.card.viewSource"))} + (item.sourceName || t("marketplace:items.card.viewSource"))}
-
- { - const matchCount = item.items?.filter((subItem) => subItem.matchInfo?.matched).length ?? 0 - return matchCount > 0 ? t("package-manager:items.components", { count: matchCount }) : undefined - })()} - defaultExpanded={item.items?.some((subItem) => subItem.matchInfo?.matched) ?? false}> -
- {groupedItems && - Object.entries(groupedItems).map(([type, group]) => ( - - ))} -
-
-
+ {item.type === "package" && ( +
+ { + const matchCount = item.items?.filter((subItem) => subItem.matchInfo?.matched).length ?? 0 + return matchCount > 0 ? t("marketplace:items.components", { count: matchCount }) : undefined + })()} + defaultExpanded={item.items?.some((subItem) => subItem.matchInfo?.matched) ?? false}> +
+ {groupedItems && + Object.entries(groupedItems).map(([type, group]) => ( + + ))} +
+
+
+ )}
) } diff --git a/webview-ui/src/components/package-manager/components/TypeGroup.tsx b/webview-ui/src/components/marketplace/components/TypeGroup.tsx similarity index 86% rename from webview-ui/src/components/package-manager/components/TypeGroup.tsx rename to webview-ui/src/components/marketplace/components/TypeGroup.tsx index 4f714dbfdd..5b21bfc560 100644 --- a/webview-ui/src/components/package-manager/components/TypeGroup.tsx +++ b/webview-ui/src/components/marketplace/components/TypeGroup.tsx @@ -22,15 +22,15 @@ export const TypeGroup: React.FC = ({ type, items, className }) const typeLabel = useMemo(() => { switch (type) { case "mode": - return t("package-manager:type-group.modes") + return t("marketplace:type-group.modes") case "mcp server": - return t("package-manager:type-group.mcp-servers") + return t("marketplace:type-group.mcp-servers") case "prompt": - return t("package-manager:type-group.prompts") + return t("marketplace:type-group.prompts") case "package": - return t("package-manager:type-group.packages") + return t("marketplace:type-group.packages") default: - return t("package-manager:type-group.generic-type", { + return t("marketplace:type-group.generic-type", { type: type.charAt(0).toUpperCase() + type.slice(1), }) } @@ -57,7 +57,7 @@ export const TypeGroup: React.FC = ({ type, items, className }) )} {item.matchInfo?.matched && ( - {t("package-manager:type-group.match")} + {t("marketplace:type-group.match")} )} diff --git a/webview-ui/src/components/package-manager/components/__tests__/ExpandableSection.test.tsx b/webview-ui/src/components/marketplace/components/__tests__/ExpandableSection.test.tsx similarity index 100% rename from webview-ui/src/components/package-manager/components/__tests__/ExpandableSection.test.tsx rename to webview-ui/src/components/marketplace/components/__tests__/ExpandableSection.test.tsx diff --git a/webview-ui/src/components/package-manager/components/__tests__/PackageManagerItemCard.test.tsx b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx similarity index 72% rename from webview-ui/src/components/package-manager/components/__tests__/PackageManagerItemCard.test.tsx rename to webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx index f7429c7e26..5fd21679c9 100644 --- a/webview-ui/src/components/package-manager/components/__tests__/PackageManagerItemCard.test.tsx +++ b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx @@ -1,7 +1,7 @@ import React from "react" import { screen, fireEvent } from "@testing-library/react" -import { PackageManagerItemCard } from "../PackageManagerItemCard" -import { PackageManagerItem } from "../../../../../../src/services/package-manager/types" +import { MarketplaceItemCard } from "../MarketplaceItemCard" +import { MarketplaceItem } from "../../../../../../src/services/marketplace/types" import { renderWithProviders } from "@/test/test-utils" // Mock vscode API @@ -12,8 +12,8 @@ jest.mock("@/utils/vscode", () => ({ }, })) -describe("PackageManagerItemCard", () => { - const mockItem: PackageManagerItem = { +describe("MarketplaceItemCard", () => { + const mockItem: MarketplaceItem = { name: "Test Package", description: "A test package", type: "package", @@ -60,7 +60,7 @@ describe("PackageManagerItemCard", () => { }) it("should render basic item information", () => { - renderWithProviders() + renderWithProviders() expect(screen.getByText("Test Package")).toBeInTheDocument() expect(screen.getByText("A test package")).toBeInTheDocument() @@ -79,7 +79,7 @@ describe("PackageManagerItemCard", () => { }) it("should render tags", () => { - renderWithProviders() + renderWithProviders() expect(screen.getByText("test")).toBeInTheDocument() expect(screen.getByText("mock")).toBeInTheDocument() @@ -87,7 +87,7 @@ describe("PackageManagerItemCard", () => { it("should handle tag clicks", () => { const setFilters = jest.fn() - renderWithProviders() + renderWithProviders() fireEvent.click(screen.getByText("test")) expect(setFilters).toHaveBeenCalledWith( @@ -98,7 +98,7 @@ describe("PackageManagerItemCard", () => { }) it("should render version and date information", () => { - renderWithProviders() + renderWithProviders() expect(screen.getByText("1.0.0")).toBeInTheDocument() // Use a regex to match the date since it depends on the timezone @@ -113,7 +113,7 @@ describe("PackageManagerItemCard", () => { defaultBranch: "main", path: "some/path", } - renderWithProviders() + renderWithProviders() const button = screen.getByRole("button", { name: /^$/ }) // Button with no text, only icon fireEvent.click(button) @@ -130,7 +130,7 @@ describe("PackageManagerItemCard", () => { defaultBranch: "main", path: "some/path", } - renderWithProviders() + renderWithProviders() const button = screen.getByRole("button", { name: /View/i }) fireEvent.click(button) @@ -145,7 +145,7 @@ describe("PackageManagerItemCard", () => { ...mockItem, sourceUrl: "https://example.com/direct-link", } - renderWithProviders() + renderWithProviders() // Find the source button by its empty aria-label const button = screen.getByRole("button", { @@ -156,7 +156,7 @@ describe("PackageManagerItemCard", () => { }) it("should show text label when sourceUrl is not present", () => { - renderWithProviders() + renderWithProviders() // Find the source button by its aria-label const button = screen.getByRole("button", { @@ -170,28 +170,28 @@ describe("PackageManagerItemCard", () => { describe("Details section", () => { it("should render expandable details section with correct count when item has no components", () => { const itemWithNoItems = { ...mockItem, items: [] } - renderWithProviders() + renderWithProviders() - // The component uses t("package-manager:items.components", { count: 0 }) + // The component uses t("marketplace:items.components", { count: 0 }) expect(screen.getByText("0 components")).toBeInTheDocument() }) it("should render expandable details section with correct count when item has components", () => { - renderWithProviders() + renderWithProviders() - // The component uses t("package-manager:items.components", { count: 2 }) + // The component uses t("marketplace:items.components", { count: 2 }) expect(screen.getByText("2 components")).toBeInTheDocument() }) it("should not render details section when item has no subcomponents", () => { const itemWithoutItems = { ...mockItem, items: [] } - renderWithProviders() + renderWithProviders() expect(screen.queryByText("Component Details")).not.toBeInTheDocument() }) it("should show grouped items when expanded", () => { - renderWithProviders() + renderWithProviders() fireEvent.click(screen.getByText("2 components")) // These use the type-group translations @@ -207,12 +207,33 @@ describe("PackageManagerItemCard", () => { }) it("should maintain proper order of items within groups", () => { - renderWithProviders() + renderWithProviders() fireEvent.click(screen.getByText("2 components")) const items = screen.getAllByRole("listitem") expect(items[0]).toHaveTextContent("Test Server") expect(items[1]).toHaveTextContent("Test Mode") }) + + it("should show expandable section for package type", () => { + const packageItem = { ...mockItem, type: "package" as const } + renderWithProviders() + + expect(screen.getByText("2 components")).toBeInTheDocument() + }) + + it("should not show expandable section for mode type", () => { + const modeItem = { ...mockItem, type: "mode" as const } + renderWithProviders() + + expect(screen.queryByText("2 components")).not.toBeInTheDocument() + }) + + it("should not show expandable section for mcp server type", () => { + const mcpServerItem = { ...mockItem, type: "mcp server" as const } + renderWithProviders() + + expect(screen.queryByText("2 components")).not.toBeInTheDocument() + }) }) }) diff --git a/webview-ui/src/components/package-manager/components/__tests__/TypeGroup.test.tsx b/webview-ui/src/components/marketplace/components/__tests__/TypeGroup.test.tsx similarity index 100% rename from webview-ui/src/components/package-manager/components/__tests__/TypeGroup.test.tsx rename to webview-ui/src/components/marketplace/components/__tests__/TypeGroup.test.tsx diff --git a/webview-ui/src/components/marketplace/useStateManager.ts b/webview-ui/src/components/marketplace/useStateManager.ts new file mode 100644 index 0000000000..dff2e9c474 --- /dev/null +++ b/webview-ui/src/components/marketplace/useStateManager.ts @@ -0,0 +1,43 @@ +import { useState, useEffect } from "react" +import { MarketplaceViewStateManager, ViewState } from "./MarketplaceViewStateManager" + +export function useStateManager(existingManager?: MarketplaceViewStateManager) { + const [manager] = useState(() => existingManager || new MarketplaceViewStateManager()) + const [state, setState] = useState(() => manager.getState()) + + useEffect(() => { + const handleStateChange = (newState: ViewState) => { + setState((prevState) => { + // Compare specific state properties that matter for rendering + const hasChanged = + prevState.isFetching !== newState.isFetching || + prevState.activeTab !== newState.activeTab || + prevState.allItems !== newState.allItems || + prevState.displayItems !== newState.displayItems || + prevState.filters !== newState.filters || + prevState.sources !== newState.sources || + prevState.refreshingUrls !== newState.refreshingUrls + + return hasChanged ? newState : prevState + }) + } + + const handleMessage = (event: MessageEvent) => { + manager.handleMessage(event.data) + } + + window.addEventListener("message", handleMessage) + const unsubscribe = manager.onStateChange(handleStateChange) + + return () => { + window.removeEventListener("message", handleMessage) + unsubscribe() + // Don't cleanup the manager if it was provided externally + if (!existingManager) { + manager.cleanup() + } + } + }, [manager, existingManager]) + + return [state, manager] as const +} diff --git a/webview-ui/src/components/package-manager/utils/__tests__/grouping.test.ts b/webview-ui/src/components/marketplace/utils/__tests__/grouping.test.ts similarity index 93% rename from webview-ui/src/components/package-manager/utils/__tests__/grouping.test.ts rename to webview-ui/src/components/marketplace/utils/__tests__/grouping.test.ts index fe025bc6d9..c6cbcc1891 100644 --- a/webview-ui/src/components/package-manager/utils/__tests__/grouping.test.ts +++ b/webview-ui/src/components/marketplace/utils/__tests__/grouping.test.ts @@ -1,5 +1,5 @@ import { groupItemsByType, formatItemText, getTotalItemCount, getUniqueTypes } from "../grouping" -import { PackageManagerItem } from "../../../../../../src/services/package-manager/types" +import { MarketplaceItem } from "../../../../../../src/services/marketplace/types" describe("grouping utilities", () => { const mockItems = [ @@ -30,7 +30,7 @@ describe("grouping utilities", () => { version: "1.1.0", }, }, - ] as PackageManagerItem["items"] + ] as MarketplaceItem["items"] describe("groupItemsByType", () => { it("should group items by type correctly", () => { @@ -55,7 +55,7 @@ describe("grouping utilities", () => { type: "mcp server", path: "test/path", }, - ] as PackageManagerItem["items"] + ] as MarketplaceItem["items"] const result = groupItemsByType(itemsWithMissingData) expect(result["mcp server"].items[0].name).toBe("Unnamed item") @@ -75,7 +75,7 @@ describe("grouping utilities", () => { path: "test/path", metadata: { name: "Test" }, }, - ] as PackageManagerItem["items"] + ] as MarketplaceItem["items"] const result = groupItemsByType(itemsWithoutType) expect(Object.keys(result)).toHaveLength(0) diff --git a/webview-ui/src/components/package-manager/utils/grouping.ts b/webview-ui/src/components/marketplace/utils/grouping.ts similarity index 93% rename from webview-ui/src/components/package-manager/utils/grouping.ts rename to webview-ui/src/components/marketplace/utils/grouping.ts index 592b1cc8ec..6089e4bc0a 100644 --- a/webview-ui/src/components/package-manager/utils/grouping.ts +++ b/webview-ui/src/components/marketplace/utils/grouping.ts @@ -1,4 +1,4 @@ -import { PackageManagerItem } from "../../../../../src/services/package-manager/types" +import { MarketplaceItem } from "../../../../../src/services/marketplace/types" export interface GroupedItems { [type: string]: { @@ -24,7 +24,7 @@ export interface GroupedItems { // Cache for group objects to avoid recreating them const groupCache = new Map() -export function groupItemsByType(items: PackageManagerItem["items"] = []): GroupedItems { +export function groupItemsByType(items: MarketplaceItem["items"] = []): GroupedItems { if (!items?.length) { return {} } diff --git a/webview-ui/src/components/package-manager/PackageManagerViewStateManager.ts b/webview-ui/src/components/package-manager/PackageManagerViewStateManager.ts deleted file mode 100644 index b5679ec58a..0000000000 --- a/webview-ui/src/components/package-manager/PackageManagerViewStateManager.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { PackageManagerItem, PackageManagerSource } from "../../../../src/services/package-manager/types" -import { vscode } from "../../utils/vscode" -import { WebviewMessage } from "../../../../src/shared/WebviewMessage" -import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../../../src/services/package-manager/constants" - -export interface ViewState { - allItems: PackageManagerItem[] - displayItems?: PackageManagerItem[] // Items currently being displayed (filtered or all) - isFetching: boolean - activeTab: "browse" | "sources" - refreshingUrls: string[] - sources: PackageManagerSource[] - filters: { - type: string - search: string - tags: string[] - } - sortConfig: { - by: "name" | "author" | "lastUpdated" - order: "asc" | "desc" - } -} - -type TransitionPayloads = { - FETCH_ITEMS: undefined - FETCH_COMPLETE: { items: PackageManagerItem[] } - FETCH_ERROR: undefined - SET_ACTIVE_TAB: { tab: ViewState["activeTab"] } - UPDATE_FILTERS: { filters: Partial } - UPDATE_SORT: { sortConfig: Partial } - REFRESH_SOURCE: { url: string } - REFRESH_SOURCE_COMPLETE: { url: string } - UPDATE_SOURCES: { sources: PackageManagerSource[] } -} - -export interface ViewStateTransition { - type: keyof TransitionPayloads - payload?: TransitionPayloads[keyof TransitionPayloads] -} - -export type StateChangeHandler = (state: ViewState) => void - -export class PackageManagerViewStateManager { - private state: ViewState = this.loadInitialState() - - private loadInitialState(): ViewState { - // Try to restore state from sessionStorage - const savedState = sessionStorage.getItem("packageManagerState") - if (savedState) { - try { - return JSON.parse(savedState) - } catch { - return this.getDefaultState() - } - } - return this.getDefaultState() - } - - private getDefaultState(): ViewState { - return { - allItems: [], - displayItems: [] as PackageManagerItem[], - isFetching: false, - activeTab: "browse", - refreshingUrls: [], - sources: [DEFAULT_PACKAGE_MANAGER_SOURCE], - filters: { - type: "", - search: "", - tags: [], - }, - sortConfig: { - by: "name", - order: "asc", - }, - } - } - private fetchTimeoutId?: NodeJS.Timeout - private readonly FETCH_TIMEOUT = 30000 // 30 seconds - private stateChangeHandlers: Set = new Set() - private sourcesModified = false // Track if sources have been modified - - public initialize(): void { - // Send initial sources to extension - vscode.postMessage({ - type: "packageManagerSources", - sources: [DEFAULT_PACKAGE_MANAGER_SOURCE], - } as WebviewMessage) - } - - public onStateChange(handler: StateChangeHandler): () => void { - this.stateChangeHandlers.add(handler) - return () => this.stateChangeHandlers.delete(handler) - } - - public cleanup(): void { - // Clear any pending timeouts - if (this.fetchTimeoutId) { - clearTimeout(this.fetchTimeoutId) - this.fetchTimeoutId = undefined - } - - // Reset fetching state - if (this.state.isFetching) { - this.state.isFetching = false - this.notifyStateChange() - } - - // Clear handlers but preserve state - this.stateChangeHandlers.clear() - } - - public getState(): ViewState { - // Only create new arrays if they exist and have items - const displayItems = this.state.displayItems?.length ? [...this.state.displayItems] : this.state.displayItems - const refreshingUrls = this.state.refreshingUrls.length ? [...this.state.refreshingUrls] : [] - const tags = this.state.filters.tags.length ? [...this.state.filters.tags] : [] - - // Create minimal new state object - return { - ...this.state, - allItems: this.state.allItems.length ? [...this.state.allItems] : [], - displayItems, - refreshingUrls, - sources: this.state.sources.length ? [...this.state.sources] : [DEFAULT_PACKAGE_MANAGER_SOURCE], - filters: { - ...this.state.filters, - tags, - }, - } - } - - private notifyStateChange(): void { - const newState = this.getState() // Use getState to ensure proper copying - this.stateChangeHandlers.forEach((handler) => { - handler(newState) - }) - - // Save state to sessionStorage - try { - sessionStorage.setItem("packageManagerState", JSON.stringify(this.state)) - } catch (error) { - console.warn("Failed to save package manager state:", error) - } - } - - public async transition(transition: ViewStateTransition): Promise { - switch (transition.type) { - case "FETCH_ITEMS": { - if (this.state.isFetching) { - return - } - - // Clear any existing timeout before starting new fetch - this.clearFetchTimeout() - - // Update state directly - this.state.isFetching = true - this.notifyStateChange() - - // Set timeout for fetch operation - this.fetchTimeoutId = setTimeout(() => { - void this.transition({ type: "FETCH_ERROR" }) - }, this.FETCH_TIMEOUT) - - // Request items from extension - vscode.postMessage({ - type: "fetchPackageManagerItems", - bool: true, - } as WebviewMessage) - - break - } - - case "FETCH_COMPLETE": { - const { items } = transition.payload as TransitionPayloads["FETCH_COMPLETE"] - // Clear any existing timeout - this.clearFetchTimeout() - - // Create a new state object with sorted items - // Sort items in place to avoid creating unnecessary copies - const sortedItems = this.sortItems(items) - - // Minimize state updates - if (this.isFilterActive()) { - this.state.displayItems = sortedItems - this.state.isFetching = false - } else { - this.state.allItems = sortedItems - this.state.displayItems = sortedItems - this.state.isFetching = false - } - - // Notify state change - this.notifyStateChange() - break - } - - case "FETCH_ERROR": { - this.clearFetchTimeout() - - // Update state directly - this.state.isFetching = false - this.notifyStateChange() - break - } - - case "SET_ACTIVE_TAB": { - const { tab } = transition.payload as TransitionPayloads["SET_ACTIVE_TAB"] - - // Update state directly - this.state.activeTab = tab - - // Add default source when switching to sources tab if no sources exist - if (tab === "sources" && this.state.sources.length === 0) { - this.state.sources = [DEFAULT_PACKAGE_MANAGER_SOURCE] - vscode.postMessage({ - type: "packageManagerSources", - sources: [DEFAULT_PACKAGE_MANAGER_SOURCE], - } as WebviewMessage) - } - - this.notifyStateChange() - - // Handle browse tab switch - if (tab === "browse") { - // Clear any existing timeouts - this.clearFetchTimeout() - - // Reset fetching state when switching tabs - if (this.state.isFetching) { - this.state.isFetching = false - this.notifyStateChange() - } - - // Restore previous display items if they exist - if (this.state.allItems.length > 0) { - if (this.isFilterActive()) { - // Re-apply filters to ensure display items are current - this.state.displayItems = this.filterItems(this.state.allItems) - } else { - // Use all items if no filters are active - this.state.displayItems = this.state.allItems - } - this.notifyStateChange() - } else if (this.sourcesModified) { - // Fetch new items only if sources were modified or we have no items - this.sourcesModified = false - void this.transition({ type: "FETCH_ITEMS" }) - } - } - break - } - - case "UPDATE_FILTERS": { - const { filters = {} } = (transition.payload as TransitionPayloads["UPDATE_FILTERS"]) || {} - // Create new filters object with explicit checks for undefined and proper defaults - const updatedFilters = { - type: "type" in filters ? filters.type || "" : this.state.filters.type, - search: "search" in filters ? filters.search || "" : this.state.filters.search, - tags: "tags" in filters ? filters.tags || [] : this.state.filters.tags, - } - - // Update state with new filters - this.state = { - ...this.state, - filters: updatedFilters, - } - - // If all filters are cleared, restore all items - if ( - !updatedFilters.type && - !updatedFilters.search && - (!updatedFilters.tags || updatedFilters.tags.length === 0) - ) { - this.state.displayItems = [...this.state.allItems] - this.notifyStateChange() - } else { - // Otherwise, apply the filters - this.notifyStateChange() - vscode.postMessage({ - type: "filterPackageManagerItems", - filters: updatedFilters, - } as WebviewMessage) - } - - break - } - - case "UPDATE_SORT": { - const { sortConfig } = transition.payload as TransitionPayloads["UPDATE_SORT"] - // Create new state with updated sort config - this.state = { - ...this.state, - sortConfig: { - ...this.state.sortConfig, - ...sortConfig, - }, - } - // Apply sorting to both allItems and displayItems - // Sort items immutably - // Sort arrays in place - if (this.state.allItems.length) { - this.sortItems(this.state.allItems) - } - if (this.state.displayItems?.length) { - this.sortItems(this.state.displayItems) - } - this.notifyStateChange() - break - } - - case "REFRESH_SOURCE": { - const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE"] - if (!this.state.refreshingUrls.includes(url)) { - this.state = { - ...this.state, - refreshingUrls: [...this.state.refreshingUrls, url], - } - this.notifyStateChange() - vscode.postMessage({ - type: "refreshPackageManagerSource", - url, - } as WebviewMessage) - } - break - } - - case "REFRESH_SOURCE_COMPLETE": { - const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE_COMPLETE"] - this.state = { - ...this.state, - refreshingUrls: this.state.refreshingUrls.filter((existingUrl) => existingUrl !== url), - } - this.notifyStateChange() - break - } - - case "UPDATE_SOURCES": { - const { sources } = transition.payload as TransitionPayloads["UPDATE_SOURCES"] - // If all sources are removed, add the default source - const updatedSources = sources.length === 0 ? [DEFAULT_PACKAGE_MANAGER_SOURCE] : [...sources] - this.state = { - ...this.state, - sources: updatedSources, - isFetching: false, // Reset fetching state first - } - this.sourcesModified = true // Set the flag when sources are modified - - this.notifyStateChange() - - // Send sources update to extension - vscode.postMessage({ - type: "packageManagerSources", - sources: updatedSources, - } as WebviewMessage) - - // Only start fetching if we have sources - if (updatedSources.length > 0) { - // Set fetching state and notify - this.state = { - ...this.state, - isFetching: true, - } - this.notifyStateChange() - - // Send fetch request - vscode.postMessage({ - type: "fetchPackageManagerItems", - bool: true, - } as WebviewMessage) - } - break - } - } - } - - private clearFetchTimeout(): void { - // Clear fetch timeout - if (this.fetchTimeoutId) { - clearTimeout(this.fetchTimeoutId) - this.fetchTimeoutId = undefined - } - } - - public isFilterActive(): boolean { - return !!(this.state.filters.type || this.state.filters.search || this.state.filters.tags.length > 0) - } - - public filterItems(items: PackageManagerItem[]): PackageManagerItem[] { - const { type, search, tags } = this.state.filters - - return items.filter((item) => { - // Check if the item itself matches all filters - const mainItemMatches = - (!type || item.type === type) && - (!search || - item.name.toLowerCase().includes(search.toLowerCase()) || - (item.description || "").toLowerCase().includes(search.toLowerCase()) || - (item.author || "").toLowerCase().includes(search.toLowerCase())) && - (!tags.length || item.tags?.some((tag) => tags.includes(tag))) - - if (mainItemMatches) return true - - // For packages, check if any subcomponent matches all filters - if (item.type === "package" && item.items?.length) { - return item.items.some( - (subItem) => - (!type || subItem.type === type) && - (!search || - (subItem.metadata && - (subItem.metadata.name.toLowerCase().includes(search.toLowerCase()) || - subItem.metadata.description.toLowerCase().includes(search.toLowerCase())))) && - (!tags.length || subItem.metadata?.tags?.some((tag) => tags.includes(tag))), - ) - } - - return false - }) - } - - private sortItems(items: PackageManagerItem[]): PackageManagerItem[] { - const { by, order } = this.state.sortConfig - - // Sort array in place - items.sort((a, b) => { - const aValue = by === "lastUpdated" ? a[by] || "1970-01-01T00:00:00Z" : a[by] || "" - const bValue = by === "lastUpdated" ? b[by] || "1970-01-01T00:00:00Z" : b[by] || "" - - return order === "asc" ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue) - }) - - return items - } - - public async handleMessage(message: any): Promise { - // Handle state updates from extension - if (message.type === "state") { - // Update sources from either sources or packageManagerSources in state - if (message.state?.sources || message.state?.packageManagerSources) { - const sources = message.state.packageManagerSources || message.state.sources - this.state = { - ...this.state, - sources: sources?.length > 0 ? [...sources] : [DEFAULT_PACKAGE_MANAGER_SOURCE], - } - this.notifyStateChange() - } - - if (message.state?.packageManagerItems) { - // Clear fetching state before updating items - this.state.isFetching = false - - void this.transition({ - type: "FETCH_COMPLETE", - payload: { items: message.state.packageManagerItems }, - }) - } - } - - // Handle repository refresh completion - if (message.type === "repositoryRefreshComplete" && message.url) { - void this.transition({ - type: "REFRESH_SOURCE_COMPLETE", - payload: { url: message.url }, - }) - } - - // Handle package manager button clicks - if (message.type === "packageManagerButtonClicked") { - if (message.text) { - // Error case - void this.transition({ type: "FETCH_ERROR" }) - } else { - // Refresh request - void this.transition({ type: "FETCH_ITEMS" }) - } - } - } -} diff --git a/webview-ui/src/components/package-manager/__tests__/PackageManagerView.test.tsx b/webview-ui/src/components/package-manager/__tests__/PackageManagerView.test.tsx deleted file mode 100644 index 96d92270b1..0000000000 --- a/webview-ui/src/components/package-manager/__tests__/PackageManagerView.test.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { render, screen, fireEvent, act } from "@testing-library/react" -import PackageManagerView from "../PackageManagerView" -import { ComponentMetadata, PackageManagerItem } from "../../../../../src/services/package-manager/types" -import { TranslationProvider } from "@/i18n/TranslationContext" - -// Mock vscode API for external communication -const mockPostMessage = jest.fn() -jest.mock("../../../utils/vscode", () => ({ - vscode: { - postMessage: (msg: any) => mockPostMessage(msg), - getState: () => undefined, - setState: (state: any) => state, - }, -})) - -// Mock ExtensionStateContext -jest.mock("../../../context/ExtensionStateContext", () => ({ - useExtensionState: () => ({ - packageManagerSources: [{ url: "test-url", enabled: true }], - setPackageManagerSources: jest.fn(), - language: "en", - experiments: { - search_and_replace: false, - insert_content: false, - powerSteering: false, - }, - }), -})) - -const mockMetadata: ComponentMetadata = { - name: "Test Server", - description: "A test server", - type: "mcp server", - version: "1.0.0", -} - -describe("PackageManagerView", () => { - beforeAll(() => { - jest.setTimeout(5000) // 5 second timeout for all tests - }) - - const mockItems: PackageManagerItem[] = [ - { - name: "Test Package", - description: "A test package", - type: "package", - repoUrl: "https://github.com/org/repo", - url: "test-url", - defaultBranch: "main", - 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(() => { - jest.useFakeTimers() - mockPostMessage.mockClear() - - // Mock window event listener to handle messages - const listeners = new Map() - window.addEventListener = jest.fn((event, handler) => { - if (event === "message") { - listeners.set("message", handler) - } else { - listeners.set(event, handler) - } - }) - window.removeEventListener = jest.fn() - window.dispatchEvent = jest.fn((event: Event) => { - const messageEvent = event as MessageEvent - const handler = listeners.get(messageEvent.type) - if (handler) { - handler(messageEvent) - } - return true - }) - }) - - const renderWithTranslation = (ui: React.ReactElement) => { - return render({ui}) - } - - it("should automatically fetch items on mount", async () => { - renderWithTranslation() - - // Should immediately trigger a fetch - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "fetchPackageManagerItems", - bool: true, - }) - - // Should show loading state - expect( - screen.getByText((content, element) => { - // Match either the translated text or the raw key - return content === "Refreshing..." || content === "items.refresh.refreshing" - }), - ).toBeInTheDocument() - - // Simulate receiving items - await act(async () => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - type: "state", - state: { - packageManagerItems: mockItems, - isFetching: false, - activeTab: "browse", - refreshingUrls: [], - sources: [], - filters: { type: "", search: "", tags: [] }, - sortConfig: { by: "name", order: "asc" }, - }, - }, - }), - ) - }) - - // Should show items - expect( - screen.getByText((content) => { - // Match either the translated text or the raw key - return content === "2 items found" || content === "items.count" - }), - ).toBeInTheDocument() - expect(screen.getByText("Test Package")).toBeInTheDocument() - expect(screen.getByText("Another Package")).toBeInTheDocument() - }) - - it("should update display items when receiving filtered results from backend", async () => { - renderWithTranslation() - - // Load initial items - await act(async () => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - type: "state", - state: { - packageManagerItems: [ - { - name: "MCP Server 1", - type: "mcp server", - repoUrl: "test-url-1", - url: "test-url-1", - }, - { - name: "Mode 1", - type: "mode", - repoUrl: "test-url-2", - url: "test-url-2", - }, - { - name: "MCP Server 2", - type: "mcp server", - repoUrl: "test-url-3", - url: "test-url-3", - }, - ], - isFetching: false, - activeTab: "browse", - refreshingUrls: [], - sources: [], - filters: { type: "", search: "", tags: [] }, - sortConfig: { by: "name", order: "asc" }, - }, - }, - }), - ) - }) - - // Verify initial items are shown - expect( - screen.getByText((content) => { - // Match either the translated text or the raw key - return content === "3 items found" || content === "items.count" - }), - ).toBeInTheDocument() - expect(screen.getByText("MCP Server 1")).toBeInTheDocument() - expect(screen.getByText("Mode 1")).toBeInTheDocument() - expect(screen.getByText("MCP Server 2")).toBeInTheDocument() - - // Select MCP Server from type filter - const typeFilter = screen.getByLabelText((content) => { - return content === "Filter by type:" || content === "filters.type.label" - }) - await act(async () => { - fireEvent.change(typeFilter, { target: { value: "mcp server" } }) - }) - - // Verify initial fetch and filter requests were sent - expect(mockPostMessage).toHaveBeenCalledTimes(2) - expect(mockPostMessage).toHaveBeenLastCalledWith({ - type: "filterPackageManagerItems", - filters: { type: "mcp server", search: "", tags: [] }, - }) - }) -}) diff --git a/webview-ui/src/components/package-manager/useStateManager.ts b/webview-ui/src/components/package-manager/useStateManager.ts deleted file mode 100644 index 818fd277a7..0000000000 --- a/webview-ui/src/components/package-manager/useStateManager.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useState, useEffect } from "react" -import { PackageManagerViewStateManager, ViewState } from "./PackageManagerViewStateManager" - -export function useStateManager(existingManager?: PackageManagerViewStateManager) { - const [manager] = useState(() => existingManager || new PackageManagerViewStateManager()) - const [state, setState] = useState(() => manager.getState()) - - useEffect(() => { - const handleStateChange = (newState: ViewState) => { - setState((prevState) => { - // Only update if something actually changed - if (JSON.stringify(prevState) === JSON.stringify(newState)) { - return prevState - } - return newState - }) - } - - const handleMessage = (event: MessageEvent) => { - manager.handleMessage(event.data) - } - - window.addEventListener("message", handleMessage) - const unsubscribe = manager.onStateChange(handleStateChange) - - return () => { - window.removeEventListener("message", handleMessage) - unsubscribe() - // Don't cleanup the manager if it was provided externally - if (!existingManager) { - manager.cleanup() - } - } - }, [manager, existingManager]) - - return [state, manager] as const -} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 74d96641ad..c00100fedc 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -11,8 +11,8 @@ import { Mode, CustomModePrompts, defaultModeSlug, defaultPrompts, ModeConfig } import { CustomSupportPrompts } from "../../../src/shared/support-prompt" import { experimentDefault, ExperimentId } from "../../../src/shared/experiments" import { TelemetrySetting } from "../../../src/shared/TelemetrySetting" -import { PackageManagerSource } from "../../../src/services/package-manager/types" -import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../../src/services/package-manager/constants" +import { MarketplaceSource } from "../../../src/services/marketplace/types" +import { DEFAULT_MARKETPLACE_SOURCE } from "../../../src/services/marketplace/constants" export interface ExtensionStateContextType extends ExtensionState { didHydrateState: boolean @@ -88,7 +88,7 @@ export interface ExtensionStateContextType extends ExtensionState { pinnedApiConfigs?: Record setPinnedApiConfigs: (value: Record) => void togglePinnedApiConfig: (configName: string) => void - setPackageManagerSources: (value: PackageManagerSource[]) => void + setMarketplaceSources: (value: MarketplaceSource[]) => void } export const ExtensionStateContext = createContext(undefined) @@ -161,7 +161,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior). renderContext: "sidebar", maxReadFileLine: 500, // Default max read file line limit - packageManagerSources: [DEFAULT_PACKAGE_MANAGER_SOURCE], + marketplaceSources: [DEFAULT_MARKETPLACE_SOURCE], pinnedApiConfigs: {}, // Empty object for pinned API configs terminalZshOhMy: false, // Default Oh My Zsh integration setting terminalZshP10k: false, // Default Powerlevel10k integration setting @@ -188,8 +188,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const newState = message.state! console.log("DEBUG: ExtensionStateContext received state message:", { hasApiConfig: !!newState.apiConfiguration, - hasPackageManagerItems: !!newState.packageManagerItems, - packageManagerItemsCount: newState.packageManagerItems?.length || 0, + hasMarketplaceItems: !!newState.marketplaceItems, + marketplaceItemsCount: newState.marketplaceItems?.length || 0, }) setState((prevState) => mergeExtensionState(prevState, newState)) @@ -349,7 +349,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode return { ...prevState, pinnedApiConfigs: newPinned } }), - setPackageManagerSources: (value) => setState((prevState) => ({ ...prevState, packageManagerSources: value })), + setMarketplaceSources: (value) => setState((prevState) => ({ ...prevState, marketplaceSources: value })), } return {children} diff --git a/webview-ui/src/i18n/locales/ca/package-manager.json b/webview-ui/src/i18n/locales/ca/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/ca/package-manager.json rename to webview-ui/src/i18n/locales/ca/marketplace.json diff --git a/webview-ui/src/i18n/locales/de/package-manager.json b/webview-ui/src/i18n/locales/de/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/de/package-manager.json rename to webview-ui/src/i18n/locales/de/marketplace.json diff --git a/webview-ui/src/i18n/locales/en/package-manager.json b/webview-ui/src/i18n/locales/en/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/en/package-manager.json rename to webview-ui/src/i18n/locales/en/marketplace.json diff --git a/webview-ui/src/i18n/locales/es/package-manager.json b/webview-ui/src/i18n/locales/es/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/es/package-manager.json rename to webview-ui/src/i18n/locales/es/marketplace.json diff --git a/webview-ui/src/i18n/locales/fr/package-manager.json b/webview-ui/src/i18n/locales/fr/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/fr/package-manager.json rename to webview-ui/src/i18n/locales/fr/marketplace.json diff --git a/webview-ui/src/i18n/locales/hi/package-manager.json b/webview-ui/src/i18n/locales/hi/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/hi/package-manager.json rename to webview-ui/src/i18n/locales/hi/marketplace.json diff --git a/webview-ui/src/i18n/locales/it/package-manager.json b/webview-ui/src/i18n/locales/it/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/it/package-manager.json rename to webview-ui/src/i18n/locales/it/marketplace.json diff --git a/webview-ui/src/i18n/locales/ja/package-manager.json b/webview-ui/src/i18n/locales/ja/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/ja/package-manager.json rename to webview-ui/src/i18n/locales/ja/marketplace.json diff --git a/webview-ui/src/i18n/locales/ko/package-manager.json b/webview-ui/src/i18n/locales/ko/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/ko/package-manager.json rename to webview-ui/src/i18n/locales/ko/marketplace.json diff --git a/webview-ui/src/i18n/locales/pl/package-manager.json b/webview-ui/src/i18n/locales/pl/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/pl/package-manager.json rename to webview-ui/src/i18n/locales/pl/marketplace.json diff --git a/webview-ui/src/i18n/locales/pt-BR/package-manager.json b/webview-ui/src/i18n/locales/pt-BR/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/pt-BR/package-manager.json rename to webview-ui/src/i18n/locales/pt-BR/marketplace.json diff --git a/webview-ui/src/i18n/locales/tr/package-manager.json b/webview-ui/src/i18n/locales/tr/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/tr/package-manager.json rename to webview-ui/src/i18n/locales/tr/marketplace.json diff --git a/webview-ui/src/i18n/locales/vi/package-manager.json b/webview-ui/src/i18n/locales/vi/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/vi/package-manager.json rename to webview-ui/src/i18n/locales/vi/marketplace.json diff --git a/webview-ui/src/i18n/locales/zh-CN/package-manager.json b/webview-ui/src/i18n/locales/zh-CN/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/zh-CN/package-manager.json rename to webview-ui/src/i18n/locales/zh-CN/marketplace.json diff --git a/webview-ui/src/i18n/locales/zh-TW/package-manager.json b/webview-ui/src/i18n/locales/zh-TW/marketplace.json similarity index 100% rename from webview-ui/src/i18n/locales/zh-TW/package-manager.json rename to webview-ui/src/i18n/locales/zh-TW/marketplace.json diff --git a/webview-ui/src/i18n/setup.ts b/webview-ui/src/i18n/setup.ts index 984b32c4c8..bae597277b 100644 --- a/webview-ui/src/i18n/setup.ts +++ b/webview-ui/src/i18n/setup.ts @@ -37,8 +37,8 @@ i18next.use(initReactI18next).init({ interpolation: { escapeValue: false, // React already escapes by default }, - defaultNS: "package-manager", - ns: ["package-manager"], + defaultNS: "marketplace", + ns: ["marketplace"], }) export function loadTranslations() { diff --git a/webview-ui/src/i18n/test-utils.ts b/webview-ui/src/i18n/test-utils.ts index 55208237df..daad16bdea 100644 --- a/webview-ui/src/i18n/test-utils.ts +++ b/webview-ui/src/i18n/test-utils.ts @@ -29,7 +29,7 @@ export const setupI18nForTests = () => { chat: { test: "Test", }, - "package-manager": { + marketplace: { items: { card: { by: "by {{author}}", diff --git a/webview-ui/src/test/test-utils.tsx b/webview-ui/src/test/test-utils.tsx index 09d10edf70..d26b31eb57 100644 --- a/webview-ui/src/test/test-utils.tsx +++ b/webview-ui/src/test/test-utils.tsx @@ -19,15 +19,15 @@ i18next.use(initReactI18next).init({ }, resources: { en: { - "package-manager": { - title: "Package Manager", + marketplace: { + title: "Marketplace", tabs: { browse: "Browse", sources: "Sources", }, filters: { search: { - placeholder: "Search package manager items...", + placeholder: "Search marketplace items...", }, type: { label: "Filter by type:", @@ -55,7 +55,7 @@ i18next.use(initReactI18next).init({ }, items: { empty: { - noItems: "No package manager items found", + noItems: "No marketplace items found", withFilters: "Try adjusting your filters", noSources: "Try adding a source in the Sources tab", }, @@ -88,8 +88,8 @@ i18next.use(initReactI18next).init({ // Minimal mock state const mockExtensionState = { language: "en", - packageManagerSources: [{ url: "test-url", enabled: true }], - setPackageManagerSources: jest.fn(), + marketplaceSources: [{ url: "test-url", enabled: true }], + setMarketplaceSources: jest.fn(), experiments: { search_and_replace: false, insert_content: false,