UI Control work

This commit is contained in:
Smartsheet-JB-Brown 2025-04-11 06:22:50 -07:00
parent fd48ffcfe9
commit d323015b25
14 changed files with 790 additions and 147 deletions

View file

@ -35,9 +35,7 @@ The `metadata.yml` file at the root of the repository contains information about
```yaml
name: "Example Package Manager Repository"
description: "A collection of example package manager items for Roo-Code"
author: "Roo Team"
version: "1.0.0"
lastUpdated: "2025-04-08"
```
## Item Metadata
@ -48,9 +46,7 @@ Each item in the package manager has its own `metadata.yml` file that contains i
name: "Item Name"
description: "Item description"
type: "role|mcp-server|storage|other"
author: "Author Name"
version: "1.0.0"
lastUpdated: "2025-04-08"
tags: ["tag1", "tag2"]
sourceUrl: "https://github.com/username/repo" # Optional URL for the "view source" button
```

View file

@ -1,8 +1,6 @@
name: "File Analyzer MCP Server"
description: "An MCP server that analyzes files for code quality, security issues, and performance optimizations"
type: "mcp-server"
author: "Roo Team"
version: "1.0.0"
lastUpdated: "2025-04-08"
tags: ["file-analyzer", "code-quality", "security", "performance"]
sourceUrl: "https://github.com/roo-team/file-analyzer-server"

View file

@ -1,5 +1,3 @@
name: "Example Package Manager Repository"
description: "A collection of example package manager items for Roo-Code"
author: "Roo Team"
version: "1.0.0"
lastUpdated: "2025-04-08"
version: "1.0.0"

View file

@ -1,8 +1,6 @@
name: "Full-Stack Developer Role"
description: "A role for a full-stack developer with expertise in web development, databases, and APIs"
type: "role"
author: "Roo Team"
version: "1.0.0"
lastUpdated: "2025-04-08"
tags: ["developer", "full-stack", "web", "database", "api"]
sourceUrl: "https://github.com/roo-team/developer-resources"

View file

@ -1,8 +1,6 @@
name: "GitHub Storage System"
description: "A storage system that uses GitHub repositories to store and retrieve data"
type: "storage"
author: "Roo Team"
version: "1.0.0"
lastUpdated: "2025-04-08"
tags: ["storage", "github", "git", "repository"]
sourceUrl: "https://github.com/roo-team/github-storage-system"

View file

@ -4,6 +4,7 @@ import fs from "fs/promises"
import EventEmitter from "events"
import { Anthropic } from "@anthropic-ai/sdk"
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../services/package-manager/constants"
import delay from "delay"
import axios from "axios"
import pWaitFor from "p-wait-for"
@ -1278,13 +1279,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
renderContext: this.renderContext,
maxReadFileLine: maxReadFileLine ?? 500,
settingsImportedAt: this.settingsImportedAt,
packageManagerSources: packageManagerSources ?? [
{
url: "https://github.com/Smartsheet-JB-Brown/Package-Manager-Test",
name: "Official Roo-Code Package Manager",
enabled: true
}
],
packageManagerSources: packageManagerSources ?? [DEFAULT_PACKAGE_MANAGER_SOURCE],
}
}
@ -1367,13 +1362,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
telemetrySetting: stateValues.telemetrySetting || "unset",
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true,
maxReadFileLine: stateValues.maxReadFileLine ?? 500,
packageManagerSources: stateValues.packageManagerSources ?? [
{
url: "https://github.com/Smartsheet-JB-Brown/Package-Manager-Test",
name: "Official Roo-Code Package Manager",
enabled: true
}
],
packageManagerSources: stateValues.packageManagerSources ?? [DEFAULT_PACKAGE_MANAGER_SOURCE],
}
}

View file

@ -4,6 +4,8 @@ import { WebviewMessage } from "../../shared/WebviewMessage"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
import { PackageManagerManager } from "../../services/package-manager"
import { PackageManagerItem, PackageManagerSource } from "../../services/package-manager/types"
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../services/package-manager/constants"
import { validateSources } from "../../services/package-manager/validation"
import { GlobalState } from "../../schemas"
/**
@ -39,13 +41,7 @@ export async function handlePackageManagerMessages(
if (!sources || sources.length === 0) {
console.log("Package Manager: No sources found, initializing default sources")
sources = [
{
url: "https://github.com/Smartsheet-JB-Brown/Package-Manager-Test",
name: "Official Roo-Code Package Manager",
enabled: true
}
];
sources = [DEFAULT_PACKAGE_MANAGER_SOURCE];
// Save the default sources
await provider.contextProxy.setValue("packageManagerSources", sources)
@ -148,7 +144,36 @@ export async function handlePackageManagerMessages(
updatedSources = message.sources;
}
// Update the global state with the new sources
// Validate sources using the validation utility
const validationErrors = validateSources(updatedSources);
// Filter out invalid sources
if (validationErrors.length > 0) {
console.log("Package Manager: Validation errors found in sources", validationErrors);
// Create a map of invalid indices
const invalidIndices = new Set<number>();
validationErrors.forEach(error => {
// Extract index from error message (Source #X: ...)
const match = error.message.match(/Source #(\d+):/);
if (match && match[1]) {
const index = parseInt(match[1], 10) - 1; // Convert to 0-based index
if (index >= 0 && index < updatedSources.length) {
invalidIndices.add(index);
}
}
});
// Filter out invalid sources
updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index));
// Show validation errors
const errorMessage = `Package manager sources validation failed:\n${validationErrors.map(e => e.message).join('\n')}`;
console.error(errorMessage);
vscode.window.showErrorMessage(errorMessage);
}
// Update the global state with the validated sources
await updateGlobalState("packageManagerSources", updatedSources);
// Clean up cache directories for repositories that are no longer in the sources list
@ -194,8 +219,8 @@ export async function handlePackageManagerMessages(
if (source) {
try {
// Refresh the repository
await packageManagerManager.refreshRepository(message.url);
// Refresh the repository with the source name
await packageManagerManager.refreshRepository(message.url, source.name);
vscode.window.showInformationMessage(`Successfully refreshed package manager source: ${source.name || message.url}`);
// Trigger a fetch to update the UI with the refreshed data

View file

@ -16,13 +16,13 @@ export class GitFetcher {
constructor(private readonly context: vscode.ExtensionContext) {
this.cacheDir = path.join(context.globalStorageUri.fsPath, "package-manager-cache");
}
/**
* Fetches repository data from a Git URL
* @param url The Git repository URL
* @param sourceName Optional name to override the repository name
* @returns A PackageManagerRepository object containing metadata and items
*/
async fetchRepository(url: string): Promise<PackageManagerRepository> {
async fetchRepository(url: string, sourceName?: string): Promise<PackageManagerRepository> {
console.log(`GitFetcher: Fetching repository from ${url}`);
try {
@ -41,10 +41,11 @@ export class GitFetcher {
console.log(`GitFetcher: Repository directory: ${repoDir}`);
// Clone or pull repository with timeout protection
let activeBranch: string;
try {
console.log(`GitFetcher: Cloning or pulling repository ${url}`);
await this.cloneOrPullRepository(url, repoDir);
console.log(`GitFetcher: Repository cloned/pulled successfully`);
activeBranch = await this.cloneOrPullRepository(url, repoDir);
console.log(`GitFetcher: Repository cloned/pulled successfully on branch ${activeBranch}`);
} catch (gitError) {
console.error(`GitFetcher: Git operation failed: ${gitError.message}`);
throw new Error(`Git operation failed: ${gitError.message}`);
@ -61,7 +62,9 @@ export class GitFetcher {
// Parse items
console.log(`GitFetcher: Parsing package manager items`);
const items = await this.parsePackageManagerItems(repoDir, url);
// Use the provided sourceName if available, otherwise use metadata name or fallback to URL-derived name
const itemSourceName = sourceName || metadata.name || this.getRepoNameFromUrl(url);
const items = await this.parsePackageManagerItems(repoDir, url, activeBranch, itemSourceName);
console.log(`GitFetcher: Successfully fetched repository with ${items.length} items`);
return {
@ -115,7 +118,7 @@ export class GitFetcher {
* @param url The Git repository URL
* @param repoDir The directory to clone to or pull in
*/
private async cloneOrPullRepository(url: string, repoDir: string): Promise<void> {
private async cloneOrPullRepository(url: string, repoDir: string): Promise<string> {
console.log(`GitFetcher: Checking if repository exists at ${repoDir}`);
try {
@ -158,6 +161,12 @@ export class GitFetcher {
await clonePromise;
console.log(`GitFetcher: Successfully cloned repository`);
}
// Get the active branch name
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: repoDir });
console.log(`GitFetcher: Active branch is ${branchName.trim()}`);
return branchName.trim();
} catch (error) {
console.error(`GitFetcher: Failed to clone or pull repository: ${error.message}`);
throw new Error(`Failed to clone or pull repository: ${error.message}`);
@ -228,9 +237,11 @@ export class GitFetcher {
* Parses package manager items from a repository
* @param repoDir The repository directory
* @param repoUrl The repository URL
* @param branch The branch to use (default: "main")
* @param sourceName The name of the source repository
* @returns An array of PackageManagerItem objects
*/
private async parsePackageManagerItems(repoDir: string, repoUrl: string, branch: string = "main"): Promise<PackageManagerItem[]> {
private async parsePackageManagerItems(repoDir: string, repoUrl: string, branch: string = "main", sourceName?: string): Promise<PackageManagerItem[]> {
const items: PackageManagerItem[] = [];
// Check for items in each directory type
@ -284,18 +295,46 @@ export class GitFetcher {
tagsMatch[1].split(",").map(tag => tag.trim().replace(/["']/g, "")) :
undefined;
const item: PackageManagerItem = {
// Create base item without author and lastUpdated
let item: PackageManagerItem = {
name,
description,
type: type as "role" | "mcp-server" | "storage" | "other",
url: `${repoUrl}/tree/${branch}/${dirType.urlPath}/${itemDir}`,
repoUrl,
author,
sourceName: sourceName,
tags,
version,
sourceUrl
};
// Try to get the last non-merge commit info
try {
// Get the last non-merge commit by any author for this path
const { stdout: commitInfo } = await execAsync(
`git log --no-merges -1 --format="%aI%n%an" -- "${itemPath}"`,
{ cwd: repoDir }
);
// Split into date and author (they're on separate lines)
const [lastCommitDate, commitAuthor] = commitInfo.trim().split('\n');
// Update item with both date and author from git
item = {
...item,
lastUpdated: lastCommitDate.trim(), // ISO 8601 format
author: commitAuthor.trim() // Use Git author instead of metadata author
};
} catch (error) {
console.error(`Failed to get commit info for ${itemPath}:`, error);
// If git info fails, try to use author from metadata as fallback
if (author) {
item = {
...item,
author
};
}
}
items.push(item);
}
} catch (error) {

View file

@ -36,7 +36,8 @@ export class PackageManagerManager {
for (const source of enabledSources) {
try {
console.log(`PackageManagerManager: Processing source ${source.url}`);
const repo = await this.getRepositoryData(source.url);
// Pass the source name to getRepositoryData
const repo = await this.getRepositoryData(source.url, false, source.name);
if (repo.items && repo.items.length > 0) {
console.log(`PackageManagerManager: Found ${repo.items.length} items in ${source.url}`);
@ -66,9 +67,10 @@ export class PackageManagerManager {
* Gets repository data from a URL, using cache if available
* @param url The repository URL
* @param forceRefresh Whether to bypass the cache and force a refresh
* @param sourceName The name of the source
* @returns A PackageManagerRepository object
*/
async getRepositoryData(url: string, forceRefresh: boolean = false): Promise<PackageManagerRepository> {
async getRepositoryData(url: string, forceRefresh: boolean = false, sourceName?: string): Promise<PackageManagerRepository> {
try {
console.log(`PackageManagerManager: Getting repository data for ${url}`);
@ -87,7 +89,7 @@ export class PackageManagerManager {
console.log(`PackageManagerManager: Cache miss or expired for ${url}, fetching fresh data`);
// Fetch fresh data with timeout protection
const fetchPromise = this.gitFetcher.fetchRepository(url);
const fetchPromise = this.gitFetcher.fetchRepository(url, sourceName);
// Create a timeout promise
const timeoutPromise = new Promise<PackageManagerRepository>((_, reject) => {
@ -119,14 +121,15 @@ export class PackageManagerManager {
/**
* Refreshes a specific repository, bypassing the cache
* @param url The repository URL to refresh
* @param sourceName Optional name of the source
* @returns The refreshed repository data
*/
async refreshRepository(url: string): Promise<PackageManagerRepository> {
async refreshRepository(url: string, sourceName?: string): Promise<PackageManagerRepository> {
console.log(`PackageManagerManager: Refreshing repository ${url}`);
try {
// Force a refresh by bypassing the cache
const data = await this.getRepositoryData(url, true);
const data = await this.getRepositoryData(url, true, sourceName);
console.log(`PackageManagerManager: Repository ${url} refreshed successfully`);
return data;
} catch (error) {

View file

@ -0,0 +1,22 @@
/**
* Constants for the package manager
*/
/**
* Default package manager repository URL
*/
export const DEFAULT_PACKAGE_MANAGER_REPO_URL = "https://github.com/Smartsheet-JB-Brown/Package-Manager-Test";
/**
* Default package manager repository name
*/
export const DEFAULT_PACKAGE_MANAGER_REPO_NAME = "Roo Code";
/**
* Default package manager source
*/
export const DEFAULT_PACKAGE_MANAGER_SOURCE = {
url: DEFAULT_PACKAGE_MANAGER_REPO_URL,
name: DEFAULT_PACKAGE_MANAGER_REPO_NAME,
enabled: true
};

View file

@ -7,12 +7,11 @@ export interface PackageManagerItem {
type: "role" | "mcp-server" | "storage" | "other";
url: string;
repoUrl: string;
sourceName?: string; // Name of the source repository
author?: string;
tags?: string[];
version?: string;
lastUpdated?: string;
stars?: number;
downloads?: number;
sourceUrl?: string; // Optional URL to use for the "view source" button
}

View file

@ -0,0 +1,259 @@
/**
* Validation utilities for package manager sources
*/
import { PackageManagerSource } from "./types";
/**
* Error type for package manager source validation
*/
export interface ValidationError {
field: string;
message: string;
}
/**
* Validates a package manager source URL
* @param url The URL to validate
* @returns An array of validation errors, empty if valid
*/
/**
* Checks if a URL is a valid Git repository URL
* @param url The URL to validate
* @returns True if the URL is a valid Git repository URL, false otherwise
*/
export function isValidGitRepositoryUrl(url: string): boolean {
// Trim the URL to remove any leading/trailing whitespace
const trimmedUrl = url.trim();
// HTTPS pattern (GitHub, GitLab, Bitbucket, etc.)
// Examples:
// - https://github.com/username/repo
// - https://github.com/username/repo.git
// - https://gitlab.com/username/repo
// - https://bitbucket.org/username/repo
const httpsPattern = /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\/.+)*(\.git)?$/;
// SSH pattern
// Examples:
// - git@github.com:username/repo.git
// - git@gitlab.com:username/repo.git
const sshPattern = /^git@(github\.com|gitlab\.com|bitbucket\.org):([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(\.git)?$/;
// Git protocol pattern
// Examples:
// - git://github.com/username/repo.git
const gitProtocolPattern = /^git:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\.git)?$/;
return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl);
}
export function validateSourceUrl(url: string): ValidationError[] {
const errors: ValidationError[] = [];
// Check if URL is empty
if (!url) {
errors.push({
field: "url",
message: "URL cannot be empty"
});
return errors; // Return early if URL is empty
}
// Check if URL is valid format
try {
new URL(url);
} catch (e) {
errors.push({
field: "url",
message: "Invalid URL format"
});
return errors; // Return early if URL is not valid
}
// Check for non-visible characters (except spaces)
const nonVisibleCharRegex = /[^\S ]/;
if (nonVisibleCharRegex.test(url)) {
errors.push({
field: "url",
message: "URL contains non-visible characters other than spaces"
});
}
// Check if URL is a valid Git repository URL
if (!isValidGitRepositoryUrl(url)) {
errors.push({
field: "url",
message: "URL must be a valid Git repository URL (e.g., https://github.com/username/repo)"
});
}
return errors;
}
/**
* Validates a package manager source name
* @param name The name to validate
* @returns An array of validation errors, empty if valid
*/
export function validateSourceName(name?: string): ValidationError[] {
const errors: ValidationError[] = [];
// Skip validation if name is not provided
if (!name) {
return errors;
}
// Check name length
if (name.length > 20) {
errors.push({
field: "name",
message: "Name must be 20 characters or less"
});
}
// Check for non-visible characters (except spaces)
const nonVisibleCharRegex = /[^\S ]/;
if (nonVisibleCharRegex.test(name)) {
errors.push({
field: "name",
message: "Name contains non-visible characters other than spaces"
});
}
return errors;
}
/**
* Validates a list of package manager sources for duplicates
* @param sources The list of sources to validate
* @param newSource The new source to check against the list (optional)
* @returns An array of validation errors, empty if valid
*/
export function validateSourceDuplicates(
sources: PackageManagerSource[],
newSource?: PackageManagerSource
): ValidationError[] {
const errors: ValidationError[] = [];
const normalizedUrls: { url: string; index: number }[] = [];
const normalizedNames: { name: string; index: number }[] = [];
// Process existing sources
sources.forEach((source, index) => {
// Normalize URL (case and whitespace insensitive)
const normalizedUrl = source.url.toLowerCase().replace(/\s+/g, '');
normalizedUrls.push({ url: normalizedUrl, index });
// Normalize name if it exists (case and whitespace insensitive)
if (source.name) {
const normalizedName = source.name.toLowerCase().replace(/\s+/g, '');
normalizedNames.push({ name: normalizedName, index });
}
});
// Check for duplicates within the existing sources
normalizedUrls.forEach((item, index) => {
const duplicates = normalizedUrls.filter(
(other, otherIndex) => other.url === item.url && otherIndex !== index
);
if (duplicates.length > 0) {
errors.push({
field: "url",
message: `Source #${item.index + 1} has a duplicate URL with Source #${duplicates[0].index + 1} (case and whitespace insensitive match)`
});
}
});
normalizedNames.forEach((item, index) => {
const duplicates = normalizedNames.filter(
(other, otherIndex) => other.name === item.name && otherIndex !== index
);
if (duplicates.length > 0) {
errors.push({
field: "name",
message: `Source #${item.index + 1} has a duplicate name with Source #${duplicates[0].index + 1} (case and whitespace insensitive match)`
});
}
});
// Check new source against existing sources if provided
if (newSource) {
// Validate URL
if (newSource.url) {
const normalizedNewUrl = newSource.url.toLowerCase().replace(/\s+/g, '');
const duplicateUrl = normalizedUrls.find(item => item.url === normalizedNewUrl);
if (duplicateUrl) {
errors.push({
field: "url",
message: `URL is a duplicate of Source #${duplicateUrl.index + 1} (case and whitespace insensitive match)`
});
}
}
// Validate name
if (newSource.name) {
const normalizedNewName = newSource.name.toLowerCase().replace(/\s+/g, '');
const duplicateName = normalizedNames.find(item => item.name === normalizedNewName);
if (duplicateName) {
errors.push({
field: "name",
message: `Name is a duplicate of Source #${duplicateName.index + 1} (case and whitespace insensitive match)`
});
}
}
}
return errors;
}
/**
* Validates a package manager source
* @param source The source to validate
* @param existingSources Existing sources to check for duplicates
* @returns An array of validation errors, empty if valid
*/
export function validateSource(
source: PackageManagerSource,
existingSources: PackageManagerSource[] = []
): ValidationError[] {
// Combine all validation errors
return [
...validateSourceUrl(source.url),
...validateSourceName(source.name),
...validateSourceDuplicates(existingSources, source)
];
}
/**
* Validates a list of package manager sources
* @param sources The sources to validate
* @returns An array of validation errors, empty if valid
*/
export function validateSources(sources: PackageManagerSource[]): ValidationError[] {
const errors: ValidationError[] = [];
// Validate each source individually
sources.forEach((source, index) => {
const sourceErrors = [
...validateSourceUrl(source.url),
...validateSourceName(source.name)
];
// Add index to error messages
sourceErrors.forEach(error => {
errors.push({
field: error.field,
message: `Source #${index + 1}: ${error.message}`
});
});
});
// Check for duplicates across all sources
const duplicateErrors = validateSourceDuplicates(sources);
errors.push(...duplicateErrors);
return errors;
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react";
import { useExtensionState } from "../../context/ExtensionStateContext";
@ -6,13 +6,12 @@ import { useAppTranslation } from "../../i18n/TranslationContext";
import { Tab, TabContent, TabHeader } from "../common/Tab";
import { vscode } from "@/utils/vscode";
import { PackageManagerItem, PackageManagerSource } from "../../../../src/services/package-manager/types";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk";
type PackageManagerViewProps = {
onDone: () => void;
};
type PackageManagerViewProps = {};
const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
const PackageManagerView = ({}: PackageManagerViewProps) => {
const { packageManagerSources, setPackageManagerSources } = useExtensionState();
console.log("DEBUG: PackageManagerView initialized with sources:", packageManagerSources);
const { t } = useAppTranslation();
@ -24,7 +23,9 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
useEffect(() => {
console.log("DEBUG: activeTab changed to", activeTab);
}, [activeTab]);
const [filters, setFilters] = useState({ type: "", search: "" });
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");
@ -207,6 +208,20 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
}
}
// Filter by tags (OR logic - item passes if it has ANY of the selected tags)
if (filters.tags.length > 0) {
// If the item has no tags, it doesn't match when tag filtering is active
if (!item.tags || item.tags.length === 0) {
return false;
}
// Check if any of the item's tags match any of the selected tags
const hasMatchingTag = item.tags.some(tag => filters.tags.includes(tag));
if (!hasMatchingTag) {
return false;
}
}
return true;
});
console.log("DEBUG: After filtering", { filteredItemsCount: filteredItems.length });
@ -226,12 +241,6 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
case "lastUpdated":
comparison = (a.lastUpdated || "").localeCompare(b.lastUpdated || "");
break;
case "stars":
comparison = (a.stars || 0) - (b.stars || 0);
break;
case "downloads":
comparison = (a.downloads || 0) - (b.downloads || 0);
break;
default:
comparison = a.name.localeCompare(b.name);
}
@ -243,13 +252,25 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
firstItem: sortedItems.length > 0 ? sortedItems[0].name : 'none'
});
// Collect all unique tags from items
const allTags = useMemo(() => {
const tagSet = new Set<string>();
items.forEach(item => {
if (item.tags && item.tags.length > 0) {
item.tags.forEach(tag => tagSet.add(tag));
}
});
return Array.from(tagSet).sort();
}, [items]);
// Add debug logging right before rendering
useEffect(() => {
console.log("DEBUG: Rendering with", {
sortedItemsCount: sortedItems.length,
firstItem: sortedItems.length > 0 ? `${sortedItems[0].name} (${sortedItems[0].type})` : 'none'
firstItem: sortedItems.length > 0 ? `${sortedItems[0].name} (${sortedItems[0].type})` : 'none',
availableTags: allTags.length
});
}, [sortedItems]);
}, [sortedItems, allTags]);
// Log right before rendering
console.log("DEBUG: About to render with", {
@ -278,7 +299,6 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
>
Sources
</Button>
<Button onClick={onDone}>Done</Button>
</div>
</TabHeader>
@ -293,41 +313,125 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
onChange={(e) => setFilters({ ...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 justify-between mt-2">
<div>
<label className="mr-2">Filter by type:</label>
<select
value={filters.type}
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded"
>
<option value="">All types</option>
<option value="role">Role</option>
<option value="mcp-server">MCP Server</option>
<option value="storage">Storage</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="mr-2">Sort by:</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded mr-2"
>
<option value="name">Name</option>
<option value="author">Author</option>
<option value="lastUpdated">Last Updated</option>
<option value="stars">Stars</option>
<option value="downloads">Downloads</option>
</select>
<button
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded"
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
<div className="flex flex-col gap-3 mt-2">
<div className="flex flex-wrap justify-between gap-2">
<div className="whitespace-nowrap">
<label className="mr-2">Filter by type:</label>
<select
value={filters.type}
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded"
>
<option value="">All types</option>
<option value="role">Role</option>
<option value="mcp-server">MCP Server</option>
<option value="storage">Storage</option>
<option value="other">Other</option>
</select>
</div>
<div className="whitespace-nowrap">
<label className="mr-2">Sort by:</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded mr-2"
>
<option value="name">Name</option>
<option value="author">Author</option>
<option value="lastUpdated">Last Updated</option>
</select>
<button
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded"
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
{allTags.length > 0 && (
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center">
<label className="mr-2">Filter by tags:</label>
<span className="text-xs text-vscode-descriptionForeground">
({allTags.length} available)
</span>
</div>
{filters.tags.length > 0 && (
<button
onClick={() => setFilters({ ...filters, tags: [] })}
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded text-xs"
>
Clear tags ({filters.tags.length})
</button>
)}
</div>
<Command className="rounded-lg border border-vscode-dropdown-border">
<CommandInput
placeholder="Type to search and select tags..."
value={tagSearch}
onValueChange={setTagSearch}
onFocus={() => setIsTagInputActive(true)}
onBlur={(e) => {
// Only hide if not clicking within the command list
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">
No matching tags found
</CommandEmpty>
<CommandGroup>
{allTags
.filter(tag => tag.toLowerCase().includes(tagSearch.toLowerCase()))
.map(tag => (
<CommandItem
key={tag}
onSelect={() => {
const isSelected = filters.tags.includes(tag);
if (isSelected) {
setFilters({
...filters,
tags: filters.tags.filter(t => t !== tag)
});
} else {
setFilters({
...filters,
tags: [...filters.tags, tag]
});
}
}}
className={`flex items-center gap-2 p-1 cursor-pointer text-sm hover:bg-vscode-button-secondaryBackground ${
filters.tags.includes(tag)
? 'bg-vscode-button-background text-vscode-button-foreground'
: 'text-vscode-dropdown-foreground'
}`}
onMouseDown={(e) => {
// Prevent blur event when clicking items
e.preventDefault();
}}
>
<span className={`codicon ${filters.tags.includes(tag) ? 'codicon-check' : ''}`} />
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</Command>
<div className="text-xs text-vscode-descriptionForeground mt-1">
{filters.tags.length > 0
? `Showing items with any of the selected tags (${filters.tags.length} selected)`
: 'Click tags to filter items'}
</div>
</div>
)}
</div>
</div>
@ -342,15 +446,14 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
<Button
onClick={() => {
isManualRefresh.current = true;
vscode.postMessage({
type: "fetchPackageManagerItems",
forceRefresh: true
} as any);
setIsFetching(false); // Reset fetching state first
fetchPackageManagerItems(); // Use the fetchPackageManagerItems function
}}
className="mt-4"
disabled={isFetching}
>
<span className="codicon codicon-refresh mr-2"></span>
Refresh
<span className={`codicon ${isFetching ? 'codicon-sync codicon-modifier-spin' : 'codicon-refresh'} mr-2`}></span>
{isFetching ? "Refreshing..." : "Refresh"}
</Button>
</div>
) : (
@ -362,20 +465,26 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
<Button
onClick={() => {
isManualRefresh.current = true;
vscode.postMessage({
type: "fetchPackageManagerItems",
forceRefresh: true
} as any);
setIsFetching(false); // Reset fetching state first
fetchPackageManagerItems(); // Use the fetchPackageManagerItems function
}}
size="sm"
disabled={isFetching}
>
<span className="codicon codicon-refresh mr-2"></span>
Refresh
<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">
{sortedItems.map((item) => (
<PackageManagerItemCard key={`${item.repoUrl}-${item.name}`} item={item} />
<PackageManagerItemCard
key={`${item.repoUrl}-${item.name}`}
item={item}
filters={filters}
setFilters={setFilters}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
))}
</div>
</div>
@ -397,7 +506,19 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
);
};
const PackageManagerItemCard = ({ item }: { item: PackageManagerItem }) => {
const PackageManagerItemCard = ({
item,
filters,
setFilters,
activeTab,
setActiveTab
}: {
item: PackageManagerItem;
filters: { type: string; search: string; tags: string[] };
setFilters: React.Dispatch<React.SetStateAction<{ type: string; search: string; tags: string[] }>>;
activeTab: "browse" | "sources";
setActiveTab: React.Dispatch<React.SetStateAction<"browse" | "sources">>;
}) => {
const { t } = useAppTranslation();
// Helper function to validate URL
@ -468,12 +589,38 @@ const PackageManagerItemCard = ({ item }: { item: PackageManagerItem }) => {
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1 my-2">
{item.tags.map(tag => (
<span
key={tag}
className="px-2 py-1 text-xs bg-vscode-badge-background text-vscode-badge-foreground rounded-full"
<button
key={tag}
className={`px-2 py-1 text-xs rounded-full hover:bg-vscode-button-secondaryBackground ${
filters.tags.includes(tag)
? 'bg-vscode-button-background text-vscode-button-foreground'
: 'bg-vscode-badge-background text-vscode-badge-foreground'
}`}
onClick={(e) => {
e.stopPropagation(); // Prevent event bubbling
// Toggle tag selection
if (filters.tags.includes(tag)) {
// Remove tag if already selected
setFilters({
...filters,
tags: filters.tags.filter(t => t !== tag)
});
} else {
// Add tag if not already selected
setFilters({
...filters,
tags: [...filters.tags, tag]
});
// Switch to browse tab if not already there
if (activeTab !== "browse") {
setActiveTab("browse");
}
}
}}
title={filters.tags.includes(tag) ? `Remove tag filter: ${tag}` : `Filter by tag: ${tag}`}
>
{tag}
</span>
</button>
))}
</div>
)}
@ -489,32 +636,163 @@ const PackageManagerItemCard = ({ item }: { item: PackageManagerItem }) => {
{item.lastUpdated && (
<span className="flex items-center">
<span className="codicon codicon-calendar mr-1"></span>
{item.lastUpdated}
</span>
)}
{item.stars !== undefined && (
<span className="flex items-center">
<span className="codicon codicon-star-full mr-1"></span>
{item.stars}
</span>
)}
{item.downloads !== undefined && (
<span className="flex items-center">
<span className="codicon codicon-cloud-download mr-1"></span>
{item.downloads}
{new Date(item.lastUpdated).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
)}
</div>
<Button onClick={handleOpenUrl}>
<span className="codicon codicon-link-external mr-2"></span>
{item.sourceUrl && isValidUrl(item.sourceUrl) ? "View Source" : "View on GitHub"}
{item.sourceUrl ? "View" : `View on ${item.sourceName || "Source"}`}
</Button>
</div>
</div>
);
};
// Validation utilities for the frontend
interface ValidationError {
field: string;
message: string;
}
const validateSourceUrl = (url: string): ValidationError[] => {
const errors: ValidationError[] = [];
// Check if URL is empty
if (!url) {
errors.push({
field: "url",
message: "URL cannot be empty"
});
return errors;
}
// Check if URL is valid format
try {
new URL(url);
} catch (e) {
errors.push({
field: "url",
message: "Invalid URL format"
});
}
// Check for non-visible characters (except spaces)
const nonVisibleCharRegex = /[^\S ]/;
if (nonVisibleCharRegex.test(url)) {
errors.push({
field: "url",
message: "URL contains non-visible characters other than spaces"
});
}
return errors;
};
const validateSourceName = (name?: string): ValidationError[] => {
const errors: ValidationError[] = [];
// Skip validation if name is not provided
if (!name) {
return errors;
}
// Check name length
if (name.length > 20) {
errors.push({
field: "name",
message: "Name must be 20 characters or less"
});
}
// Check for non-visible characters (except spaces)
const nonVisibleCharRegex = /[^\S ]/;
if (nonVisibleCharRegex.test(name)) {
errors.push({
field: "name",
message: "Name contains non-visible characters other than spaces"
});
}
return errors;
};
const validateSourceDuplicates = (
sources: PackageManagerSource[],
newUrl: string,
newName?: string
): ValidationError[] => {
const errors: ValidationError[] = [];
if (newUrl) {
// Check for duplicate URLs (case and whitespace insensitive)
const normalizedNewUrl = newUrl.toLowerCase().replace(/\s+/g, '');
const duplicateUrl = sources.some(source =>
source.url.toLowerCase().replace(/\s+/g, '') === normalizedNewUrl
);
if (duplicateUrl) {
errors.push({
field: "url",
message: "This URL is already in the list (case and whitespace insensitive match)"
});
}
}
if (newName) {
// Check for duplicate names (case and whitespace insensitive)
const normalizedNewName = newName.toLowerCase().replace(/\s+/g, '');
const duplicateName = sources.some(source =>
source.name && source.name.toLowerCase().replace(/\s+/g, '') === normalizedNewName
);
if (duplicateName) {
errors.push({
field: "name",
message: "This name is already in use (case and whitespace insensitive match)"
});
}
}
return errors;
};
/**
* Checks if a URL is a valid Git repository URL
* @param url The URL to validate
* @returns True if the URL is a valid Git repository URL, false otherwise
*/
const isValidGitRepositoryUrl = (url: string): boolean => {
// Trim the URL to remove any leading/trailing whitespace
const trimmedUrl = url.trim();
// HTTPS pattern (GitHub, GitLab, Bitbucket, etc.)
// Examples:
// - https://github.com/username/repo
// - https://github.com/username/repo.git
// - https://gitlab.com/username/repo
// - https://bitbucket.org/username/repo
const httpsPattern = /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\/.+)*(\.git)?$/;
// SSH pattern
// Examples:
// - git@github.com:username/repo.git
// - git@gitlab.com:username/repo.git
const sshPattern = /^git@(github\.com|gitlab\.com|bitbucket\.org):([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(\.git)?$/;
// Git protocol pattern
// Examples:
// - git://github.com/username/repo.git
const gitProtocolPattern = /^git:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(\.git)?$/;
return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl);
};
const PackageManagerSourcesConfig = ({
sources,
refreshingUrls,
@ -545,12 +823,50 @@ const PackageManagerSourcesConfig = ({
return;
}
// Check if URL already exists
if (sources.some(source => source.url === newSourceUrl)) {
setError("This URL is already in the list");
// Check for non-visible characters in URL (except spaces)
const nonVisibleCharRegex = /[^\S ]/;
if (nonVisibleCharRegex.test(newSourceUrl)) {
setError("URL contains non-visible characters other than spaces");
return;
}
// Check if URL is a valid Git repository URL
if (!isValidGitRepositoryUrl(newSourceUrl)) {
setError("URL must be a valid Git repository URL (e.g., https://github.com/username/repo)");
return;
}
// Check if URL already exists (case and whitespace insensitive)
const normalizedNewUrl = newSourceUrl.toLowerCase().replace(/\s+/g, '');
if (sources.some(source => source.url.toLowerCase().replace(/\s+/g, '') === normalizedNewUrl)) {
setError("This URL is already in the list (case and whitespace insensitive match)");
return;
}
// Validate name if provided
if (newSourceName) {
// Check name length
if (newSourceName.length > 20) {
setError("Name must be 20 characters or less");
return;
}
// Check for non-visible characters in name (except spaces)
if (nonVisibleCharRegex.test(newSourceName)) {
setError("Name contains non-visible characters other than spaces");
return;
}
// Check if name already exists (case and whitespace insensitive)
const normalizedNewName = newSourceName.toLowerCase().replace(/\s+/g, '');
if (sources.some(source =>
source.name && source.name.toLowerCase().replace(/\s+/g, '') === normalizedNewName
)) {
setError("This name is already in use (case and whitespace insensitive match)");
return;
}
}
// Check if maximum number of sources has been reached
const MAX_SOURCES = 10;
if (sources.length >= MAX_SOURCES) {
@ -615,11 +931,19 @@ const PackageManagerSourcesConfig = ({
}}
className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
/>
<p className="text-xs text-vscode-descriptionForeground mt-1 mb-2">
Supported formats: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), or Git protocol (git://github.com/username/repo.git)
</p>
<input
type="text"
placeholder="Display name (optional)"
placeholder="Display name (optional, max 20 chars)"
value={newSourceName}
onChange={(e) => setNewSourceName(e.target.value)}
onChange={(e) => {
// Limit input to 20 characters
setNewSourceName(e.target.value.slice(0, 20));
setError("");
}}
maxLength={20} // HTML attribute to limit input length
className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
/>
</div>

View file

@ -12,6 +12,7 @@ import { CustomSupportPrompts } from "../../../src/shared/support-prompt"
import { experimentDefault, ExperimentId } from "../../../src/shared/experiments"
import { TelemetrySetting } from "../../../src/shared/TelemetrySetting"
import { PackageManagerSource } from "../../../src/services/package-manager/types"
import { DEFAULT_PACKAGE_MANAGER_SOURCE } from "../../../src/services/package-manager/constants"
export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
@ -163,13 +164,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior).
renderContext: "sidebar",
maxReadFileLine: 500, // Default max read file line limit
packageManagerSources: [
{
url: "https://github.com/Smartsheet-JB-Brown/Package-Manager-Test",
name: "Official Roo-Code Package Manager",
enabled: true
}
],
packageManagerSources: [DEFAULT_PACKAGE_MANAGER_SOURCE],
pinnedApiConfigs: {}, // Empty object for pinned API configs
})