feat(marketplace): UI form for configurable install (#9)

* feat(wip): installation UI

* chore: rebase, refactor and fixes

---------

Co-authored-by: elianiva <51877647+elianiva@users.noreply.github.com>
This commit is contained in:
Trung Dang 2025-05-05 14:35:19 +07:00 committed by GitHub
parent bbd790f4bd
commit f89c11ec67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1937 additions and 302 deletions

View file

@ -1,6 +1,6 @@
import * as vscode from "vscode"
import { ClineProvider } from "./ClineProvider"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { installMarketplaceItemWithParametersPayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
import {
MarketplaceManager,
MarketplaceItemType,
@ -224,6 +224,32 @@ export async function handleMarketplaceMessages(
}
return true
}
case "installMarketplaceItemWithParameters":
if (message.payload) {
const result = installMarketplaceItemWithParametersPayloadSchema.safeParse(message.payload)
if (result.success) {
const { item, parameters } = result.data
try {
await marketplaceManager.installMarketplaceItem(item, { parameters })
} catch (error) {
console.error(`Error submitting marketplace parameters: ${error}`)
vscode.window.showErrorMessage(
`Failed to install item "${item.name}":\n${error instanceof Error ? error.message : String(error)}`,
)
}
} else {
console.error("Invalid payload for installMarketplaceItemWithParameters message:", message.payload)
vscode.window.showErrorMessage(
'Invalid "payload" received for installation: item or parameters missing.',
)
}
}
return true
case "cancelMarketplaceInstall":
vscode.window.showInformationMessage("Marketplace installation cancelled.")
return true
case "refreshMarketplaceSource": {
if (message.url) {

View file

@ -1283,6 +1283,8 @@ export const webviewMessageHandler = async (
(message.type === "marketplaceSources" ||
message.type === "openExternal" ||
message.type === "installMarketplaceItem" ||
message.type === "installMarketplaceItemWithParameters" ||
message.type === "cancelMarketplaceInstall" ||
message.type === "refreshMarketplaceSource" ||
message.type === "filterMarketplaceItems")
) {

View file

@ -13,9 +13,9 @@ import {
} from "./types"
import { getUserLocale } from "./utils"
import { GlobalFileNames } from "../../../src/shared/globalFileNames"
import { TerminalRegistry } from "../../../src/integrations/terminal/TerminalRegistry"
import { assertsMpContext, createHookable, MarketplaceContext, registerMarketplaceHooks } from "roo-rocket"
import { assertsBinarySha256, unpackFromUint8, uint8IsConfigPackWithParameters } from "config-rocket/cli"
import { assertsBinarySha256, unpackFromUint8, extractRocketConfigFromUint8 } from "config-rocket/cli"
import { getPanel } from "../../activate/registerCommands"
/**
* Service for managing marketplace data
@ -573,8 +573,8 @@ export class MarketplaceManager {
}
}
async installMarketplaceItem(item: MarketplaceItem, options?: InstallMarketplaceItemOptions) {
const { target = "project" } = options || {}
async installMarketplaceItem(item: MarketplaceItem, options?: InstallMarketplaceItemOptions): Promise<void | any> {
const { target = "project", parameters } = options || {}
vscode.window.showInformationMessage(`Installing item: "${item.name}"`)
@ -590,7 +590,7 @@ export class MarketplaceManager {
return vscode.window.showErrorMessage("Item does not have a binary URL or hash")
// Creates `mpContext` to delegate context to `roo-rocket`
const mpContext = (
const mpContext: MarketplaceContext =
target === "project"
? { target }
: {
@ -600,58 +600,56 @@ export class MarketplaceManager {
mode: GlobalFileNames.customModes,
},
}
) satisfies MarketplaceContext
assertsMpContext(mpContext)
const binaryUint8 = await fetchBinary(item.binaryUrl)
// `parameters` only exists in flows where we already check everything and then requires parameters input
// so we can optimize and skip the latter checks
if (parameters) return await _doInstall()
// Check binary integrity
await assertsBinarySha256(binaryUint8, item.binaryHash)
// Install via CLI if binary is a configurable pack.
// TODO: think of a way to send the binary to the npx process
if (await uint8IsConfigPackWithParameters(binaryUint8)) {
vscode.window.showInformationMessage(`"${item.name}" is configurable, invoking interactive CLI...`)
// Extract config and check if it has prompt parameters.
const config = await extractRocketConfigFromUint8(binaryUint8)
const configHavePromptParameters = config?.parameters?.some((param) => param.resolver.operation === "prompt")
let pResult: string[] = []
let pExitCode: number | undefined
// We don't want to create a new terminal at the global dir, so I'm not using cwd here
const terminalClass = await TerminalRegistry.getOrCreateTerminal(
vscode.workspace.workspaceFolders?.[0]?.uri?.fsPath ?? "",
false,
`IMI-${item.name}`,
)
terminalClass.terminal.show()
await terminalClass.runCommand(
`npx -y roo-rocket@0.4 --mp="${JSON.stringify(mpContext).replaceAll(/"/g, '\\"')}" --cwd="${cwd}" --url="${item.binaryUrl}"`,
{
onLine: (line) => {
pResult.push(line)
},
onShellExecutionComplete: (details) => {
pExitCode = details.exitCode
},
},
)
if (configHavePromptParameters) {
vscode.window.showInformationMessage(`"${item.name}" is configurable, opening UI form...`)
if (pExitCode === 0) vscode.window.showInformationMessage(`"${item.name}" CLI reported success!`)
else {
console.error(pResult)
// Revert so error search is potentially faster
pResult.reverse()
// Search for error line in the result
const errorLine =
pResult.find((line) => /^((\r)?\n)+ ERROR /.test(line)) ?? // Prefer formatting error
pResult.find((line) => /error/i.test(line)) ?? // General error
"N/A"
return vscode.window.showErrorMessage(`"${item.name}" CLI reported error: (${pExitCode}): ${errorLine}`)
const panel = getPanel()
if (panel) {
panel.webview.postMessage({
type: "openMarketplaceInstallSidebarWithConfig",
payload: {
item,
config,
},
})
} else {
vscode.window.showErrorMessage("Could not open UI form: Webview panel not found.")
}
return false // Stop installation process here, wait for parameters from frontend
}
// Fast install for non-configurable packs.
else {
await _doInstall()
async function _doInstall() {
// Create a custom hookable instance to support global installations
const customHookable = createHookable()
registerMarketplaceHooks(customHookable, mpContext)
vscode.window.showInformationMessage(`"${item.name}" is non-configurable, fast install...`)
// Register hook to set parameters if provided
if (parameters)
customHookable.hook("onParameter", ({ parameter, resolvedParameters }) => {
if (parameter.id in parameters)
return (resolvedParameters[parameter.id] = parameters[parameter.id as keyof typeof parameters])
// If there is unresolved prompt operation, throw error or else it would hang the installation
if (parameter.resolver.operation === "prompt") throw new Error("Unexpected prompt operation")
})
vscode.window.showInformationMessage(`"${item.name}" is installing...`)
await unpackFromUint8(binaryUint8, {
hookable: customHookable,
nonAssemblyBehavior: true,
@ -659,8 +657,6 @@ export class MarketplaceManager {
})
vscode.window.showInformationMessage(`"${item.name}" installed successfully`)
}
return true
}
/**

View file

@ -18,7 +18,7 @@ export const baseMetadataSchema = z.object({
/**
* Component type validation
*/
export const MarketplaceItemTypeSchema = z.enum(["mode", "prompt", "package", "mcp"] as const)
export const marketplaceItemTypeSchema = z.enum(["mode", "prompt", "package", "mcp"] as const)
/**
* Repository metadata schema
@ -29,14 +29,14 @@ export const repositoryMetadataSchema = baseMetadataSchema
* Component metadata schema
*/
export const componentMetadataSchema = baseMetadataSchema.extend({
type: MarketplaceItemTypeSchema,
type: marketplaceItemTypeSchema,
})
/**
* External item reference schema
*/
export const externalItemSchema = z.object({
type: MarketplaceItemTypeSchema,
type: marketplaceItemTypeSchema,
path: z.string().min(1, "Path is required"),
})
@ -115,3 +115,63 @@ export function validateAnyMetadata(data: unknown) {
throw new Error("Invalid metadata: must be an object")
}
/**
* Schema for a single marketplace item parameter
*/
export const parameterSchema = z.record(z.string(), z.any())
/**
* Schema for a marketplace item
*/
export const marketplaceItemSchema = baseMetadataSchema.extend({
type: marketplaceItemTypeSchema,
url: z.string(),
repoUrl: z.string(),
sourceName: z.string().optional(),
lastUpdated: z.string().optional(),
defaultBranch: z.string().optional(),
path: z.string().optional(),
items: z
.array(
z.object({
type: marketplaceItemTypeSchema,
path: z.string(),
metadata: componentMetadataSchema.optional(),
lastUpdated: z.string().optional(),
matchInfo: z
.object({
// Assuming MatchInfo is an object, adjust if needed
matched: z.boolean(),
matchReason: z
.object({
nameMatch: z.boolean().optional(),
descriptionMatch: z.boolean().optional(),
tagMatch: z.boolean().optional(),
typeMatch: z.boolean().optional(),
hasMatchingSubcomponents: z.boolean().optional(),
})
.optional(),
})
.optional(),
}),
)
.optional(),
matchInfo: z
.object({
// Assuming MatchInfo is an object, adjust if needed
matched: z.boolean(),
matchReason: z
.object({
nameMatch: z.boolean().optional(),
descriptionMatch: z.boolean().optional(),
tagMatch: z.boolean().optional(),
typeMatch: z.boolean().optional(),
hasMatchingSubcomponents: z.boolean().optional(),
})
.optional(),
})
.optional(),
parameters: z.record(z.string(), z.any()).optional(),
version: z.string().optional(), // Override version to make it optional
})

View file

@ -1,3 +1,5 @@
import { RocketConfig } from "config-rocket"
/**
* Information about why an item matched search/filter criteria
*/
@ -94,6 +96,7 @@ export interface MarketplaceItem {
matchInfo?: MatchInfo // Add match information for subcomponents
}[]
matchInfo?: MatchInfo // Add match information for the package itself
config?: RocketConfig // Revert to using RocketConfig
}
/**
@ -138,4 +141,8 @@ export interface InstallMarketplaceItemOptions {
* @default 'project'
*/
target?: "global" | "project"
/**
* Parameters provided by the user for configurable marketplace items
*/
parameters?: Record<string, any>
}

View file

@ -69,7 +69,10 @@ export interface ExtensionMessage {
| "repositoryRefreshComplete"
| "acceptInput"
| "setHistoryPreviewCollapsed"
| "openMarketplaceInstallSidebarWithConfig"
text?: string
payload?: any // Add a generic payload for now, can refine later
// Expected payload for "openMarketplaceInstallSidebarWithConfig": { item: MarketplaceItem, config: RocketConfig | undefined }
action?:
| "chatButtonClicked"
| "mcpButtonClicked"

View file

@ -134,6 +134,9 @@ export interface WebviewMessage {
| "repositoryRefreshComplete"
| "openExternal"
| "setHistoryPreviewCollapsed"
| "installMarketplaceItemWithParameters"
| "cancelMarketplaceInstall"
| "openMarketplaceInstallSidebarWithConfig" // New message type
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
@ -166,6 +169,7 @@ export interface WebviewMessage {
mpInstallOptions?: InstallMarketplaceItemOptions
hasSystemPromptOverride?: boolean
historyPreviewCollapsed?: boolean
config?: Record<string, any> // Add config to the payload
}
export const checkoutDiffPayloadSchema = z.object({
@ -185,4 +189,25 @@ export const checkoutRestorePayloadSchema = z.object({
export type CheckpointRestorePayload = z.infer<typeof checkoutRestorePayloadSchema>
export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload
import { marketplaceItemSchema } from "../services/marketplace/schemas"
export const installMarketplaceItemWithParametersPayloadSchema = z.object({
item: marketplaceItemSchema.strict(),
parameters: z.record(z.string(), z.any()),
})
export type InstallMarketplaceItemWithParametersPayload = z.infer<
typeof installMarketplaceItemWithParametersPayloadSchema
>
export const cancelMarketplaceInstallPayloadSchema = z.object({
itemId: z.string(),
})
export type CancelMarketplaceInstallPayload = z.infer<typeof cancelMarketplaceInstallPayloadSchema>
export type WebViewMessagePayload =
| CheckpointDiffPayload
| CheckpointRestorePayload
| InstallMarketplaceItemWithParametersPayload
| CancelMarketplaceInstallPayload

File diff suppressed because it is too large Load diff

View file

@ -56,6 +56,7 @@
"rehype-highlight": "^7.0.0",
"remark-gfm": "^4.0.1",
"remove-markdown": "^0.6.0",
"rocket-config": "^1.0.7",
"shell-quote": "^1.8.2",
"styled-components": "^6.1.13",
"tailwind-merge": "^2.6.0",

View file

@ -0,0 +1,90 @@
import React, { useState } from "react"
import { VSCodeButton, VSCodeTextField, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { MarketplaceItem } from "../../../../src/services/marketplace/types"
import { RocketConfig } from "config-rocket"
interface MarketplaceInstallSidebarProps {
item: MarketplaceItem
config: RocketConfig
onClose?: () => void
onSubmit?: (item: MarketplaceItem, parameters: Record<string, any>) => void
}
const InstallSidebar: React.FC<MarketplaceInstallSidebarProps> = ({ item, config, onClose, onSubmit }) => {
const initialUserParameters = config.parameters!.reduce(
(acc, param) => {
if (param.resolver.operation === "prompt")
acc[param.id] = param.resolver.initial ?? (param.resolver.type === "confirm" ? true : "")
return acc
},
{} as Record<string, any>,
)
const [userParameters, setUserParameters] = useState<Record<string, any>>(initialUserParameters)
const handleParameterChange = (name: string, value: any) => {
setUserParameters({ ...userParameters, [name]: value })
}
const handleSubmit = () => {
if (onSubmit && item) {
onSubmit(item, userParameters)
}
}
return (
<div
className="fixed inset-0 flex justify-end bg-black/50 z-50"
onClick={onClose} // Close sidebar when clicking outside
>
<div
className="flex flex-col p-4 bg-vscode-sideBar-background text-vscode-foreground h-full w-3/4 shadow-lg" // Adjust width and add shadow
onClick={(e) => e.stopPropagation()}>
<h2 className="text-xl font-bold mb-4">Install {item.name}</h2>
<div className="flex-grow overflow-y-auto space-y-4">
{config.parameters?.map((param) => {
// Only render prompt parameters
if (param.resolver.operation !== "prompt") return null
return (
<div key={param.id} className="flex flex-col">
<label htmlFor={param.id} className="text-sm font-semibold mb-1">
{param.resolver.label || param.id}{" "}
{/* Use label from resolver if available, otherwise name */}
</label>
{/* Render input based on param.resolver.type */}
{param.resolver.type === "text" && (
<VSCodeTextField
id={param.id}
value={userParameters[param.id]}
onChange={(e) =>
handleParameterChange(param.id, (e.target as HTMLInputElement).value)
}
className="w-full"></VSCodeTextField>
)}
{param.resolver.type === "confirm" && (
<VSCodeCheckbox
id={param.id}
checked={userParameters[param.id]}
onChange={(e) =>
handleParameterChange(param.id, (e.target as HTMLInputElement).checked)
}></VSCodeCheckbox>
)}
</div>
)
})}
</div>
<div className="flex gap-2 mt-4">
<VSCodeButton onClick={handleSubmit} className="flex-1">
Install
</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={onClose} className="flex-1">
Cancel
</VSCodeButton>
</div>
</div>
</div>
)
}
export default InstallSidebar

View file

@ -2,13 +2,18 @@ import { useState, useEffect, useMemo, useCallback } from "react"
import { Button } from "@/components/ui/button"
import { Tab, TabContent, TabHeader } from "../common/Tab"
import { cn } from "@/lib/utils"
import { MarketplaceSource } from "../../../../src/services/marketplace/types"
import { MarketplaceItem, MarketplaceSource } from "../../../../src/services/marketplace/types"
import { validateSource } from "../../../../src/shared/MarketplaceValidation"
import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk"
import { MarketplaceItemCard } from "./components/MarketplaceItemCard"
import { useStateManager } from "./useStateManager"
import { useAppTranslation } from "@/i18n/TranslationContext"
import InstallSidebar from "./InstallSidebar"
import { useEvent } from "react-use"
import { ExtensionMessage } from "@roo/shared/ExtensionMessage"
import { vscode } from "@/utils/vscode"
import { RocketConfig } from "config-rocket"
interface MarketplaceViewProps {
onDone?: () => void
@ -20,6 +25,33 @@ const MarketplaceView: React.FC<MarketplaceViewProps> = ({ stateManager }) => {
const [tagSearch, setTagSearch] = useState("")
const [isTagInputActive, setIsTagInputActive] = useState(false)
const [showInstallSidebar, setShowInstallSidebar] = useState<
| {
item: MarketplaceItem
config: RocketConfig
}
| false
>(false)
const handleInstallSubmit = (item: MarketplaceItem, parameters: Record<string, any>) => {
vscode.postMessage({
type: "installMarketplaceItemWithParameters",
payload: { item, parameters },
})
setShowInstallSidebar(false)
}
const onMessage = useCallback(
(e: MessageEvent) => {
const message: ExtensionMessage = e.data
if (message.type === "openMarketplaceInstallSidebarWithConfig") {
setShowInstallSidebar({ item: message.payload.item, config: message.payload.config })
}
},
[setShowInstallSidebar],
)
useEvent("message", onMessage)
// Fetch items on first mount or when returning to empty state
useEffect(() => {
@ -42,269 +74,292 @@ const MarketplaceView: React.FC<MarketplaceViewProps> = ({ stateManager }) => {
)
return (
<Tab>
<TabHeader className="flex justify-between items-center sticky top-0 z-10 bg-vscode-editor-background border-b border-vscode-panel-border">
<div className="flex items-center">
<h3 className="text-vscode-foreground m-0">{t("marketplace:title")}</h3>
</div>
<div className="flex gap-2">
<Button
variant={state.activeTab === "browse" ? "default" : "secondary"}
className={cn(
state.activeTab === "browse" &&
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
)}
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "browse" } })}>
{t("marketplace:tabs.browse")}
</Button>
<Button
variant={state.activeTab === "sources" ? "default" : "secondary"}
className={cn(
state.activeTab === "sources" &&
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
)}
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "sources" } })}>
{t("marketplace:tabs.sources")}
</Button>
</div>
</TabHeader>
<>
<Tab>
<TabHeader className="flex justify-between items-center sticky top-0 z-10 bg-vscode-editor-background border-b border-vscode-panel-border">
<div className="flex items-center">
<h3 className="text-vscode-foreground m-0">{t("marketplace:title")}</h3>
</div>
<div className="flex gap-2">
<Button
variant={state.activeTab === "browse" ? "default" : "secondary"}
className={cn(
state.activeTab === "browse" &&
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
)}
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "browse" } })}>
{t("marketplace:tabs.browse")}
</Button>
<Button
variant={state.activeTab === "sources" ? "default" : "secondary"}
className={cn(
state.activeTab === "sources" &&
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
)}
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "sources" } })}>
{t("marketplace:tabs.sources")}
</Button>
</div>
</TabHeader>
<TabContent>
{state.activeTab === "browse" ? (
<>
<div className="mb-4">
<input
type="text"
placeholder={t("marketplace:filters.search.placeholder")}
value={state.filters.search}
onChange={(e) =>
manager.transition({
type: "UPDATE_FILTERS",
payload: { filters: { search: e.target.value } },
})
}
className="w-full p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
/>
<div className="flex flex-col gap-3 mt-2">
<div className="flex flex-wrap justify-between gap-2">
<div className="whitespace-nowrap">
<label htmlFor="type-filter" className="mr-2">
{t("marketplace:filters.type.label")}
</label>
<select
id="type-filter"
value={state.filters.type}
onChange={(e) =>
manager.transition({
type: "UPDATE_FILTERS",
payload: { filters: { type: e.target.value } },
})
}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded">
<option value="">{t("marketplace:filters.type.all")}</option>
<option value="mode">{t("marketplace:filters.type.mode")}</option>
<option value="mcp">{t("marketplace:filters.type.mcp")}</option>
<option value="prompt">{t("marketplace:filters.type.prompt")}</option>
<option value="package">{t("marketplace:filters.type.package")}</option>
</select>
</div>
<TabContent>
{state.activeTab === "browse" ? (
<>
<div className="mb-4">
<input
type="text"
placeholder={t("marketplace:filters.search.placeholder")}
value={state.filters.search}
onChange={(e) =>
manager.transition({
type: "UPDATE_FILTERS",
payload: { filters: { search: e.target.value } },
})
}
className="w-full p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
/>
<div className="flex flex-col gap-3 mt-2">
<div className="flex flex-wrap justify-between gap-2">
<div className="whitespace-nowrap">
<label htmlFor="type-filter" className="mr-2">
{t("marketplace:filters.type.label")}
</label>
<select
id="type-filter"
value={state.filters.type}
onChange={(e) =>
manager.transition({
type: "UPDATE_FILTERS",
payload: { filters: { type: e.target.value } },
})
}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded">
<option value="">{t("marketplace:filters.type.all")}</option>
<option value="mode">{t("marketplace:filters.type.mode")}</option>
<option value="mcp server">
{t("marketplace:filters.type.mcp server")}
</option>
<option value="prompt">{t("marketplace:filters.type.prompt")}</option>
<option value="package">{t("marketplace:filters.type.package")}</option>
</select>
</div>
<div className="whitespace-nowrap">
<label className="mr-2">{t("marketplace:filters.sort.label")}</label>
<select
value={state.sortConfig.by}
onChange={(e) =>
manager.transition({
type: "UPDATE_SORT",
payload: { sortConfig: { by: e.target.value as any } },
})
}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded mr-2">
<option value="name">{t("marketplace:filters.sort.name")}</option>
<option value="lastUpdated">
{t("marketplace:filters.sort.lastUpdated")}
</option>
</select>
<button
onClick={() =>
manager.transition({
type: "UPDATE_SORT",
payload: {
sortConfig: {
order: state.sortConfig.order === "asc" ? "desc" : "asc",
<div className="whitespace-nowrap">
<label className="mr-2">{t("marketplace:filters.sort.label")}</label>
<select
value={state.sortConfig.by}
onChange={(e) =>
manager.transition({
type: "UPDATE_SORT",
payload: { sortConfig: { by: e.target.value as any } },
})
}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded mr-2">
<option value="name">{t("marketplace:filters.sort.name")}</option>
<option value="lastUpdated">
{t("marketplace:filters.sort.lastUpdated")}
</option>
</select>
<button
onClick={() =>
manager.transition({
type: "UPDATE_SORT",
payload: {
sortConfig: {
order:
state.sortConfig.order === "asc" ? "desc" : "asc",
},
},
},
})
}
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded">
{state.sortConfig.order === "asc" ? "↑" : "↓"}
</button>
})
}
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded">
{state.sortConfig.order === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
</div>
{allTags.length > 0 && (
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center">
<label className="mr-2">{t("marketplace:filters.tags.label")}</label>
<span className="text-xs text-vscode-descriptionForeground">
{t("marketplace:filters.tags.available", {
count: allTags.length,
})}
</span>
{allTags.length > 0 && (
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center">
<label className="mr-2">
{t("marketplace:filters.tags.label")}
</label>
<span className="text-xs text-vscode-descriptionForeground">
{t("marketplace:filters.tags.available", {
count: allTags.length,
})}
</span>
</div>
{state.filters.tags.length > 0 && (
<button
onClick={() =>
manager.transition({
type: "UPDATE_FILTERS",
payload: { filters: { tags: [] } },
})
}
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded text-xs">
{t("marketplace:filters.tags.clear", {
count: state.filters.tags.length,
})}
</button>
)}
</div>
{state.filters.tags.length > 0 && (
<button
onClick={() =>
<Command className="rounded-lg border border-vscode-dropdown-border">
<CommandInput
placeholder={t("marketplace:filters.tags.placeholder")}
value={tagSearch}
onValueChange={setTagSearch}
onFocus={() => setIsTagInputActive(true)}
onBlur={(e) => {
if (!e.relatedTarget?.closest("[cmdk-list]")) {
setIsTagInputActive(false)
}
}}
className="w-full p-1 bg-vscode-input-background text-vscode-input-foreground border-b border-vscode-dropdown-border"
/>
{(isTagInputActive || tagSearch) && (
<CommandList className="max-h-[200px] overflow-y-auto bg-vscode-dropdown-background">
<CommandEmpty className="p-2 text-sm text-vscode-descriptionForeground">
{t("marketplace:filters.tags.noResults")}
</CommandEmpty>
<CommandGroup>
{filteredTags.map((tag: string) => (
<CommandItem
key={tag}
onSelect={() => {
const isSelected =
state.filters.tags.includes(tag)
if (isSelected) {
manager.transition({
type: "UPDATE_FILTERS",
payload: {
filters: {
tags: state.filters.tags.filter(
(t) => t !== tag,
),
},
},
})
} else {
manager.transition({
type: "UPDATE_FILTERS",
payload: {
filters: {
tags: [
...state.filters.tags,
tag,
],
},
},
})
}
}}
className={`flex items-center gap-2 p-1 cursor-pointer text-sm hover:bg-vscode-button-secondaryBackground ${
state.filters.tags.includes(tag)
? "bg-vscode-button-background text-vscode-button-foreground"
: "text-vscode-dropdown-foreground"
}`}
onMouseDown={(e) => {
e.preventDefault()
}}>
<span
className={`codicon ${state.filters.tags.includes(tag) ? "codicon-check" : ""}`}
/>
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</Command>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{state.filters.tags.length > 0
? t("marketplace:filters.tags.selected", {
count: state.filters.tags.length,
})
: t("marketplace:filters.tags.clickToFilter")}
</div>
</div>
)}
</div>
</div>
{(() => {
// Use items directly from backend
const items = state.displayItems || []
const isEmpty = items.length === 0
// Only show loading state if we're fetching and have no items to display
if (state.isFetching && isEmpty) {
return (
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
<p>{t("marketplace:items.refresh.refreshing")}</p>
</div>
)
}
// Show empty state if no items
if (isEmpty) {
return (
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
<p>{t("marketplace:items.empty.noItems")}</p>
</div>
)
}
// Show items view
return (
<div>
<p className="text-vscode-descriptionForeground mb-4">
{t("marketplace:items.count", { count: items.length })}
</p>
<div className="grid grid-cols-1 gap-4 pb-4">
{items.map((item) => (
<MarketplaceItemCard
key={`${item.repoUrl}-${item.name}`}
item={item}
filters={state.filters}
setFilters={(filters) =>
manager.transition({
type: "UPDATE_FILTERS",
payload: { filters: { tags: [] } },
payload: { filters },
})
}
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded text-xs">
{t("marketplace:filters.tags.clear", {
count: state.filters.tags.length,
})}
</button>
)}
</div>
<Command className="rounded-lg border border-vscode-dropdown-border">
<CommandInput
placeholder={t("marketplace:filters.tags.placeholder")}
value={tagSearch}
onValueChange={setTagSearch}
onFocus={() => setIsTagInputActive(true)}
onBlur={(e) => {
if (!e.relatedTarget?.closest("[cmdk-list]")) {
setIsTagInputActive(false)
activeTab={state.activeTab}
setActiveTab={(tab) =>
manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab } })
}
}}
className="w-full p-1 bg-vscode-input-background text-vscode-input-foreground border-b border-vscode-dropdown-border"
/>
{(isTagInputActive || tagSearch) && (
<CommandList className="max-h-[200px] overflow-y-auto bg-vscode-dropdown-background">
<CommandEmpty className="p-2 text-sm text-vscode-descriptionForeground">
{t("marketplace:filters.tags.noResults")}
</CommandEmpty>
<CommandGroup>
{filteredTags.map((tag: string) => (
<CommandItem
key={tag}
onSelect={() => {
const isSelected = state.filters.tags.includes(tag)
if (isSelected) {
manager.transition({
type: "UPDATE_FILTERS",
payload: {
filters: {
tags: state.filters.tags.filter(
(t) => t !== tag,
),
},
},
})
} else {
manager.transition({
type: "UPDATE_FILTERS",
payload: {
filters: {
tags: [...state.filters.tags, tag],
},
},
})
}
}}
className={`flex items-center gap-2 p-1 cursor-pointer text-sm hover:bg-vscode-button-secondaryBackground ${
state.filters.tags.includes(tag)
? "bg-vscode-button-background text-vscode-button-foreground"
: "text-vscode-dropdown-foreground"
}`}
onMouseDown={(e) => {
e.preventDefault()
}}>
<span
className={`codicon ${state.filters.tags.includes(tag) ? "codicon-check" : ""}`}
/>
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</Command>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{state.filters.tags.length > 0
? t("marketplace:filters.tags.selected", {
count: state.filters.tags.length,
})
: t("marketplace:filters.tags.clickToFilter")}
/>
))}
</div>
</div>
)}
</div>
</div>
{(() => {
// Use items directly from backend
const items = state.displayItems || []
const isEmpty = items.length === 0
// Only show loading state if we're fetching and have no items to display
if (state.isFetching && isEmpty) {
return (
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
<p>{t("marketplace:items.refresh.refreshing")}</p>
</div>
)
})()}
</>
) : (
<MarketplaceSourcesConfig
sources={state.sources}
refreshingUrls={state.refreshingUrls}
onRefreshSource={(url) => manager.transition({ type: "REFRESH_SOURCE", payload: { url } })}
onSourcesChange={(sources) =>
manager.transition({ type: "UPDATE_SOURCES", payload: { sources } })
}
/>
)}
</TabContent>
</Tab>
// Show empty state if no items
if (isEmpty) {
return (
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
<p>{t("marketplace:items.empty.noItems")}</p>
</div>
)
}
// Show items view
return (
<div>
<p className="text-vscode-descriptionForeground mb-4">
{t("marketplace:items.count", { count: items.length })}
</p>
<div className="grid grid-cols-1 gap-4 pb-4">
{items.map((item) => (
<MarketplaceItemCard
key={`${item.repoUrl}-${item.name}`}
item={item}
filters={state.filters}
setFilters={(filters) =>
manager.transition({ type: "UPDATE_FILTERS", payload: { filters } })
}
activeTab={state.activeTab}
setActiveTab={(tab) =>
manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab } })
}
/>
))}
</div>
</div>
)
})()}
</>
) : (
<MarketplaceSourcesConfig
sources={state.sources}
refreshingUrls={state.refreshingUrls}
onRefreshSource={(url) => manager.transition({ type: "REFRESH_SOURCE", payload: { url } })}
onSourcesChange={(sources) =>
manager.transition({ type: "UPDATE_SOURCES", payload: { sources } })
}
/>
)}
</TabContent>
</Tab>
{showInstallSidebar && (
<InstallSidebar
onClose={() => setShowInstallSidebar(false)}
onSubmit={handleInstallSubmit}
item={showInstallSidebar.item}
config={showInstallSidebar.config}
/>
)}
</>
)
}

View file

@ -10,7 +10,7 @@
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,