diff --git a/package-manager-template/README.md b/package-manager-template/README.md index 314b4e5430..2beebf7cb0 100644 --- a/package-manager-template/README.md +++ b/package-manager-template/README.md @@ -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 ``` diff --git a/package-manager-template/mcp-servers/file-analyzer/metadata.yml b/package-manager-template/mcp-servers/file-analyzer/metadata.yml index a0af98a760..5546f4c4b7 100644 --- a/package-manager-template/mcp-servers/file-analyzer/metadata.yml +++ b/package-manager-template/mcp-servers/file-analyzer/metadata.yml @@ -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" \ No newline at end of file diff --git a/package-manager-template/metadata.yml b/package-manager-template/metadata.yml index 8de2db050a..556121a4ae 100644 --- a/package-manager-template/metadata.yml +++ b/package-manager-template/metadata.yml @@ -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" \ No newline at end of file +version: "1.0.0" \ No newline at end of file diff --git a/package-manager-template/roles/developer-role/metadata.yml b/package-manager-template/roles/developer-role/metadata.yml index c9c0627502..30fc9ea18c 100644 --- a/package-manager-template/roles/developer-role/metadata.yml +++ b/package-manager-template/roles/developer-role/metadata.yml @@ -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" \ No newline at end of file diff --git a/package-manager-template/storage-systems/github-storage/metadata.yml b/package-manager-template/storage-systems/github-storage/metadata.yml index b943653865..2153c376dd 100644 --- a/package-manager-template/storage-systems/github-storage/metadata.yml +++ b/package-manager-template/storage-systems/github-storage/metadata.yml @@ -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" \ No newline at end of file diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7f223b7a93..26f5666deb 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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 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 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], } } diff --git a/src/core/webview/packageManagerMessageHandler.ts b/src/core/webview/packageManagerMessageHandler.ts index c0a7a15561..ce69282276 100644 --- a/src/core/webview/packageManagerMessageHandler.ts +++ b/src/core/webview/packageManagerMessageHandler.ts @@ -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(); + 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 diff --git a/src/services/package-manager/GitFetcher.ts b/src/services/package-manager/GitFetcher.ts index a694c1de32..2289a47197 100644 --- a/src/services/package-manager/GitFetcher.ts +++ b/src/services/package-manager/GitFetcher.ts @@ -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 { + async fetchRepository(url: string, sourceName?: string): Promise { 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 { + private async cloneOrPullRepository(url: string, repoDir: string): Promise { 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 { + private async parsePackageManagerItems(repoDir: string, repoUrl: string, branch: string = "main", sourceName?: string): Promise { 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) { diff --git a/src/services/package-manager/PackageManagerManager.ts b/src/services/package-manager/PackageManagerManager.ts index 9deac5949a..2ba01e958d 100644 --- a/src/services/package-manager/PackageManagerManager.ts +++ b/src/services/package-manager/PackageManagerManager.ts @@ -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 { + async getRepositoryData(url: string, forceRefresh: boolean = false, sourceName?: string): Promise { 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((_, 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 { + async refreshRepository(url: string, sourceName?: string): Promise { 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) { diff --git a/src/services/package-manager/constants.ts b/src/services/package-manager/constants.ts new file mode 100644 index 0000000000..01782417d8 --- /dev/null +++ b/src/services/package-manager/constants.ts @@ -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 +}; \ No newline at end of file diff --git a/src/services/package-manager/types.ts b/src/services/package-manager/types.ts index e0bfa6cb14..d0c8aa5c83 100644 --- a/src/services/package-manager/types.ts +++ b/src/services/package-manager/types.ts @@ -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 } diff --git a/src/services/package-manager/validation.ts b/src/services/package-manager/validation.ts new file mode 100644 index 0000000000..188345ef11 --- /dev/null +++ b/src/services/package-manager/validation.ts @@ -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; +} \ No newline at end of file diff --git a/webview-ui/src/components/package-manager/PackageManagerView.tsx b/webview-ui/src/components/package-manager/PackageManagerView.tsx index 0dafd5f2f4..66df314626 100644 --- a/webview-ui/src/components/package-manager/PackageManagerView.tsx +++ b/webview-ui/src/components/package-manager/PackageManagerView.tsx @@ -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(); + 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 - @@ -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" /> -
-
- - -
-
- - - +
+
+
+ + +
+ +
+ + + +
+ + {allTags.length > 0 && ( +
+
+
+ + + ({allTags.length} available) + +
+ {filters.tags.length > 0 && ( + + )} +
+ + 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) && ( + + + No matching tags found + + + {allTags + .filter(tag => tag.toLowerCase().includes(tagSearch.toLowerCase())) + .map(tag => ( + { + 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(); + }} + > + + {tag} + + ))} + + + )} + +
+ {filters.tags.length > 0 + ? `Showing items with any of the selected tags (${filters.tags.length} selected)` + : 'Click tags to filter items'} +
+
+ )}
@@ -342,15 +446,14 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
) : ( @@ -362,20 +465,26 @@ const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
{sortedItems.map((item) => ( - + ))}
@@ -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>; + activeTab: "browse" | "sources"; + setActiveTab: React.Dispatch>; +}) => { const { t } = useAppTranslation(); // Helper function to validate URL @@ -468,12 +589,38 @@ const PackageManagerItemCard = ({ item }: { item: PackageManagerItem }) => { {item.tags && item.tags.length > 0 && (
{item.tags.map(tag => ( - { + 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} - + ))}
)} @@ -489,32 +636,163 @@ const PackageManagerItemCard = ({ item }: { item: PackageManagerItem }) => { {item.lastUpdated && ( - {item.lastUpdated} - - )} - {item.stars !== undefined && ( - - - {item.stars} - - )} - {item.downloads !== undefined && ( - - - {item.downloads} + {new Date(item.lastUpdated).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + })} )} ); }; +// 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" /> +

+ Supported formats: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), or Git protocol (git://github.com/username/repo.git) +

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" /> diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 9785a9be66..8095660a7a 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -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 })