mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
bbd790f4bd
commit
f89c11ec67
12 changed files with 1937 additions and 302 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1374
webview-ui/package-lock.json
generated
1374
webview-ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
90
webview-ui/src/components/marketplace/InstallSidebar.tsx
Normal file
90
webview-ui/src/components/marketplace/InstallSidebar.tsx
Normal 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
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue