mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
UI Control work
This commit is contained in:
parent
fd48ffcfe9
commit
d323015b25
14 changed files with 790 additions and 147 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
22
src/services/package-manager/constants.ts
Normal file
22
src/services/package-manager/constants.ts
Normal 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
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
259
src/services/package-manager/validation.ts
Normal file
259
src/services/package-manager/validation.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue