fix linting errors

This commit is contained in:
Smartsheet-JB-Brown 2025-04-11 06:34:50 -07:00
parent d323015b25
commit aa066935ce
4 changed files with 1560 additions and 1639 deletions

View file

@ -12,248 +12,272 @@ import { GlobalState } from "../../schemas"
* Handle package manager-related messages from the webview
*/
export async function handlePackageManagerMessages(
provider: ClineProvider,
message: WebviewMessage,
packageManagerManager: PackageManagerManager
provider: ClineProvider,
message: WebviewMessage,
packageManagerManager: PackageManagerManager,
): Promise<boolean> {
// Utility function for updating global state
const updateGlobalState = async <K extends keyof GlobalState>(key: K, value: GlobalState[K]) =>
await provider.contextProxy.setValue(key, value)
// Utility function for updating global state
const updateGlobalState = async <K extends keyof GlobalState>(key: K, value: GlobalState[K]) =>
await provider.contextProxy.setValue(key, value)
switch (message.type) {
case "webviewDidLaunch": {
// For webviewDidLaunch, we don't do anything - package manager items will be loaded by explicit fetchPackageManagerItems
console.log("Package Manager: webviewDidLaunch received, but skipping fetch (will be triggered by explicit fetchPackageManagerItems)");
return true;
}
case "fetchPackageManagerItems": {
// Check if we need to force refresh using type assertion
const forceRefresh = (message as any).forceRefresh === true;
console.log(`Package Manager: Fetch requested with forceRefresh=${forceRefresh}`);
try {
console.log("Package Manager: Received request to fetch package manager items")
console.log("DEBUG: Processing package manager request")
// Wrap the entire initialization in a try-catch block
try {
// Initialize default sources if none exist
let sources = await provider.contextProxy.getValue("packageManagerSources") as PackageManagerSource[] || []
if (!sources || sources.length === 0) {
console.log("Package Manager: No sources found, initializing default sources")
sources = [DEFAULT_PACKAGE_MANAGER_SOURCE];
// Save the default sources
await provider.contextProxy.setValue("packageManagerSources", sources)
console.log("Package Manager: Default sources initialized")
}
console.log(`Package Manager: Fetching items from ${sources.length} sources`)
console.log(`DEBUG: PackageManagerManager instance: ${packageManagerManager ? "exists" : "null"}`)
// Add timing information
const startTime = Date.now()
// Simplify the initialization by limiting the number of items and adding more error handling
let items: PackageManagerItem[] = [];
try {
console.log("DEBUG: Starting to fetch items from sources");
// Only fetch from the first enabled source to reduce complexity
const enabledSources = sources.filter(s => s.enabled);
if (enabledSources.length > 0) {
const firstSource = enabledSources[0];
console.log(`Package Manager: Fetching items from first source: ${firstSource.url}`);
// Get items from the first source only
const sourceItems = await packageManagerManager.getPackageManagerItems([firstSource]);
items = sourceItems;
console.log("DEBUG: Successfully fetched items:", items.length);
} else {
console.log("DEBUG: No enabled sources found");
}
} catch (fetchError) {
console.error("Failed to fetch package manager items:", fetchError);
// Continue with empty items array
items = [];
}
console.log("DEBUG: Fetch completed, preparing to send items to webview");
const endTime = Date.now()
console.log(`Package Manager: Found ${items.length} items in ${endTime - startTime}ms`)
console.log(`Package Manager: First item:`, items.length > 0 ? items[0] : 'No items')
// Send the items to the webview
console.log("DEBUG: Creating message to send items to webview");
// Get the current state to include apiConfiguration to prevent welcome screen from showing
const currentState = await provider.getState();
const message = {
type: "state",
state: {
// Include the current apiConfiguration to prevent welcome screen from showing
// This is critical because ExtensionStateContext checks apiConfiguration to determine if welcome screen should be shown
apiConfiguration: currentState.apiConfiguration,
packageManagerItems: items
}
} as ExtensionMessage;
console.log(`Package Manager: Sending message to webview:`, message);
console.log("DEBUG: About to call postMessageToWebview with apiConfiguration:",
currentState.apiConfiguration ? "present" : "missing");
provider.postMessageToWebview(message);
console.log("DEBUG: Called postMessageToWebview");
console.log(`Package Manager: Message sent to webview`);
} catch (initError) {
console.error("Error in package manager initialization:", initError);
// Send an empty items array to the webview to prevent the spinner from spinning forever
// Get the current state to include apiConfiguration to prevent welcome screen from showing
const currentState = await provider.getState();
provider.postMessageToWebview({
type: "state",
state: {
// Include the current apiConfiguration to prevent welcome screen from showing
// This is critical because ExtensionStateContext checks apiConfiguration to determine if welcome screen should be shown
apiConfiguration: currentState.apiConfiguration,
packageManagerItems: []
}
} as any); // Use type assertion to bypass TypeScript checking
vscode.window.showErrorMessage(`Package manager initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`);
}
} catch (error) {
console.error("Failed to fetch package manager items:", error);
vscode.window.showErrorMessage(`Failed to fetch package manager items: ${error instanceof Error ? error.message : String(error)}`)
}
return true
}
case "packageManagerSources": {
if (message.sources) {
// Enforce maximum of 10 sources
const MAX_SOURCES = 10;
let updatedSources: PackageManagerSource[];
if (message.sources.length > MAX_SOURCES) {
// Truncate to maximum allowed and show warning
updatedSources = message.sources.slice(0, MAX_SOURCES);
vscode.window.showWarningMessage(`Maximum of ${MAX_SOURCES} package manager sources allowed. Additional sources have been removed.`);
} else {
updatedSources = message.sources;
}
// Validate sources using the validation utility
const validationErrors = validateSources(updatedSources);
switch (message.type) {
case "webviewDidLaunch": {
// For webviewDidLaunch, we don't do anything - package manager items will be loaded by explicit fetchPackageManagerItems
console.log(
"Package Manager: webviewDidLaunch received, but skipping fetch (will be triggered by explicit fetchPackageManagerItems)",
)
return true
}
case "fetchPackageManagerItems": {
// Check if we need to force refresh using type assertion
const forceRefresh = (message as any).forceRefresh === true
console.log(`Package Manager: Fetch requested with forceRefresh=${forceRefresh}`)
try {
console.log("Package Manager: Received request to fetch package manager items")
console.log("DEBUG: Processing package manager request")
// Filter out invalid sources
if (validationErrors.length > 0) {
console.log("Package Manager: Validation errors found in sources", validationErrors);
// Wrap the entire initialization in a try-catch block
try {
// Initialize default sources if none exist
let sources =
((await provider.contextProxy.getValue("packageManagerSources")) as PackageManagerSource[]) ||
[]
// 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);
}
}
});
if (!sources || sources.length === 0) {
console.log("Package Manager: No sources found, initializing default sources")
sources = [DEFAULT_PACKAGE_MANAGER_SOURCE]
// Filter out invalid sources
updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index));
// Save the default sources
await provider.contextProxy.setValue("packageManagerSources", sources)
console.log("Package Manager: Default sources initialized")
}
// 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);
}
console.log(`Package Manager: Fetching items from ${sources.length} sources`)
console.log(`DEBUG: PackageManagerManager instance: ${packageManagerManager ? "exists" : "null"}`)
// 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
try {
console.log("Package Manager: Cleaning up cache directories for removed sources");
await packageManagerManager.cleanupCacheDirectories(updatedSources);
console.log("Package Manager: Cache cleanup completed");
} catch (error) {
console.error("Package Manager: Error during cache cleanup:", error);
}
// Update the webview with the new state
await provider.postStateToWebview();
}
return true;
}
case "openExternal": {
if (message.url) {
console.log(`Package Manager: Opening external URL: ${message.url}`);
try {
vscode.env.openExternal(vscode.Uri.parse(message.url));
console.log(`Package Manager: Successfully opened URL: ${message.url}`);
} catch (error) {
console.error(`Package Manager: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`);
vscode.window.showErrorMessage(`Failed to open URL: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
console.error("Package Manager: openExternal called without a URL");
}
return true;
}
case "refreshPackageManagerSource": {
if (message.url) {
try {
console.log(`Package Manager: Received request to refresh source ${message.url}`);
// Get the current sources
const sources = await provider.contextProxy.getValue("packageManagerSources") as PackageManagerSource[] || [];
// Find the source with the matching URL
const source = sources.find(s => s.url === message.url);
if (source) {
try {
// 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
const currentState = await provider.getState();
provider.postMessageToWebview({
type: "state",
state: {
apiConfiguration: currentState.apiConfiguration,
packageManagerItems: await packageManagerManager.getPackageManagerItems(sources.filter(s => s.enabled))
}
} as ExtensionMessage);
} finally {
// Always notify the webview that the refresh is complete, even if it failed
console.log(`Package Manager: Sending repositoryRefreshComplete message for ${message.url}`);
provider.postMessageToWebview({
type: "repositoryRefreshComplete",
url: message.url
});
}
} else {
console.error(`Package Manager: Source URL not found: ${message.url}`);
vscode.window.showErrorMessage(`Source URL not found: ${message.url}`);
}
} catch (error) {
console.error(`Package Manager: Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`);
vscode.window.showErrorMessage(`Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`);
}
}
return true;
}
default:
return false
}
}
// Add timing information
const startTime = Date.now()
// Simplify the initialization by limiting the number of items and adding more error handling
let items: PackageManagerItem[] = []
try {
console.log("DEBUG: Starting to fetch items from sources")
// Only fetch from the first enabled source to reduce complexity
const enabledSources = sources.filter((s) => s.enabled)
if (enabledSources.length > 0) {
const firstSource = enabledSources[0]
console.log(`Package Manager: Fetching items from first source: ${firstSource.url}`)
// Get items from the first source only
const sourceItems = await packageManagerManager.getPackageManagerItems([firstSource])
items = sourceItems
console.log("DEBUG: Successfully fetched items:", items.length)
} else {
console.log("DEBUG: No enabled sources found")
}
} catch (fetchError) {
console.error("Failed to fetch package manager items:", fetchError)
// Continue with empty items array
items = []
}
console.log("DEBUG: Fetch completed, preparing to send items to webview")
const endTime = Date.now()
console.log(`Package Manager: Found ${items.length} items in ${endTime - startTime}ms`)
console.log(`Package Manager: First item:`, items.length > 0 ? items[0] : "No items")
// Send the items to the webview
console.log("DEBUG: Creating message to send items to webview")
// Get the current state to include apiConfiguration to prevent welcome screen from showing
const currentState = await provider.getState()
const message = {
type: "state",
state: {
// Include the current apiConfiguration to prevent welcome screen from showing
// This is critical because ExtensionStateContext checks apiConfiguration to determine if welcome screen should be shown
apiConfiguration: currentState.apiConfiguration,
packageManagerItems: items,
},
} as ExtensionMessage
console.log(`Package Manager: Sending message to webview:`, message)
console.log(
"DEBUG: About to call postMessageToWebview with apiConfiguration:",
currentState.apiConfiguration ? "present" : "missing",
)
provider.postMessageToWebview(message)
console.log("DEBUG: Called postMessageToWebview")
console.log(`Package Manager: Message sent to webview`)
} catch (initError) {
console.error("Error in package manager initialization:", initError)
// Send an empty items array to the webview to prevent the spinner from spinning forever
// Get the current state to include apiConfiguration to prevent welcome screen from showing
const currentState = await provider.getState()
provider.postMessageToWebview({
type: "state",
state: {
// Include the current apiConfiguration to prevent welcome screen from showing
// This is critical because ExtensionStateContext checks apiConfiguration to determine if welcome screen should be shown
apiConfiguration: currentState.apiConfiguration,
packageManagerItems: [],
},
} as any) // Use type assertion to bypass TypeScript checking
vscode.window.showErrorMessage(
`Package manager initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`,
)
}
} catch (error) {
console.error("Failed to fetch package manager items:", error)
vscode.window.showErrorMessage(
`Failed to fetch package manager items: ${error instanceof Error ? error.message : String(error)}`,
)
}
return true
}
case "packageManagerSources": {
if (message.sources) {
// Enforce maximum of 10 sources
const MAX_SOURCES = 10
let updatedSources: PackageManagerSource[]
if (message.sources.length > MAX_SOURCES) {
// Truncate to maximum allowed and show warning
updatedSources = message.sources.slice(0, MAX_SOURCES)
vscode.window.showWarningMessage(
`Maximum of ${MAX_SOURCES} package manager sources allowed. Additional sources have been removed.`,
)
} else {
updatedSources = message.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
try {
console.log("Package Manager: Cleaning up cache directories for removed sources")
await packageManagerManager.cleanupCacheDirectories(updatedSources)
console.log("Package Manager: Cache cleanup completed")
} catch (error) {
console.error("Package Manager: Error during cache cleanup:", error)
}
// Update the webview with the new state
await provider.postStateToWebview()
}
return true
}
case "openExternal": {
if (message.url) {
console.log(`Package Manager: Opening external URL: ${message.url}`)
try {
vscode.env.openExternal(vscode.Uri.parse(message.url))
console.log(`Package Manager: Successfully opened URL: ${message.url}`)
} catch (error) {
console.error(
`Package Manager: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
)
vscode.window.showErrorMessage(
`Failed to open URL: ${error instanceof Error ? error.message : String(error)}`,
)
}
} else {
console.error("Package Manager: openExternal called without a URL")
}
return true
}
case "refreshPackageManagerSource": {
if (message.url) {
try {
console.log(`Package Manager: Received request to refresh source ${message.url}`)
// Get the current sources
const sources =
((await provider.contextProxy.getValue("packageManagerSources")) as PackageManagerSource[]) ||
[]
// Find the source with the matching URL
const source = sources.find((s) => s.url === message.url)
if (source) {
try {
// 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
const currentState = await provider.getState()
provider.postMessageToWebview({
type: "state",
state: {
apiConfiguration: currentState.apiConfiguration,
packageManagerItems: await packageManagerManager.getPackageManagerItems(
sources.filter((s) => s.enabled),
),
},
} as ExtensionMessage)
} finally {
// Always notify the webview that the refresh is complete, even if it failed
console.log(`Package Manager: Sending repositoryRefreshComplete message for ${message.url}`)
provider.postMessageToWebview({
type: "repositoryRefreshComplete",
url: message.url,
})
}
} else {
console.error(`Package Manager: Source URL not found: ${message.url}`)
vscode.window.showErrorMessage(`Source URL not found: ${message.url}`)
}
} catch (error) {
console.error(
`Package Manager: Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`,
)
vscode.window.showErrorMessage(
`Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
return true
}
default:
return false
}
}

View file

@ -1,286 +1,287 @@
import * as vscode from "vscode";
import * as path from "path";
import * as fs from "fs/promises";
import { GitFetcher } from "./GitFetcher";
import { PackageManagerItem, PackageManagerRepository, PackageManagerSource } from "./types";
import * as vscode from "vscode"
import * as path from "path"
import * as fs from "fs/promises"
import { GitFetcher } from "./GitFetcher"
import { PackageManagerItem, PackageManagerRepository, PackageManagerSource } from "./types"
/**
* Service for managing package manager data
*/
export class PackageManagerManager {
// Cache expiry time in milliseconds (set to a low value for testing)
private static readonly CACHE_EXPIRY_MS = 10 * 1000; // 10 seconds (normally 3600000 = 1 hour)
private gitFetcher: GitFetcher;
private cache: Map<string, { data: PackageManagerRepository, timestamp: number }> = new Map();
constructor(private readonly context: vscode.ExtensionContext) {
this.gitFetcher = new GitFetcher(context);
}
/**
* Gets package manager items from all enabled sources
* @param sources The package manager sources
* @returns An array of PackageManagerItem objects
*/
async getPackageManagerItems(sources: PackageManagerSource[]): Promise<PackageManagerItem[]> {
console.log(`PackageManagerManager: Getting items from ${sources.length} sources`);
const items: PackageManagerItem[] = [];
const errors: Error[] = [];
// Filter enabled sources
const enabledSources = sources.filter(s => s.enabled);
console.log(`PackageManagerManager: ${enabledSources.length} enabled sources`);
// Process sources sequentially to avoid overwhelming the system
for (const source of enabledSources) {
try {
console.log(`PackageManagerManager: Processing source ${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}`);
items.push(...repo.items);
} else {
console.log(`PackageManagerManager: No items found in ${source.url}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`PackageManagerManager: Failed to fetch data from ${source.url}:`, error);
errors.push(new Error(`Source ${source.url}: ${errorMessage}`));
}
}
// Show a single error message with all failures
if (errors.length > 0) {
const errorMessage = `Failed to fetch from ${errors.length} sources: ${errors.map(e => e.message).join("; ")}`;
console.error(`PackageManagerManager: ${errorMessage}`);
vscode.window.showErrorMessage(errorMessage);
}
console.log(`PackageManagerManager: Returning ${items.length} total items`);
return items;
}
/**
* 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, sourceName?: string): Promise<PackageManagerRepository> {
try {
console.log(`PackageManagerManager: Getting repository data for ${url}`);
// Check cache first (unless force refresh is requested)
const cached = this.cache.get(url);
if (!forceRefresh && cached && (Date.now() - cached.timestamp) < PackageManagerManager.CACHE_EXPIRY_MS) {
console.log(`PackageManagerManager: Using cached data for ${url} (age: ${Date.now() - cached.timestamp}ms)`);
return cached.data;
}
if (forceRefresh) {
console.log(`PackageManagerManager: Force refresh requested for ${url}, bypassing cache`);
}
console.log(`PackageManagerManager: Cache miss or expired for ${url}, fetching fresh data`);
// Fetch fresh data with timeout protection
const fetchPromise = this.gitFetcher.fetchRepository(url, sourceName);
// Create a timeout promise
const timeoutPromise = new Promise<PackageManagerRepository>((_, reject) => {
setTimeout(() => {
reject(new Error(`Repository fetch timed out after 30 seconds: ${url}`));
}, 30000); // 30 second timeout
});
// Race the fetch against the timeout
const data = await Promise.race([fetchPromise, timeoutPromise]);
// Cache the result
this.cache.set(url, { data, timestamp: Date.now() });
console.log(`PackageManagerManager: Successfully fetched and cached data for ${url}`);
return data;
} catch (error) {
console.error(`PackageManagerManager: Error fetching repository data for ${url}:`, error);
// Return empty repository data instead of throwing
return {
metadata: {},
items: [],
url
};
}
}
/**
* 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, 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, sourceName);
console.log(`PackageManagerManager: Repository ${url} refreshed successfully`);
return data;
} catch (error) {
console.error(`PackageManagerManager: Failed to refresh repository ${url}:`, error);
throw error;
}
}
/**
* Clears the in-memory cache
*/
clearCache(): void {
this.cache.clear();
}
/**
* Cleans up cache directories for repositories that are no longer in the configured sources
* @param currentSources The current list of package manager sources
*/
async cleanupCacheDirectories(currentSources: PackageManagerSource[]): Promise<void> {
try {
// Get the cache directory path
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "package-manager-cache");
// Check if cache directory exists
try {
await fs.stat(cacheDir);
} catch (error) {
console.log("PackageManagerManager: Cache directory doesn't exist yet, nothing to clean up");
return;
}
// Get all subdirectories in the cache directory
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
const cachedRepoDirs = entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
console.log(`PackageManagerManager: Found ${cachedRepoDirs.length} cached repositories`);
// Get the list of repository names from current sources
const currentRepoNames = currentSources.map(source => this.getRepoNameFromUrl(source.url));
// Find directories to delete
const dirsToDelete = cachedRepoDirs.filter(dir => !currentRepoNames.includes(dir));
console.log(`PackageManagerManager: Found ${dirsToDelete.length} repositories to delete`);
// Delete each directory that's no longer in the sources
for (const dirName of dirsToDelete) {
try {
const dirPath = path.join(cacheDir, dirName);
console.log(`PackageManagerManager: Deleting cache directory ${dirPath}`);
await fs.rm(dirPath, { recursive: true, force: true });
console.log(`PackageManagerManager: Successfully deleted ${dirPath}`);
} catch (error) {
console.error(`PackageManagerManager: Failed to delete directory ${dirName}:`, error);
}
}
console.log(`PackageManagerManager: Cache cleanup completed, deleted ${dirsToDelete.length} directories`);
} catch (error) {
console.error("PackageManagerManager: Error cleaning up cache directories:", error);
}
}
/**
* Extracts a safe directory name from a Git URL
* @param url The Git repository URL
* @returns A sanitized directory name
*/
private getRepoNameFromUrl(url: string): string {
// Extract repo name from URL and sanitize it
const urlParts = url.split("/").filter(part => part !== "");
const repoName = urlParts[urlParts.length - 1].replace(/\.git$/, "");
return repoName.replace(/[^a-zA-Z0-9-_]/g, "-");
}
/**
* Filters package manager items based on criteria
* @param items The items to filter
* @param filters The filter criteria
* @returns Filtered items
*/
filterItems(items: PackageManagerItem[], filters: { type?: string, search?: string, tags?: string[] }): PackageManagerItem[] {
return items.filter(item => {
// Filter by type
if (filters.type && item.type !== filters.type) {
return false;
}
// Filter by search term
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
const nameMatch = item.name.toLowerCase().includes(searchTerm);
const descMatch = item.description.toLowerCase().includes(searchTerm);
const authorMatch = item.author?.toLowerCase().includes(searchTerm);
if (!nameMatch && !descMatch && !authorMatch) {
return false;
}
}
// Filter by tags
if (filters.tags && filters.tags.length > 0) {
if (!item.tags || item.tags.length === 0) {
return false;
}
const hasMatchingTag = filters.tags.some(tag => item.tags!.includes(tag));
if (!hasMatchingTag) {
return false;
}
}
return true;
});
}
/**
* Sorts package manager items
* @param items The items to sort
* @param sortBy The field to sort by
* @param sortOrder The sort order
* @returns Sorted items
*/
sortItems(items: PackageManagerItem[], sortBy: string, sortOrder: "asc" | "desc"): PackageManagerItem[] {
return [...items].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case "name":
comparison = a.name.localeCompare(b.name);
break;
case "author":
comparison = (a.author || "").localeCompare(b.author || "");
break;
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);
}
return sortOrder === "asc" ? comparison : -comparison;
});
}
}
// Cache expiry time in milliseconds (set to a low value for testing)
private static readonly CACHE_EXPIRY_MS = 10 * 1000 // 10 seconds (normally 3600000 = 1 hour)
private gitFetcher: GitFetcher
private cache: Map<string, { data: PackageManagerRepository; timestamp: number }> = new Map()
constructor(private readonly context: vscode.ExtensionContext) {
this.gitFetcher = new GitFetcher(context)
}
/**
* Gets package manager items from all enabled sources
* @param sources The package manager sources
* @returns An array of PackageManagerItem objects
*/
async getPackageManagerItems(sources: PackageManagerSource[]): Promise<PackageManagerItem[]> {
console.log(`PackageManagerManager: Getting items from ${sources.length} sources`)
const items: PackageManagerItem[] = []
const errors: Error[] = []
// Filter enabled sources
const enabledSources = sources.filter((s) => s.enabled)
console.log(`PackageManagerManager: ${enabledSources.length} enabled sources`)
// Process sources sequentially to avoid overwhelming the system
for (const source of enabledSources) {
try {
console.log(`PackageManagerManager: Processing source ${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}`)
items.push(...repo.items)
} else {
console.log(`PackageManagerManager: No items found in ${source.url}`)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`PackageManagerManager: Failed to fetch data from ${source.url}:`, error)
errors.push(new Error(`Source ${source.url}: ${errorMessage}`))
}
}
// Show a single error message with all failures
if (errors.length > 0) {
const errorMessage = `Failed to fetch from ${errors.length} sources: ${errors.map((e) => e.message).join("; ")}`
console.error(`PackageManagerManager: ${errorMessage}`)
vscode.window.showErrorMessage(errorMessage)
}
console.log(`PackageManagerManager: Returning ${items.length} total items`)
return items
}
/**
* 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,
sourceName?: string,
): Promise<PackageManagerRepository> {
try {
console.log(`PackageManagerManager: Getting repository data for ${url}`)
// Check cache first (unless force refresh is requested)
const cached = this.cache.get(url)
if (!forceRefresh && cached && Date.now() - cached.timestamp < PackageManagerManager.CACHE_EXPIRY_MS) {
console.log(
`PackageManagerManager: Using cached data for ${url} (age: ${Date.now() - cached.timestamp}ms)`,
)
return cached.data
}
if (forceRefresh) {
console.log(`PackageManagerManager: Force refresh requested for ${url}, bypassing cache`)
}
console.log(`PackageManagerManager: Cache miss or expired for ${url}, fetching fresh data`)
// Fetch fresh data with timeout protection
const fetchPromise = this.gitFetcher.fetchRepository(url, sourceName)
// Create a timeout promise
const timeoutPromise = new Promise<PackageManagerRepository>((_, reject) => {
setTimeout(() => {
reject(new Error(`Repository fetch timed out after 30 seconds: ${url}`))
}, 30000) // 30 second timeout
})
// Race the fetch against the timeout
const data = await Promise.race([fetchPromise, timeoutPromise])
// Cache the result
this.cache.set(url, { data, timestamp: Date.now() })
console.log(`PackageManagerManager: Successfully fetched and cached data for ${url}`)
return data
} catch (error) {
console.error(`PackageManagerManager: Error fetching repository data for ${url}:`, error)
// Return empty repository data instead of throwing
return {
metadata: {},
items: [],
url,
}
}
}
/**
* 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, 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, sourceName)
console.log(`PackageManagerManager: Repository ${url} refreshed successfully`)
return data
} catch (error) {
console.error(`PackageManagerManager: Failed to refresh repository ${url}:`, error)
throw error
}
}
/**
* Clears the in-memory cache
*/
clearCache(): void {
this.cache.clear()
}
/**
* Cleans up cache directories for repositories that are no longer in the configured sources
* @param currentSources The current list of package manager sources
*/
async cleanupCacheDirectories(currentSources: PackageManagerSource[]): Promise<void> {
try {
// Get the cache directory path
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "package-manager-cache")
// Check if cache directory exists
try {
await fs.stat(cacheDir)
} catch (error) {
console.log("PackageManagerManager: Cache directory doesn't exist yet, nothing to clean up")
return
}
// Get all subdirectories in the cache directory
const entries = await fs.readdir(cacheDir, { withFileTypes: true })
const cachedRepoDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
console.log(`PackageManagerManager: Found ${cachedRepoDirs.length} cached repositories`)
// Get the list of repository names from current sources
const currentRepoNames = currentSources.map((source) => this.getRepoNameFromUrl(source.url))
// Find directories to delete
const dirsToDelete = cachedRepoDirs.filter((dir) => !currentRepoNames.includes(dir))
console.log(`PackageManagerManager: Found ${dirsToDelete.length} repositories to delete`)
// Delete each directory that's no longer in the sources
for (const dirName of dirsToDelete) {
try {
const dirPath = path.join(cacheDir, dirName)
console.log(`PackageManagerManager: Deleting cache directory ${dirPath}`)
await fs.rm(dirPath, { recursive: true, force: true })
console.log(`PackageManagerManager: Successfully deleted ${dirPath}`)
} catch (error) {
console.error(`PackageManagerManager: Failed to delete directory ${dirName}:`, error)
}
}
console.log(`PackageManagerManager: Cache cleanup completed, deleted ${dirsToDelete.length} directories`)
} catch (error) {
console.error("PackageManagerManager: Error cleaning up cache directories:", error)
}
}
/**
* Extracts a safe directory name from a Git URL
* @param url The Git repository URL
* @returns A sanitized directory name
*/
private getRepoNameFromUrl(url: string): string {
// Extract repo name from URL and sanitize it
const urlParts = url.split("/").filter((part) => part !== "")
const repoName = urlParts[urlParts.length - 1].replace(/\.git$/, "")
return repoName.replace(/[^a-zA-Z0-9-_]/g, "-")
}
/**
* Filters package manager items based on criteria
* @param items The items to filter
* @param filters The filter criteria
* @returns Filtered items
*/
filterItems(
items: PackageManagerItem[],
filters: { type?: string; search?: string; tags?: string[] },
): PackageManagerItem[] {
return items.filter((item) => {
// Filter by type
if (filters.type && item.type !== filters.type) {
return false
}
// Filter by search term
if (filters.search) {
const searchTerm = filters.search.toLowerCase()
const nameMatch = item.name.toLowerCase().includes(searchTerm)
const descMatch = item.description.toLowerCase().includes(searchTerm)
const authorMatch = item.author?.toLowerCase().includes(searchTerm)
if (!nameMatch && !descMatch && !authorMatch) {
return false
}
}
// Filter by tags
if (filters.tags && filters.tags.length > 0) {
if (!item.tags || item.tags.length === 0) {
return false
}
const hasMatchingTag = filters.tags.some((tag) => item.tags!.includes(tag))
if (!hasMatchingTag) {
return false
}
}
return true
})
}
/**
* Sorts package manager items
* @param items The items to sort
* @param sortBy The field to sort by
* @param sortOrder The sort order
* @returns Sorted items
*/
sortItems(items: PackageManagerItem[], sortBy: string, sortOrder: "asc" | "desc"): PackageManagerItem[] {
return [...items].sort((a, b) => {
let comparison = 0
switch (sortBy) {
case "name":
comparison = a.name.localeCompare(b.name)
break
case "author":
comparison = (a.author || "").localeCompare(b.author || "")
break
case "lastUpdated":
comparison = (a.lastUpdated || "").localeCompare(b.lastUpdated || "")
break
default:
comparison = a.name.localeCompare(b.name)
}
return sortOrder === "asc" ? comparison : -comparison
})
}
}

View file

@ -1,14 +1,14 @@
/**
* Validation utilities for package manager sources
*/
import { PackageManagerSource } from "./types";
import { PackageManagerSource } from "./types"
/**
* Error type for package manager source validation
*/
export interface ValidationError {
field: string;
message: string;
field: string
message: string
}
/**
@ -22,72 +22,74 @@ export interface ValidationError {
* @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();
// 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)?$/;
// 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)?$/;
// 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)?$/;
// 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);
return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl)
}
export function validateSourceUrl(url: string): ValidationError[] {
const errors: 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 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 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 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)"
});
}
// 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;
return errors
}
/**
@ -96,31 +98,31 @@ export function validateSourceUrl(url: string): ValidationError[] {
* @returns An array of validation errors, empty if valid
*/
export function validateSourceName(name?: string): ValidationError[] {
const errors: ValidationError[] = [];
const errors: ValidationError[] = []
// Skip validation if name is not provided
if (!name) {
return errors;
}
// 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 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"
});
}
// 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;
return errors
}
/**
@ -130,83 +132,81 @@ export function validateSourceName(name?: string): ValidationError[] {
* @returns An array of validation errors, empty if valid
*/
export function validateSourceDuplicates(
sources: PackageManagerSource[],
newSource?: PackageManagerSource
sources: PackageManagerSource[],
newSource?: PackageManagerSource,
): ValidationError[] {
const errors: ValidationError[] = [];
const normalizedUrls: { url: string; index: number }[] = [];
const normalizedNames: { name: string; index: number }[] = [];
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 });
// 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 });
}
});
// 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
);
// 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)`
});
}
});
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
);
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)`
});
}
});
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);
// 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)`
});
}
}
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);
// 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)`
});
}
}
}
if (duplicateName) {
errors.push({
field: "name",
message: `Name is a duplicate of Source #${duplicateName.index + 1} (case and whitespace insensitive match)`,
})
}
}
}
return errors;
return errors
}
/**
@ -216,15 +216,15 @@ export function validateSourceDuplicates(
* @returns An array of validation errors, empty if valid
*/
export function validateSource(
source: PackageManagerSource,
existingSources: PackageManagerSource[] = []
source: PackageManagerSource,
existingSources: PackageManagerSource[] = [],
): ValidationError[] {
// Combine all validation errors
return [
...validateSourceUrl(source.url),
...validateSourceName(source.name),
...validateSourceDuplicates(existingSources, source)
];
// Combine all validation errors
return [
...validateSourceUrl(source.url),
...validateSourceName(source.name),
...validateSourceDuplicates(existingSources, source),
]
}
/**
@ -233,27 +233,24 @@ export function validateSource(
* @returns An array of validation errors, empty if valid
*/
export function validateSources(sources: PackageManagerSource[]): ValidationError[] {
const errors: ValidationError[] = [];
const errors: ValidationError[] = []
// Validate each source individually
sources.forEach((source, index) => {
const sourceErrors = [
...validateSourceUrl(source.url),
...validateSourceName(source.name)
];
// 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}`
});
});
});
// 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);
// Check for duplicates across all sources
const duplicateErrors = validateSourceDuplicates(sources)
errors.push(...duplicateErrors)
return errors;
}
return errors
}