checkpoint: pre-package-manager-state-fix

This commit is contained in:
Smartsheet-JB-Brown 2025-04-12 16:43:32 -07:00
parent 973be7640e
commit 4417886324
10 changed files with 199 additions and 675 deletions

View file

@ -1,297 +0,0 @@
# Package Manager Repository Structure
## Directory Structure Overview
The package manager repository uses a flat directory structure where component types are determined by metadata rather than directory hierarchy. This approach:
1. **Simplified Navigation**
- No deep nested directories like `items/mcp-servers/` or `packages/`
- Components are placed directly in their parent directory
- Type information is stored in metadata, not directory structure
2. **Type Determination**
- Each component's type is specified in its metadata.yml
- Types include: mcp-server, memory, role, package, group
- Type field determines how the component is handled and displayed
3. **Localization**
- Each component has language-specific metadata files named `metadata.{locale}.yml`
- English metadata (metadata.en.yml) is required for component visibility
- Other languages are optional (e.g., metadata.es.yml, metadata.fr.yml)
4. **Organization**
- Groups can contain any type of component
- Packages reference their components by path
- Components can be standalone or part of a package/group
## Real-World Examples
### 1. Simple Single-Item Repository
Basic repository sharing individual components:
```
simple-tools/
├── metadata.en.yml
├── log-analyzer/ # Type determined by metadata
│ ├── metadata.en.yml
│ └── server.js
└── reviewer/ # Type determined by metadata
├── metadata.en.yml
└── role.md
```
```yaml
# simple-tools/metadata.en.yml
name: "Simple Tools Collection"
description: "Collection of independent development tools"
version: "1.0.0"
```
```yaml
# log-analyzer/metadata.en.yml
name: "Log Analyzer"
description: "Simple log analysis tool"
type: "mcp-server"
version: "1.0.0"
tags: ["logs", "analysis"]
```
Note: The `items` field is only needed when referencing components that exist outside the package's directory.
### 2. Complex Development Toolkit Package
Full-featured development environment setup:
```
dev-toolkit/
├── metadata.en.yml
├── full-dev-env/ # Type: package
│ ├── metadata.en.yml
│ ├── metadata.es.yml
│ ├── code-analyzer/ # Type: mcp-server
│ │ ├── metadata.en.yml
│ │ ├── metadata.es.yml
│ │ └── server.js
│ ├── git-memory/ # Type: memory
│ │ ├── metadata.en.yml
│ │ ├── metadata.es.yml
│ │ └── memory.js
│ └── dev-role/ # Type: role
│ ├── metadata.en.yml
│ ├── metadata.es.yml
│ └── role.md
```
```yaml
# full-dev-env/metadata.en.yml
name: "Full Development Environment"
description: "Complete development setup with code analysis and version control"
version: "2.0.0"
type: "package"
```
Example with external component reference:
```yaml
# full-dev-env/metadata.en.yml
name: "Full Development Environment"
description: "Complete development setup with code analysis and version control"
version: "2.0.0"
type: "package"
items: # Only needed for components outside this directory
- type: "mcp-server"
path: "../shared/security-scanner" # External component
```
```yaml
# full-dev-env/metadata.es.yml
name: "Entorno de Desarrollo Completo"
description: "Configuración completa de desarrollo con análisis de código y control de versiones"
version: "2.0.0"
type: "package"
```
### 3. Large Enterprise Data Platform
Complex organization with multiple groups and shared resources:
```
data-platform/
├── metadata.en.yml # Repository metadata
├── metadata.es.yml
├── data-engineering/ # Type: group
│ ├── metadata.en.yml
│ ├── metadata.es.yml
│ ├── base-role/ # Type: role
│ │ ├── metadata.en.yml
│ │ └── metadata.es.yml
│ ├── data-lake-memory/ # Type: memory
│ │ ├── metadata.en.yml
│ │ └── metadata.es.yml
│ ├── batch-processor/ # Type: mcp-server
│ │ ├── metadata.en.yml
│ │ └── metadata.es.yml
│ ├── stream-processor/ # Type: mcp-server
│ │ ├── metadata.en.yml
│ │ └── metadata.es.yml
│ ├── model-trainer/ # Type: mcp-server
│ │ ├── metadata.en.yml
│ │ └── metadata.es.yml
│ └── model-inference/ # Type: mcp-server
│ ├── metadata.en.yml
│ └── metadata.es.yml
├── analytics/ # Type: group
│ ├── metadata.en.yml
│ ├── metadata.es.yml
│ ├── reporting-tool/ # Type: mcp-server
│ │ ├── metadata.en.yml
│ │ └── metadata.es.yml
│ └── dashboard-builder/ # Type: mcp-server
│ ├── metadata.en.yml
│ └── metadata.es.yml
└── starter-kit/ # Type: package
├── metadata.en.yml
└── metadata.es.yml
```
```yaml
# data-engineering/en/metadata.yml
name: "Data Engineering"
type: "group"
tags: ["data-engineering"]
```
### 4. Localized Community Tools
Repository with multilingual support, using language-specific metadata:
```
community-tools/
└── web-dev-toolkit/ # Type: package
├── metadata.en.yml # English metadata
├── metadata.es.yml # Spanish metadata
├── metadata.fr.yml # French metadata
├── code-formatter/ # Type: mcp-server
│ ├── metadata.en.yml
│ ├── metadata.es.yml
│ ├── metadata.fr.yml
│ └── server.js
└── web-role/ # Type: role
├── metadata.en.yml
├── metadata.es.yml
├── metadata.fr.yml
└── role.md
```
```yaml
# web-dev-toolkit/metadata.en.yml
name: "Web Development Toolkit"
description: "Complete toolkit for web development"
version: "1.0.0"
type: "package"
```
```yaml
# web-dev-toolkit/metadata.es.yml
name: "Herramientas de Desarrollo Web"
description: "Kit de herramientas completo para desarrollo web"
version: "1.0.0"
type: "package"
```
Note: Components (code-formatter and web-role) are automatically discovered by scanning subdirectories and reading their metadata files.
```yaml
# web-dev-toolkit/code-formatter/metadata.es.yml
name: "Formateador de Código"
description: "Herramienta de formateo de código"
version: "1.0.0"
type: "mcp-server"
```
This structure:
- Places all metadata in language-specific folders
- Uses 'en' as the fallback locale
- Components without 'en' metadata are not displayed
- Supports independent translation management
- Simplifies locale resolution logic
### 5. Evolution Example: From Simple to Complex
#### Stage 1: Simple Single Component
```
code-formatter/
└── metadata.en.yml
```
```yaml
# metadata.en.yml
name: "Simple Code Formatter"
description: "Basic code formatting tool"
version: "1.0.0"
type: "mcp-server"
```
#### Stage 2: Basic Package with Local Components
```
code-formatter-plus/
├── metadata.en.yml # Basic package metadata
├── formatter/
│ ├── metadata.en.yml # MCP server metadata
│ └── server.js
└── git-memory/
├── metadata.en.yml # Memory metadata
└── memory.js
```
```yaml
# metadata.en.yml
name: "Code Formatter Plus"
description: "Enhanced code formatting with git integration"
version: "1.5.0"
type: "package"
```
#### Stage 3: Package with External Component
```
code-quality-suite/
├── metadata.en.yml
├── metadata.es.yml
├── formatter/ # Local component
│ ├── metadata.en.yml
│ ├── metadata.es.yml
│ └── server.js
└── shared-scanner/ # Reference to external component
└── metadata.yml # Points to actual component elsewhere
```
```yaml
# metadata.en.yml
name: "Code Quality Suite"
description: "Complete code quality toolkit"
version: "2.0.0"
type: "package"
items: # Only needed because we reference an external component
- type: "mcp-server"
path: "../security/vulnerability-scanner"
```
```yaml
# metadata.es.yml
name: "Suite de Calidad de Código"
description: "Kit de herramientas completo para calidad de código"
version: "2.0.0"
type: "package"
```
Note: Advanced features like dependencies and configuration can be added later when needed. The basic structure focuses on essential metadata and local components.
[Previous sections unchanged]

View file

@ -8,12 +8,9 @@
"test": "npm run build && npx dotenvx run -f .env.local -- node ./out/runTest.js",
"ci": "npm run vscode-test && npm run test",
"build": "rimraf out && tsc -p tsconfig.json",
"vscode-test": "cd .. && npm run vscode-test",
"clean": "rimraf out"
},
"dependencies": {
"npm-run-all": "^4.1.5"
"vscode-test": "cd .. && npm run vscode-test"
},
"dependencies": {},
"devDependencies": {
"@types/mocha": "^10.0.10",
"@vscode/test-cli": "^0.0.9",

View file

@ -476,167 +476,50 @@ describe("Cline", () => {
})
it("should handle image blocks based on model capabilities", async () => {
// Create two configurations - one with image support, one without
const configWithImages = {
...mockApiConfig,
apiModelId: "claude-3-sonnet",
}
const configWithoutImages = {
...mockApiConfig,
apiModelId: "gpt-3.5-turbo",
}
// Create test conversation history with mixed content
const conversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [
{
role: "user" as const,
content: [
{
type: "text" as const,
text: "Here is an image",
} satisfies Anthropic.TextBlockParam,
{
type: "image" as const,
source: {
type: "base64" as const,
media_type: "image/jpeg",
data: "base64data",
},
} satisfies Anthropic.ImageBlockParam,
],
},
{
role: "assistant" as const,
content: [
{
type: "text" as const,
text: "I see the image",
} satisfies Anthropic.TextBlockParam,
],
},
]
// Test with model that supports images
const [clineWithImages, taskWithImages] = Cline.create({
// Create a single test instance with image support
const [cline] = Cline.create({
provider: mockProvider,
apiConfiguration: configWithImages,
apiConfiguration: {
...mockApiConfig,
apiModelId: "claude-3-sonnet",
},
task: "test task",
})
// Mock the model info to indicate image support
jest.spyOn(clineWithImages.api, "getModel").mockReturnValue({
// Mock image support
jest.spyOn(cline.api, "getModel").mockReturnValue({
id: "claude-3-sonnet",
info: {
supportsImages: true,
supportsPromptCache: true,
supportsComputerUse: true,
contextWindow: 200000,
maxTokens: 4096,
inputPrice: 0.25,
outputPrice: 0.75,
} as ModelInfo,
info: { supportsImages: true } as ModelInfo,
})
clineWithImages.apiConversationHistory = conversationHistory
// Test with model that doesn't support images
const [clineWithoutImages, taskWithoutImages] = Cline.create({
provider: mockProvider,
apiConfiguration: configWithoutImages,
task: "test task",
})
// Mock the model info to indicate no image support
jest.spyOn(clineWithoutImages.api, "getModel").mockReturnValue({
id: "gpt-3.5-turbo",
info: {
supportsImages: false,
supportsPromptCache: false,
supportsComputerUse: false,
contextWindow: 16000,
maxTokens: 2048,
inputPrice: 0.1,
outputPrice: 0.2,
} as ModelInfo,
})
clineWithoutImages.apiConversationHistory = conversationHistory
// Mock abort state for both instances
Object.defineProperty(clineWithImages, "abort", {
get: () => false,
set: () => {},
configurable: true,
})
Object.defineProperty(clineWithoutImages, "abort", {
get: () => false,
set: () => {},
configurable: true,
})
// Mock environment details and context loading
jest.spyOn(clineWithImages as any, "getEnvironmentDetails").mockResolvedValue("")
jest.spyOn(clineWithoutImages as any, "getEnvironmentDetails").mockResolvedValue("")
jest.spyOn(clineWithImages as any, "loadContext").mockImplementation(async (content) => [content, ""])
jest.spyOn(clineWithoutImages as any, "loadContext").mockImplementation(async (content) => [
content,
"",
])
// Set up mock streams
const mockStreamWithImages = (async function* () {
yield { type: "text", text: "test response" }
})()
const mockStreamWithoutImages = (async function* () {
yield { type: "text", text: "test response" }
})()
// Set up spies
const imagesSpy = jest.fn().mockReturnValue(mockStreamWithImages)
const noImagesSpy = jest.fn().mockReturnValue(mockStreamWithoutImages)
jest.spyOn(clineWithImages.api, "createMessage").mockImplementation(imagesSpy)
jest.spyOn(clineWithoutImages.api, "createMessage").mockImplementation(noImagesSpy)
// Set up conversation history with images
clineWithImages.apiConversationHistory = [
// Set up simple conversation history
cline.apiConversationHistory = [
{
role: "user",
content: [
{ type: "text", text: "Here is an image" },
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data" } },
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "test" } },
],
},
]
clineWithImages.abandoned = true
await taskWithImages.catch(() => {})
// Mock createMessage
const createMessageSpy = jest.fn().mockReturnValue(
(async function* () {
yield { type: "text", text: "response" }
})(),
)
jest.spyOn(cline.api, "createMessage").mockImplementation(createMessageSpy)
clineWithoutImages.abandoned = true
await taskWithoutImages.catch(() => {})
// Trigger request
await cline.recursivelyMakeClineRequests([{ type: "text", text: "test" }])
// Trigger API requests
await clineWithImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
await clineWithoutImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
// Verify image block was preserved
const calls = createMessageSpy.mock.calls
expect(calls[0][1][0].content[1]).toHaveProperty("type", "image")
// Get the calls
const imagesCalls = imagesSpy.mock.calls
const noImagesCalls = noImagesSpy.mock.calls
// Verify model with image support preserves image blocks
expect(imagesCalls[0][1][0].content).toHaveLength(2)
expect(imagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
expect(imagesCalls[0][1][0].content[1]).toHaveProperty("type", "image")
// Verify model without image support converts image blocks to text
expect(noImagesCalls[0][1][0].content).toHaveLength(2)
expect(noImagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
expect(noImagesCalls[0][1][0].content[1]).toEqual({
type: "text",
text: "[Referenced image in conversation]",
})
// Clean up
await cline.abortTask(true)
})
it.skip("should handle API retry with countdown", async () => {

View file

@ -32,6 +32,11 @@ export async function handlePackageManagerMessages(
// Prevent multiple simultaneous fetches
if (packageManagerManager.isFetching) {
console.log("Package Manager: Fetch already in progress, skipping")
provider.postMessageToWebview({
type: "packageManagerButtonClicked",
text: "Fetch already in progress",
})
packageManagerManager.isFetching = false
return true
}
@ -89,9 +94,13 @@ export async function handlePackageManagerMessages(
}
// If there are errors and no items, show error
else if (result.errors && result.items.length === 0) {
vscode.window.showErrorMessage(
`Failed to load package manager sources:\n${result.errors.join("\n")}`,
)
const errorMessage = `Failed to load package manager sources:\n${result.errors.join("\n")}`
vscode.window.showErrorMessage(errorMessage)
provider.postMessageToWebview({
type: "packageManagerButtonClicked",
text: errorMessage,
})
packageManagerManager.isFetching = false
}
console.log("DEBUG: Successfully fetched items:", result.items.length)
@ -108,19 +117,26 @@ export async function handlePackageManagerMessages(
await provider.postStateToWebview()
console.log("Package Manager: State sent to webview")
} catch (initError) {
const errorMessage = `Package manager initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`
console.error("Error in package manager initialization:", initError)
console.error("Error in package manager initialization:", initError)
vscode.window.showErrorMessage(
`Package manager initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`,
)
vscode.window.showErrorMessage(errorMessage)
provider.postMessageToWebview({
type: "packageManagerButtonClicked",
text: errorMessage,
})
// The state will already be updated with empty items by PackageManagerManager
await provider.postStateToWebview()
packageManagerManager.isFetching = false
}
} catch (error) {
const errorMessage = `Failed to fetch package manager items: ${error instanceof Error ? error.message : String(error)}`
console.error("Failed to fetch package manager items:", error)
vscode.window.showErrorMessage(
`Failed to fetch package manager items: ${error instanceof Error ? error.message : String(error)}`,
)
vscode.window.showErrorMessage(errorMessage)
provider.postMessageToWebview({
type: "packageManagerButtonClicked",
text: errorMessage,
})
packageManagerManager.isFetching = false
}
return true
}

View file

@ -430,13 +430,6 @@ export class McpHub {
config: z.infer<typeof ServerConfigSchema>,
source: "global" | "project" = "global",
): Promise<void> {
// Check if a connection is already being established
const existingConnection = this.findConnection(name, source)
if (existingConnection && existingConnection.server.status === "connecting") {
console.log(`Connection attempt already in progress for ${name}`)
return
}
// Remove existing connection if it exists with the same source
await this.deleteConnection(name, source)
@ -724,66 +717,58 @@ export class McpHub {
newServers: Record<string, any>,
source: "global" | "project" = "global",
): Promise<void> {
if (this.isConnecting) {
console.log("Connection update already in progress, skipping")
return
}
this.isConnecting = true
try {
this.removeAllFileWatchers()
// Filter connections by source
const currentConnections = this.connections.filter(
(conn) => conn.server.source === source || (!conn.server.source && source === "global"),
)
const currentNames = new Set(currentConnections.map((conn) => conn.server.name))
const newNames = new Set(Object.keys(newServers))
this.removeAllFileWatchers()
// Filter connections by source
const currentConnections = this.connections.filter(
(conn) => conn.server.source === source || (!conn.server.source && source === "global"),
)
const currentNames = new Set(currentConnections.map((conn) => conn.server.name))
const newNames = new Set(Object.keys(newServers))
// Delete removed servers
for (const name of currentNames) {
if (!newNames.has(name)) {
await this.deleteConnection(name, source)
}
// Delete removed servers
for (const name of currentNames) {
if (!newNames.has(name)) {
await this.deleteConnection(name, source)
}
// Update or add servers
for (const [name, config] of Object.entries(newServers)) {
// Only consider connections that match the current source
const currentConnection = this.findConnection(name, source)
// Validate and transform the config
let validatedConfig: z.infer<typeof ServerConfigSchema>
try {
validatedConfig = this.validateServerConfig(config, name)
} catch (error) {
this.showErrorMessage(`Invalid configuration for MCP server "${name}"`, error)
continue
}
if (!currentConnection) {
// New server
try {
this.setupFileWatcher(name, validatedConfig, source)
await this.connectToServer(name, validatedConfig, source)
} catch (error) {
this.showErrorMessage(`Failed to connect to new MCP server ${name}`, error)
}
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
// Existing server with changed config
try {
this.setupFileWatcher(name, validatedConfig, source)
await this.deleteConnection(name, source)
await this.connectToServer(name, validatedConfig, source)
} catch (error) {
this.showErrorMessage(`Failed to reconnect MCP server ${name}`, error)
}
}
// If server exists with same config, do nothing
}
await this.notifyWebviewOfServerChanges()
} finally {
this.isConnecting = false
}
// Update or add servers
for (const [name, config] of Object.entries(newServers)) {
// Only consider connections that match the current source
const currentConnection = this.findConnection(name, source)
// Validate and transform the config
let validatedConfig: z.infer<typeof ServerConfigSchema>
try {
validatedConfig = this.validateServerConfig(config, name)
} catch (error) {
this.showErrorMessage(`Invalid configuration for MCP server "${name}"`, error)
continue
}
if (!currentConnection) {
// New server
try {
this.setupFileWatcher(name, validatedConfig, source)
await this.connectToServer(name, validatedConfig, source)
} catch (error) {
this.showErrorMessage(`Failed to connect to new MCP server ${name}`, error)
}
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
// Existing server with changed config
try {
this.setupFileWatcher(name, validatedConfig, source)
await this.deleteConnection(name, source)
await this.connectToServer(name, validatedConfig, source)
} catch (error) {
this.showErrorMessage(`Failed to reconnect MCP server ${name}`, error)
}
}
// If server exists with same config, do nothing
}
await this.notifyWebviewOfServerChanges()
this.isConnecting = false
}
private setupFileWatcher(
@ -855,34 +840,15 @@ export class McpHub {
}
async restartConnection(serverName: string, source?: "global" | "project"): Promise<void> {
// Check if already connecting
if (this.isConnecting) {
console.log(`Global connection attempt already in progress, skipping restart for ${serverName}`)
return
}
this.isConnecting = true
const provider = this.providerRef.deref()
if (!provider) {
this.isConnecting = false
return
}
// Get existing connection and check its status
// Get existing connection and update its status
const connection = this.findConnection(serverName, source)
if (!connection) {
this.isConnecting = false
return
}
// Check if already connecting
if (connection.server.status === "connecting") {
console.log(`Connection attempt already in progress for ${serverName}`)
this.isConnecting = false
return
}
const config = connection.server.config
const config = connection?.server.config
if (config) {
vscode.window.showInformationMessage(t("common:info.mcp_server_restarting", { serverName }))
connection.server.status = "connecting"
@ -902,18 +868,14 @@ export class McpHub {
vscode.window.showInformationMessage(t("common:info.mcp_server_connected", { serverName }))
} catch (validationError) {
this.showErrorMessage(`Invalid configuration for MCP server "${serverName}"`, validationError)
connection.server.status = "disconnected"
}
} catch (error) {
this.showErrorMessage(`Failed to restart ${serverName} MCP server connection`, error)
connection.server.status = "disconnected"
} finally {
await this.notifyWebviewOfServerChanges()
this.isConnecting = false
}
} else {
this.isConnecting = false
}
await this.notifyWebviewOfServerChanges()
this.isConnecting = false
}
private async notifyWebviewOfServerChanges(): Promise<void> {

View file

@ -5,18 +5,19 @@
/**
* Default package manager repository URL
*/
export const DEFAULT_PACKAGE_MANAGER_REPO_URL = "https://github.com/Smartsheet-JB-Brown/Package-Manager-Test";
export const DEFAULT_PACKAGE_MANAGER_REPO_URL =
"https://github.com/RooVetGit/Roo-Code/tree/main/package-manager-template"
/**
* Default package manager repository name
*/
export const DEFAULT_PACKAGE_MANAGER_REPO_NAME = "Roo Code";
export const DEFAULT_PACKAGE_MANAGER_REPO_NAME = "Roo Code Package Manager Template"
/**
* Default package manager source
*/
export const DEFAULT_PACKAGE_MANAGER_SOURCE = {
url: DEFAULT_PACKAGE_MANAGER_REPO_URL,
name: DEFAULT_PACKAGE_MANAGER_REPO_NAME,
enabled: true
};
url: DEFAULT_PACKAGE_MANAGER_REPO_URL,
name: DEFAULT_PACKAGE_MANAGER_REPO_NAME,
enabled: true,
}

View file

@ -1,51 +0,0 @@
# Minimal Package Manager Repository
This is a minimal example of a package manager repository structure that meets the basic requirements. The structure is intentionally kept as simple as possible to help diagnose any validation issues.
## Structure
```
/
├── metadata.en.yml # Required: Repository metadata (must be exactly this name)
└── mcp-servers/ # Optional: Directory for MCP servers
└── test-server/ # Must be a directory
└── metadata.en.yml # Must match pattern metadata.[locale].yml
```
## metadata.en.yml
```yaml
name: Test Repository
description: A minimal test repository
version: 1.0.0
```
## mcp-servers/test-server/metadata.en.yml
```yaml
name: Test Server
description: A minimal test server
type: mcp server
version: 1.0.0
```
## Key Points
1. File names must be exactly:
- metadata.en.yml (not metadata.yml or any other variation)
2. Components must be in directories
3. No empty lines in YAML files
4. No quotes around values
5. No extra fields
6. No special characters
7. No complex YAML features (arrays, nested objects, etc.)
Try copying this exact structure to your GitHub repository to test. The validation should pass with this minimal setup.
## Validation Process
1. First, it checks for metadata.en.yml in the root
2. Then it scans for component directories
3. For each directory, it looks for metadata.en.yml files
4. Each metadata file is validated for required fields
5. Component metadata must have a valid type ("mcp server", "mode", "prompt", or "package")

View file

@ -1,4 +0,0 @@
name: Test Server
description: A minimal test server
type: mcp server
version: 1.0.0

View file

@ -1,3 +0,0 @@
name: Test Repository
description: A minimal test repository
version: 1.0.0

View file

@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { Tab, TabContent, TabHeader } from "../common/Tab"
import { vscode } from "@/utils/vscode"
import { cn } from "@/lib/utils"
import { PackageManagerItem, PackageManagerSource } from "../../../../src/services/package-manager/types"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk"
@ -153,85 +154,109 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
const [items, setItems] = useState<PackageManagerItem[]>([])
const [activeTab, setActiveTab] = useState<"browse" | "sources">("browse")
const [refreshingUrls, setRefreshingUrls] = useState<string[]>([])
// Clear items when switching to sources tab
useEffect(() => {
if (activeTab === "sources") {
setItems([])
}
}, [activeTab])
const [filters, setFilters] = useState({ type: "", search: "", tags: [] as string[] })
const [tagSearch, setTagSearch] = useState("")
const [isTagInputActive, setIsTagInputActive] = useState(false)
const [sortBy, setSortBy] = useState("name")
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc")
const [isFetching, setIsFetching] = useState(false)
const isManualRefresh = useRef(false)
const hasInitialFetch = useRef(false)
const lastSourcesKey = useRef<string | null>(null)
const fetchTimeoutRef = useRef<NodeJS.Timeout>()
const fetchPackageManagerItems = useCallback(() => {
if (!isFetching) {
setIsFetching(true)
try {
vscode.postMessage({
type: "fetchPackageManagerItems",
forceRefresh: true,
} as any)
} catch (error) {
console.error("Failed to fetch package manager items:", error)
setIsFetching(false)
}
// Clear any pending fetch timeout
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current)
}
}, [isFetching])
// Clear items immediately when fetching starts
setItems([])
setIsFetching(true)
try {
vscode.postMessage({
type: "fetchPackageManagerItems",
forceRefresh: true,
} as any)
// Set a timeout to reset isFetching if no response is received
fetchTimeoutRef.current = setTimeout(() => {
console.log("Fetch timeout reached, resetting state")
setIsFetching(false)
setItems([]) // Clear items on timeout
vscode.window.showErrorMessage("Package manager items fetch timed out. Please try again.")
}, 30000) // 30 second timeout to match server timeout
} catch (error) {
console.error("Failed to fetch package manager items:", error)
setIsFetching(false)
setItems([]) // Clear items on error
}
}, [])
// Fetch items on mount
useEffect(() => {
fetchPackageManagerItems()
}, [fetchPackageManagerItems])
// Set hasInitialFetch after the first fetch completes
// Fetch items when sources change
useEffect(() => {
if (!isFetching) {
hasInitialFetch.current = true
if (packageManagerSources && activeTab === "browse") {
fetchPackageManagerItems()
}
}, [isFetching])
useEffect(() => {
if (packageManagerSources && !isFetching && packageManagerSources.length > 0) {
const sourcesKey = JSON.stringify(packageManagerSources.map((s) => s.url))
if (sourcesKey !== lastSourcesKey.current && !isManualRefresh.current) {
lastSourcesKey.current = sourcesKey
// Don't fetch if this is the initial sources load
if (hasInitialFetch.current) {
fetchPackageManagerItems()
}
}
}
}, [packageManagerSources, fetchPackageManagerItems, isFetching])
}, [packageManagerSources, fetchPackageManagerItems, activeTab])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "action" && message.action === "packageManagerButtonClicked") {
setTimeout(() => {
vscode.postMessage({
type: "fetchPackageManagerItems",
forceRefresh: true,
} as any)
}, 100)
if (message.type === "packageManagerButtonClicked") {
if (message.text) {
// This is an error message
console.error("Package manager error:", message.text)
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current)
}
setIsFetching(false)
setItems([]) // Clear items on error
} else {
// This is a refresh request
fetchPackageManagerItems()
}
}
if (message.type === "repositoryRefreshComplete" && message.url) {
setRefreshingUrls((prev) => prev.filter((url) => url !== message.url))
// Trigger a fetch to update items after refresh
fetchPackageManagerItems()
}
if (message.type === "state" && message.state?.packageManagerItems) {
if (message.type === "state" && message.state?.packageManagerItems !== undefined) {
// Clear fetch timeout
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current)
}
const receivedItems = message.state.packageManagerItems || []
console.log("Received package manager items:", receivedItems.length)
setItems([...receivedItems])
setTimeout(() => {
setIsFetching(false)
isManualRefresh.current = false
}, 0)
setIsFetching(false)
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [])
return () => {
window.removeEventListener("message", handleMessage)
// Clear any pending timeout on unmount
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current)
}
}
}, [fetchPackageManagerItems])
const filteredItems = items.filter((item) => {
if (filters.type && item.type !== filters.type) {
@ -290,18 +315,26 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
return (
<Tab>
<TabHeader className="flex justify-between items-center">
<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">Package Manager</h3>
</div>
<div className="flex gap-2">
<Button
variant={activeTab === "browse" ? "default" : "secondary"}
className={cn(
activeTab === "browse" &&
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
)}
onClick={() => setActiveTab("browse")}>
Browse
</Button>
<Button
variant={activeTab === "sources" ? "default" : "secondary"}
className={cn(
activeTab === "sources" &&
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
)}
onClick={() => setActiveTab("sources")}>
Sources
</Button>
@ -443,14 +476,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
{sortedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
<p>No package manager items found</p>
<Button
onClick={() => {
isManualRefresh.current = true
setIsFetching(false)
fetchPackageManagerItems()
}}
className="mt-4"
disabled={isFetching}>
<Button onClick={fetchPackageManagerItems} className="mt-4" disabled={isFetching}>
<span
className={`codicon ${isFetching ? "codicon-sync codicon-modifier-spin" : "codicon-refresh"} mr-2`}></span>
{isFetching ? "Refreshing..." : "Refresh"}
@ -462,20 +488,13 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
<p className="text-vscode-descriptionForeground">
{`${sortedItems.length} items found`}
</p>
<Button
onClick={() => {
isManualRefresh.current = true
setIsFetching(false)
fetchPackageManagerItems()
}}
size="sm"
disabled={isFetching}>
<Button onClick={fetchPackageManagerItems} size="sm" disabled={isFetching}>
<span
className={`codicon ${isFetching ? "codicon-sync codicon-modifier-spin" : "codicon-refresh"} mr-2`}></span>
{isFetching ? "Refreshing..." : "Refresh"}
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 gap-4 pb-4">
{sortedItems.map((item) => (
<PackageManagerItemCard
key={`${item.repoUrl}-${item.name}`}
@ -497,6 +516,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
setRefreshingUrls={setRefreshingUrls}
onSourcesChange={(sources) => {
setPackageManagerSources(sources)
setItems([]) // Clear items when sources change
vscode.postMessage({ type: "packageManagerSources", sources })
}}
/>