From 63d31250add868cbf26b6f6c80854cfc3cbf3988 Mon Sep 17 00:00:00 2001 From: Jonathan Nafta Date: Mon, 22 Sep 2025 00:02:31 -0700 Subject: [PATCH 1/9] feat(git-catalog): scan remote Git repositories for catalogs Add GitCatalogService and instantiate during activation. Introduce commands: - copilotCatalog.dev.scanGitRepository (clone, scan, select catalogs, update config) - copilotCatalog.dev.refreshGitRepositories (initial support) Persist remote metadata and discovered catalog directories. Validate Git availability and restrict to HTTPS for initial MVP. Bump extension version to 0.3.6. Motivation: enable discovering and selectively importing catalog folders from external Git repos to streamline catalog curation and reuse. --- package-lock.json | 4 +- src/extension.ts | 126 +++++- src/services/gitCatalogService.ts | 673 ++++++++++++++++++++++++++++ src/tree/optionsTreeProvider.ts | 6 +- test/gitCatalog.integration.test.ts | 230 ++++++++++ 5 files changed, 1031 insertions(+), 8 deletions(-) create mode 100644 src/services/gitCatalogService.ts create mode 100644 test/gitCatalog.integration.test.ts diff --git a/package-lock.json b/package-lock.json index dde59cf..9dc687c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "contextshare", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "contextshare", - "version": "0.3.5", + "version": "0.3.6", "license": "MIT", "devDependencies": { "@types/glob": "^8.1.0", diff --git a/src/extension.ts b/src/extension.ts index 6f8354a..c2e4741 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { Repository, Resource, ResourceCategory, ResourceState } from './models'; import { FileService } from './services/fileService'; +import { GitCatalogService, RemoteGitSpec } from './services/gitCatalogService'; import { HatService } from './services/hatService'; import { ResourceService } from './services/resourceService'; import { CategoryTreeProvider } from './tree/categoryTreeProvider'; @@ -122,9 +123,10 @@ export async function activate(context: vscode.ExtensionContext) { preflightLog('Activating...'); - try { - const fileService = new FileService(); - const resourceService = new ResourceService(fileService); +try { +const fileService = new FileService(); +const resourceService = new ResourceService(fileService); +const gitCatalogService = new GitCatalogService(fileService); // Configure structured logger now that we have context and config. enableFileLogging = !!vscode.workspace.getConfiguration().get('copilotCatalog.enableFileLogging', false); logFilePath = path.join(context.globalStorageUri.fsPath, LOG_FILENAME); @@ -748,7 +750,123 @@ export async function activate(context: vscode.ExtensionContext) { await refresh(); } catch(e:any){ vscode.window.showErrorMessage('Failed to create template catalog: ' + getErrorMessage(e)); } }), - vscode.commands.registerCommand('copilotCatalog.dev.configureSettings', async () => { +vscode.commands.registerCommand('copilotCatalog.dev.scanGitRepository', async () => { +// Command to scan a remote Git repository for catalogs +const url = await vscode.window.showInputBox({ +prompt: 'Enter Git repository URL (e.g., https://github.com/org/repo.git)', +placeHolder: 'https://github.com/org/repo.git' +}); +if (!url) return; + +const branch = await vscode.window.showInputBox({ +prompt: 'Enter branch name (optional, defaults to main)', +placeHolder: 'main' +}); + +try { +await logger.info(`Adding Git repository: ${url} (branch: ${branch || 'main'})`); +vscode.window.showInformationMessage('Scanning repository for catalogs...'); + +// Initialize the git catalog service with global storage +await gitCatalogService.init([], context.globalStorageUri.fsPath); + +// Add the remote and scan for catalogs +await gitCatalogService.addRemoteInteractively(url, branch || 'main'); + +// Get the discovered catalog directories +const catalogMap = gitCatalogService.getCatalogDirectoryMap(); +const catalogs = Object.entries(catalogMap); + +if (catalogs.length === 0) { +vscode.window.showInformationMessage('No catalogs found in the repository.'); +return; +} + +// Show discovered catalogs and let user select which to add +const picks = catalogs.map(([catalogPath, displayName]) => ({ +label: path.basename(catalogPath), +description: displayName, +detail: catalogPath, +path: catalogPath, +displayName: displayName +})); + +const selected = await vscode.window.showQuickPick(picks, { +canPickMany: true, +placeHolder: 'Select catalogs to add' +}); + +if (!selected || selected.length === 0) return; + +// Add selected catalogs to configuration +const cfg = vscode.workspace.getConfiguration(); +const current = cfg.get>('copilotCatalog.catalogDirectory', {}); +const newEntries = { ...current }; + +for (const item of selected) { +// Use the local clone path for now (Git URL format can be added later) +newEntries[item.path] = item.displayName; +} + +await cfg.update('copilotCatalog.catalogDirectory', newEntries, vscode.ConfigurationTarget.Workspace); +vscode.window.showInformationMessage(`Added ${selected.length} catalog(s) from Git repository.`); +await refresh(); + +} catch (error) { +await logger.error(`Failed to scan Git repository: ${getErrorMessage(error)}`); +vscode.window.showErrorMessage(`Failed to scan repository: ${getErrorMessage(error)}`); +} +}), +// Refresh all previously added Git repositories (pull latest + rescan) +vscode.commands.registerCommand('copilotCatalog.dev.refreshGitRepositories', async () => { +try { +await logger.info('Dev: Refresh Git Repositories command invoked'); +// Load previously persisted remotes (without wiping) if needed +await gitCatalogService.ensureLoaded(context.globalStorageUri.fsPath); +const remotes = gitCatalogService.getRemotes(); + +if(remotes.length === 0){ +vscode.window.showInformationMessage('No persisted Git repositories found. Use "Scan Git Repository…" first.'); +return; +} + +vscode.window.showInformationMessage(`Refreshing ${remotes.length} Git repos...`); +await logger.info(`Refreshing ${remotes.length} git remotes`); + +// Perform fetch/reset + catalog rescan +await gitCatalogService.refreshAll(); + +// Collect catalog directory map after refresh +const catalogMap = gitCatalogService.getCatalogDirectoryMap(); + +// Merge newly discovered catalog paths into configuration +const cfg = vscode.workspace.getConfiguration(); +const current = cfg.get>('copilotCatalog.catalogDirectory', {}); +let changed = false; +for(const [p, name] of Object.entries(catalogMap)){ +if(!current.hasOwnProperty(p)){ +current[p] = name; +changed = true; +} +} +if(changed){ +await cfg.update('copilotCatalog.catalogDirectory', current, vscode.ConfigurationTarget.Workspace); +await logger.info(`Added ${Object.keys(catalogMap).length} catalog path(s) from refreshed remotes (some may have already existed).`); +} + +// Trigger standard UI refresh +await refresh(); + +const updated = gitCatalogService.getRemotes(); +const withCommits = updated.filter(r=> !!r.lastCommit).length; +vscode.window.showInformationMessage(`Git repositories refreshed. (${withCommits}/${updated.length} updated)`); +await logger.info(`Git refresh complete: withCommits=${withCommits} total=${updated.length}`); +} catch(e:any){ +await logger.error(`Git repositories refresh failed: ${getErrorMessage(e)}`); +vscode.window.showErrorMessage('Failed to refresh Git repositories: ' + getErrorMessage(e)); +} +}), +vscode.commands.registerCommand('copilotCatalog.dev.configureSettings', async () => { let pick: { label: string; action: 'openSettings'|'addDirectory'|'setTarget' } | undefined; try { pick = await vscode.window.showQuickPick([ diff --git a/src/services/gitCatalogService.ts b/src/services/gitCatalogService.ts new file mode 100644 index 0000000..99afcb1 --- /dev/null +++ b/src/services/gitCatalogService.ts @@ -0,0 +1,673 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { exec, ExecException } from 'child_process'; +import { promisify } from 'util'; +import { IFileService, ResourceCategory } from '../models'; +import { sanitizeErrorMessage } from '../utils/security'; +import { getErrorMessage } from '../utils/errors'; + +const execAsync = promisify(exec); + +// Configuration for a remote git repository +export interface RemoteGitSpec { + url: string; // HTTPS or SSH URL (HTTPS preferred for MVP) + branch?: string; // default 'main' + includePaths?: string[]; // optional relative paths to constrain scan + displayNamePrefix?: string; // optional UI prefix + depth?: number; // optional shallow depth default 1 + disabled?: boolean; // optional flag to disable without removing +} + +// Metadata about a discovered catalog within a remote +interface CatalogCandidate { + catalogPath: string; // absolute path to catalog root + repoRelPath: string; // path relative to repo root + displayName: string; // generated display name +} + +// Persisted metadata about remote repositories +interface RemoteMeta { + url: string; + branch: string; + clonePath: string; + lastUpdated: string; + lastCommit?: string; + specsHash: string; + catalogs: CatalogCandidate[]; + includePaths?: string[]; + displayNamePrefix?: string; +} + +interface GitCatalogMeta { + version: number; + remotes: RemoteMeta[]; +} + +const CATEGORY_DIRS: Record = { + chatmodes: 'chatmodes', + instructions: 'instructions', + prompts: 'prompts', + tasks: 'tasks', + mcp: 'mcp' +}; + +export class GitCatalogService { + private remotes: RemoteMeta[] = []; + private globalStoragePath: string = ''; + private gitAvailable: boolean | undefined; + private logger?: (msg: string) => void; + private fileService: IFileService; + private execOverride?: (cmd: string) => Promise<{ stdout: string; stderr: string }>; + + constructor(fileService: IFileService) { + this.fileService = fileService; + } + + // Test hook to override git execution + setExecOverride(fn?: (cmd: string) => Promise<{ stdout: string; stderr: string }>) { + this.execOverride = fn; + this.gitAvailable = undefined; // force re-check + } + + private async runGit(cmd: string): Promise<{ stdout: string; stderr: string }> { + if (this.execOverride) { + return this.execOverride(cmd); + } + return execAsync(cmd); + } + + setLogger(fn?: (msg: string) => void) { + this.logger = fn; + } + + private log(msg: string) { + try { + const sanitized = sanitizeErrorMessage(msg); + this.logger?.(`[GitCatalog] ${sanitized}`); + } catch { + // Fail silently if logger fails + } + } + + async init(specs: RemoteGitSpec[], globalStoragePath: string): Promise { + this.globalStoragePath = globalStoragePath; + this.log(`init with ${specs.length} remote specs`); + + // Ensure git-remotes directory exists + const remotesDir = path.join(globalStoragePath, 'git-remotes'); + await this.fileService.ensureDirectory(remotesDir); + + // Load existing meta + const metaPath = path.join(globalStoragePath, 'git-remotes', 'meta.json'); + const existingMeta = await this.loadMeta(metaPath); + + if (specs.length === 0) { + // No new specs provided: just load existing remotes without wiping them + this.remotes = existingMeta.remotes; + this.log(`init loaded ${this.remotes.length} existing remotes (no new specs provided)`); + } else { + // Update remotes based on provided specs + this.remotes = await this.reconcileRemotes(specs, existingMeta.remotes, remotesDir); + this.log(`init reconciled remotes to ${this.remotes.length} entries from specs`); + } + + // Persist current state + await this.saveMeta(metaPath); + } + + // Ensure existing remotes are loaded without requiring spec reconciliation. + async ensureLoaded(globalStoragePath: string): Promise { + if (!this.globalStoragePath) { + this.globalStoragePath = globalStoragePath; + } + const remotesDir = path.join(this.globalStoragePath, 'git-remotes'); + await this.fileService.ensureDirectory(remotesDir); + + if (this.remotes.length === 0) { + const metaPath = path.join(this.globalStoragePath, 'git-remotes', 'meta.json'); + const meta = await this.loadMeta(metaPath); + this.remotes = meta.remotes; + this.log(`ensureLoaded loaded ${this.remotes.length} remotes from meta.json`); + + // Recovery: detect clone directories that are not represented in meta.json + try { + const entries = await this.fileService.listDirectory(remotesDir); + for (const entry of entries) { + const full = path.join(remotesDir, entry); + const gitDir = path.join(full, '.git'); + const normFull = path.resolve(full); + const alreadyTracked = this.remotes.some(r => path.resolve(r.clonePath) === normFull); + if (alreadyTracked) continue; + if (!(await this.fileService.pathExists(gitDir))) continue; + + // Attempt to recover origin URL & current branch via git commands + let originUrl: string | undefined; + let branch: string = 'main'; + try { + if (await this.validateGitAvailable()) { + const { stdout: urlStdout } = await this.runGit(`git -C "${full}" config --get remote.origin.url`); + originUrl = urlStdout.trim() || undefined; + const { stdout: brStdout } = await this.runGit(`git -C "${full}" rev-parse --abbrev-ref HEAD`); + const br = brStdout.trim(); + if (br && br !== 'HEAD') branch = br; + } + } catch (e) { + this.log(`ensureLoaded recovery: git probe failed for ${entry}: ${getErrorMessage(e)}`); + } + + if (!originUrl) { + // Fallback: derive pseudo URL from directory name (best-effort) + originUrl = `recovered://${entry}`; + } + + const spec: RemoteGitSpec = { url: originUrl, branch }; + const recovered: RemoteMeta = { + url: originUrl, + branch, + clonePath: full, + lastUpdated: new Date().toISOString(), + lastCommit: undefined, + specsHash: this.hashSpec(spec), + catalogs: [], + }; + this.remotes.push(recovered); + this.log(`ensureLoaded recovery: added missing remote clonePath=${full} url=${originUrl} branch=${branch}`); + } + + if (this.remotes.length !== meta.remotes.length) { + await this.saveMeta(path.join(this.globalStoragePath, 'git-remotes', 'meta.json')); + this.log(`ensureLoaded recovery: meta.json updated with ${this.remotes.length} remotes`); + } + } catch (e) { + this.log(`ensureLoaded recovery scan failed: ${getErrorMessage(e)}`); + } + } else { + this.log(`ensureLoaded skipped (already have ${this.remotes.length} remotes)`); + } + return this.remotes.length; + } + + private async loadMeta(metaPath: string): Promise { + try { + const raw = await this.fileService.readFile(metaPath); + const meta = JSON.parse(raw); + if (meta.version === 1 && Array.isArray(meta.remotes)) { + return meta; + } + } catch { + // File doesn't exist or invalid + } + return { version: 1, remotes: [] }; + } + + private async saveMeta(metaPath: string): Promise { + const meta: GitCatalogMeta = { + version: 1, + remotes: this.remotes + }; + await this.fileService.writeFile(metaPath, JSON.stringify(meta, null, 2)); + this.log(`Saved meta with ${this.remotes.length} remotes`); + } + + private async reconcileRemotes( + specs: RemoteGitSpec[], + existing: RemoteMeta[], + remotesDir: string + ): Promise { + const result: RemoteMeta[] = []; + + for (const spec of specs) { + if (spec.disabled) { + this.log(`Skipping disabled remote: ${spec.url}`); + continue; + } + + const branch = spec.branch || 'main'; + const specsHash = this.hashSpec(spec); + + // Check if we already have this remote + const existingRemote = existing.find(r => + r.url === spec.url && r.branch === branch + ); + + if (existingRemote && existingRemote.specsHash === specsHash) { + // No changes, keep existing + result.push(existingRemote); + this.log(`Remote unchanged: ${spec.url}@${branch}`); + } else { + // New or changed remote + const clonePath = path.join(remotesDir, this.slugify(`${spec.url}_${branch}`)); + + const remote: RemoteMeta = { + url: spec.url, + branch, + clonePath, + lastUpdated: new Date().toISOString(), + specsHash, + catalogs: existingRemote?.catalogs || [], + includePaths: spec.includePaths, + displayNamePrefix: spec.displayNamePrefix + }; + + result.push(remote); + this.log(`Remote configured: ${spec.url}@${branch}`); + } + } + + return result; + } + + async ensureAllCloned(): Promise { + this.log(`ensureAllCloned: Processing ${this.remotes.length} remotes`); + + // Check git availability first + if (!await this.validateGitAvailable()) { + this.log('Git not available, skipping remote clones'); + return; + } + + // Process remotes with bounded concurrency (max 2 at a time) + const concurrencyLimit = 2; + const queue = [...this.remotes]; + const active: Promise[] = []; + + while (queue.length > 0 || active.length > 0) { + // Start new clones up to limit + while (active.length < concurrencyLimit && queue.length > 0) { + const remote = queue.shift()!; + const promise = this.ensureRemoteCloned(remote) + .then(() => { + const index = active.indexOf(promise); + if (index > -1) active.splice(index, 1); + }) + .catch(err => { + this.log(`Failed to clone ${remote.url}: ${getErrorMessage(err)}`); + const index = active.indexOf(promise); + if (index > -1) active.splice(index, 1); + }); + active.push(promise); + } + + // Wait for at least one to complete + if (active.length > 0) { + await Promise.race(active); + } + } + + // Save meta with updated info + const metaPath = path.join(this.globalStoragePath, 'git-remotes', 'meta.json'); + await this.saveMeta(metaPath); + } + + private async ensureRemoteCloned(remote: RemoteMeta): Promise { + const exists = await this.fileService.pathExists(remote.clonePath); + + if (!exists) { + // Clone repository + this.log(`Cloning ${remote.url}@${remote.branch} to ${remote.clonePath}`); + const depth = 1; // Shallow clone by default + const cmd = `git clone --depth=${depth} --branch ${remote.branch} ${remote.url} "${remote.clonePath}"`; + + try { + await this.runGit(cmd); + this.log(`Clone successful: ${remote.url}`); + } catch (err) { + throw new Error(`Git clone failed: ${sanitizeErrorMessage(err)}`); + } + } else { + // Update existing clone + this.log(`Updating ${remote.url}@${remote.branch}`); + try { + await this.runGit(`git -C "${remote.clonePath}" fetch origin ${remote.branch} --depth=1`); + await this.runGit(`git -C "${remote.clonePath}" reset --hard origin/${remote.branch}`); + this.log(`Update successful: ${remote.url}`); + } catch (err) { + this.log(`Update failed (will continue): ${getErrorMessage(err)}`); + } + } + + // Get current commit + try { + const { stdout } = await this.runGit(`git -C "${remote.clonePath}" rev-parse HEAD`); + const currentCommit = stdout.trim(); + + // Rescan if commit changed or first scan + if (currentCommit !== remote.lastCommit || remote.catalogs.length === 0) { + this.log(`Scanning for catalogs (commit: ${currentCommit.substring(0, 8)})`); + remote.lastCommit = currentCommit; + remote.catalogs = await this.discoverCatalogRoots( + remote.clonePath, + remote.includePaths, + remote.url, + remote.branch, + remote.displayNamePrefix + ); + remote.lastUpdated = new Date().toISOString(); + this.log(`Found ${remote.catalogs.length} catalog(s) in ${remote.url}`); + } + } catch (err) { + this.log(`Failed to get commit hash: ${getErrorMessage(err)}`); + } + } + + private async discoverCatalogRoots( + clonePath: string, + includePaths?: string[], + repoUrl?: string, + branch?: string, + displayNamePrefix?: string + ): Promise { + const candidates: CatalogCandidate[] = []; + const repoName = this.getRepoName(repoUrl || clonePath); + const branchName = branch || 'main'; + + // If includePaths specified, only scan those + const pathsToScan = includePaths?.map(p => path.join(clonePath, p)) || [clonePath]; + + for (const scanPath of pathsToScan) { + if (!await this.fileService.pathExists(scanPath)) { + this.log(`Include path does not exist: ${scanPath}`); + continue; + } + + // Recursively scan for catalog roots + const found = await this.scanForCatalogRoots(scanPath, clonePath); + + for (const catalogPath of found) { + const repoRelPath = path.relative(clonePath, catalogPath).replace(/\\/g, '/'); + const displayName = this.generateDisplayName( + displayNamePrefix || repoName, + branchName, + repoRelPath + ); + + candidates.push({ + catalogPath, + repoRelPath, + displayName + }); + } + } + + // Deduplicate nested catalogs + return this.deduplicateCatalogs(candidates); + } + + private async scanForCatalogRoots(dir: string, repoRoot: string, depth: number = 0): Promise { + const results: string[] = []; + + // Limit recursion depth to avoid excessive scanning + if (depth > 10) return results; + + try { + const entries = await this.fileService.listDirectory(dir); + + // Check if current dir is a catalog root + if (await this.isCatalogRoot(dir, entries)) { + results.push(dir); + // Don't scan subdirectories if we found a catalog here + return results; + } + + // Recursively scan subdirectories + for (const entry of entries) { + const fullPath = path.join(dir, entry); + const stat = await this.fileService.stat(fullPath); + + if (stat === 'dir' && !entry.startsWith('.') && entry !== 'node_modules') { + const subResults = await this.scanForCatalogRoots(fullPath, repoRoot, depth + 1); + results.push(...subResults); + } + } + } catch (err) { + this.log(`Error scanning ${dir}: ${getErrorMessage(err)}`); + } + + return results; + } + + private async isCatalogRoot(dir: string, entries: string[]): Promise { + const dirName = path.basename(dir); + + // Rule A: Directory named 'copilot_catalog' + if (dirName === 'copilot_catalog') { + this.log(`Found catalog by name: ${dir}`); + return true; + } + + // Rule B: Contains category subdirectories with resources + const categoryDirs = Object.values(CATEGORY_DIRS); + const presentCategories = entries.filter(e => categoryDirs.includes(e)); + + if (presentCategories.length >= 1) { + // Verify at least one category has actual resource files + for (const catDir of presentCategories) { + const catPath = path.join(dir, catDir); + const stat = await this.fileService.stat(catPath); + + if (stat === 'dir') { + try { + const files = await this.fileService.listDirectory(catPath); + const resourceFiles = files.filter(f => + f.endsWith('.md') || + f.endsWith('.json') || + f.includes('.chatmode.') || + f.includes('.instructions.') || + f.includes('.prompt.') || + f.includes('.task.') || + f.includes('.mcp.') + ); + + if (resourceFiles.length > 0) { + this.log(`Found catalog by structure: ${dir}`); + return true; + } + } catch { + // Category dir not accessible + } + } + } + } + + return false; + } + + private deduplicateCatalogs(candidates: CatalogCandidate[]): CatalogCandidate[] { + // Remove nested duplicates - if one catalog is inside another, keep the innermost + const filtered: CatalogCandidate[] = []; + + for (const candidate of candidates) { + let isNested = false; + + for (const other of candidates) { + if (candidate === other) continue; + + // Check if candidate is nested inside other + if (candidate.catalogPath.startsWith(other.catalogPath + path.sep)) { + // Keep the innermost (candidate) not the outer + const index = filtered.findIndex(f => f.catalogPath === other.catalogPath); + if (index >= 0) { + filtered.splice(index, 1); + } + } + } + + // Add if not already present + if (!filtered.some(f => f.catalogPath === candidate.catalogPath)) { + filtered.push(candidate); + } + } + + return filtered; + } + + getCatalogDirectoryMap(): Record { + const map: Record = {}; + + for (const remote of this.remotes) { + for (const catalog of remote.catalogs) { + map[catalog.catalogPath] = catalog.displayName; + } + } + + return map; + } + + async addRemoteInteractively( + url: string, + branch: string = 'main', + includePaths?: string[], + displayNamePrefix?: string + ): Promise { + this.log(`Adding remote interactively: ${url}@${branch}`); + + // Validate git availability + if (!await this.validateGitAvailable()) { + throw new Error('Git is not installed or not available in PATH'); + } + + // Validate URL (HTTPS only for MVP) + if (!url.startsWith('https://')) { + throw new Error('Only HTTPS URLs are supported in this version'); + } + + const spec: RemoteGitSpec = { + url, + branch, + includePaths, + displayNamePrefix, + depth: 1 + }; + + // Add to remotes + const remotesDir = path.join(this.globalStoragePath, 'git-remotes'); + await this.fileService.ensureDirectory(remotesDir); + + const clonePath = path.join(remotesDir, this.slugify(`${url}_${branch}`)); + const remote: RemoteMeta = { + url, + branch, + clonePath, + lastUpdated: new Date().toISOString(), + specsHash: this.hashSpec(spec), + catalogs: [], + includePaths, + displayNamePrefix + }; + + // Check if already exists + const existing = this.remotes.findIndex(r => r.url === url && r.branch === branch); + if (existing >= 0) { + this.remotes[existing] = remote; + } else { + this.remotes.push(remote); + } + + // Clone and scan + await this.ensureRemoteCloned(remote); + + // Save meta + const metaPath = path.join(this.globalStoragePath, 'git-remotes', 'meta.json'); + await this.saveMeta(metaPath); + + this.log(`Successfully added remote with ${remote.catalogs.length} catalog(s)`); + } + + async removeRemote(url: string, branch?: string): Promise { + const index = this.remotes.findIndex(r => + r.url === url && (!branch || r.branch === branch) + ); + + if (index < 0) { + throw new Error('Remote not found'); + } + + const remote = this.remotes[index]; + this.remotes.splice(index, 1); + + // Optionally delete clone directory + try { + const files = await this.fileService.listDirectory(remote.clonePath); + if (files.length > 0) { + // Directory exists, could delete it + this.log(`Clone directory preserved: ${remote.clonePath}`); + } + } catch { + // Directory doesn't exist + } + + // Save updated meta + const metaPath = path.join(this.globalStoragePath, 'git-remotes', 'meta.json'); + await this.saveMeta(metaPath); + + this.log(`Removed remote: ${url}@${remote.branch}`); + } + + async refreshAll(): Promise { + this.log('Refreshing all remotes'); + await this.ensureAllCloned(); + } + + getRemotes(): RemoteMeta[] { + return [...this.remotes]; + } + + async validateGitAvailable(): Promise { + if (this.gitAvailable !== undefined) { + return this.gitAvailable; + } + + try { + const { stdout } = await this.runGit('git --version'); + this.gitAvailable = stdout.includes('git version'); + this.log(`Git available: ${this.gitAvailable}`); + return this.gitAvailable; + } catch { + this.gitAvailable = false; + this.log('Git not available'); + return false; + } + } + + private slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 100); + } + + private hashSpec(spec: RemoteGitSpec): string { + const str = JSON.stringify({ + url: spec.url, + branch: spec.branch, + includePaths: spec.includePaths, + displayNamePrefix: spec.displayNamePrefix + }); + + // Simple hash for change detection + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return hash.toString(16); + } + + private getRepoName(url: string): string { + // Extract repo name from URL or path + const parts = url.replace(/\.git$/, '').split('/'); + return parts[parts.length - 1] || 'repo'; + } + + private generateDisplayName(prefix: string, branch: string, repoRelPath: string): string { + let name = `${prefix}@${branch}`; + if (repoRelPath && repoRelPath !== '.' && repoRelPath !== '') { + name += `:${repoRelPath}`; + } + return name; + } +} diff --git a/src/tree/optionsTreeProvider.ts b/src/tree/optionsTreeProvider.ts index 4263d95..cefc6bb 100644 --- a/src/tree/optionsTreeProvider.ts +++ b/src/tree/optionsTreeProvider.ts @@ -74,8 +74,10 @@ export class OptionsTreeProvider { children: [ { id: 'dev-open-settings', label: 'Open Settings', icon: 'gear', command: 'copilotCatalog.openSettings' }, { id: 'dev-create-template', label: 'Create Template Catalog', icon: 'new-folder', command: 'copilotCatalog.dev.createTemplateCatalog' }, - { id: 'dev-add-dir', label: 'Add Catalog Directory…', icon: 'folder-opened', command: 'copilotCatalog.addCatalogDirectory' } - ] + { id: 'dev-add-dir', label: 'Add Catalog Directory…', icon: 'folder-opened', command: 'copilotCatalog.addCatalogDirectory' }, + { id: 'dev-scan-git', label: 'Scan Git Repository…', icon: 'repo', command: 'copilotCatalog.dev.scanGitRepository' }, + { id: 'dev-refresh-git', label: 'Refresh Git Repositories', icon: 'sync', command: 'copilotCatalog.dev.refreshGitRepositories' } + ] } ]; } diff --git a/test/gitCatalog.integration.test.ts b/test/gitCatalog.integration.test.ts new file mode 100644 index 0000000..64be043 --- /dev/null +++ b/test/gitCatalog.integration.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import * as path from 'path'; +import { GitCatalogService, RemoteGitSpec } from '../src/services/gitCatalogService'; +import { MockFileService } from './fileService.mock'; + +// Helper to build a deterministic git exec override +function makeExecOverride(extra?: Record) { + return async (cmd: string): Promise<{ stdout: string; stderr: string }> => { + if (cmd.startsWith('git --version')) { + return { stdout: 'git version 2.30.0', stderr: '' }; + } + if (cmd.startsWith('git clone')) { + return { stdout: '', stderr: '' }; + } + if (cmd.includes(' fetch origin ')) { + return { stdout: '', stderr: '' }; + } + if (cmd.includes(' reset --hard ')) { + return { stdout: '', stderr: '' }; + } + if (cmd.includes('config --get remote.origin.url')) { + if (cmd.includes('repo1-main')) return { stdout: 'https://github.com/test/repo1.git\n', stderr: '' }; + if (cmd.includes('repo2-main')) return { stdout: 'https://github.com/test/repo2.git\n', stderr: '' }; + return { stdout: 'https://github.com/test/repo.git\n', stderr: '' }; + } + if (cmd.includes('rev-parse --abbrev-ref HEAD')) { + return { stdout: 'main\n', stderr: '' }; + } + if (cmd.includes('rev-parse HEAD')) { + return { stdout: 'abcdef1234567890\n', stderr: '' }; + } + if (extra) { + for (const [k, v] of Object.entries(extra)) { + if (cmd.includes(k)) { + return { stdout: v.stdout ?? '', stderr: v.stderr ?? '' }; + } + } + } + return { stdout: '', stderr: '' }; + }; +} + +// Simple test runner +async function runTests() { + console.log('\n🧪 Git Catalog Integration Tests\n'); + const tests: Array<{ name: string; fn: () => Promise }> = []; + let passed = 0; + let failed = 0; + + function test(name: string, fn: () => Promise) { + tests.push({ name, fn }); + } + + function assert(condition: boolean, message: string) { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } + } + + function assertEquals(actual: any, expected: any, message?: string) { + const actualStr = JSON.stringify(actual); + const expectedStr = JSON.stringify(expected); + if (actualStr !== expectedStr) { + throw new Error(`${message || 'Values not equal'}\nExpected: ${expectedStr}\nActual: ${actualStr}`); + } + } + + // Tests + + test('should initialize with empty remotes', async () => { + const mockFileService = new MockFileService({}); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(makeExecOverride()); + await service.init([], '/test/global'); + const remotes = service.getRemotes(); + assertEquals(remotes.length, 0, 'Should have no remotes initially'); + }); + + test('should add a remote repository', async () => { + const mockFileService = new MockFileService({ + '/test/global/git-remotes/meta.json': JSON.stringify({ + version: 1, + remotes: [] + }) + }); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(makeExecOverride()); + await service.init([], '/test/global'); + + const url = 'https://github.com/test/repo.git'; + const branch = 'main'; + + await service.addRemoteInteractively(url, branch); + const remotes = service.getRemotes(); + assertEquals(remotes.length, 1, 'Should have one remote'); + assertEquals(remotes[0].url, url, 'URL should match'); + assertEquals(remotes[0].branch, branch, 'Branch should match'); + }); + + test('should get catalog directory map', async () => { + const mockFileService = new MockFileService({}); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(makeExecOverride()); + const specs: RemoteGitSpec[] = []; + await service.init(specs, '/test/global'); + const catalogMap = service.getCatalogDirectoryMap(); + assertEquals(Object.keys(catalogMap).length, 0, 'Should have empty catalog map initially'); + }); + + test('should save and load remote configuration', async () => { + const mockFileService = new MockFileService({}); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(makeExecOverride()); + const specs: RemoteGitSpec[] = [ + { url: 'https://github.com/test/repo1.git', branch: 'main' }, + { url: 'https://github.com/test/repo2.git', branch: 'develop', disabled: true } + ]; + await service.init(specs, '/test/global'); + const remotes = service.getRemotes(); + assertEquals(remotes.length, 1, 'Should load only enabled remotes'); + assertEquals(remotes[0].url, specs[0].url, 'URL should match'); + assertEquals(remotes[0].branch, specs[0].branch, 'Branch should match'); + }); + + test('should remove a remote repository', async () => { + const mockFileService = new MockFileService({ + '/test/global/git-remotes/meta.json': JSON.stringify({ version: 1, remotes: [] }) + }); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(makeExecOverride()); + await service.init([], '/test/global'); + + const url = 'https://github.com/test/repo.git'; + await service.addRemoteInteractively(url, 'main'); + assertEquals(service.getRemotes().length, 1, 'Should have one remote'); + + await service.removeRemote(url); + assertEquals(service.getRemotes().length, 0, 'Should have no remotes after removal'); + }); + + test('should refresh all remotes', async () => { + const mockFileService = new MockFileService({}); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(makeExecOverride()); + const specs: RemoteGitSpec[] = [{ url: 'https://github.com/test/repo.git', branch: 'main' }]; + await service.init(specs, '/test/global'); + await service.refreshAll(); // Should not throw + assert(true, 'Refresh completed'); + }); + + test('should validate git availability', async () => { + const mockFileService = new MockFileService({}); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(makeExecOverride()); + await service.init([], '/test/global'); + const available = await service.validateGitAvailable(); + assert(available, 'Git should be available'); + }); + + test('should handle non-HTTPS URLs', async () => { + const mockFileService = new MockFileService({}); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(makeExecOverride()); + await service.init([], '/test/global'); + try { + await service.addRemoteInteractively('git@github.com:user/repo.git', 'main'); + throw new Error('Should have rejected non-HTTPS URL'); + } catch (error: any) { + assert(error.message.includes('HTTPS'), 'Should mention HTTPS requirement'); + } + }); + + test('should recover missing remote clone directories not in meta.json', async () => { + const repo1Clone = '/test/global/git-remotes/repo1-main'; + const repo2Clone = '/test/global/git-remotes/repo2-main'; + const metaContent = JSON.stringify({ + version: 1, + remotes: [ + { + url: 'https://github.com/test/repo1.git', + branch: 'main', + clonePath: repo1Clone, + lastUpdated: new Date().toISOString(), + specsHash: 'abc123', + catalogs: [] + } + ] + }, null, 2); + + const mockFileService = new MockFileService({ + '/test/global/git-remotes/meta.json': metaContent, + [path.join(repo1Clone, '.git', 'HEAD')]: 'ref: refs/heads/main', + [path.join(repo2Clone, '.git', 'HEAD')]: 'ref: refs/heads/main' + }); + + const service = new GitCatalogService(mockFileService); + service.setExecOverride(makeExecOverride()); + + const count = await service.ensureLoaded('/test/global'); + assertEquals(count, 2, 'Service should recover second remote from filesystem'); + const remotes = service.getRemotes(); + const urls = remotes.map(r => r.url).sort(); + assertEquals(urls, ['https://github.com/test/repo1.git', 'https://github.com/test/repo2.git'], 'Recovered remote URLs mismatch'); + }); + + // Run all tests + for (const { name, fn } of tests) { + try { + await fn(); + console.log(`✅ ${name}`); + passed++; + } catch (error: any) { + console.log(`❌ ${name}`); + console.log(` ${error.message}`); + failed++; + } + } + + console.log('\n' + '='.repeat(50)); + console.log(`Tests: ${passed} passed, ${failed} failed, ${tests.length} total`); + if (failed > 0) { + process.exit(1); + } +} + +runTests().catch(error => { + console.error('Test runner failed:', error); + process.exit(1); +}); From b57e1d5410eb7cb03c1926f5e24571776aa04c2b Mon Sep 17 00:00:00 2001 From: Jonathan Nafta Date: Mon, 22 Sep 2025 14:20:07 -0700 Subject: [PATCH 2/9] refactor(core): Rename 'Hats' to 'Presets' for clarity This commit introduces a significant terminology change across the extension, renaming "Hats" to "Presets". This change is intended to make the feature's purpose more intuitive and align with more common industry language for saved configurations or templates. Key changes include: - Renaming all instances of "hat" and "hats" to "preset" and "presets" in the UI, commands, and internal code. - Updating associated icons and file names (e.g., `hat-dark.svg` to `preset-dark.svg`). - Renaming the example catalog directory from `hats` to `presets`. - Adding `.clinerules/` to `.gitignore`. --- .gitignore | 3 + .../Copilot-Catalog-Setup.json | 0 .../icons/{hat-dark.svg => preset-dark.svg} | 0 .../icons/{hat-light.svg => preset-light.svg} | 0 src/extension.ts | 2516 +++++++++-------- src/models.ts | 19 +- src/services/fileService.ts | 44 +- src/services/gitCatalogService.ts | 223 +- .../{hatService.ts => presetService.ts} | 0 src/tree/optionsTreeProvider.ts | 3 +- src/utils/security.ts | 7 +- test/fileService.mock.ts | 18 + test/gitCatalog.integration.test.ts | 192 +- test/{hats.test.ts => presets.test.ts} | 0 14 files changed, 1717 insertions(+), 1308 deletions(-) rename example-catalog/{hats => presets}/Copilot-Catalog-Setup.json (100%) rename resources/icons/{hat-dark.svg => preset-dark.svg} (100%) rename resources/icons/{hat-light.svg => preset-light.svg} (100%) rename src/services/{hatService.ts => presetService.ts} (100%) rename test/{hats.test.ts => presets.test.ts} (100%) diff --git a/.gitignore b/.gitignore index afe91d0..58ea60e 100644 --- a/.gitignore +++ b/.gitignore @@ -510,3 +510,6 @@ app-registration.json .DS_Store? ehthumbs.db Thumbs.db + +# Cline memory bank +.clinerules/ diff --git a/example-catalog/hats/Copilot-Catalog-Setup.json b/example-catalog/presets/Copilot-Catalog-Setup.json similarity index 100% rename from example-catalog/hats/Copilot-Catalog-Setup.json rename to example-catalog/presets/Copilot-Catalog-Setup.json diff --git a/resources/icons/hat-dark.svg b/resources/icons/preset-dark.svg similarity index 100% rename from resources/icons/hat-dark.svg rename to resources/icons/preset-dark.svg diff --git a/resources/icons/hat-light.svg b/resources/icons/preset-light.svg similarity index 100% rename from resources/icons/hat-light.svg rename to resources/icons/preset-light.svg diff --git a/src/extension.ts b/src/extension.ts index c2e4741..8f4454a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,1238 +26,1384 @@ let logFilePath: string | undefined; // logger.init will be called during activation once context and config are available async function discoverRepositories(runtimeDirName: string): Promise { - const repos: Repository[] = []; - const config = vscode.workspace.getConfiguration(); - const catalogDirectories = config.get>('copilotCatalog.catalogDirectory', {}); - - function normalizeFsPath(p: string): string { - // Ensure consistent separators so tests comparing Windows paths don't fail due to stray '/' - if(process.platform === 'win32'){ - return p.replace(/\\+/g,'\\').replace(/\//g,'\\'); - } - return p; - } - - // If no catalog directories are configured, try to find default ones in workspace folders - if (Object.keys(catalogDirectories).length === 0) { - const folders = vscode.workspace.workspaceFolders || []; - for (const folder of folders) { - const root = folder.uri.fsPath; - const catalogPath = path.join(root, 'copilot_catalog'); // Default fallback - const runtimePath = path.join(root, runtimeDirName); - try { - await vscode.workspace.fs.stat(vscode.Uri.file(catalogPath)); - repos.push({ - id: path.basename(root), - name: path.basename(root), - rootPath: root, - catalogPath, - runtimePath, - isActive: true - }); - } catch { /* not a catalog repo */ } - } - } else { - // Use explicitly configured catalog directories - for (const [catalogPath, displayName] of Object.entries(catalogDirectories)) { - try { - // Handle both absolute and relative paths - let absoluteCatalogPath: string; - if (path.isAbsolute(catalogPath)) { - absoluteCatalogPath = catalogPath; - } else { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) continue; - absoluteCatalogPath = path.join(workspaceFolder.uri.fsPath, catalogPath); - } - - await vscode.workspace.fs.stat(vscode.Uri.file(absoluteCatalogPath)); - - // Determine the repo root and runtime path based on target workspace setting - let repoRoot: string; - let runtimePath: string; - - // Check if targetWorkspace is explicitly set - const targetWorkspace = config.get('copilotCatalog.targetWorkspace'); - if (targetWorkspace && targetWorkspace.trim()) { - // When targetWorkspace is set, use it as the repository root - repoRoot = targetWorkspace.trim(); - runtimePath = path.join(repoRoot, runtimeDirName); - } else { - // Fallback to old logic when no targetWorkspace is set - if (path.basename(absoluteCatalogPath).toLowerCase() === 'copilot_catalog') { - // Traditional structure: repo/copilot_catalog -> repo root is parent - repoRoot = path.dirname(absoluteCatalogPath); - runtimePath = path.join(repoRoot, runtimeDirName); - } else { - // Direct catalog directory: use catalog directory as repo root - repoRoot = absoluteCatalogPath; - runtimePath = path.join(repoRoot, runtimeDirName); - } - } - - const repoName = displayName || path.basename(absoluteCatalogPath); - - // Normalize paths for consistency across platforms & tests - repos.push({ - id: path.basename(repoRoot) + '_' + path.basename(absoluteCatalogPath), - name: repoName, - rootPath: normalizeFsPath(repoRoot), - catalogPath: normalizeFsPath(absoluteCatalogPath), - runtimePath: normalizeFsPath(runtimePath), - isActive: true - }); - } catch { /* invalid catalog path */ } - } - } - return repos; +const repos: Repository[] = []; +const config = vscode.workspace.getConfiguration(); +const catalogDirectories = config.get>('copilotCatalog.catalogDirectory', {}); + +function normalizeFsPath(p: string): string { +// Ensure consistent separators so tests comparing Windows paths don't fail due to stray '/' +if(process.platform === 'win32'){ +return p.replace(/\\+/g,'\\').replace(/\//g,'\\'); +} +return p; +} + +// If no catalog directories are configured, try to find default ones in workspace folders +if (Object.keys(catalogDirectories).length === 0) { +const folders = vscode.workspace.workspaceFolders || []; +for (const folder of folders) { +const root = folder.uri.fsPath; +const catalogPath = path.join(root, 'copilot_catalog'); // Default fallback +const runtimePath = path.join(root, runtimeDirName); +try { +await vscode.workspace.fs.stat(vscode.Uri.file(catalogPath)); +repos.push({ +id: path.basename(root), +name: path.basename(root), +rootPath: root, +catalogPath, +runtimePath, +isActive: true +}); +} catch { /* not a catalog repo */ } +} +} else { +// Use explicitly configured catalog directories +for (const [catalogPath, displayName] of Object.entries(catalogDirectories)) { +try { +// Handle both absolute and relative paths +let absoluteCatalogPath: string; +if (path.isAbsolute(catalogPath)) { +absoluteCatalogPath = catalogPath; +} else { +const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; +if (!workspaceFolder) continue; +absoluteCatalogPath = path.join(workspaceFolder.uri.fsPath, catalogPath); +} + +await vscode.workspace.fs.stat(vscode.Uri.file(absoluteCatalogPath)); + +// Determine the repo root and runtime path based on target workspace setting +let repoRoot: string; +let runtimePath: string; + +// Check if targetWorkspace is explicitly set +const targetWorkspace = config.get('copilotCatalog.targetWorkspace'); +if (targetWorkspace && targetWorkspace.trim()) { +// When targetWorkspace is set, use it as the repository root +repoRoot = targetWorkspace.trim(); +runtimePath = path.join(repoRoot, runtimeDirName); +} else { +// Fallback to old logic when no targetWorkspace is set +if (path.basename(absoluteCatalogPath).toLowerCase() === 'copilot_catalog') { +// Traditional structure: repo/copilot_catalog -> repo root is parent +repoRoot = path.dirname(absoluteCatalogPath); +runtimePath = path.join(repoRoot, runtimeDirName); +} else { +// Direct catalog directory: use catalog directory as repo root +repoRoot = absoluteCatalogPath; +runtimePath = path.join(repoRoot, runtimeDirName); +} +} + +const repoName = displayName || path.basename(absoluteCatalogPath); + +// Normalize paths for consistency across platforms & tests +repos.push({ +id: path.basename(repoRoot) + '_' + path.basename(absoluteCatalogPath), +name: repoName, +rootPath: normalizeFsPath(repoRoot), +catalogPath: normalizeFsPath(absoluteCatalogPath), +runtimePath: normalizeFsPath(runtimePath), +isActive: true +}); +} catch { /* invalid catalog path */ } +} +} +return repos; } export async function activate(context: vscode.ExtensionContext) { - // Pre-flight check to ensure we can log any startup errors - const preflightLog = (msg: string) => console.log(`[ContextShare Pre-flight] ${msg}`); - const preflightError = (msg: string, err: any) => { - console.error(`[ContextShare Pre-flight ERROR] ${msg}`, err); - vscode.window.showErrorMessage(`ContextShare failed to activate: ${msg}. See Developer Tools console.`); - }; +// Pre-flight check to ensure we can log any startup errors +const preflightLog = (msg: string) => console.log(`[ContextShare Pre-flight] ${msg}`); +const preflightError = (msg: string, err: any) => { +console.error(`[ContextShare Pre-flight ERROR] ${msg}`, err); +vscode.window.showErrorMessage(`ContextShare failed to activate: ${msg}. See Developer Tools console.`); +}; - preflightLog('Activating...'); +preflightLog('Activating...'); try { const fileService = new FileService(); const resourceService = new ResourceService(fileService); const gitCatalogService = new GitCatalogService(fileService); - // Configure structured logger now that we have context and config. - enableFileLogging = !!vscode.workspace.getConfiguration().get('copilotCatalog.enableFileLogging', false); - logFilePath = path.join(context.globalStorageUri.fsPath, LOG_FILENAME); - logger.init(context, { enableFileLogging, filePath: logFilePath, channelName: 'ContextShare' }); - // Wire service-level logger so core operations emit to the output channel/file - (resourceService as any).setLogger?.(logger.asFunction()); - // Create tree providers for each category and overview - const overviewTree = new OverviewTreeProvider(); - const chatmodesTree = new CategoryTreeProvider(ResourceCategory.CHATMODES); - const instructionsTree = new CategoryTreeProvider(ResourceCategory.INSTRUCTIONS); - const promptsTree = new CategoryTreeProvider(ResourceCategory.PROMPTS); - const tasksTree = new CategoryTreeProvider(ResourceCategory.TASKS); - const mcpTree = new CategoryTreeProvider(ResourceCategory.MCP); - const optionsTree = new OptionsTreeProvider(); - const hatService = new HatService(fileService, resourceService, context.globalStorageUri.fsPath); - - // Track whether we've warned user about read-only catalog views - let shownReadonlyNotice = false; - - // Multiple catalog support - let catalogFilter: string | undefined; - let allResources: Resource[] = []; // All resources before filtering - - const config = vscode.workspace.getConfiguration(); - const resolveWorkspacePath = (input?: string): string | undefined => { - if(!input) return undefined; - let out = input; - const folders = vscode.workspace.workspaceFolders || []; - // ${workspaceFolder} - if(out.includes('${workspaceFolder}')){ - const base = folders[0]?.uri.fsPath; - if(base){ out = out.replace(/\$\{workspaceFolder\}/g, base); } - } - // ${workspaceFolder:name} - out = out.replace(/\$\{workspaceFolder:([^}]+)\}/g, (_m, name) => { - const f = folders.find(f => f.name === name || f.uri.fsPath.endsWith('/'+name) || f.uri.fsPath.endsWith('\\'+name)); - // Fallback to first workspace folder if named folder not found - return f ? f.uri.fsPath : (folders[0]?.uri.fsPath || _m); - }); - // Normalize to absolute if still relative - if(!path.isAbsolute(out) && folders[0]){ out = path.resolve(folders[0].uri.fsPath, out); } - return out; - }; - const runtimeDirName = config.get('copilotCatalog.runtimeDirectory', '.github'); - // Configure logging destination - enableFileLogging = !!config.get('copilotCatalog.enableFileLogging', false); - logFilePath = path.join(context.globalStorageUri.fsPath, LOG_FILENAME); - - // In development (extension debugging) automatically create a temporary external workspace - // if the user has not specified a targetWorkspace. This makes F5 debugging easier by - // providing a clean sandbox separate from the extension source repository. - let rawTargetWs = config.get('copilotCatalog.targetWorkspace'); - if(!rawTargetWs && context.extensionMode === vscode.ExtensionMode.Development){ - try { - const dummyRoot = path.join(os.tmpdir(), 'contextshare-dummy-workspace'); - await fs.mkdir(dummyRoot, { recursive: true }); - // Seed a README so folder isn't empty - const readmePath = path.join(dummyRoot, 'README_ContextShare_Dummy.md'); - try { await fs.access(readmePath); } catch { await fs.writeFile(readmePath, '# ContextShare Dummy Workspace\nAutomatically created for extension debugging.'); } - // Ensure .vscode exists for potential settings the user might add - await fs.mkdir(path.join(dummyRoot, '.vscode'), { recursive: true }); - // Point targetWorkspace to this dummy root (workspace-scoped so it does not leak globally) - await config.update('copilotCatalog.targetWorkspace', dummyRoot, vscode.ConfigurationTarget.Workspace); - rawTargetWs = dummyRoot; - // Add it to the current (development host) workspace folders if not already present - if(!(vscode.workspace.workspaceFolders||[]).some(f => f.uri.fsPath === dummyRoot)){ - vscode.workspace.updateWorkspaceFolders((vscode.workspace.workspaceFolders||[]).length, 0, { uri: vscode.Uri.file(dummyRoot), name: 'DummyWorkspace' }); - } - // If no catalog directories are configured, map the extension example-catalog for convenience - const currentCatalogDirs = config.get>('copilotCatalog.catalogDirectory', {}); - if(Object.keys(currentCatalogDirs).length === 0){ - const wsRoot = vscode.workspace.workspaceFolders?.find(f => /vscode-copilot-catalog-manager/i.test(f.name || path.basename(f.uri.fsPath)))?.uri.fsPath - || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if(wsRoot){ - const exampleCatalog = path.join(wsRoot, 'example-catalog'); - try { - await vscode.workspace.fs.stat(vscode.Uri.file(exampleCatalog)); - await config.update('copilotCatalog.catalogDirectory', { [exampleCatalog]: 'ExampleCatalog' }, vscode.ConfigurationTarget.Workspace); - } catch (error) { - await logger.warn(`Failed to setup example catalog: ${getErrorMessage(error)}`); - } - } - } - vscode.window.showInformationMessage(`ContextShare: Created dummy target workspace at ${dummyRoot}`); - } catch(e:any){ - await logger.warn('Auto dummy workspace creation failed: ' + (e?.message || e)); - } - } - // Respect target workspace override on startup (expand ${workspaceFolder} tokens) - const resolvedTargetWs = resolveWorkspacePath(rawTargetWs); - resourceService.setTargetWorkspaceOverride(resolvedTargetWs); - await logger.info(`Resolved targetWorkspace: raw=${rawTargetWs || '(none)'} -> ${resolvedTargetWs || '(none)'} `); - // Set current workspace root as fallback for target workspace - const currentWorkspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (currentWorkspaceRoot) { - resourceService.setCurrentWorkspaceRoot(currentWorkspaceRoot); - } - resourceService.setRuntimeDirectoryName(runtimeDirName); - resourceService.setRemoteCacheTtl(config.get('copilotCatalog.remoteCacheTtlSeconds', 300)); - - let repositories: Repository[] = await discoverRepositories(runtimeDirName); - let currentRepo: Repository | undefined = repositories[0]; - let resources: Resource[] = []; - - // Helper function to refresh all tree providers - function refreshAllTrees() { - overviewTree.refresh(); - chatmodesTree.refresh(); - instructionsTree.refresh(); - promptsTree.refresh(); - tasksTree.refresh(); - mcpTree.refresh(); - optionsTree.refresh(); - } - - // Helper function to apply catalog filter to all trees - function applyCatalogFilter(filter?: string) { - catalogFilter = filter; - overviewTree.setCatalogFilter(filter); - chatmodesTree.setCatalogFilter(filter); - instructionsTree.setCatalogFilter(filter); - promptsTree.setCatalogFilter(filter); - tasksTree.setCatalogFilter(filter); - mcpTree.setCatalogFilter(filter); - } - - // Helper function to discover resources from multiple catalog directories - async function discoverMultipleCatalogs(repository: Repository): Promise { - const cfg = vscode.workspace.getConfiguration(); - const catalogDirectories = cfg.get>('copilotCatalog.catalogDirectory', {}); - const allResources: Resource[] = []; - // We'll collect catalog resources first, then add user/runtime resources exactly once - const catalogOnly: Resource[] = []; - let addedUserRuntime = false; - - // If catalog directories are configured, use them - const directoryPaths = Object.keys(catalogDirectories); - if (directoryPaths.length > 0) { - for (const directory of directoryPaths) { - try { - // Capture previous overrides so we can restore after each catalog scan - const prevRoot = (resourceService as any).rootCatalogOverride; - // Set ONLY for catalog scanning – user runtime scan must not use this root override - resourceService.setRootCatalogOverride(directory); - resourceService.setSourceOverrides({}); - const sourceResources = await resourceService.discoverResources(repository); - // Restore root override so subsequent user runtime discovery (later) uses target workspace - resourceService.setRootCatalogOverride(prevRoot); - - // On first loop, add user/runtime resources (those with origin 'user') once - if(!addedUserRuntime){ - for(const r of sourceResources){ if(r.origin === 'user') catalogOnly.push(r); } - addedUserRuntime = true; // Prevent duplication across catalogs - } - // Always add catalog / remote resources (non-user) - const pureCatalog = sourceResources.filter(r => r.origin !== 'user'); - - // Use custom display name or fall back to directory basename - const catalogName = getCatalogDisplayName(directory, catalogDirectories); - pureCatalog.forEach(r => { - r.catalogName = catalogName; - // Ensure unique IDs across catalogs - r.id = `${repository.name}:${catalogName}:${r.relativePath}`; - }); - catalogOnly.push(...pureCatalog); - } catch (e: any) { - await logger.warn(`Failed to load catalog directory "${directory}": ${getErrorMessage(e)}`); - } - } - // Merge catalog resources with (single) user runtime resources - allResources.push(...catalogOnly); - } else { - // Fall back to default catalog discovery - const resources = await resourceService.discoverResources(repository); - resources.forEach(r => { - r.catalogName = 'Default'; - }); - allResources.push(...resources); - } - - return allResources; - } - - async function loadResources() { - const t0 = Date.now(); - allResources = currentRepo ? await discoverMultipleCatalogs(currentRepo) : []; - const preCounts: Record = {}; - for(const r of allResources){ preCounts[r.category] = (preCounts[r.category]||0)+1; } - await logger.info(`loadResources: discovered total=${allResources.length} byCat=${JSON.stringify(preCounts)}`); - - // Collapse duplicate entries (catalog + user runtime copy) by hiding the user-origin entry - // when a catalog/remote resource with the same category + basename is ACTIVE or MODIFIED. - // Rationale: users expect to see the catalog asset itself (with checkmark) rather than a - // synthetic "user" row for activated catalog resources. True user-only assets (no catalog - // source) must continue to appear. - try { - const activeCatalogKeys = new Set(); - for(const r of allResources){ - if((r as any).origin !== 'user' && (r.state === ResourceState.ACTIVE || r.state === ResourceState.MODIFIED)){ - const key = r.category + '::' + path.basename(r.relativePath).toLowerCase(); - activeCatalogKeys.add(key); - } - } - if(activeCatalogKeys.size){ - allResources = allResources.filter(r => { - if((r as any).origin === 'user'){ - const key = r.category + '::' + path.basename(r.relativePath).toLowerCase(); - // Hide the user entry if there is an active catalog counterpart - if(activeCatalogKeys.has(key)) return false; - } - return true; // keep catalog + remote + unmatched user assets - }); - } - } catch { /* best-effort duplicate collapse */ } - - // Apply current filter - const filteredResources = catalogFilter ? - allResources.filter(r => r.catalogName === catalogFilter) : - allResources; - const postCounts: Record = {}; - for(const r of filteredResources){ postCounts[r.category] = (postCounts[r.category]||0)+1; } - await logger.info(`loadResources: filtered=${filteredResources.length} byCat=${JSON.stringify(postCounts)} filter=${catalogFilter||'(none)'} dt=${Date.now()-t0}ms`); - - // Update all tree providers with filtered resources - overviewTree.setRepository(currentRepo, filteredResources); - chatmodesTree.setRepository(currentRepo, filteredResources); - instructionsTree.setRepository(currentRepo, filteredResources); - promptsTree.setRepository(currentRepo, filteredResources); - tasksTree.setRepository(currentRepo, filteredResources); - mcpTree.setRepository(currentRepo, filteredResources); - // optionsTree has no resource dependency - - // Set context for showing/hiding views - const hasResources = filteredResources.length > 0; - vscode.commands.executeCommand('setContext', 'copilotCatalog.hasResources', hasResources); - } - - async function deriveVirtualRepoFromOverride(absPath: string, runtimeRootPreference?: string): Promise { - if(!absPath) return undefined; - let catalogPath = absPath; - try { - const stat = await vscode.workspace.fs.stat(vscode.Uri.file(absPath)); - if(stat.type !== vscode.FileType.Directory){ catalogPath = path.dirname(absPath); } - } catch (error) { - await logger.warn(`Failed to stat catalog path: ${getErrorMessage(error)}`); - } - const runtimeRoot = runtimeRootPreference || path.dirname(catalogPath); - return { - id: `virtual:${catalogPath}`, - name: path.basename(catalogPath) + ' (virtual)', - rootPath: runtimeRoot, - catalogPath, - runtimePath: path.join(runtimeRoot, runtimeDirName), - isActive: true - }; - } - - async function ensureVirtualRepoIfNeeded(){ - if(repositories.length>0) return; - const config = vscode.workspace.getConfiguration(); - const catalogDirectories = config.get>('copilotCatalog.catalogDirectory', {}); - const candidatePaths: string[] = []; - - for(const directory of Object.keys(catalogDirectories)){ - if(path.isAbsolute(directory)) { - candidatePaths.push(directory); - } else { - const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if(ws) candidatePaths.push(path.join(ws, directory)); - } - } - - for(const abs of candidatePaths){ - try { - await vscode.workspace.fs.stat(vscode.Uri.file(abs)); - const workspaceRoot = (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]?.uri.fsPath) || undefined; - const virt = await deriveVirtualRepoFromOverride(abs, workspaceRoot); - if(virt){ repositories.push(virt); await logger.info(`Created virtual repository for external catalog: ${virt.catalogPath}`); break; } - } catch (e:any) { await logger.warn(`Virtual repo candidate failed ${abs}: ${getErrorMessage(e)}`); } - } - } - - async function refresh() { - try { - logger.info('Refresh started'); - repositories = await discoverRepositories(runtimeDirName); - await ensureVirtualRepoIfNeeded(); - if (!currentRepo || !repositories.find(r => r.id === currentRepo?.id)) { - currentRepo = repositories[0]; - if(!currentRepo){ - await logger.info('No repositories detected after refresh.'); - } - } - await loadResources(); - // No-op: hats are discovered on demand when command is invoked - logger.info(`Refresh complete. Repo count=${repositories.length} resources=${resources.length}`); - try { updateStatus(); } catch (error) { - logger.warn(`Failed to update status: ${error}`); - } - } catch(e:any){ - logger.error('Refresh error: ' + (e?.stack || e)); - vscode.window.showErrorMessage('ContextShare refresh failed: ' + getErrorMessage(e)); - } - } - - logger.info('Activating ContextShare extension'); - - const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); - status.command = 'copilotCatalog.refresh'; - function updateStatus() { - const filteredResources = catalogFilter ? - allResources.filter(r => r.catalogName === catalogFilter) : - allResources; - const active = filteredResources.filter(r => r.state === ResourceState.ACTIVE).length; - const statusText = catalogFilter ? - `ContextShare $(library) ${active}/${filteredResources.length} [${catalogFilter}]` : - `ContextShare $(library) ${active}/${filteredResources.length}`; - status.text = statusText; - status.tooltip = 'ContextShare: Refresh'; - status.show(); - } - context.subscriptions.push(status); - - context.subscriptions.push( - // Provide read-only virtual documents for catalog resources to prevent accidental edits - vscode.workspace.registerTextDocumentContentProvider('copilot-catalog', { - provideTextDocumentContent: async (uri: vscode.Uri) => { - // uri.path should be the absolute fs path (normalized with forward slashes). Convert for Windows. - let realPath = uri.path; - // On Windows VS Code may prefix with /c:/... - if(process.platform === 'win32' && /^\/[A-Za-z]:\//.test(realPath)){ - realPath = realPath.substring(1); // drop leading slash - } - try { return await fs.readFile(realPath, 'utf8'); } catch { return `⚠ Unable to load catalog resource: ${realPath}`; } - } - }), - vscode.window.registerTreeDataProvider('copilotCatalogOverview', overviewTree), - vscode.window.registerTreeDataProvider('copilotCatalogChatmodes', chatmodesTree), - vscode.window.registerTreeDataProvider('copilotCatalogInstructions', instructionsTree), - vscode.window.registerTreeDataProvider('copilotCatalogPrompts', promptsTree), - vscode.window.registerTreeDataProvider('copilotCatalogTasks', tasksTree), - vscode.window.registerTreeDataProvider('copilotCatalogMcp', mcpTree), - vscode.window.registerTreeDataProvider('copilotCatalogOptions', optionsTree), - vscode.commands.registerCommand('copilotCatalog.refresh', async () => refresh()), - vscode.commands.registerCommand('copilotCatalog.openResource', async (item: any) => { - const res = pickResourceFromItem(item); - if(!res) return; - // If user resource or active copy exists, open the runtime (editable) file; else open read-only catalog view - const runtimeTarget = resourceService.getTargetPath(res); - const activeExists = await fileService.pathExists(runtimeTarget); - if((res as any).origin === 'user' || activeExists){ - vscode.window.showTextDocument(vscode.Uri.file(activeExists ? runtimeTarget : res.absolutePath)); - // After a short delay, recompute state if file was edited (auto-detect modifications without manual refresh) - setTimeout(async ()=>{ - try { res.state = await resourceService.getResourceState(res); refreshAllTrees(); updateStatus(); } catch {} - }, 750); - } else { - const uri = vscode.Uri.from({ scheme:'copilot-catalog', path: res.absolutePath.replace(/\\/g,'/') }); - if(!shownReadonlyNotice){ - shownReadonlyNotice = true; - vscode.window.showInformationMessage('Opened read-only catalog resource. Activate it first to edit a runtime copy.'); - } - vscode.window.showTextDocument(uri, { preview: true }); - } - }), - vscode.commands.registerCommand('copilotCatalog.editActivatedCopy', async (item: any) => { - // Convenience: if inactive, activate then open runtime copy; if already active just open - const res = pickResourceFromItem(item); - if(!res) return; - const runtimeTarget = resourceService.getTargetPath(res); - const exists = await fileService.pathExists(runtimeTarget); - if(res.state === ResourceState.INACTIVE && (res as any).origin !== 'user'){ - await resourceService.activateResource(res); - } - const finalPath = await fileService.pathExists(runtimeTarget) ? runtimeTarget : res.absolutePath; - vscode.window.showTextDocument(vscode.Uri.file(finalPath)); - // Optionally refresh state to update icon (inactive -> active) - try { res.state = await resourceService.getResourceState(res); refreshAllTrees(); updateStatus(); } catch {} - }), - vscode.commands.registerCommand('copilotCatalog.diagnostics', async () => { - const redact = (p?: string) => p ? path.basename(p) : p; - const config = vscode.workspace.getConfiguration(); - const catalogDirectories = config.get>('copilotCatalog.catalogDirectory', {}); - const diag: any = { - catalogDirectories: Object.keys(catalogDirectories).map(redact), - repositories: repositories.map(r=>({id:r.id, catalogPath: redact(r.catalogPath), runtimePath: redact(r.runtimePath)})), - resourceCount: resources.length, - workspaceFolders: (vscode.workspace.workspaceFolders||[]).map((f: vscode.WorkspaceFolder)=>redact(f.uri.fsPath)), - runtimeDirName - }; - await logger.info('Diagnostics:\n'+ JSON.stringify(diag,null,2)); - vscode.window.showInformationMessage('ContextShare diagnostics written to output channel.'); - }), - vscode.commands.registerCommand('copilotCatalog.dumpResources', async () => { - await logger.info(`DumpResources: count=${resources.length}`); - for(const r of resources.slice(0,200)){ - await logger.info(` - ${r.category} ${r.state} ${r.origin} :: ${r.absolutePath}`); - } - vscode.window.showInformationMessage('Resource list written to log'); - }), - vscode.commands.registerCommand('copilotCatalog.activate', async (item: any) => { - const res = pickResourceFromItem(item); - if (!res) return; - await logger.info(`Command.activate start id=${res.id} cat=${res.category} origin=${(res as any).origin} state=${res.state}`); - // If activating over a modified runtime copy, warn user before overwriting - if(res.state === ResourceState.MODIFIED){ - const runtimePath = resourceService.getTargetPath(res); - const choice = await vscode.window.showWarningMessage( - 'Local copy has modifications that differ from catalog', - { modal: true }, - 'Discard Changes', 'Preserve as New File', 'Cancel' - ); - if(!choice || choice === 'Cancel') return; - if(choice === 'Preserve as New File'){ - try { - const newPath = await preserveFileWithVariant(runtimePath, logger.info.bind(logger)); - vscode.window.showInformationMessage(`Preserved as: ${path.basename(newPath)}`); - // Force refresh to show the new user resource in the tree - setTimeout(async () => { - await refresh(); - }, 500); - } catch(e:any){ - await handleErrorWithNotification(e, 'preserve file', logger.info.bind(logger), vscode); - } - } - } - const result = await resourceService.activateResource(res); - await logger.info(`Command.activate result success=${result.success} msg=${result.message} details=${result.details||''}`); - refreshAllTrees(); - updateStatus(); - }), - vscode.commands.registerCommand('copilotCatalog.deactivate', async (item: any) => { - const res = pickResourceFromItem(item); - if (!res) return; - await logger.info(`Command.deactivate start id=${res.id} cat=${res.category} origin=${(res as any).origin} state=${res.state}`); - // If modified, offer preservation choices - if(res.state === ResourceState.MODIFIED){ - const choice = await vscode.window.showWarningMessage('Resource has local modifications. How would you like to proceed?', { modal:true }, 'Discard Changes', 'Preserve as New', 'Cancel'); - if(choice === 'Cancel' || !choice) return; - if(choice === 'Preserve as New'){ - // Copy current runtime file to a new user resource name before deactivation - const runtimePath = resourceService.getTargetPath(res); - try { - const newPath = await preserveFileWithVariant(runtimePath, logger.info.bind(logger)); - vscode.window.showInformationMessage(`Preserved as: ${path.basename(newPath)}`); - // Force refresh to show the new user resource in the tree - setTimeout(async () => { - await refresh(); - }, 500); - } catch(e:any){ - await handleErrorWithNotification(e, 'preserve file', logger.info.bind(logger), vscode); - } - } - } - const result = await resourceService.deactivateResource(res); - await logger.info(`Command.deactivate result success=${result.success} msg=${result.message} details=${result.details||''}`); - res.state = await resourceService.getResourceState(res); - refreshAllTrees(); - updateStatus(); - }), - vscode.commands.registerCommand('copilotCatalog.activateAll', async () => { - const currentResources = catalogFilter ? - allResources.filter(r => r.catalogName === catalogFilter) : - allResources; - for (const r of currentResources) { await resourceService.activateResource(r); } - refreshAllTrees(); - updateStatus(); - }), - vscode.commands.registerCommand('copilotCatalog.deactivateAll', async () => { - const currentResources = catalogFilter ? - allResources.filter(r => r.catalogName === catalogFilter) : - allResources; - for (const r of currentResources) { if((r as any).origin !== 'user') await resourceService.deactivateResource(r); } - refreshAllTrees(); - updateStatus(); - }), - vscode.commands.registerCommand('copilotCatalog.showDiff', async (item: any) => { - const res = pickResourceFromItem(item); - if (!res) return; - const targetPath = resourceService.getTargetPath(res); - const left = vscode.Uri.file(res.absolutePath); - const right = vscode.Uri.file(targetPath); - vscode.commands.executeCommand('vscode.diff', left, right, `${path.basename(res.relativePath)} (catalog ↔ active)`); - }), - vscode.commands.registerCommand('copilotCatalog.selectRepository', async () => { - if (repositories.length === 0) { - vscode.window.showWarningMessage('No repositories with a ContextShare catalog found.'); - return; - } - const pick = await vscode.window.showQuickPick(repositories.map(r => ({ label: r.name, description: r.rootPath })), { placeHolder: 'Select repository' }); - if (!pick) return; - currentRepo = repositories.find(r => r.name === pick.label); - await loadResources(); - updateStatus(); - }) - , - // --- Dev submenu commands --- - vscode.commands.registerCommand('copilotCatalog.dev.createTemplateCatalog', async () => { - // Choose base directory via folder picker with manual fallback - const wsDefault = vscode.workspace.workspaceFolders?.[0]?.uri; - let baseUri: vscode.Uri | undefined; - let picked: vscode.Uri[] | undefined; - try { - picked = await vscode.window.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - openLabel: 'Select base folder', - defaultUri: wsDefault - }); - } catch(e:any){ await logger.warn('showOpenDialog failed (createTemplateCatalog), falling back to manual: ' + getErrorMessage(e)); } - if(picked && picked.length){ baseUri = picked[0]; } - else { - // fallback to manual path input - const manual = await vscode.window.showInputBox({ prompt: 'Absolute path to the folder where the catalog should be created', value: wsDefault?.fsPath || '' }); - if(manual === undefined) return; // cancelled - if(!manual.trim()){ vscode.window.showWarningMessage('No base folder provided.'); return; } - baseUri = vscode.Uri.file(manual.trim()); - } - const cfg = vscode.workspace.getConfiguration(); - const name = await vscode.window.showInputBox({ prompt: 'Folder name for the catalog', value: 'copilot_catalog' }); - if(!name) return; - const root = path.join(baseUri.fsPath, name); - try { - await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'chatmodes'))); - await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'instructions'))); - await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'prompts'))); - await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'tasks'))); - await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'mcp'))); - await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'hats'))); - // Seed sample files (best-effort) – catalog setup only - await fs.writeFile( - path.join(root, 'chatmodes', 'catalog-manager-agent.chatmode.md'), - [ - '# Catalog Setup Helper (Chatmode)', - '', - 'This chatmode must ONLY help the user set up and manage a ContextShare catalog using this extension. It must NOT answer or perform any unrelated tasks.', - '', - 'Rules:', - '- Scope strictly to catalog setup: scaffolding folders, configuring settings, activating/deactivating resources, understanding Hats, and packaging/installation steps.', - '- If asked anything outside catalog setup, politely refuse and redirect: "I can only help with ContextShare catalog setup and management. Please ask a catalog-related question."', - '- Remind the user of their duties: they own repo structure, security reviews, versioning, and Marketplace publishing credentials.', - '- Never run shell commands unless explicitly asked; provide minimal, copyable commands and explain effects.', - '- Be concise, concrete, and avoid speculative answers.', - '', - 'Quick references:', - '- Settings: "ContextShare" → rootCatalogPath, targetWorkspace, catalogDirectory, runtimeDirectory', - '- Hats: presets to activate/deactivate groups of resources', - ].join('\n') - ); - await fs.writeFile( - path.join(root, 'instructions', 'catalog-setup-guardrails.instructions.md'), - [ - '# Instruction: Catalog-Setup-Only Guardrails', - '', - 'When this instruction is active:', - '- Answer only questions about setting up and managing the ContextShare catalog.', - '- If the user asks about coding, debugging, or anything unrelated, respond with a brief refusal and suggest a catalog-related next step.', - '- Always remind the user they are responsible for repository structure, security policies, and versioning decisions.', - '- Prefer short actionable guidance referencing VS Code UI locations (e.g., view title bar → Dev menu).', - ].join('\n') - ); - await fs.writeFile( - path.join(root, 'prompts', 'init-catalog.prompt.md'), - [ - '# Prompt: Initialize a ContextShare Catalog in this Workspace', - '', - 'Goal: Help me set up a ContextShare catalog that I can share with my team, and remind me of my responsibilities.', - '', - 'Constraints:', - '- Do NOT answer non-catalog questions; refuse and redirect to catalog setup.', - '- Keep responses concise; show only the minimal steps.', - '', - 'What I need now:', - '1) How to create the template catalog and where to put it', - '2) How to configure rootCatalogPath or per-category sources and targetWorkspace', - '3) How to activate/deactivate a resource and apply a Hat', - '4) What I must own (security reviews, versioning, publishing)', - ].join('\n') - ); - await fs.writeFile( - path.join(root, 'tasks', 'catalog-setup-walkthrough.task.json'), - JSON.stringify({ - name: 'Task: Catalog Setup Walkthrough', - description: 'Step-by-step guide to initialize and use your catalog (setup-only).', - steps: [ - 'Open the ContextShare view and use the Dev menu to Create Template Catalog.', - 'Place the catalog in your desired folder and name it (default: copilot_catalog).', - 'Configure catalog directories using Dev menu → Configure Settings, then add catalog directories.', - 'Use Activate on a resource to copy it to your runtime (e.g., .github).', - 'Create/apply a Hat to quickly activate a set of resources.', - 'Remember: you own security reviews, repo layout, version bumps, and publishing.' - ] - }, null, 2) - ); - await fs.writeFile(path.join(root, 'mcp', 'catalog-servers.mcp.json'), '{"servers": {}}'); - await fs.writeFile(path.join(root, 'hats', 'Copilot-Catalog-Setup.json'), JSON.stringify({ name: 'ContextShare Setup', description: 'Only the example assets generated by the template', resources: [ - 'chatmodes/catalog-manager-agent.chatmode.md', - 'instructions/catalog-setup-guardrails.instructions.md', - 'prompts/init-catalog.prompt.md', - 'tasks/catalog-setup-walkthrough.task.json' - ]}, null, 2)); - vscode.window.showInformationMessage(`Template catalog created at ${root}`); - // If created outside current workspace, auto-point rootCatalogPath to it so it shows up immediately - const inWorkspace = (vscode.workspace.workspaceFolders||[]).some((f: vscode.WorkspaceFolder) => { - const ws = f.uri.fsPath.replace(/\\/g,'/'); - const dir = root.replace(/\\/g,'/'); - return dir.startsWith(ws + '/') || dir === ws; - }); - if(!inWorkspace){ - try { - const cfg = vscode.workspace.getConfiguration(undefined, baseUri); - const currentCatalogDirectories = cfg.get>('copilotCatalog.catalogDirectory', {}); - const newCatalogDirectories = {...currentCatalogDirectories, [root]: ''}; - await cfg.update('copilotCatalog.catalogDirectory', newCatalogDirectories, vscode.ConfigurationTarget.WorkspaceFolder); - vscode.window.showInformationMessage('Configured ContextShare to use the new template directory.'); - } catch (error) { - await logger.warn(`Failed to update catalog directory configuration: ${getErrorMessage(error)}`); - } - } - await refresh(); - } catch(e:any){ vscode.window.showErrorMessage('Failed to create template catalog: ' + getErrorMessage(e)); } - }), -vscode.commands.registerCommand('copilotCatalog.dev.scanGitRepository', async () => { -// Command to scan a remote Git repository for catalogs -const url = await vscode.window.showInputBox({ -prompt: 'Enter Git repository URL (e.g., https://github.com/org/repo.git)', -placeHolder: 'https://github.com/org/repo.git' -}); -if (!url) return; +// Configure structured logger now that we have context and config. +enableFileLogging = !!vscode.workspace.getConfiguration().get('copilotCatalog.enableFileLogging', false); +logFilePath = path.join(context.globalStorageUri.fsPath, LOG_FILENAME); +logger.init(context, { enableFileLogging, filePath: logFilePath, channelName: 'ContextShare' }); +// Wire service-level logger so core operations emit to the output channel/file +(resourceService as any).setLogger?.(logger.asFunction()); +// Create tree providers for each category and overview +const overviewTree = new OverviewTreeProvider(); +const chatmodesTree = new CategoryTreeProvider(ResourceCategory.CHATMODES); +const instructionsTree = new CategoryTreeProvider(ResourceCategory.INSTRUCTIONS); +const promptsTree = new CategoryTreeProvider(ResourceCategory.PROMPTS); +const tasksTree = new CategoryTreeProvider(ResourceCategory.TASKS); +const mcpTree = new CategoryTreeProvider(ResourceCategory.MCP); +const optionsTree = new OptionsTreeProvider(); +const hatService = new HatService(fileService, resourceService, context.globalStorageUri.fsPath); -const branch = await vscode.window.showInputBox({ -prompt: 'Enter branch name (optional, defaults to main)', -placeHolder: 'main' +// Track whether we've warned user about read-only catalog views +let shownReadonlyNotice = false; + +// Multiple catalog support +let catalogFilter: string | undefined; +let allResources: Resource[] = []; // All resources before filtering + +const config = vscode.workspace.getConfiguration(); +const resolveWorkspacePath = (input?: string): string | undefined => { +if(!input) return undefined; +let out = input; +const folders = vscode.workspace.workspaceFolders || []; +// ${workspaceFolder} +if(out.includes('${workspaceFolder}')){ +const base = folders[0]?.uri.fsPath; +if(base){ out = out.replace(/\$\{workspaceFolder\}/g, base); } +} +// ${workspaceFolder:name} +out = out.replace(/\$\{workspaceFolder:([^}]+)\}/g, (_m, name) => { +const f = folders.find(f => f.name === name || f.uri.fsPath.endsWith('/'+name) || f.uri.fsPath.endsWith('\\'+name)); +// Fallback to first workspace folder if named folder not found +return f ? f.uri.fsPath : (folders[0]?.uri.fsPath || _m); }); +// Normalize to absolute if still relative +if(!path.isAbsolute(out) && folders[0]){ out = path.resolve(folders[0].uri.fsPath, out); } +return out; +}; +const runtimeDirName = config.get('copilotCatalog.runtimeDirectory', '.github'); +// Configure logging destination +enableFileLogging = !!config.get('copilotCatalog.enableFileLogging', false); +logFilePath = path.join(context.globalStorageUri.fsPath, LOG_FILENAME); +// In development (extension debugging) automatically create a temporary external workspace +// if the user has not specified a targetWorkspace. This makes F5 debugging easier by +// providing a clean sandbox separate from the extension source repository. +let rawTargetWs = config.get('copilotCatalog.targetWorkspace'); +if(!rawTargetWs && context.extensionMode === vscode.ExtensionMode.Development){ try { -await logger.info(`Adding Git repository: ${url} (branch: ${branch || 'main'})`); -vscode.window.showInformationMessage('Scanning repository for catalogs...'); +const dummyRoot = path.join(os.tmpdir(), 'contextshare-dummy-workspace'); +await fs.mkdir(dummyRoot, { recursive: true }); +// Seed a README so folder isn't empty +const readmePath = path.join(dummyRoot, 'README_ContextShare_Dummy.md'); +try { await fs.access(readmePath); } catch { await fs.writeFile(readmePath, '# ContextShare Dummy Workspace\nAutomatically created for extension debugging.'); } +// Ensure .vscode exists for potential settings the user might add +await fs.mkdir(path.join(dummyRoot, '.vscode'), { recursive: true }); +// Point targetWorkspace to this dummy root (workspace-scoped so it does not leak globally) +await config.update('copilotCatalog.targetWorkspace', dummyRoot, vscode.ConfigurationTarget.Workspace); +rawTargetWs = dummyRoot; +// Add it to the current (development host) workspace folders if not already present +if(!(vscode.workspace.workspaceFolders||[]).some(f => f.uri.fsPath === dummyRoot)){ +vscode.workspace.updateWorkspaceFolders((vscode.workspace.workspaceFolders||[]).length, 0, { uri: vscode.Uri.file(dummyRoot), name: 'DummyWorkspace' }); +} +// If no catalog directories are configured, map the extension example-catalog for convenience +const currentCatalogDirs = config.get>('copilotCatalog.catalogDirectory', {}); +if(Object.keys(currentCatalogDirs).length === 0){ +const wsRoot = vscode.workspace.workspaceFolders?.find(f => /vscode-copilot-catalog-manager/i.test(f.name || path.basename(f.uri.fsPath)))?.uri.fsPath +|| vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +if(wsRoot){ +const exampleCatalog = path.join(wsRoot, 'example-catalog'); +try { +await vscode.workspace.fs.stat(vscode.Uri.file(exampleCatalog)); +await config.update('copilotCatalog.catalogDirectory', { [exampleCatalog]: 'ExampleCatalog' }, vscode.ConfigurationTarget.Workspace); +} catch (error) { +await logger.warn(`Failed to setup example catalog: ${getErrorMessage(error)}`); +} +} +} +vscode.window.showInformationMessage(`ContextShare: Created dummy target workspace at ${dummyRoot}`); +} catch(e:any){ +await logger.warn('Auto dummy workspace creation failed: ' + (e?.message || e)); +} +} +// Respect target workspace override on startup (expand ${workspaceFolder} tokens) +const resolvedTargetWs = resolveWorkspacePath(rawTargetWs); +resourceService.setTargetWorkspaceOverride(resolvedTargetWs); +await logger.info(`Resolved targetWorkspace: raw=${rawTargetWs || '(none)'} -> ${resolvedTargetWs || '(none)'} `); +// Set current workspace root as fallback for target workspace +const currentWorkspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +if (currentWorkspaceRoot) { +resourceService.setCurrentWorkspaceRoot(currentWorkspaceRoot); +} +resourceService.setRuntimeDirectoryName(runtimeDirName); +resourceService.setRemoteCacheTtl(config.get('copilotCatalog.remoteCacheTtlSeconds', 300)); + +let repositories: Repository[] = await discoverRepositories(runtimeDirName); +let currentRepo: Repository | undefined = repositories[0]; +let resources: Resource[] = []; -// Initialize the git catalog service with global storage -await gitCatalogService.init([], context.globalStorageUri.fsPath); +// Helper function to refresh all tree providers +function refreshAllTrees() { +overviewTree.refresh(); +chatmodesTree.refresh(); +instructionsTree.refresh(); +promptsTree.refresh(); +tasksTree.refresh(); +mcpTree.refresh(); +optionsTree.refresh(); +} -// Add the remote and scan for catalogs -await gitCatalogService.addRemoteInteractively(url, branch || 'main'); +// Helper function to apply catalog filter to all trees +function applyCatalogFilter(filter?: string) { +catalogFilter = filter; +overviewTree.setCatalogFilter(filter); +chatmodesTree.setCatalogFilter(filter); +instructionsTree.setCatalogFilter(filter); +promptsTree.setCatalogFilter(filter); +tasksTree.setCatalogFilter(filter); +mcpTree.setCatalogFilter(filter); +} -// Get the discovered catalog directories -const catalogMap = gitCatalogService.getCatalogDirectoryMap(); -const catalogs = Object.entries(catalogMap); +// Helper function to discover resources from multiple catalog directories +async function discoverMultipleCatalogs(repository: Repository): Promise { +const cfg = vscode.workspace.getConfiguration(); +const catalogDirectories = cfg.get>('copilotCatalog.catalogDirectory', {}); +const allResources: Resource[] = []; +// We'll collect catalog resources first, then add user/runtime resources exactly once +const catalogOnly: Resource[] = []; +let addedUserRuntime = false; -if (catalogs.length === 0) { -vscode.window.showInformationMessage('No catalogs found in the repository.'); -return; +// If catalog directories are configured, use them +const directoryPaths = Object.keys(catalogDirectories); +if (directoryPaths.length > 0) { +for (const directory of directoryPaths) { +try { +// Capture previous overrides so we can restore after each catalog scan +const prevRoot = (resourceService as any).rootCatalogOverride; +// Set ONLY for catalog scanning – user runtime scan must not use this root override +resourceService.setRootCatalogOverride(directory); +resourceService.setSourceOverrides({}); +const sourceResources = await resourceService.discoverResources(repository); +// Restore root override so subsequent user runtime discovery (later) uses target workspace +resourceService.setRootCatalogOverride(prevRoot); + +// On first loop, add user/runtime resources (those with origin 'user') once +if(!addedUserRuntime){ +for(const r of sourceResources){ if(r.origin === 'user') catalogOnly.push(r); } +addedUserRuntime = true; // Prevent duplication across catalogs } +// Always add catalog / remote resources (non-user) +const pureCatalog = sourceResources.filter(r => r.origin !== 'user'); -// Show discovered catalogs and let user select which to add -const picks = catalogs.map(([catalogPath, displayName]) => ({ -label: path.basename(catalogPath), -description: displayName, -detail: catalogPath, -path: catalogPath, -displayName: displayName -})); +// Use custom display name or fall back to directory basename +const catalogName = getCatalogDisplayName(directory, catalogDirectories); +pureCatalog.forEach(r => { +r.catalogName = catalogName; +// Ensure unique IDs across catalogs +r.id = `${repository.name}:${catalogName}:${r.relativePath}`; +}); +catalogOnly.push(...pureCatalog); +} catch (e: any) { +await logger.warn(`Failed to load catalog directory "${directory}": ${getErrorMessage(e)}`); +} +} +// Merge catalog resources with (single) user runtime resources +allResources.push(...catalogOnly); +} else { +// Fall back to default catalog discovery +const resources = await resourceService.discoverResources(repository); +resources.forEach(r => { +r.catalogName = 'Default'; +}); +allResources.push(...resources); +} + +return allResources; +} -const selected = await vscode.window.showQuickPick(picks, { -canPickMany: true, -placeHolder: 'Select catalogs to add' +async function loadResources() { +const t0 = Date.now(); +allResources = currentRepo ? await discoverMultipleCatalogs(currentRepo) : []; +const preCounts: Record = {}; +for(const r of allResources){ preCounts[r.category] = (preCounts[r.category]||0)+1; } +await logger.info(`loadResources: discovered total=${allResources.length} byCat=${JSON.stringify(preCounts)}`); + +// Collapse duplicate entries (catalog + user runtime copy) by hiding the user-origin entry +// when a catalog/remote resource with the same category + basename is ACTIVE or MODIFIED. +// Rationale: users expect to see the catalog asset itself (with checkmark) rather than a +// synthetic "user" row for activated catalog resources. True user-only assets (no catalog +// source) must continue to appear. +try { +const activeCatalogKeys = new Set(); +for(const r of allResources){ +if((r as any).origin !== 'user' && (r.state === ResourceState.ACTIVE || r.state === ResourceState.MODIFIED)){ +const key = r.category + '::' + path.basename(r.relativePath).toLowerCase(); +activeCatalogKeys.add(key); +} +} +if(activeCatalogKeys.size){ +allResources = allResources.filter(r => { +if((r as any).origin === 'user'){ +const key = r.category + '::' + path.basename(r.relativePath).toLowerCase(); +// Hide the user entry if there is an active catalog counterpart +if(activeCatalogKeys.has(key)) return false; +} +return true; // keep catalog + remote + unmatched user assets }); +} +} catch { /* best-effort duplicate collapse */ } -if (!selected || selected.length === 0) return; +// Apply current filter +const filteredResources = catalogFilter ? +allResources.filter(r => r.catalogName === catalogFilter) : +allResources; +const postCounts: Record = {}; +for(const r of filteredResources){ postCounts[r.category] = (postCounts[r.category]||0)+1; } +await logger.info(`loadResources: filtered=${filteredResources.length} byCat=${JSON.stringify(postCounts)} filter=${catalogFilter||'(none)'} dt=${Date.now()-t0}ms`); -// Add selected catalogs to configuration -const cfg = vscode.workspace.getConfiguration(); -const current = cfg.get>('copilotCatalog.catalogDirectory', {}); -const newEntries = { ...current }; +// Update all tree providers with filtered resources +overviewTree.setRepository(currentRepo, filteredResources); +chatmodesTree.setRepository(currentRepo, filteredResources); +instructionsTree.setRepository(currentRepo, filteredResources); +promptsTree.setRepository(currentRepo, filteredResources); +tasksTree.setRepository(currentRepo, filteredResources); +mcpTree.setRepository(currentRepo, filteredResources); +// optionsTree has no resource dependency -for (const item of selected) { -// Use the local clone path for now (Git URL format can be added later) -newEntries[item.path] = item.displayName; +// Set context for showing/hiding views +const hasResources = filteredResources.length > 0; +vscode.commands.executeCommand('setContext', 'copilotCatalog.hasResources', hasResources); } -await cfg.update('copilotCatalog.catalogDirectory', newEntries, vscode.ConfigurationTarget.Workspace); -vscode.window.showInformationMessage(`Added ${selected.length} catalog(s) from Git repository.`); -await refresh(); +async function deriveVirtualRepoFromOverride(absPath: string, runtimeRootPreference?: string): Promise { +if(!absPath) return undefined; +let catalogPath = absPath; +try { +const stat = await vscode.workspace.fs.stat(vscode.Uri.file(absPath)); +if(stat.type !== vscode.FileType.Directory){ catalogPath = path.dirname(absPath); } +} catch (error) { +await logger.warn(`Failed to stat catalog path: ${getErrorMessage(error)}`); +} +const runtimeRoot = runtimeRootPreference || path.dirname(catalogPath); +return { +id: `virtual:${catalogPath}`, +name: path.basename(catalogPath) + ' (virtual)', +rootPath: runtimeRoot, +catalogPath, +runtimePath: path.join(runtimeRoot, runtimeDirName), +isActive: true +}; +} + +async function ensureVirtualRepoIfNeeded(){ +if(repositories.length>0) return; +const config = vscode.workspace.getConfiguration(); +const catalogDirectories = config.get>('copilotCatalog.catalogDirectory', {}); +const candidatePaths: string[] = []; + +for(const directory of Object.keys(catalogDirectories)){ +if(path.isAbsolute(directory)) { +candidatePaths.push(directory); +} else { +const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +if(ws) candidatePaths.push(path.join(ws, directory)); +} +} -} catch (error) { -await logger.error(`Failed to scan Git repository: ${getErrorMessage(error)}`); -vscode.window.showErrorMessage(`Failed to scan repository: ${getErrorMessage(error)}`); +for(const abs of candidatePaths){ +try { +await vscode.workspace.fs.stat(vscode.Uri.file(abs)); +const workspaceRoot = (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]?.uri.fsPath) || undefined; +const virt = await deriveVirtualRepoFromOverride(abs, workspaceRoot); +if(virt){ repositories.push(virt); await logger.info(`Created virtual repository for external catalog: ${virt.catalogPath}`); break; } +} catch (e:any) { await logger.warn(`Virtual repo candidate failed ${abs}: ${getErrorMessage(e)}`); } +} +} + +async function refresh() { +try { +logger.info('Refresh started'); +repositories = await discoverRepositories(runtimeDirName); +await ensureVirtualRepoIfNeeded(); +if (!currentRepo || !repositories.find(r => r.id === currentRepo?.id)) { +currentRepo = repositories[0]; +if(!currentRepo){ +await logger.info('No repositories detected after refresh.'); +} +} +await loadResources(); +// No-op: hats are discovered on demand when command is invoked +logger.info(`Refresh complete. Repo count=${repositories.length} resources=${resources.length}`); +try { updateStatus(); } catch (error) { +logger.warn(`Failed to update status: ${error}`); +} +} catch(e:any){ +logger.error('Refresh error: ' + (e?.stack || e)); +vscode.window.showErrorMessage('ContextShare refresh failed: ' + getErrorMessage(e)); +} +} + +logger.info('Activating ContextShare extension'); + +const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); +status.command = 'copilotCatalog.refresh'; +function updateStatus() { +const filteredResources = catalogFilter ? +allResources.filter(r => r.catalogName === catalogFilter) : +allResources; +const active = filteredResources.filter(r => r.state === ResourceState.ACTIVE).length; +const statusText = catalogFilter ? +`ContextShare $(library) ${active}/${filteredResources.length} [${catalogFilter}]` : +`ContextShare $(library) ${active}/${filteredResources.length}`; +status.text = statusText; +status.tooltip = 'ContextShare: Refresh'; +status.show(); +} +context.subscriptions.push(status); + +context.subscriptions.push( +// Provide read-only virtual documents for catalog resources to prevent accidental edits +vscode.workspace.registerTextDocumentContentProvider('copilot-catalog', { +provideTextDocumentContent: async (uri: vscode.Uri) => { +// uri.path should be the absolute fs path (normalized with forward slashes). Convert for Windows. +let realPath = uri.path; +// On Windows VS Code may prefix with /c:/... +if(process.platform === 'win32' && /^\/[A-Za-z]:\//.test(realPath)){ +realPath = realPath.substring(1); // drop leading slash +} +try { return await fs.readFile(realPath, 'utf8'); } catch { return `⚠ Unable to load catalog resource: ${realPath}`; } +} +}), +vscode.window.registerTreeDataProvider('copilotCatalogOverview', overviewTree), +vscode.window.registerTreeDataProvider('copilotCatalogChatmodes', chatmodesTree), +vscode.window.registerTreeDataProvider('copilotCatalogInstructions', instructionsTree), +vscode.window.registerTreeDataProvider('copilotCatalogPrompts', promptsTree), +vscode.window.registerTreeDataProvider('copilotCatalogTasks', tasksTree), +vscode.window.registerTreeDataProvider('copilotCatalogMcp', mcpTree), +vscode.window.registerTreeDataProvider('copilotCatalogOptions', optionsTree), +vscode.commands.registerCommand('copilotCatalog.refresh', async () => refresh()), +vscode.commands.registerCommand('copilotCatalog.openResource', async (item: any) => { +const res = pickResourceFromItem(item); +if(!res) return; +// If user resource or active copy exists, open the runtime (editable) file; else open read-only catalog view +const runtimeTarget = resourceService.getTargetPath(res); +const activeExists = await fileService.pathExists(runtimeTarget); +if((res as any).origin === 'user' || activeExists){ +vscode.window.showTextDocument(vscode.Uri.file(activeExists ? runtimeTarget : res.absolutePath)); +// After a short delay, recompute state if file was edited (auto-detect modifications without manual refresh) +setTimeout(async ()=>{ +try { res.state = await resourceService.getResourceState(res); refreshAllTrees(); updateStatus(); } catch {} +}, 750); +} else { +const uri = vscode.Uri.from({ scheme:'copilot-catalog', path: res.absolutePath.replace(/\\/g,'/') }); +if(!shownReadonlyNotice){ +shownReadonlyNotice = true; +vscode.window.showInformationMessage('Opened read-only catalog resource. Activate it first to edit a runtime copy.'); } +vscode.window.showTextDocument(uri, { preview: true }); +} +}), +vscode.commands.registerCommand('copilotCatalog.editActivatedCopy', async (item: any) => { +// Convenience: if inactive, activate then open runtime copy; if already active just open +const res = pickResourceFromItem(item); +if(!res) return; +const runtimeTarget = resourceService.getTargetPath(res); +const exists = await fileService.pathExists(runtimeTarget); +if(res.state === ResourceState.INACTIVE && (res as any).origin !== 'user'){ +await resourceService.activateResource(res); +} +const finalPath = await fileService.pathExists(runtimeTarget) ? runtimeTarget : res.absolutePath; +vscode.window.showTextDocument(vscode.Uri.file(finalPath)); +// Optionally refresh state to update icon (inactive -> active) +try { res.state = await resourceService.getResourceState(res); refreshAllTrees(); updateStatus(); } catch {} +}), +vscode.commands.registerCommand('copilotCatalog.diagnostics', async () => { +const redact = (p?: string) => p ? path.basename(p) : p; +const config = vscode.workspace.getConfiguration(); +const catalogDirectories = config.get>('copilotCatalog.catalogDirectory', {}); +const diag: any = { +catalogDirectories: Object.keys(catalogDirectories).map(redact), +repositories: repositories.map(r=>({id:r.id, catalogPath: redact(r.catalogPath), runtimePath: redact(r.runtimePath)})), +resourceCount: resources.length, +workspaceFolders: (vscode.workspace.workspaceFolders||[]).map((f: vscode.WorkspaceFolder)=>redact(f.uri.fsPath)), +runtimeDirName +}; +await logger.info('Diagnostics:\n'+ JSON.stringify(diag,null,2)); +vscode.window.showInformationMessage('ContextShare diagnostics written to output channel.'); +}), +vscode.commands.registerCommand('copilotCatalog.dumpResources', async () => { +await logger.info(`DumpResources: count=${resources.length}`); +for(const r of resources.slice(0,200)){ +await logger.info(` - ${r.category} ${r.state} ${r.origin} :: ${r.absolutePath}`); +} +vscode.window.showInformationMessage('Resource list written to log'); +}), +vscode.commands.registerCommand('copilotCatalog.activate', async (item: any) => { +const res = pickResourceFromItem(item); +if (!res) return; +await logger.info(`Command.activate start id=${res.id} cat=${res.category} origin=${(res as any).origin} state=${res.state}`); +// If activating over a modified runtime copy, warn user before overwriting +if(res.state === ResourceState.MODIFIED){ +const runtimePath = resourceService.getTargetPath(res); +const choice = await vscode.window.showWarningMessage( +'Local copy has modifications that differ from catalog', +{ modal: true }, +'Discard Changes', 'Preserve as New File', 'Cancel' +); +if(!choice || choice === 'Cancel') return; +if(choice === 'Preserve as New File'){ +try { +const newPath = await preserveFileWithVariant(runtimePath, logger.info.bind(logger)); +vscode.window.showInformationMessage(`Preserved as: ${path.basename(newPath)}`); +// Force refresh to show the new user resource in the tree +setTimeout(async () => { +await refresh(); +}, 500); +} catch(e:any){ +await handleErrorWithNotification(e, 'preserve file', logger.info.bind(logger), vscode); +} +} +} +const result = await resourceService.activateResource(res); +await logger.info(`Command.activate result success=${result.success} msg=${result.message} details=${result.details||''}`); +refreshAllTrees(); +updateStatus(); }), -// Refresh all previously added Git repositories (pull latest + rescan) -vscode.commands.registerCommand('copilotCatalog.dev.refreshGitRepositories', async () => { +vscode.commands.registerCommand('copilotCatalog.deactivate', async (item: any) => { +const res = pickResourceFromItem(item); +if (!res) return; +await logger.info(`Command.deactivate start id=${res.id} cat=${res.category} origin=${(res as any).origin} state=${res.state}`); +// If modified, offer preservation choices +if(res.state === ResourceState.MODIFIED){ +const choice = await vscode.window.showWarningMessage('Resource has local modifications. How would you like to proceed?', { modal:true }, 'Discard Changes', 'Preserve as New', 'Cancel'); +if(choice === 'Cancel' || !choice) return; +if(choice === 'Preserve as New'){ +// Copy current runtime file to a new user resource name before deactivation +const runtimePath = resourceService.getTargetPath(res); +try { +const newPath = await preserveFileWithVariant(runtimePath, logger.info.bind(logger)); +vscode.window.showInformationMessage(`Preserved as: ${path.basename(newPath)}`); +// Force refresh to show the new user resource in the tree +setTimeout(async () => { +await refresh(); +}, 500); +} catch(e:any){ +await handleErrorWithNotification(e, 'preserve file', logger.info.bind(logger), vscode); +} +} +} +const result = await resourceService.deactivateResource(res); +await logger.info(`Command.deactivate result success=${result.success} msg=${result.message} details=${result.details||''}`); +res.state = await resourceService.getResourceState(res); +refreshAllTrees(); +updateStatus(); +}), +vscode.commands.registerCommand('copilotCatalog.activateAll', async () => { +const currentResources = catalogFilter ? +allResources.filter(r => r.catalogName === catalogFilter) : +allResources; +for (const r of currentResources) { await resourceService.activateResource(r); } +refreshAllTrees(); +updateStatus(); +}), +vscode.commands.registerCommand('copilotCatalog.deactivateAll', async () => { +const currentResources = catalogFilter ? +allResources.filter(r => r.catalogName === catalogFilter) : +allResources; +for (const r of currentResources) { if((r as any).origin !== 'user') await resourceService.deactivateResource(r); } +refreshAllTrees(); +updateStatus(); +}), +vscode.commands.registerCommand('copilotCatalog.showDiff', async (item: any) => { +const res = pickResourceFromItem(item); +if (!res) return; +const targetPath = resourceService.getTargetPath(res); +const left = vscode.Uri.file(res.absolutePath); +const right = vscode.Uri.file(targetPath); +vscode.commands.executeCommand('vscode.diff', left, right, `${path.basename(res.relativePath)} (catalog ↔ active)`); +}), +vscode.commands.registerCommand('copilotCatalog.selectRepository', async () => { +if (repositories.length === 0) { +vscode.window.showWarningMessage('No repositories with a ContextShare catalog found.'); +return; +} +const pick = await vscode.window.showQuickPick(repositories.map(r => ({ label: r.name, description: r.rootPath })), { placeHolder: 'Select repository' }); +if (!pick) return; +currentRepo = repositories.find(r => r.name === pick.label); +await loadResources(); +updateStatus(); +}) +, +// --- Dev submenu commands --- +vscode.commands.registerCommand('copilotCatalog.dev.createTemplateCatalog', async () => { +// Choose base directory via folder picker with manual fallback +const wsDefault = vscode.workspace.workspaceFolders?.[0]?.uri; +let baseUri: vscode.Uri | undefined; +let picked: vscode.Uri[] | undefined; +try { +picked = await vscode.window.showOpenDialog({ +canSelectFiles: false, +canSelectFolders: true, +canSelectMany: false, +openLabel: 'Select base folder', +defaultUri: wsDefault +}); +} catch(e:any){ await logger.warn('showOpenDialog failed (createTemplateCatalog), falling back to manual: ' + getErrorMessage(e)); } +if(picked && picked.length){ baseUri = picked[0]; } +else { +// fallback to manual path input +const manual = await vscode.window.showInputBox({ prompt: 'Absolute path to the folder where the catalog should be created', value: wsDefault?.fsPath || '' }); +if(manual === undefined) return; // cancelled +if(!manual.trim()){ vscode.window.showWarningMessage('No base folder provided.'); return; } +baseUri = vscode.Uri.file(manual.trim()); +} +const cfg = vscode.workspace.getConfiguration(); +const name = await vscode.window.showInputBox({ prompt: 'Folder name for the catalog', value: 'copilot_catalog' }); +if(!name) return; +const root = path.join(baseUri.fsPath, name); try { -await logger.info('Dev: Refresh Git Repositories command invoked'); -// Load previously persisted remotes (without wiping) if needed -await gitCatalogService.ensureLoaded(context.globalStorageUri.fsPath); -const remotes = gitCatalogService.getRemotes(); +await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'chatmodes'))); +await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'instructions'))); +await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'prompts'))); +await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'tasks'))); +await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'mcp'))); +await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'hats'))); +// Seed sample files (best-effort) – catalog setup only +await fs.writeFile( +path.join(root, 'chatmodes', 'catalog-manager-agent.chatmode.md'), +[ +'# Catalog Setup Helper (Chatmode)', +'', +'This chatmode must ONLY help the user set up and manage a ContextShare catalog using this extension. It must NOT answer or perform any unrelated tasks.', +'', +'Rules:', +'- Scope strictly to catalog setup: scaffolding folders, configuring settings, activating/deactivating resources, understanding Hats, and packaging/installation steps.', +'- If asked anything outside catalog setup, politely refuse and redirect: "I can only help with ContextShare catalog setup and management. Please ask a catalog-related question."', +'- Remind the user of their duties: they own repo structure, security reviews, versioning, and Marketplace publishing credentials.', +'- Never run shell commands unless explicitly asked; provide minimal, copyable commands and explain effects.', +'- Be concise, concrete, and avoid speculative answers.', +'', +'Quick references:', +'- Settings: "ContextShare" → rootCatalogPath, targetWorkspace, catalogDirectory, runtimeDirectory', +'- Hats: presets to activate/deactivate groups of resources', +].join('\n') +); +await fs.writeFile( +path.join(root, 'instructions', 'catalog-setup-guardrails.instructions.md'), +[ +'# Instruction: Catalog-Setup-Only Guardrails', +'', +'When this instruction is active:', +'- Answer only questions about setting up and managing the ContextShare catalog.', +'- If the user asks about coding, debugging, or anything unrelated, respond with a brief refusal and suggest a catalog-related next step.', +'- Always remind the user they are responsible for repository structure, security policies, and versioning decisions.', +'- Prefer short actionable guidance referencing VS Code UI locations (e.g., view title bar → Dev menu).', +].join('\n') +); +await fs.writeFile( +path.join(root, 'prompts', 'init-catalog.prompt.md'), +[ +'# Prompt: Initialize a ContextShare Catalog in this Workspace', +'', +'Goal: Help me set up a ContextShare catalog that I can share with my team, and remind me of my responsibilities.', +'', +'Constraints:', +'- Do NOT answer non-catalog questions; refuse and redirect to catalog setup.', +'- Keep responses concise; show only the minimal steps.', +'', +'What I need now:', +'1) How to create the template catalog and where to put it', +'2) How to configure rootCatalogPath or per-category sources and targetWorkspace', +'3) How to activate/deactivate a resource and apply a Hat', +'4) What I must own (security reviews, versioning, publishing)', +].join('\n') +); +await fs.writeFile( +path.join(root, 'tasks', 'catalog-setup-walkthrough.task.json'), +JSON.stringify({ +name: 'Task: Catalog Setup Walkthrough', +description: 'Step-by-step guide to initialize and use your catalog (setup-only).', +steps: [ +'Open the ContextShare view and use the Dev menu to Create Template Catalog.', +'Place the catalog in your desired folder and name it (default: copilot_catalog).', +'Configure catalog directories using Dev menu → Configure Settings, then add catalog directories.', +'Use Activate on a resource to copy it to your runtime (e.g., .github).', +'Create/apply a Hat to quickly activate a set of resources.', +'Remember: you own security reviews, repo layout, version bumps, and publishing.' +] +}, null, 2) +); +await fs.writeFile(path.join(root, 'mcp', 'catalog-servers.mcp.json'), '{"servers": {}}'); +await fs.writeFile(path.join(root, 'hats', 'Copilot-Catalog-Setup.json'), JSON.stringify({ name: 'ContextShare Setup', description: 'Only the example assets generated by the template', resources: [ +'chatmodes/catalog-manager-agent.chatmode.md', +'instructions/catalog-setup-guardrails.instructions.md', +'prompts/init-catalog.prompt.md', +'tasks/catalog-setup-walkthrough.task.json' +]}, null, 2)); +vscode.window.showInformationMessage(`Template catalog created at ${root}`); +// If created outside current workspace, auto-point rootCatalogPath to it so it shows up immediately +const inWorkspace = (vscode.workspace.workspaceFolders||[]).some((f: vscode.WorkspaceFolder) => { +const ws = f.uri.fsPath.replace(/\\/g,'/'); +const dir = root.replace(/\\/g,'/'); +return dir.startsWith(ws + '/') || dir === ws; +}); +if(!inWorkspace){ +try { +const cfg = vscode.workspace.getConfiguration(undefined, baseUri); +const currentCatalogDirectories = cfg.get>('copilotCatalog.catalogDirectory', {}); +const newCatalogDirectories = {...currentCatalogDirectories, [root]: ''}; +await cfg.update('copilotCatalog.catalogDirectory', newCatalogDirectories, vscode.ConfigurationTarget.WorkspaceFolder); +vscode.window.showInformationMessage('Configured ContextShare to use the new template directory.'); +} catch (error) { +await logger.warn(`Failed to update catalog directory configuration: ${getErrorMessage(error)}`); +} +} +await refresh(); +} catch(e:any){ vscode.window.showErrorMessage('Failed to create template catalog: ' + getErrorMessage(e)); } +}), +vscode.commands.registerCommand('copilotCatalog.dev.scanGitRepository', async () => { + // Enhanced: branch enumeration + commit lock option + const url = await vscode.window.showInputBox({ + prompt: 'Enter Git repository URL (e.g., https://github.com/org/repo.git)', + placeHolder: 'https://github.com/org/repo.git' + }); + if (!url) return; + + let branches: Array<{ label: string; description?: string; detail?: string; _name: string; _commit: string }> = []; + try { + await gitCatalogService.init([], context.globalStorageUri.fsPath); + const list = await gitCatalogService.listRemoteBranches(url); + branches = list.map(b => ({ + label: (b.name === 'main' || b.name === 'master') ? `$(git-branch) ${b.name}` : b.name, + description: b.commit.substring(0,8), + detail: 'Remote branch', + _name: b.name, + _commit: b.commit + })); + } catch (e:any) { + await logger.warn(`Branch enumeration failed (fallback to manual): ${getErrorMessage(e)}`); + } + + let chosenBranch: string | undefined; + let chosenHeadCommit: string | undefined; + + if (branches.length) { + const pick = await vscode.window.showQuickPick( + [ + ...branches, + { label: '$(edit) Enter branch manually…', description: 'Manual branch name', _name: '__manual__', _commit: '' } + ], + { placeHolder: 'Select branch (or choose manual entry)' } + ); + if (!pick) return; + if (pick._name === '__manual__') { + const manual = await vscode.window.showInputBox({ prompt: 'Branch name', value: 'main' }); + if (!manual) return; + chosenBranch = manual.trim(); + } else { + chosenBranch = pick._name; + chosenHeadCommit = pick._commit; + } + } else { + const manual = await vscode.window.showInputBox({ prompt: 'Branch name (unable to enumerate, defaults to main)', value: 'main' }); + if (!manual) return; + chosenBranch = manual.trim(); + } + + // Decide tracking vs locking + let lockedCommit: string | undefined; + if (chosenHeadCommit) { + const mode = await vscode.window.showQuickPick([ + { label: `Track branch "${chosenBranch}" (auto updates)`, detail: 'Always latest on refresh', _mode: 'track' }, + { label: `Lock to current commit ${chosenHeadCommit.substring(0,8)}`, detail: 'Pinned until changed manually', _mode: 'lock-head' }, + { label: 'Lock to another commit…', detail: 'Enter a specific commit SHA', _mode: 'lock-other' } + ], { placeHolder: 'Select tracking mode' }); + if (!mode) return; + if (mode._mode === 'lock-head') lockedCommit = chosenHeadCommit; + else if (mode._mode === 'lock-other') { + const sha = await vscode.window.showInputBox({ + prompt: 'Commit SHA to lock to', + validateInput: v => /^[0-9a-f]{7,40}$/i.test(v.trim()) ? undefined : 'Enter a valid SHA (7-40 hex)' + }); + if (!sha) return; + lockedCommit = sha.trim(); + } + } else { + const mode = await vscode.window.showQuickPick([ + { label: `Track branch "${chosenBranch}"`, _mode: 'track' }, + { label: 'Lock to specific commit…', _mode: 'lock-other' } + ], { placeHolder: 'Select tracking mode' }); + if (!mode) return; + if (mode._mode === 'lock-other') { + const sha = await vscode.window.showInputBox({ + prompt: 'Commit SHA to lock to', + validateInput: v => /^[0-9a-f]{7,40}$/i.test(v.trim()) ? undefined : 'Invalid SHA' + }); + if (!sha) return; + lockedCommit = sha.trim(); + } + } + + try { + await logger.info(`Adding Git repository: ${url} branch=${chosenBranch} lock=${lockedCommit ? lockedCommit.substring(0,8) : '(none)'}`); + vscode.window.showInformationMessage('Scanning repository for catalogs...'); + + // Ensure state (re-init best effort) + await gitCatalogService.init([], context.globalStorageUri.fsPath); + + // Add remote (with optional lock) + await gitCatalogService.addRemoteInteractively(url, chosenBranch || 'main', lockedCommit); + + // Discover catalogs + const catalogMap = gitCatalogService.getCatalogDirectoryMap(); + const catalogs = Object.entries(catalogMap); + + if (catalogs.length === 0) { + vscode.window.showInformationMessage('No catalogs found in repository.'); + return; + } + + const picks = catalogs.map(([catalogPath, displayName]) => ({ + label: path.basename(catalogPath), + description: displayName, + detail: catalogPath, + path: catalogPath, + displayName + })); + + const selected = await vscode.window.showQuickPick(picks, { + canPickMany: true, + placeHolder: 'Select catalogs to add' + }); + + if (!selected || !selected.length) return; + + const cfg = vscode.workspace.getConfiguration(); + const current = cfg.get>('copilotCatalog.catalogDirectory', {}); + const newEntries = { ...current }; + for (const item of selected) newEntries[item.path] = item.displayName; + + await cfg.update('copilotCatalog.catalogDirectory', newEntries, vscode.ConfigurationTarget.Workspace); + vscode.window.showInformationMessage(`Added ${selected.length} catalog(s) from Git repository.`); + await refresh(); + } catch (error:any) { + await logger.error(`Failed to scan Git repository: ${getErrorMessage(error)}`); + vscode.window.showErrorMessage(`Failed to scan repository: ${getErrorMessage(error)}`); + } +}), + // Refresh all previously added Git repositories (interactive updates) + vscode.commands.registerCommand('copilotCatalog.dev.refreshGitRepositories', async () => { + try { + await logger.info('Dev: Refresh Git Repositories command invoked'); + await gitCatalogService.ensureLoaded(context.globalStorageUri.fsPath); + + // Prune unused catalogs before refreshing + const config = vscode.workspace.getConfiguration(); + const catalogDirectories = config.get>('copilotCatalog.catalogDirectory', {}); + const activeCatalogPaths = Object.keys(catalogDirectories); + await gitCatalogService.pruneUnusedCatalogs(activeCatalogPaths); + + const remotes = gitCatalogService.getRemotes(); + + if (remotes.length === 0) { + vscode.window.showInformationMessage('No persisted Git repositories found. Use "Scan Git Repository…" first.'); + return; + } + + let updatedCount = 0; + let skippedCount = 0; + let lockedCount = 0; + + for (const remote of remotes) { + // Locked: ensure present/scan once, never auto update + if (remote.lockedCommit) { + lockedCount++; + if (!remote.lastCommit) { + // First-time scan for locked repo + try { await gitCatalogService.updateTrackedRemote(remote); } catch {} + } + continue; + } -if(remotes.length === 0){ -vscode.window.showInformationMessage('No persisted Git repositories found. Use "Scan Git Repository…" first.'); + // First time (no lastCommit) -> update silently + if (!remote.lastCommit) { + const ok = await gitCatalogService.updateTrackedRemote(remote); + if (ok) updatedCount++; + continue; + } + + // Detect update + const info = await gitCatalogService.detectBranchUpdate(remote); + if (info.hasUpdate && info.remoteHead) { + const choice = await vscode.window.showQuickPick([ + { label: `Update ${remote.url}@${remote.branch} → ${info.remoteHead.substring(0,8)}`, _act: 'update' }, + { label: `Skip (stay at ${info.current?.substring(0,8)})`, _act: 'skip' }, + { label: `Lock to ${info.remoteHead.substring(0,8)}`, _act: 'lock' } + ], { placeHolder: `Update available for ${remote.url}@${remote.branch}` }); + if (!choice) { skippedCount++; continue; } + if (choice._act === 'update') { + const ok = await gitCatalogService.updateTrackedRemote(remote); + if (ok) updatedCount++; else skippedCount++; + } else if (choice._act === 'skip') { + skippedCount++; + } else if (choice._act === 'lock') { + await gitCatalogService.addRemoteInteractively(remote.url, remote.branch, info.remoteHead); + updatedCount++; + } + } + } + + // Merge catalogs + const catalogMap = gitCatalogService.getCatalogDirectoryMap(); + const cfg = vscode.workspace.getConfiguration(); + const current = cfg.get>('copilotCatalog.catalogDirectory', {}); + let changed = false; + for (const [p, name] of Object.entries(catalogMap)) { + if (!current.hasOwnProperty(p)) { + current[p] = name; + changed = true; + } + } + if (changed) { + await cfg.update('copilotCatalog.catalogDirectory', current, vscode.ConfigurationTarget.Workspace); + await logger.info(`Added ${Object.keys(catalogMap).length} catalog path(s) (merged).`); + } + + await refresh(); + + const updated = gitCatalogService.getRemotes(); + const withCommits = updated.filter(r=> !!r.lastCommit).length; + vscode.window.showInformationMessage(`Git refresh done. Updated:${updatedCount} Skipped:${skippedCount} Locked:${lockedCount}`); + await logger.info(`Git refresh complete: updated=${updatedCount} skipped=${skippedCount} locked=${lockedCount} withCommits=${withCommits}/${updated.length}`); + } catch(e:any) { + await logger.error(`Git repositories refresh failed: ${getErrorMessage(e)}`); + vscode.window.showErrorMessage('Failed to refresh Git repositories: ' + getErrorMessage(e)); + } + }), +vscode.commands.registerCommand('copilotCatalog.dev.listGitRemotes', async () => { + try { + await gitCatalogService.ensureLoaded(context.globalStorageUri.fsPath); + const remotes = gitCatalogService.getRemotes(); + if (remotes.length === 0) { + vscode.window.showInformationMessage('No Git remotes configured.'); + return; + } + + const items: vscode.QuickPickItem[] = []; + for (const r of remotes) { + let detail: string; + if (r.lockedCommit) { + detail = `locked ${r.lockedCommit.substring(0,8)}`; + } else if (r.lastCommit) { + let updateDetail = r.lastCommit.substring(0,8); + try { + const info = await gitCatalogService.detectBranchUpdate(r as any); + if (info.hasUpdate && info.remoteHead) { + updateDetail = `${r.lastCommit.substring(0,8)} → ${info.remoteHead.substring(0,8)} (update available)`; + } + } catch { + // ignore detection failures + } + detail = updateDetail; + } else { + detail = '(not cloned yet)'; + } + + items.push({ + label: `${r.url} @ ${r.branch}`, + description: r.lockedCommit ? 'Locked' : 'Tracking', + detail + }); + } + + // Display list (no selection action yet; purely informational) + await vscode.window.showQuickPick(items, { placeHolder: 'Git remotes (status overview)' }); + } catch (e:any) { + await logger.error('List Git Remotes failed: ' + getErrorMessage(e)); + vscode.window.showErrorMessage('Failed to list Git remotes: ' + getErrorMessage(e)); + } +}), +vscode.commands.registerCommand('copilotCatalog.dev.configureSettings', async () => { +let pick: { label: string; action: 'openSettings'|'addDirectory'|'setTarget' } | undefined; +try { +pick = await vscode.window.showQuickPick([ +{ label: 'Open Settings UI (ContextShare)', action: 'openSettings' }, +{ label: 'Add Catalog Directory…', action: 'addDirectory' }, +{ label: 'Set Target Workspace…', action: 'setTarget' } +], { placeHolder: 'Configure catalog directories and settings' }); +} catch(e:any){ +await logger.warn('showQuickPick failed (configureSettings), falling back to manual selection: ' + getErrorMessage(e)); +const choice = await vscode.window.showInputBox({ prompt: 'Type one: openSettings | addDirectory | setTarget' }); +if(!choice) return; +const v = choice.trim().toLowerCase(); +if(v==='opensettings') pick = { label:'Open Settings UI (ContextShare)', action:'openSettings' }; +else if(v==='adddirectory') pick = { label:'Add Catalog Directory…', action:'addDirectory' } as any; +else if(v==='settarget') pick = { label:'Set Target Workspace…', action:'setTarget' } as any; +else return; +} +if(!pick) return; +const cfg = vscode.workspace.getConfiguration(); +if(pick.action === 'openSettings'){ +try { +await vscode.commands.executeCommand('workbench.action.openSettings', 'copilotCatalog'); +} catch(e:any){ +await logger.warn('openSettings UI failed, falling back to settings.json: ' + getErrorMessage(e)); +const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +if(ws){ +const settingsPath = path.join(ws, '.vscode', 'settings.json'); +try { +await fs.mkdir(path.dirname(settingsPath), { recursive: true }); +if(!(await fileService.pathExists(settingsPath))){ await fs.writeFile(settingsPath, '{\n}\n'); } +await vscode.window.showTextDocument(vscode.Uri.file(settingsPath)); +} catch(err:any){ await logger.warn('Failed to open settings.json: ' + (err?.message||err)); } +} +} return; } +if(pick.action === 'addDirectory'){ +// prefer folder picker; user can cancel and choose manual +let chosen: vscode.Uri[] | undefined; +try { +chosen = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: 'Select catalog directory' }); +} catch(e:any){ await logger.warn('showOpenDialog failed (addDirectory), falling back to manual: ' + getErrorMessage(e)); } +let val: string | undefined; +if(chosen && chosen.length){ val = chosen[0].fsPath; } +else { +const manual = await vscode.window.showInputBox({ prompt: 'Path to catalog directory (absolute or relative to workspace)' }); +if(manual === undefined) return; // cancelled +val = manual.trim(); +} +if (!val) return; + +const currentCatalogDirectories = cfg.get>('copilotCatalog.catalogDirectory', {}); +if (!currentCatalogDirectories.hasOwnProperty(val)) { +const newCatalogDirectories = {...currentCatalogDirectories, [val]: ''}; +// Determine if the directory belongs to an existing workspace folder (case-insensitive on Windows) +const targetWorkspaceFolder = vscode.workspace.workspaceFolders?.find((f: vscode.WorkspaceFolder) => { +try { +const a = f.uri.fsPath.replace(/\\/g,'/'); +const b = val!.replace(/\\/g,'/'); +if(process.platform === 'win32') return b.toLowerCase().startsWith(a.toLowerCase()); +return b.startsWith(a); +} catch { return false; } +}); +const singleRoot = (vscode.workspace.workspaceFolders?.length || 0) === 1; +// Force direct write to settings.json to bypass API error ("no resource provided") +try { +const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +if(!wsRoot){ vscode.window.showErrorMessage('No workspace folder open.'); return; } +const settingsPath = path.join(wsRoot, '.vscode', 'settings.json'); +await fs.mkdir(path.dirname(settingsPath), { recursive: true }); +let json: any = {}; +try { json = JSON.parse(await fs.readFile(settingsPath,'utf8')); } catch { /* create new */ } +json['copilotCatalog.catalogDirectory'] = newCatalogDirectories; +await fs.writeFile(settingsPath, JSON.stringify(json,null,2)); +await logger.info(`addDirectory(dev): wrote settings.json directly (${Object.keys(newCatalogDirectories).length} entries)`); +vscode.window.showInformationMessage(`Added catalog directory: ${val}`); +} catch(e:any){ +await logger.warn('addDirectory(dev): direct settings.json write failed: ' + getErrorMessage(e)); +vscode.window.showErrorMessage('Failed to write settings.json for catalog directory. See log.'); +} +} else { +vscode.window.showInformationMessage(`Directory already configured: ${val}`); +} +} +if(pick.action === 'setTarget'){ +let chosen: vscode.Uri[] | undefined; +try { +chosen = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: 'Select target workspace folder' }); +} catch(e:any){ await logger.warn('showOpenDialog failed (setTarget), falling back to manual: ' + getErrorMessage(e)); } +let val: string | undefined; +if(chosen && chosen.length){ val = chosen[0].fsPath; } +else { +const manual = await vscode.window.showInputBox({ prompt: 'Absolute path to target workspace (where active copies go). Leave empty to clear.' }); +if(manual === undefined) return; // cancelled +val = manual.trim(); +} +// If the provided path exactly matches a workspace folder, scope the setting to that folder +const targetWorkspaceFolder = val ? vscode.workspace.workspaceFolders?.find((f: vscode.WorkspaceFolder) => { +if(process.platform === 'win32') return f.uri.fsPath.toLowerCase() === val!.toLowerCase(); +return f.uri.fsPath === val; +}) : undefined; +const singleRoot = (vscode.workspace.workspaceFolders?.length || 0) === 1; +let updated = false; +try { +await logger.info(`setTarget(dev): attempting update path=${val||'(clear)'} singleRoot=${singleRoot} folderMatch=${!!targetWorkspaceFolder}`); +if(targetWorkspaceFolder && !singleRoot){ +await vscode.workspace.getConfiguration(undefined, targetWorkspaceFolder.uri) +.update('copilotCatalog.targetWorkspace', val, vscode.ConfigurationTarget.WorkspaceFolder); +updated = true; +} else { +await cfg.update('copilotCatalog.targetWorkspace', val, vscode.ConfigurationTarget.Workspace); +updated = true; +} +} catch(e:any){ +await logger.warn('setTarget(dev): primary update failed: ' + getErrorMessage(e)); +if(!updated){ +try { +await cfg.update('copilotCatalog.targetWorkspace', val, vscode.ConfigurationTarget.Workspace); +updated = true; +await logger.info('setTarget(dev): fallback workspace update succeeded'); +} catch(e2:any){ await logger.warn('setTarget(dev): fallback workspace update failed: ' + getErrorMessage(e2)); } +} +} +if(!updated){ +vscode.window.showErrorMessage('Failed to update target workspace setting.'); +} +} +await refresh(); +}) +, +// --- Hats (Presets) --- +vscode.commands.registerCommand('copilotCatalog.hats.apply', async () => { +if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } +const hats = await hatService.discoverHats(currentRepo); +if(hats.length===0){ vscode.window.showInformationMessage('No hats found (check catalog hats/, workspace .vscode/copilot-hats.json, or user hats).'); return; } +const pick = await vscode.window.showQuickPick(hats.map(h=> ({ label: h.name, description: h.description || h.source, detail: `${h.resources.length} items`, hat: h })), { placeHolder: 'Select a Hat to apply' }); +if(!pick) return; +// Ask whether to enforce exclusivity (deactivate non-hat resources) +const mode = await vscode.window.showQuickPick([ +{ label: 'Apply (keep others active)', value: 'nonExclusive' }, +{ label: 'Apply Exclusively (deactivate others)', value: 'exclusive' } +], { placeHolder: 'How should the Hat be applied?' }); +if(!mode) return; +const exclusive = mode.value === 'exclusive'; +const currentResources = catalogFilter ? +allResources.filter(r => r.catalogName === catalogFilter) : +allResources; +const res = await hatService.applyHat(currentRepo, currentResources, pick.hat, { exclusive }); +await logger.info(`Applied hat ${pick.hat.name}: activated=${res.activated} deactivated=${res.deactivated} missing=${res.missing.length} errors=${res.errors.length}`); +if(res.errors.length){ vscode.window.showWarningMessage(`Hat applied with errors. Activated ${res.activated}, Deactivated ${res.deactivated}. Missing: ${res.missing.length}.`); } else { vscode.window.showInformationMessage(`Hat applied. Activated ${res.activated}, Deactivated ${res.deactivated}. Missing: ${res.missing.length}.`); } +await loadResources(); updateStatus(); +}), +vscode.commands.registerCommand('copilotCatalog.hats.createWorkspace', async () => { +if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } +const name = await vscode.window.showInputBox({ prompt: 'Name for the workspace Hat', placeHolder: 'My Hat' }); +if(!name) return; +const desc = await vscode.window.showInputBox({ prompt: 'Optional description' }); +const currentResources = catalogFilter ? +allResources.filter(r => r.catalogName === catalogFilter) : +allResources; +const hat = await hatService.createHatFromActive(name, desc, currentResources, 'workspace', currentRepo); +await logger.info(`Created workspace hat ${hat.name} with ${hat.resources.length} resources`); +vscode.window.showInformationMessage(`Saved Hat "${hat.name}" to workspace (.vscode/copilot-hats.json).`); +}), +vscode.commands.registerCommand('copilotCatalog.hats.createUser', async () => { +const name = await vscode.window.showInputBox({ prompt: 'Name for the user Hat', placeHolder: 'My Hat' }); +if(!name) return; +const desc = await vscode.window.showInputBox({ prompt: 'Optional description' }); +const currentResources = catalogFilter ? +allResources.filter(r => r.catalogName === catalogFilter) : +allResources; +const hat = await hatService.createHatFromActive(name, desc, currentResources, 'user'); +await logger.info(`Created user hat ${hat.name} with ${hat.resources.length} resources`); +vscode.window.showInformationMessage(`Saved Hat "${hat.name}" to user settings (global storage).`); +}), +vscode.commands.registerCommand('copilotCatalog.hats.delete', async () => { +if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } +const hats = await hatService.discoverHats(currentRepo); +const deletable = hats.filter(h=> h.source === 'workspace' || h.source === 'user'); +if(deletable.length === 0){ vscode.window.showInformationMessage('No workspace/user hats to delete.'); return; } +const pick = await vscode.window.showQuickPick(deletable.map(h=> ({ label: h.name, description: h.description || h.source, detail: `${h.source} hat`, hat: h })), { placeHolder: 'Select a Hat to delete' }); +if(!pick) return; +const confirm = await vscode.window.showWarningMessage(`Delete Hat "${pick.hat.name}" from ${pick.hat.source}?`, { modal: true }, 'Delete'); +if(confirm !== 'Delete') return; +const ok = await hatService.deleteHat(pick.hat, currentRepo); +if(ok) vscode.window.showInformationMessage(`Deleted Hat "${pick.hat.name}" (${pick.hat.source}).`); +else vscode.window.showWarningMessage('Hat not found or could not be deleted.'); +}) +, +// User resource enable/disable +vscode.commands.registerCommand('copilotCatalog.user.disable', async (item: any) => { +const res = pickResourceFromItem(item); +if(!res || (res as any).origin !== 'user'){ vscode.window.showWarningMessage('Select a user-created resource.'); return; } +try { await resourceService.disableUserResource(res as any); refreshAllTrees(); } catch(e:any){ vscode.window.showErrorMessage('Disable failed: ' + getErrorMessage(e)); } +}), +vscode.commands.registerCommand('copilotCatalog.user.enable', async (item: any) => { +const res = pickResourceFromItem(item); +if(!res || (res as any).origin !== 'user'){ vscode.window.showWarningMessage('Select a user-created resource.'); return; } +try { await resourceService.enableUserResource(res as any); refreshAllTrees(); } catch(e:any){ vscode.window.showErrorMessage('Enable failed: ' + getErrorMessage(e)); } +}), +// Catalog filter command +vscode.commands.registerCommand('copilotCatalog.filterCatalog', async () => { +// Get available catalog names +const catalogNames = new Set(allResources.map(r => r.catalogName).filter(Boolean)); +const catalogOptions = [ +{ label: '$(clear-all) Show All Catalogs', value: undefined }, +...Array.from(catalogNames).map(name => ({ label: `$(book) ${name}`, value: name })) +]; -vscode.window.showInformationMessage(`Refreshing ${remotes.length} Git repos...`); -await logger.info(`Refreshing ${remotes.length} git remotes`); +if (catalogOptions.length === 1) { +vscode.window.showInformationMessage('No additional catalogs found to filter by.'); +return; +} -// Perform fetch/reset + catalog rescan -await gitCatalogService.refreshAll(); +const selection = await vscode.window.showQuickPick(catalogOptions, { +placeHolder: catalogFilter ? `Current: ${catalogFilter} (click to change)` : 'Select catalog to show' +}); -// Collect catalog directory map after refresh -const catalogMap = gitCatalogService.getCatalogDirectoryMap(); +if (selection !== undefined) { +applyCatalogFilter(selection.value); +await loadResources(); // Refresh with new filter +updateStatus(); -// Merge newly discovered catalog paths into configuration +if (selection.value) { +vscode.window.showInformationMessage(`Showing resources from: ${selection.value}`); +} else { +vscode.window.showInformationMessage('Showing all catalogs'); +} +} +}) +, +vscode.commands.registerCommand('copilotCatalog.openSettings', async () => { +try { +await vscode.commands.executeCommand('workbench.action.openSettings', 'copilotCatalog'); +} catch(e:any){ +await logger.warn('openSettings command failed, falling back to settings.json: ' + getErrorMessage(e)); +const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +if(ws){ +const settingsPath = path.join(ws, '.vscode', 'settings.json'); +try { +await fs.mkdir(path.dirname(settingsPath), { recursive: true }); +if(!(await fileService.pathExists(settingsPath))){ await fs.writeFile(settingsPath, '{\n}\n'); } +await vscode.window.showTextDocument(vscode.Uri.file(settingsPath)); +} catch(err:any){ await logger.warn('Failed to open settings.json: ' + (err?.message||err)); } +} +} +}), +vscode.commands.registerCommand('copilotCatalog.addCatalogDirectory', async () => { const cfg = vscode.workspace.getConfiguration(); const current = cfg.get>('copilotCatalog.catalogDirectory', {}); -let changed = false; -for(const [p, name] of Object.entries(catalogMap)){ -if(!current.hasOwnProperty(p)){ -current[p] = name; -changed = true; +const wsFolders = vscode.workspace.workspaceFolders || []; +const suggestions: { label: string; description: string; path: string; detail?: string }[] = []; +for(const f of wsFolders){ +const base = f.uri.fsPath; +suggestions.push({ label: path.basename(base), description: base, path: base, detail: 'Workspace folder' }); +for(const candidate of ['copilot_catalog', 'example-catalog', 'catalog', 'resources', 'dev-sandbox/copilot_catalog']){ +const p = path.join(base, candidate); +try { await vscode.workspace.fs.stat(vscode.Uri.file(p)); suggestions.push({ label: path.basename(p), description: p, path: p, detail: 'Detected folder' }); } catch {} } } -if(changed){ -await cfg.update('copilotCatalog.catalogDirectory', current, vscode.ConfigurationTarget.Workspace); -await logger.info(`Added ${Object.keys(catalogMap).length} catalog path(s) from refreshed remotes (some may have already existed).`); +// Include currently configured paths (in case user wants to re-label) +for(const [p, name] of Object.entries(current)){ +suggestions.push({ label: path.basename(p), description: p, path: p, detail: name ? `Configured: ${name}` : 'Configured' }); } +// De-duplicate by absolute path +const seen = new Set(); +const unique = suggestions.filter(s => { const k = path.resolve(s.path); if(seen.has(k)) return false; seen.add(k); return true; }); -// Trigger standard UI refresh +const picks: Array = [ +{ label: '$(folder-opened) Browse…', description: 'Pick a folder from the file system', _kind: 'browse' }, +{ label: '$(edit) Enter path manually…', description: 'Type an absolute or workspace-relative path', _kind: 'manual' }, +...unique.map(s=> ({ label: `$(repo) ${s.label}`, description: s.description, detail: s.detail, _kind: 'path' as const, _path: s.path })) +]; +const chosen = await vscode.window.showQuickPick(picks, { placeHolder: 'Add a catalog directory' }); +if(!chosen) return; +let selectedPath: string | undefined; +if(chosen._kind === 'browse'){ +let picked: vscode.Uri[] | undefined; +try { picked = await vscode.window.showOpenDialog({ canSelectFiles:false, canSelectFolders:true, canSelectMany:false, openLabel:'Select catalog directory' }); } catch(e:any){ await logger.warn('showOpenDialog failed (addCatalogDirectory browse): ' + getErrorMessage(e)); } +if(picked && picked.length){ selectedPath = picked[0].fsPath; } +} else if(chosen._kind === 'manual'){ +const manual = await vscode.window.showInputBox({ prompt: 'Path to catalog directory (absolute or relative to workspace)' }); +if(manual === undefined) return; selectedPath = manual.trim(); +} else if(chosen._kind === 'path'){ +selectedPath = chosen._path; +} +if(!selectedPath) return; +// Optional display name +const displayName = await vscode.window.showInputBox({ prompt: 'Optional display name for this catalog (shown in the tree)', placeHolder: 'Leave blank to use folder name' }); +const newMap = { ...current, [selectedPath]: displayName ?? current[selectedPath] ?? '' } as Record; +// Determine owning workspace folder (case-insensitive on Windows) for proper scoping +const owningFolder = wsFolders.find(f => { +try { +const a = f.uri.fsPath.replace(/\\/g,'/'); +const b = selectedPath!.replace(/\\/g,'/'); +if(process.platform === 'win32') return b.toLowerCase().startsWith(a.toLowerCase()); +return b.startsWith(a); +} catch { return false; } +}); +try { +if(owningFolder){ +await vscode.workspace.getConfiguration(undefined, owningFolder.uri) +.update('copilotCatalog.catalogDirectory', newMap, vscode.ConfigurationTarget.WorkspaceFolder); +} else { +await cfg.update('copilotCatalog.catalogDirectory', newMap, vscode.ConfigurationTarget.Workspace); +} +vscode.window.showInformationMessage(`Added catalog directory: ${selectedPath}`); +} catch(e:any){ +await logger.warn('addCatalogDirectory: failed to write settings via API: ' + getErrorMessage(e)); +vscode.window.showErrorMessage('Failed to update settings automatically. Opening settings.json for manual edit.'); +// Fallback: open settings.json so user can manually edit +const fallbackFolder = owningFolder || wsFolders[0]; +if(fallbackFolder){ +try { +const settingsPath = path.join(fallbackFolder.uri.fsPath, '.vscode', 'settings.json'); +await fs.mkdir(path.dirname(settingsPath), { recursive: true }); +if(!(await fileService.pathExists(settingsPath))){ await fs.writeFile(settingsPath, JSON.stringify({ 'copilotCatalog.catalogDirectory': newMap }, null, 2)); } +vscode.window.showTextDocument(vscode.Uri.file(settingsPath)); +} catch(err:any){ await logger.warn('addCatalogDirectory: fallback open settings.json failed: ' + getErrorMessage(err)); } +} +} await refresh(); +}) +); -const updated = gitCatalogService.getRemotes(); -const withCommits = updated.filter(r=> !!r.lastCommit).length; -vscode.window.showInformationMessage(`Git repositories refreshed. (${withCommits}/${updated.length} updated)`); -await logger.info(`Git refresh complete: withCommits=${withCommits} total=${updated.length}`); +// Perform the initial refresh after registration so commands are available even if refresh throws in headless/tunnel +try { +await refresh(); +logger.info('Initial refresh completed inside activate()'); } catch(e:any){ -await logger.error(`Git repositories refresh failed: ${getErrorMessage(e)}`); -vscode.window.showErrorMessage('Failed to refresh Git repositories: ' + getErrorMessage(e)); +logger.warn('Initial refresh failed (continuing with commands registered): ' + getErrorMessage(e)); +} +try { updateStatus(); } catch {} + +// React to configuration changes (single consolidated handler) +context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async (e: vscode.ConfigurationChangeEvent) => { +const reloadKeys = [ +'copilotCatalog.runtimeDirectory' +]; +const refreshKeys = [ +'copilotCatalog.catalogDirectory', +'copilotCatalog.remoteCacheTtlSeconds', +'copilotCatalog.targetWorkspace' +]; + +const needsReload = reloadKeys.some(k => e.affectsConfiguration(k)); +const needsRefresh = refreshKeys.some(k => e.affectsConfiguration(k)); + +if (needsReload) { +const selection = await vscode.window.showInformationMessage( +'ContextShare settings have changed that require a reload to take effect.', +'Reload Window' +); +if (selection === 'Reload Window') { +vscode.commands.executeCommand('workbench.action.reloadWindow'); +} +} else if (needsRefresh) { +const cfg = vscode.workspace.getConfiguration(); +resourceService.setTargetWorkspaceOverride(cfg.get('copilotCatalog.targetWorkspace')); +// Update current workspace root in case it changed +const currentWorkspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +if (currentWorkspaceRoot) { +resourceService.setCurrentWorkspaceRoot(currentWorkspaceRoot); +} +resourceService.setRuntimeDirectoryName(cfg.get('copilotCatalog.runtimeDirectory', '.github')); +resourceService.setRemoteCacheTtl(cfg.get('copilotCatalog.remoteCacheTtlSeconds', 300)); +await logger.info('Configuration change: directories updated, refreshing...'); +refresh(); +} +})); + +// Watch runtime directory for edits to auto-refresh modified states quickly +if(currentRepo){ +const runtimeGlob = new vscode.RelativePattern(currentRepo.runtimePath, '**/*'); +const watcher = vscode.workspace.createFileSystemWatcher(runtimeGlob, false, false, false); +const schedule = () => setTimeout(()=> refresh(), 400); +watcher.onDidChange(schedule, null, context.subscriptions); +watcher.onDidCreate(schedule, null, context.subscriptions); +watcher.onDidDelete(schedule, null, context.subscriptions); +context.subscriptions.push(watcher); +} + +function pickResourceFromItem(item: any): Resource | undefined { +if (!item) return undefined; +const id = item.id as string | undefined; +if (!id) return undefined; +return allResources.find(r => r.id === id); +} +} catch (e: any) { +preflightError('A critical error occurred during activation.', e); } -}), -vscode.commands.registerCommand('copilotCatalog.dev.configureSettings', async () => { - let pick: { label: string; action: 'openSettings'|'addDirectory'|'setTarget' } | undefined; - try { - pick = await vscode.window.showQuickPick([ - { label: 'Open Settings UI (ContextShare)', action: 'openSettings' }, - { label: 'Add Catalog Directory…', action: 'addDirectory' }, - { label: 'Set Target Workspace…', action: 'setTarget' } - ], { placeHolder: 'Configure catalog directories and settings' }); - } catch(e:any){ - await logger.warn('showQuickPick failed (configureSettings), falling back to manual selection: ' + getErrorMessage(e)); - const choice = await vscode.window.showInputBox({ prompt: 'Type one: openSettings | addDirectory | setTarget' }); - if(!choice) return; - const v = choice.trim().toLowerCase(); - if(v==='opensettings') pick = { label:'Open Settings UI (ContextShare)', action:'openSettings' }; - else if(v==='adddirectory') pick = { label:'Add Catalog Directory…', action:'addDirectory' } as any; - else if(v==='settarget') pick = { label:'Set Target Workspace…', action:'setTarget' } as any; - else return; - } - if(!pick) return; - const cfg = vscode.workspace.getConfiguration(); - if(pick.action === 'openSettings'){ - try { - await vscode.commands.executeCommand('workbench.action.openSettings', 'copilotCatalog'); - } catch(e:any){ - await logger.warn('openSettings UI failed, falling back to settings.json: ' + getErrorMessage(e)); - const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if(ws){ - const settingsPath = path.join(ws, '.vscode', 'settings.json'); - try { - await fs.mkdir(path.dirname(settingsPath), { recursive: true }); - if(!(await fileService.pathExists(settingsPath))){ await fs.writeFile(settingsPath, '{\n}\n'); } - await vscode.window.showTextDocument(vscode.Uri.file(settingsPath)); - } catch(err:any){ await logger.warn('Failed to open settings.json: ' + (err?.message||err)); } - } - } - return; - } - if(pick.action === 'addDirectory'){ - // prefer folder picker; user can cancel and choose manual - let chosen: vscode.Uri[] | undefined; - try { - chosen = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: 'Select catalog directory' }); - } catch(e:any){ await logger.warn('showOpenDialog failed (addDirectory), falling back to manual: ' + getErrorMessage(e)); } - let val: string | undefined; - if(chosen && chosen.length){ val = chosen[0].fsPath; } - else { - const manual = await vscode.window.showInputBox({ prompt: 'Path to catalog directory (absolute or relative to workspace)' }); - if(manual === undefined) return; // cancelled - val = manual.trim(); - } - if (!val) return; - - const currentCatalogDirectories = cfg.get>('copilotCatalog.catalogDirectory', {}); - if (!currentCatalogDirectories.hasOwnProperty(val)) { - const newCatalogDirectories = {...currentCatalogDirectories, [val]: ''}; - // Determine if the directory belongs to an existing workspace folder (case-insensitive on Windows) - const targetWorkspaceFolder = vscode.workspace.workspaceFolders?.find((f: vscode.WorkspaceFolder) => { - try { - const a = f.uri.fsPath.replace(/\\/g,'/'); - const b = val!.replace(/\\/g,'/'); - if(process.platform === 'win32') return b.toLowerCase().startsWith(a.toLowerCase()); - return b.startsWith(a); - } catch { return false; } - }); - const singleRoot = (vscode.workspace.workspaceFolders?.length || 0) === 1; - // Force direct write to settings.json to bypass API error ("no resource provided") - try { - const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if(!wsRoot){ vscode.window.showErrorMessage('No workspace folder open.'); return; } - const settingsPath = path.join(wsRoot, '.vscode', 'settings.json'); - await fs.mkdir(path.dirname(settingsPath), { recursive: true }); - let json: any = {}; - try { json = JSON.parse(await fs.readFile(settingsPath,'utf8')); } catch { /* create new */ } - json['copilotCatalog.catalogDirectory'] = newCatalogDirectories; - await fs.writeFile(settingsPath, JSON.stringify(json,null,2)); - await logger.info(`addDirectory(dev): wrote settings.json directly (${Object.keys(newCatalogDirectories).length} entries)`); - vscode.window.showInformationMessage(`Added catalog directory: ${val}`); - } catch(e:any){ - await logger.warn('addDirectory(dev): direct settings.json write failed: ' + getErrorMessage(e)); - vscode.window.showErrorMessage('Failed to write settings.json for catalog directory. See log.'); - } - } else { - vscode.window.showInformationMessage(`Directory already configured: ${val}`); - } - } - if(pick.action === 'setTarget'){ - let chosen: vscode.Uri[] | undefined; - try { - chosen = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: 'Select target workspace folder' }); - } catch(e:any){ await logger.warn('showOpenDialog failed (setTarget), falling back to manual: ' + getErrorMessage(e)); } - let val: string | undefined; - if(chosen && chosen.length){ val = chosen[0].fsPath; } - else { - const manual = await vscode.window.showInputBox({ prompt: 'Absolute path to target workspace (where active copies go). Leave empty to clear.' }); - if(manual === undefined) return; // cancelled - val = manual.trim(); - } - // If the provided path exactly matches a workspace folder, scope the setting to that folder - const targetWorkspaceFolder = val ? vscode.workspace.workspaceFolders?.find((f: vscode.WorkspaceFolder) => { - if(process.platform === 'win32') return f.uri.fsPath.toLowerCase() === val!.toLowerCase(); - return f.uri.fsPath === val; - }) : undefined; - const singleRoot = (vscode.workspace.workspaceFolders?.length || 0) === 1; - let updated = false; - try { - await logger.info(`setTarget(dev): attempting update path=${val||'(clear)'} singleRoot=${singleRoot} folderMatch=${!!targetWorkspaceFolder}`); - if(targetWorkspaceFolder && !singleRoot){ - await vscode.workspace.getConfiguration(undefined, targetWorkspaceFolder.uri) - .update('copilotCatalog.targetWorkspace', val, vscode.ConfigurationTarget.WorkspaceFolder); - updated = true; - } else { - await cfg.update('copilotCatalog.targetWorkspace', val, vscode.ConfigurationTarget.Workspace); - updated = true; - } - } catch(e:any){ - await logger.warn('setTarget(dev): primary update failed: ' + getErrorMessage(e)); - if(!updated){ - try { - await cfg.update('copilotCatalog.targetWorkspace', val, vscode.ConfigurationTarget.Workspace); - updated = true; - await logger.info('setTarget(dev): fallback workspace update succeeded'); - } catch(e2:any){ await logger.warn('setTarget(dev): fallback workspace update failed: ' + getErrorMessage(e2)); } - } - } - if(!updated){ - vscode.window.showErrorMessage('Failed to update target workspace setting.'); - } - } - await refresh(); - }) - , - // --- Hats (Presets) --- - vscode.commands.registerCommand('copilotCatalog.hats.apply', async () => { - if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } - const hats = await hatService.discoverHats(currentRepo); - if(hats.length===0){ vscode.window.showInformationMessage('No hats found (check catalog hats/, workspace .vscode/copilot-hats.json, or user hats).'); return; } - const pick = await vscode.window.showQuickPick(hats.map(h=> ({ label: h.name, description: h.description || h.source, detail: `${h.resources.length} items`, hat: h })), { placeHolder: 'Select a Hat to apply' }); - if(!pick) return; - // Ask whether to enforce exclusivity (deactivate non-hat resources) - const mode = await vscode.window.showQuickPick([ - { label: 'Apply (keep others active)', value: 'nonExclusive' }, - { label: 'Apply Exclusively (deactivate others)', value: 'exclusive' } - ], { placeHolder: 'How should the Hat be applied?' }); - if(!mode) return; - const exclusive = mode.value === 'exclusive'; - const currentResources = catalogFilter ? - allResources.filter(r => r.catalogName === catalogFilter) : - allResources; - const res = await hatService.applyHat(currentRepo, currentResources, pick.hat, { exclusive }); - await logger.info(`Applied hat ${pick.hat.name}: activated=${res.activated} deactivated=${res.deactivated} missing=${res.missing.length} errors=${res.errors.length}`); - if(res.errors.length){ vscode.window.showWarningMessage(`Hat applied with errors. Activated ${res.activated}, Deactivated ${res.deactivated}. Missing: ${res.missing.length}.`); } else { vscode.window.showInformationMessage(`Hat applied. Activated ${res.activated}, Deactivated ${res.deactivated}. Missing: ${res.missing.length}.`); } - await loadResources(); updateStatus(); - }), - vscode.commands.registerCommand('copilotCatalog.hats.createWorkspace', async () => { - if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } - const name = await vscode.window.showInputBox({ prompt: 'Name for the workspace Hat', placeHolder: 'My Hat' }); - if(!name) return; - const desc = await vscode.window.showInputBox({ prompt: 'Optional description' }); - const currentResources = catalogFilter ? - allResources.filter(r => r.catalogName === catalogFilter) : - allResources; - const hat = await hatService.createHatFromActive(name, desc, currentResources, 'workspace', currentRepo); - await logger.info(`Created workspace hat ${hat.name} with ${hat.resources.length} resources`); - vscode.window.showInformationMessage(`Saved Hat "${hat.name}" to workspace (.vscode/copilot-hats.json).`); - }), - vscode.commands.registerCommand('copilotCatalog.hats.createUser', async () => { - const name = await vscode.window.showInputBox({ prompt: 'Name for the user Hat', placeHolder: 'My Hat' }); - if(!name) return; - const desc = await vscode.window.showInputBox({ prompt: 'Optional description' }); - const currentResources = catalogFilter ? - allResources.filter(r => r.catalogName === catalogFilter) : - allResources; - const hat = await hatService.createHatFromActive(name, desc, currentResources, 'user'); - await logger.info(`Created user hat ${hat.name} with ${hat.resources.length} resources`); - vscode.window.showInformationMessage(`Saved Hat "${hat.name}" to user settings (global storage).`); - }), - vscode.commands.registerCommand('copilotCatalog.hats.delete', async () => { - if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } - const hats = await hatService.discoverHats(currentRepo); - const deletable = hats.filter(h=> h.source === 'workspace' || h.source === 'user'); - if(deletable.length === 0){ vscode.window.showInformationMessage('No workspace/user hats to delete.'); return; } - const pick = await vscode.window.showQuickPick(deletable.map(h=> ({ label: h.name, description: h.description || h.source, detail: `${h.source} hat`, hat: h })), { placeHolder: 'Select a Hat to delete' }); - if(!pick) return; - const confirm = await vscode.window.showWarningMessage(`Delete Hat "${pick.hat.name}" from ${pick.hat.source}?`, { modal: true }, 'Delete'); - if(confirm !== 'Delete') return; - const ok = await hatService.deleteHat(pick.hat, currentRepo); - if(ok) vscode.window.showInformationMessage(`Deleted Hat "${pick.hat.name}" (${pick.hat.source}).`); - else vscode.window.showWarningMessage('Hat not found or could not be deleted.'); - }) - , - // User resource enable/disable - vscode.commands.registerCommand('copilotCatalog.user.disable', async (item: any) => { - const res = pickResourceFromItem(item); - if(!res || (res as any).origin !== 'user'){ vscode.window.showWarningMessage('Select a user-created resource.'); return; } - try { await resourceService.disableUserResource(res as any); refreshAllTrees(); } catch(e:any){ vscode.window.showErrorMessage('Disable failed: ' + getErrorMessage(e)); } - }), - vscode.commands.registerCommand('copilotCatalog.user.enable', async (item: any) => { - const res = pickResourceFromItem(item); - if(!res || (res as any).origin !== 'user'){ vscode.window.showWarningMessage('Select a user-created resource.'); return; } - try { await resourceService.enableUserResource(res as any); refreshAllTrees(); } catch(e:any){ vscode.window.showErrorMessage('Enable failed: ' + getErrorMessage(e)); } - }), - // Catalog filter command - vscode.commands.registerCommand('copilotCatalog.filterCatalog', async () => { - // Get available catalog names - const catalogNames = new Set(allResources.map(r => r.catalogName).filter(Boolean)); - const catalogOptions = [ - { label: '$(clear-all) Show All Catalogs', value: undefined }, - ...Array.from(catalogNames).map(name => ({ label: `$(book) ${name}`, value: name })) - ]; - - if (catalogOptions.length === 1) { - vscode.window.showInformationMessage('No additional catalogs found to filter by.'); - return; - } - - const selection = await vscode.window.showQuickPick(catalogOptions, { - placeHolder: catalogFilter ? `Current: ${catalogFilter} (click to change)` : 'Select catalog to show' - }); - - if (selection !== undefined) { - applyCatalogFilter(selection.value); - await loadResources(); // Refresh with new filter - updateStatus(); - - if (selection.value) { - vscode.window.showInformationMessage(`Showing resources from: ${selection.value}`); - } else { - vscode.window.showInformationMessage('Showing all catalogs'); - } - } - }) - , - vscode.commands.registerCommand('copilotCatalog.openSettings', async () => { - try { - await vscode.commands.executeCommand('workbench.action.openSettings', 'copilotCatalog'); - } catch(e:any){ - await logger.warn('openSettings command failed, falling back to settings.json: ' + getErrorMessage(e)); - const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if(ws){ - const settingsPath = path.join(ws, '.vscode', 'settings.json'); - try { - await fs.mkdir(path.dirname(settingsPath), { recursive: true }); - if(!(await fileService.pathExists(settingsPath))){ await fs.writeFile(settingsPath, '{\n}\n'); } - await vscode.window.showTextDocument(vscode.Uri.file(settingsPath)); - } catch(err:any){ await logger.warn('Failed to open settings.json: ' + (err?.message||err)); } - } - } - }), - vscode.commands.registerCommand('copilotCatalog.addCatalogDirectory', async () => { - const cfg = vscode.workspace.getConfiguration(); - const current = cfg.get>('copilotCatalog.catalogDirectory', {}); - const wsFolders = vscode.workspace.workspaceFolders || []; - const suggestions: { label: string; description: string; path: string; detail?: string }[] = []; - for(const f of wsFolders){ - const base = f.uri.fsPath; - suggestions.push({ label: path.basename(base), description: base, path: base, detail: 'Workspace folder' }); - for(const candidate of ['copilot_catalog', 'example-catalog', 'catalog', 'resources', 'dev-sandbox/copilot_catalog']){ - const p = path.join(base, candidate); - try { await vscode.workspace.fs.stat(vscode.Uri.file(p)); suggestions.push({ label: path.basename(p), description: p, path: p, detail: 'Detected folder' }); } catch {} - } - } - // Include currently configured paths (in case user wants to re-label) - for(const [p, name] of Object.entries(current)){ - suggestions.push({ label: path.basename(p), description: p, path: p, detail: name ? `Configured: ${name}` : 'Configured' }); - } - // De-duplicate by absolute path - const seen = new Set(); - const unique = suggestions.filter(s => { const k = path.resolve(s.path); if(seen.has(k)) return false; seen.add(k); return true; }); - - const picks: Array = [ - { label: '$(folder-opened) Browse…', description: 'Pick a folder from the file system', _kind: 'browse' }, - { label: '$(edit) Enter path manually…', description: 'Type an absolute or workspace-relative path', _kind: 'manual' }, - ...unique.map(s=> ({ label: `$(repo) ${s.label}`, description: s.description, detail: s.detail, _kind: 'path' as const, _path: s.path })) - ]; - const chosen = await vscode.window.showQuickPick(picks, { placeHolder: 'Add a catalog directory' }); - if(!chosen) return; - let selectedPath: string | undefined; - if(chosen._kind === 'browse'){ - let picked: vscode.Uri[] | undefined; - try { picked = await vscode.window.showOpenDialog({ canSelectFiles:false, canSelectFolders:true, canSelectMany:false, openLabel:'Select catalog directory' }); } catch(e:any){ await logger.warn('showOpenDialog failed (addCatalogDirectory browse): ' + getErrorMessage(e)); } - if(picked && picked.length){ selectedPath = picked[0].fsPath; } - } else if(chosen._kind === 'manual'){ - const manual = await vscode.window.showInputBox({ prompt: 'Path to catalog directory (absolute or relative to workspace)' }); - if(manual === undefined) return; selectedPath = manual.trim(); - } else if(chosen._kind === 'path'){ - selectedPath = chosen._path; - } - if(!selectedPath) return; - // Optional display name - const displayName = await vscode.window.showInputBox({ prompt: 'Optional display name for this catalog (shown in the tree)', placeHolder: 'Leave blank to use folder name' }); - const newMap = { ...current, [selectedPath]: displayName ?? current[selectedPath] ?? '' } as Record; - // Determine owning workspace folder (case-insensitive on Windows) for proper scoping - const owningFolder = wsFolders.find(f => { - try { - const a = f.uri.fsPath.replace(/\\/g,'/'); - const b = selectedPath!.replace(/\\/g,'/'); - if(process.platform === 'win32') return b.toLowerCase().startsWith(a.toLowerCase()); - return b.startsWith(a); - } catch { return false; } - }); - try { - if(owningFolder){ - await vscode.workspace.getConfiguration(undefined, owningFolder.uri) - .update('copilotCatalog.catalogDirectory', newMap, vscode.ConfigurationTarget.WorkspaceFolder); - } else { - await cfg.update('copilotCatalog.catalogDirectory', newMap, vscode.ConfigurationTarget.Workspace); - } - vscode.window.showInformationMessage(`Added catalog directory: ${selectedPath}`); - } catch(e:any){ - await logger.warn('addCatalogDirectory: failed to write settings via API: ' + getErrorMessage(e)); - vscode.window.showErrorMessage('Failed to update settings automatically. Opening settings.json for manual edit.'); - // Fallback: open settings.json so user can manually edit - const fallbackFolder = owningFolder || wsFolders[0]; - if(fallbackFolder){ - try { - const settingsPath = path.join(fallbackFolder.uri.fsPath, '.vscode', 'settings.json'); - await fs.mkdir(path.dirname(settingsPath), { recursive: true }); - if(!(await fileService.pathExists(settingsPath))){ await fs.writeFile(settingsPath, JSON.stringify({ 'copilotCatalog.catalogDirectory': newMap }, null, 2)); } - vscode.window.showTextDocument(vscode.Uri.file(settingsPath)); - } catch(err:any){ await logger.warn('addCatalogDirectory: fallback open settings.json failed: ' + getErrorMessage(err)); } - } - } - await refresh(); - }) - ); - - // Perform the initial refresh after registration so commands are available even if refresh throws in headless/tunnel - try { - await refresh(); - logger.info('Initial refresh completed inside activate()'); - } catch(e:any){ - logger.warn('Initial refresh failed (continuing with commands registered): ' + getErrorMessage(e)); - } - try { updateStatus(); } catch {} - - // React to configuration changes (single consolidated handler) - context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async (e: vscode.ConfigurationChangeEvent) => { - const reloadKeys = [ - 'copilotCatalog.runtimeDirectory' - ]; - const refreshKeys = [ - 'copilotCatalog.catalogDirectory', - 'copilotCatalog.remoteCacheTtlSeconds', - 'copilotCatalog.targetWorkspace' - ]; - - const needsReload = reloadKeys.some(k => e.affectsConfiguration(k)); - const needsRefresh = refreshKeys.some(k => e.affectsConfiguration(k)); - - if (needsReload) { - const selection = await vscode.window.showInformationMessage( - 'ContextShare settings have changed that require a reload to take effect.', - 'Reload Window' - ); - if (selection === 'Reload Window') { - vscode.commands.executeCommand('workbench.action.reloadWindow'); - } - } else if (needsRefresh) { - const cfg = vscode.workspace.getConfiguration(); - resourceService.setTargetWorkspaceOverride(cfg.get('copilotCatalog.targetWorkspace')); - // Update current workspace root in case it changed - const currentWorkspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (currentWorkspaceRoot) { - resourceService.setCurrentWorkspaceRoot(currentWorkspaceRoot); - } - resourceService.setRuntimeDirectoryName(cfg.get('copilotCatalog.runtimeDirectory', '.github')); - resourceService.setRemoteCacheTtl(cfg.get('copilotCatalog.remoteCacheTtlSeconds', 300)); - await logger.info('Configuration change: directories updated, refreshing...'); - refresh(); - } - })); - - // Watch runtime directory for edits to auto-refresh modified states quickly - if(currentRepo){ - const runtimeGlob = new vscode.RelativePattern(currentRepo.runtimePath, '**/*'); - const watcher = vscode.workspace.createFileSystemWatcher(runtimeGlob, false, false, false); - const schedule = () => setTimeout(()=> refresh(), 400); - watcher.onDidChange(schedule, null, context.subscriptions); - watcher.onDidCreate(schedule, null, context.subscriptions); - watcher.onDidDelete(schedule, null, context.subscriptions); - context.subscriptions.push(watcher); - } - - function pickResourceFromItem(item: any): Resource | undefined { - if (!item) return undefined; - const id = item.id as string | undefined; - if (!id) return undefined; - return allResources.find(r => r.id === id); - } - } catch (e: any) { - preflightError('A critical error occurred during activation.', e); - } } export function deactivate() { } diff --git a/src/models.ts b/src/models.ts index b213deb..f92187b 100644 --- a/src/models.ts +++ b/src/models.ts @@ -67,14 +67,16 @@ export interface Hat { } export interface IFileService { - readFile(p: string): Promise; - writeFile(p: string, content: string): Promise; - ensureDirectory(p: string): Promise; - pathExists(p: string): Promise; - listDirectory(p: string): Promise; - stat(p: string): Promise<'file'|'dir'|'other'|'missing'>; - copyFile(src: string, dest: string): Promise; - deleteFile?(p: string): Promise; +readFile(p: string): Promise; +writeFile(p: string, content: string): Promise; +ensureDirectory(p: string): Promise; +pathExists(p: string): Promise; +listDirectory(p: string): Promise; +stat(p: string): Promise<'file'|'dir'|'other'|'missing'>; + copyFile(src: string, dest: string): Promise; + deleteFile?(p: string): Promise; + deleteDirectory?(p: string): Promise; + rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; } export interface IResourceService { @@ -108,4 +110,3 @@ export class CatalogTreeItem { // eslint-disable-next-line @typescript-eslint/no-explicit-any command?: any; } - diff --git a/src/services/fileService.ts b/src/services/fileService.ts index 20d0dc8..dc3e36a 100644 --- a/src/services/fileService.ts +++ b/src/services/fileService.ts @@ -4,28 +4,16 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { IFileService } from '../models'; -/** - * Ensures the parent directory exists for the given file path - * @param filePath - The file path to ensure parent directory for - */ -async function ensureParentDirectory(filePath: string): Promise { - await fs.mkdir(path.dirname(filePath), { recursive: true }); -} - export class FileService implements IFileService { async readFile(p: string): Promise { - return fs.readFile(p, 'utf8'); + return fs.readFile(p, 'utf-8'); } - async writeFile(p: string, content: string): Promise { - await ensureParentDirectory(p); - await fs.writeFile(p, content, 'utf8'); + return fs.writeFile(p, content, 'utf-8'); } - async ensureDirectory(p: string): Promise { await fs.mkdir(p, { recursive: true }); } - async pathExists(p: string): Promise { try { await fs.access(p); @@ -34,36 +22,26 @@ export class FileService implements IFileService { return false; } } - async listDirectory(p: string): Promise { - try { - return await fs.readdir(p); - } catch { - return []; - } + return fs.readdir(p); } - async stat(p: string): Promise<'file' | 'dir' | 'other' | 'missing'> { try { - const s = await fs.lstat(p); - if (s.isFile()) return 'file'; - if (s.isDirectory()) return 'dir'; + const stat = await fs.stat(p); + if (stat.isFile()) return 'file'; + if (stat.isDirectory()) return 'dir'; return 'other'; } catch { return 'missing'; } } - async copyFile(src: string, dest: string): Promise { - await ensureParentDirectory(dest); - await fs.copyFile(src, dest); + return fs.copyFile(src, dest); } - async deleteFile(p: string): Promise { - try { - await fs.unlink(p); - } catch { - // Ignore errors when deleting files that don't exist - } + return fs.unlink(p); + } + async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { + return fs.rm(path, options); } } diff --git a/src/services/gitCatalogService.ts b/src/services/gitCatalogService.ts index 99afcb1..ebb2522 100644 --- a/src/services/gitCatalogService.ts +++ b/src/services/gitCatalogService.ts @@ -14,10 +14,11 @@ const execAsync = promisify(exec); export interface RemoteGitSpec { url: string; // HTTPS or SSH URL (HTTPS preferred for MVP) branch?: string; // default 'main' + lockedCommit?: string; // optional specific commit SHA to lock to (disables auto branch updates) includePaths?: string[]; // optional relative paths to constrain scan displayNamePrefix?: string; // optional UI prefix - depth?: number; // optional shallow depth default 1 - disabled?: boolean; // optional flag to disable without removing + depth?: number; // optional shallow depth default 1 + disabled?: boolean; // optional flag to disable without removing } // Metadata about a discovered catalog within a remote @@ -34,6 +35,7 @@ interface RemoteMeta { clonePath: string; lastUpdated: string; lastCommit?: string; + lockedCommit?: string; // when set, repo is pinned to this commit (no automatic fetch/reset) specsHash: string; catalogs: CatalogCandidate[]; includePaths?: string[]; @@ -45,6 +47,11 @@ interface GitCatalogMeta { remotes: RemoteMeta[]; } +interface BranchInfo { + name: string; + commit: string; +} + const CATEGORY_DIRS: Record = { chatmodes: 'chatmodes', instructions: 'instructions', @@ -193,18 +200,26 @@ export class GitCatalogService { try { const raw = await this.fileService.readFile(metaPath); const meta = JSON.parse(raw); - if (meta.version === 1 && Array.isArray(meta.remotes)) { + if ((meta.version === 1 || meta.version === 2) && Array.isArray(meta.remotes)) { + // Upgrade v1 -> v2 (introduces lockedCommit support) + if (meta.version === 1) { + meta.version = 2; + } + meta.remotes = meta.remotes.map((r: any) => ({ + ...r + // lockedCommit may already exist or be undefined + })); return meta; } } catch { // File doesn't exist or invalid } - return { version: 1, remotes: [] }; + return { version: 2, remotes: [] }; } private async saveMeta(metaPath: string): Promise { const meta: GitCatalogMeta = { - version: 1, + version: 2, remotes: this.remotes }; await this.fileService.writeFile(metaPath, JSON.stringify(meta, null, 2)); @@ -303,13 +318,61 @@ export class GitCatalogService { private async ensureRemoteCloned(remote: RemoteMeta): Promise { const exists = await this.fileService.pathExists(remote.clonePath); - + + // If locked to a specific commit, cloning/updating strategy changes: + if (remote.lockedCommit) { + if (!exists) { + // Clone the branch (or default) then checkout locked commit + this.log(`Cloning (locked) ${remote.url}@${remote.branch} -> ${remote.clonePath}`); + const depth = 1; + const cmd = `git clone --depth=${depth} --branch ${remote.branch} ${remote.url} "${remote.clonePath}"`; + try { + await this.runGit(cmd); + } catch (err) { + throw new Error(`Git clone failed: ${sanitizeErrorMessage(err)}`); + } + } + try { + // Ensure the locked commit is available (fetch by SHA best-effort) + const { stdout: headStdout } = await this.runGit(`git -C "${remote.clonePath}" rev-parse HEAD`); + const localHead = headStdout.trim(); + if (localHead !== remote.lockedCommit) { + this.log(`Checking out locked commit ${remote.lockedCommit.substring(0,8)} for ${remote.url}`); + try { + // Try to fetch the specific commit (may fail if already present) + await this.runGit(`git -C "${remote.clonePath}" fetch origin ${remote.lockedCommit} --depth=1`); + } catch { + // Ignore fetch failure; commit may already exist + } + await this.runGit(`git -C "${remote.clonePath}" checkout ${remote.lockedCommit}`); + } + const { stdout: newHeadStdout } = await this.runGit(`git -C "${remote.clonePath}" rev-parse HEAD`); + const currentCommit = newHeadStdout.trim(); + if (currentCommit !== remote.lastCommit || remote.catalogs.length === 0) { + this.log(`Scanning locked repo (commit: ${currentCommit.substring(0,8)})`); + remote.lastCommit = currentCommit; + remote.catalogs = await this.discoverCatalogRoots( + remote.clonePath, + remote.includePaths, + remote.url, + remote.branch, + remote.displayNamePrefix + ); + remote.lastUpdated = new Date().toISOString(); + this.log(`Found ${remote.catalogs.length} catalog(s) in locked repo ${remote.url}`); + } + return; // Done for locked remotes + } catch (err) { + this.log(`Locked commit handling failed: ${getErrorMessage(err)}`); + return; + } + } + + // Branch-tracking mode (no lockedCommit) if (!exists) { - // Clone repository this.log(`Cloning ${remote.url}@${remote.branch} to ${remote.clonePath}`); - const depth = 1; // Shallow clone by default + const depth = 1; const cmd = `git clone --depth=${depth} --branch ${remote.branch} ${remote.url} "${remote.clonePath}"`; - try { await this.runGit(cmd); this.log(`Clone successful: ${remote.url}`); @@ -317,7 +380,7 @@ export class GitCatalogService { throw new Error(`Git clone failed: ${sanitizeErrorMessage(err)}`); } } else { - // Update existing clone + // For future interactive update control we could skip automatic update here. this.log(`Updating ${remote.url}@${remote.branch}`); try { await this.runGit(`git -C "${remote.clonePath}" fetch origin ${remote.branch} --depth=1`); @@ -327,22 +390,19 @@ export class GitCatalogService { this.log(`Update failed (will continue): ${getErrorMessage(err)}`); } } - - // Get current commit + try { const { stdout } = await this.runGit(`git -C "${remote.clonePath}" rev-parse HEAD`); const currentCommit = stdout.trim(); - - // Rescan if commit changed or first scan if (currentCommit !== remote.lastCommit || remote.catalogs.length === 0) { this.log(`Scanning for catalogs (commit: ${currentCommit.substring(0, 8)})`); remote.lastCommit = currentCommit; remote.catalogs = await this.discoverCatalogRoots( remote.clonePath, - remote.includePaths, - remote.url, - remote.branch, - remote.displayNamePrefix + remote.includePaths, + remote.url, + remote.branch, + remote.displayNamePrefix ); remote.lastUpdated = new Date().toISOString(); this.log(`Found ${remote.catalogs.length} catalog(s) in ${remote.url}`); @@ -518,6 +578,7 @@ export class GitCatalogService { async addRemoteInteractively( url: string, branch: string = 'main', + lockedCommit?: string, includePaths?: string[], displayNamePrefix?: string ): Promise { @@ -536,6 +597,7 @@ export class GitCatalogService { const spec: RemoteGitSpec = { url, branch, + lockedCommit, includePaths, displayNamePrefix, depth: 1 @@ -551,6 +613,8 @@ export class GitCatalogService { branch, clonePath, lastUpdated: new Date().toISOString(), + lastCommit: undefined, + lockedCommit, specsHash: this.hashSpec(spec), catalogs: [], includePaths, @@ -587,15 +651,14 @@ export class GitCatalogService { const remote = this.remotes[index]; this.remotes.splice(index, 1); - // Optionally delete clone directory + // Delete clone directory try { - const files = await this.fileService.listDirectory(remote.clonePath); - if (files.length > 0) { - // Directory exists, could delete it - this.log(`Clone directory preserved: ${remote.clonePath}`); + if (await this.fileService.pathExists(remote.clonePath)) { + await this.fileService.rm(remote.clonePath, { recursive: true, force: true }); + this.log(`Deleted clone directory: ${remote.clonePath}`); } - } catch { - // Directory doesn't exist + } catch (err) { + this.log(`Failed to delete clone directory ${remote.clonePath}: ${getErrorMessage(err)}`); } // Save updated meta @@ -610,6 +673,52 @@ export class GitCatalogService { await this.ensureAllCloned(); } + async pruneUnusedCatalogs(activeCatalogPaths: string[]): Promise { + const activePathsSet = new Set(activeCatalogPaths.map(p => path.resolve(p))); + const remotesToDelete: RemoteMeta[] = []; + const remotesToKeep: RemoteMeta[] = []; + + for (const remote of this.remotes) { + // Only consider pruning if the remote has been scanned at least once + if (remote.lastCommit) { + const hasActiveCatalog = remote.catalogs.some(c => activePathsSet.has(path.resolve(c.catalogPath))); + if (hasActiveCatalog) { + remotesToKeep.push(remote); + } else { + // Also check if the repo root itself is a catalog path + if (activePathsSet.has(path.resolve(remote.clonePath))) { + remotesToKeep.push(remote); + } else { + remotesToDelete.push(remote); + } + } + } else { + // If never scanned, keep it. It might be a new remote waiting for its first clone/scan. + remotesToKeep.push(remote); + } + } + + if (remotesToDelete.length > 0) { + this.log(`Pruning ${remotesToDelete.length} unused remote(s).`); + for (const remote of remotesToDelete) { + try { + if (await this.fileService.pathExists(remote.clonePath)) { + await this.fileService.rm(remote.clonePath, { recursive: true, force: true }); + this.log(`Deleted unused clone directory: ${remote.clonePath}`); + } + } catch (err) { + this.log(`Failed to delete unused clone directory ${remote.clonePath}: ${getErrorMessage(err)}`); + } + } + + this.remotes = remotesToKeep; + const metaPath = path.join(this.globalStoragePath, 'git-remotes', 'meta.json'); + await this.saveMeta(metaPath); + } else { + this.log('No unused remotes to prune.'); + } + } + getRemotes(): RemoteMeta[] { return [...this.remotes]; } @@ -643,6 +752,7 @@ export class GitCatalogService { const str = JSON.stringify({ url: spec.url, branch: spec.branch, + lockedCommit: spec.lockedCommit, includePaths: spec.includePaths, displayNamePrefix: spec.displayNamePrefix }); @@ -657,6 +767,69 @@ export class GitCatalogService { return hash.toString(16); } + // List remote branches (name + head commit) using git ls-remote + async listRemoteBranches(url: string): Promise { + if (!await this.validateGitAvailable()) { + throw new Error('Git not available'); + } + try { + const { stdout } = await this.runGit(`git ls-remote --heads ${url}`); + // Lines: \trefs/heads/ + const branches: BranchInfo[] = stdout + .split(/\r?\n/) + .map(l => l.trim()) + .filter(l => l.length > 0) + .map(line => { + const [commit, ref] = line.split(/\s+/); + const m = ref?.match(/^refs\/heads\/(.+)$/); + return m ? { name: m[1], commit } : undefined; + }) + .filter((b): b is BranchInfo => !!b); + return branches; + } catch (err) { + this.log(`listRemoteBranches failed: ${getErrorMessage(err)}`); + return []; + } + } + + // Get remote head commit for a specific branch + async getRemoteHead(url: string, branch: string): Promise { + if (!await this.validateGitAvailable()) return undefined; + try { + const { stdout } = await this.runGit(`git ls-remote ${url} ${branch}`); + const line = stdout.split(/\r?\n/).find(l => l.includes('\t')); + if (!line) return undefined; + const [commit] = line.split(/\s+/); + return commit; + } catch (err) { + this.log(`getRemoteHead failed: ${getErrorMessage(err)}`); + return undefined; + } + } + + async detectBranchUpdate(remote: RemoteMeta): Promise<{ hasUpdate: boolean; remoteHead?: string; current?: string }> { + if (remote.lockedCommit) { + return { hasUpdate: false, current: remote.lastCommit }; + } + const remoteHead = await this.getRemoteHead(remote.url, remote.branch); + const current = remote.lastCommit; + if (remoteHead && current && remoteHead !== current) { + return { hasUpdate: true, remoteHead, current }; + } + return { hasUpdate: false, remoteHead, current }; + } + + async updateTrackedRemote(remote: RemoteMeta): Promise { + if (remote.lockedCommit) return false; + try { + await this.ensureRemoteCloned(remote); + return true; + } catch (err) { + this.log(`updateTrackedRemote failed: ${getErrorMessage(err)}`); + return false; + } + } + private getRepoName(url: string): string { // Extract repo name from URL or path const parts = url.replace(/\.git$/, '').split('/'); diff --git a/src/services/hatService.ts b/src/services/presetService.ts similarity index 100% rename from src/services/hatService.ts rename to src/services/presetService.ts diff --git a/src/tree/optionsTreeProvider.ts b/src/tree/optionsTreeProvider.ts index cefc6bb..4b5b26b 100644 --- a/src/tree/optionsTreeProvider.ts +++ b/src/tree/optionsTreeProvider.ts @@ -76,7 +76,8 @@ export class OptionsTreeProvider { { id: 'dev-create-template', label: 'Create Template Catalog', icon: 'new-folder', command: 'copilotCatalog.dev.createTemplateCatalog' }, { id: 'dev-add-dir', label: 'Add Catalog Directory…', icon: 'folder-opened', command: 'copilotCatalog.addCatalogDirectory' }, { id: 'dev-scan-git', label: 'Scan Git Repository…', icon: 'repo', command: 'copilotCatalog.dev.scanGitRepository' }, - { id: 'dev-refresh-git', label: 'Refresh Git Repositories', icon: 'sync', command: 'copilotCatalog.dev.refreshGitRepositories' } + { id: 'dev-refresh-git', label: 'Refresh Git Repositories', icon: 'sync', command: 'copilotCatalog.dev.refreshGitRepositories' }, + { id: 'dev-list-git', label: 'List Git Remotes', icon: 'list-unordered', command: 'copilotCatalog.dev.listGitRemotes' } ] } ]; diff --git a/src/utils/security.ts b/src/utils/security.ts index 1cad8be..cd9cf3b 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -66,11 +66,8 @@ export function sanitizeErrorMessage(error: any): string { // Remove potential file paths (Windows and Unix) const cleaned = message - .replace(/[A-Za-z]:\\[^\\/:*?"<>|\r\n]*\\[^\\/:*?"<>|\r\n]*\\/g, '[REDACTED_PATH]\\') - .replace(/\/[^\/\s:*?"<>|\r\n]*\/[^\/\s:*?"<>|\r\n]*\//g, '/[REDACTED_PATH]/') - .replace(/\b(file|directory|path|folder):\s*[^\s]+/gi, '$1: [REDACTED]') - .replace(/\b[A-Za-z]:[\\\/][^\s]*[\\\/]/g, '[REDACTED_PATH]') - .replace(/\b\/[^\s]*\/[^\s]*/g, '[REDACTED_PATH]'); + .replace(/[A-Za-z]:\\[^\\/:*?"<>|\r\n]*\\[^\\/:*?"<>|\r\n]*/g, '[REDACTED_PATH]') + .replace(/(? { + const norm = path.resolve(p); + if (options?.recursive) { + for (const file of this.files.keys()) { + if (file.startsWith(norm)) { + this.files.delete(file); + } + } + for (const dir of this.dirs.keys()) { + if (dir.startsWith(norm)) { + this.dirs.delete(dir); + } + } + } + this.files.delete(norm); + this.dirs.delete(norm); + } } diff --git a/test/gitCatalog.integration.test.ts b/test/gitCatalog.integration.test.ts index 64be043..b89c6e4 100644 --- a/test/gitCatalog.integration.test.ts +++ b/test/gitCatalog.integration.test.ts @@ -1,5 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import * as assert from 'assert'; +import { suite, test } from 'mocha'; import * as path from 'path'; import { GitCatalogService, RemoteGitSpec } from '../src/services/gitCatalogService'; import { MockFileService } from './fileService.mock'; @@ -41,32 +43,10 @@ function makeExecOverride(extra?: Record Promise }> = []; - let passed = 0; - let failed = 0; - - function test(name: string, fn: () => Promise) { - tests.push({ name, fn }); - } - - function assert(condition: boolean, message: string) { - if (!condition) { - throw new Error(`Assertion failed: ${message}`); - } - } - - function assertEquals(actual: any, expected: any, message?: string) { - const actualStr = JSON.stringify(actual); - const expectedStr = JSON.stringify(expected); - if (actualStr !== expectedStr) { - throw new Error(`${message || 'Values not equal'}\nExpected: ${expectedStr}\nActual: ${actualStr}`); - } - } - - // Tests +suite('Git Catalog Integration Tests', () => { + const assertEquals = (actual: any, expected: any, message?: string) => { + assert.deepStrictEqual(actual, expected, message); + }; test('should initialize with empty remotes', async () => { const mockFileService = new MockFileService({}); @@ -146,7 +126,7 @@ async function runTests() { const specs: RemoteGitSpec[] = [{ url: 'https://github.com/test/repo.git', branch: 'main' }]; await service.init(specs, '/test/global'); await service.refreshAll(); // Should not throw - assert(true, 'Refresh completed'); + assert.ok(true, 'Refresh completed'); }); test('should validate git availability', async () => { @@ -155,7 +135,7 @@ async function runTests() { service.setExecOverride(makeExecOverride()); await service.init([], '/test/global'); const available = await service.validateGitAvailable(); - assert(available, 'Git should be available'); + assert.ok(available, 'Git should be available'); }); test('should handle non-HTTPS URLs', async () => { @@ -167,7 +147,7 @@ async function runTests() { await service.addRemoteInteractively('git@github.com:user/repo.git', 'main'); throw new Error('Should have rejected non-HTTPS URL'); } catch (error: any) { - assert(error.message.includes('HTTPS'), 'Should mention HTTPS requirement'); + assert.ok(error.message.includes('HTTPS'), 'Should mention HTTPS requirement'); } }); @@ -204,27 +184,139 @@ async function runTests() { assertEquals(urls, ['https://github.com/test/repo1.git', 'https://github.com/test/repo2.git'], 'Recovered remote URLs mismatch'); }); - // Run all tests - for (const { name, fn } of tests) { - try { - await fn(); - console.log(`✅ ${name}`); - passed++; - } catch (error: any) { - console.log(`❌ ${name}`); - console.log(` ${error.message}`); - failed++; - } - } + // New tests for branch listing, locked commit, and update detection + + test('should list remote branches', async () => { + const mockFileService = new MockFileService({}); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(async (cmd: string) => { + if (cmd.startsWith('git --version')) return { stdout: 'git version 2.40.0', stderr: '' }; + if (cmd.startsWith('git ls-remote --heads')) { + return { stdout: [ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/heads/main', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\trefs/heads/feature/x', + 'cccccccccccccccccccccccccccccccccccccccc\trefs/heads/develop' + ].join('\n'), stderr: '' }; + } + if (cmd.startsWith('git clone')) return { stdout: '', stderr: '' }; + if (cmd.includes('rev-parse HEAD')) return { stdout: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n', stderr: '' }; + if (cmd.includes('rev-parse --abbrev-ref HEAD')) return { stdout: 'main\n', stderr: '' }; + if (cmd.includes(' fetch origin ')) return { stdout: '', stderr: '' }; + if (cmd.includes(' reset --hard ')) return { stdout: '', stderr: '' }; + return { stdout: '', stderr: '' }; + }); + await service.init([], '/test/global'); + const branches = await service.listRemoteBranches('https://github.com/test/repo.git'); + const names = branches.map(b => b.name).sort(); + const expected = ['develop', 'feature/x', 'main']; + assert.deepStrictEqual(names, expected, 'Branch names should match'); + }); + + test('should add remote locked to commit', async () => { + const lockedSha = '1234567890abcdef1234567890abcdef12345678'; + const mockFileService = new MockFileService({}); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(async (cmd: string) => { + if (cmd.startsWith('git --version')) return { stdout: 'git version 2.40.0', stderr: '' }; + if (cmd.startsWith('git clone')) return { stdout: '', stderr: '' }; + if (cmd.includes(' fetch origin ')) return { stdout: '', stderr: '' }; + if (cmd.includes(' checkout ')) return { stdout: '', stderr: '' }; + if (cmd.includes('rev-parse HEAD')) return { stdout: lockedSha + '\n', stderr: '' }; + if (cmd.includes('rev-parse --abbrev-ref HEAD')) return { stdout: 'main\n', stderr: '' }; + return { stdout: '', stderr: '' }; + }); + await service.init([], '/test/global'); + await service.addRemoteInteractively('https://github.com/test/locked.git', 'main', lockedSha); + const remotes = service.getRemotes(); + assert.strictEqual(remotes.length, 1, 'Expected exactly one remote'); + assert.strictEqual(remotes[0].lockedCommit, lockedSha, 'lockedCommit not set correctly'); + assert.strictEqual(remotes[0].lastCommit, lockedSha, 'lastCommit should equal locked commit after scan'); + }); + + test('should detect branch update when remote head changes', async () => { + const originalSha = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const newSha = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const mockFileService = new MockFileService({}); + const service = new GitCatalogService(mockFileService); + + // First exec override for initial add (originalSha) + service.setExecOverride(async (cmd: string) => { + if (cmd.startsWith('git --version')) return { stdout: 'git version 2.40.0', stderr: '' }; + if (cmd.startsWith('git clone')) return { stdout: '', stderr: '' }; + if (cmd.includes(' fetch origin ')) return { stdout: '', stderr: '' }; + if (cmd.includes(' reset --hard ')) return { stdout: '', stderr: '' }; + if (cmd.includes('rev-parse HEAD')) return { stdout: originalSha + '\n', stderr: '' }; + if (cmd.includes('rev-parse --abbrev-ref HEAD')) return { stdout: 'main\n', stderr: '' }; + return { stdout: '', stderr: '' }; + }); + await service.init([], '/test/global'); + await service.addRemoteInteractively('https://github.com/test/update.git', 'main'); + + // Swap override to simulate new remote head + service.setExecOverride(async (cmd: string) => { + if (cmd.startsWith('git --version')) return { stdout: 'git version 2.40.0', stderr: '' }; + if (cmd.startsWith('git ls-remote https://github.com/test/update.git main')) { + return { stdout: `${newSha}\trefs/heads/main\n`, stderr: '' }; + } + if (cmd.includes('rev-parse HEAD')) return { stdout: originalSha + '\n', stderr: '' }; + if (cmd.includes('rev-parse --abbrev-ref HEAD')) return { stdout: 'main\n', stderr: '' }; + return { stdout: '', stderr: '' }; + }); + + const remote = service.getRemotes()[0]; + const info = await service.detectBranchUpdate(remote as any); + assert.ok(info.hasUpdate, 'Expected hasUpdate=true'); + assert.strictEqual(info.remoteHead, newSha, 'remoteHead mismatch'); + assert.strictEqual(info.current, originalSha, 'current commit mismatch'); + }); - console.log('\n' + '='.repeat(50)); - console.log(`Tests: ${passed} passed, ${failed} failed, ${tests.length} total`); - if (failed > 0) { - process.exit(1); - } -} + test('should prune unused remote when its catalogs are no longer active', async () => { + const repo1Clone = '/test/global/git-remotes/repo1-main'; + const repo2Clone = '/test/global/git-remotes/repo2-main'; + const repo1Catalog = path.join(repo1Clone, 'catalogA'); + const repo2Catalog = path.join(repo2Clone, 'catalogB'); + + const metaContent = JSON.stringify({ + version: 2, + remotes: [ + { + url: 'https://github.com/test/repo1.git', + branch: 'main', + clonePath: repo1Clone, + lastCommit: 'abc', + catalogs: [{ catalogPath: repo1Catalog, repoRelPath: 'catalogA', displayName: 'repo1' }] + }, + { + url: 'https://github.com/test/repo2.git', + branch: 'main', + clonePath: repo2Clone, + lastCommit: 'def', + catalogs: [{ catalogPath: repo2Catalog, repoRelPath: 'catalogB', displayName: 'repo2' }] + } + ] + }); + + const mockFileService = new MockFileService({ + '/test/global/git-remotes/meta.json': metaContent, + [repo1Clone]: 'dir', + [repo2Clone]: 'dir', + }); + (mockFileService as any).deletedPaths = new Set(); -runTests().catch(error => { - console.error('Test runner failed:', error); - process.exit(1); + const service = new GitCatalogService(mockFileService); + service.setExecOverride(makeExecOverride()); + await service.ensureLoaded('/test/global'); + + assertEquals(service.getRemotes().length, 2, 'Should start with two remotes'); + + // Prune, keeping only repo1's catalog active + await service.pruneUnusedCatalogs([repo1Catalog]); + + const finalRemotes = service.getRemotes(); + assertEquals(finalRemotes.length, 1, 'Should have one remote after pruning'); + assertEquals(finalRemotes[0].url, 'https://github.com/test/repo1.git', 'Should keep repo1'); + + const deleted = (mockFileService as any).deletedPaths; + assert.ok(deleted.has(repo2Clone), 'Should have deleted the clone directory for repo2'); + }); }); diff --git a/test/hats.test.ts b/test/presets.test.ts similarity index 100% rename from test/hats.test.ts rename to test/presets.test.ts From d81fbc94d24e8692c03ccc8ebfafe6bbc6b05ba5 Mon Sep 17 00:00:00 2001 From: Jonathan Nafta Date: Mon, 22 Sep 2025 14:20:34 -0700 Subject: [PATCH 3/9] feat: rename "Hats" to "Presets" throughout the extension - Updated terminology in the codebase, documentation, and UI to replace "Hats" with "Presets". - Enhanced Git Catalog features including branch enumeration, commit locking, and interactive refresh flow. - Added new service APIs for managing presets. - Updated README and CHANGELOG to reflect changes and new features. - Added integration tests for new preset functionality. --- CHANGELOG.md | 23 ++++++- README.md | 24 ++++--- package.json | 36 +++++----- src/extension.ts | 78 +++++++++++----------- src/models.ts | 18 ++--- src/services/presetService.ts | 112 ++++++++++++++++---------------- src/tree/optionsTreeProvider.ts | 12 ++-- test/presets.test.ts | 50 +++++++------- test/testUtils.ts | 6 +- 9 files changed, 189 insertions(+), 170 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c29d2d..a710044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,31 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning when feasible. ## [Unreleased] +### Changed +- Renamed "Hats" to "Presets" throughout the extension, including UI, commands, and documentation. + +### Added +- Git Catalog: Branch enumeration (uses `git ls-remote --heads`) with quick pick UI when adding a remote repository. +- Git Catalog: Ability to lock a remote to a specific commit SHA (stores `lockedCommit` and prevents automatic branch updates). +- Git Catalog: Interactive refresh flow prompting per-remote when a newer branch head is detected (update / skip / lock). +- Git Catalog: Command `copilotCatalog.dev.listGitRemotes` to view status (tracking vs locked, current / pending commit). +- Git Catalog: Commit update detection without forcing fetch/reset when not desired. +- Git Catalog: Backward-compatible metadata upgrade (meta version 2 adds `lockedCommit`). +- Tests: Added integration tests for branch listing, locked commit behavior, and update detection logic. + ### Changed - Updated activity bar icon to a catalog book design that better represents the ContextShare functionality. The new icon features a book/catalog with organized content lines and a small AI indicator dot. +### Technical +- New service APIs: `listRemoteBranches`, `getRemoteHead`, `detectBranchUpdate`. +- Enhanced clone/update path with locked commit checkout logic and selective scanning. +- Persisted meta now includes `lockedCommit`; upgrade path from version 1 handled automatically. +- Added interactive UX in `scanGitRepository` and `refreshGitRepositories` commands. +- Added command palette entry to list remotes and their statuses. + +### Notes +- Locked remotes skip automatic branch fetch/reset and only rescan if local commit not yet recorded. +- Interactive refresh avoids unintended updates, giving explicit control or allowing lock-in of the new commit. ## [0.1.35] - 2025-08-23 ### Changed - Duplicate handling logic: when a catalog (or remote) resource is ACTIVE or MODIFIED its corresponding runtime copy is no longer shown as a separate `user` item; instead the catalog entry with its state icon is kept. This prevents seeing both "user" and catalog rows for the same activated asset across multiple catalogs. @@ -127,4 +149,3 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ## [0.1.22] - 2025-08-18 ### Fixed - Dev commands now work in headless/tunnel sessions: picker dialogs are wrapped in try/catch with manual input fallbacks (path input or typed action selection) and logging. - diff --git a/README.md b/README.md index 891713f..9d9c169 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![VS Code Marketplace](https://img.shields.io/visual-studio-marketplace/v/ContextShare.contextshare)](https://marketplace.visualstudio.com/items?itemName=ContextShare.contextshare) [![Security Policy](https://img.shields.io/badge/Security-Policy-blue.svg)](./SECURITY.md) -Unified AI workflow catalog for VS Code: manage and share chat modes, instructions, prompts, tasks - plus upcoming MCP server orchestration - across all your repositories from one consistent UI. Reduce prompt drift, standardize team AI usage, and reuse curated presets ("Hats"). +Unified AI workflow catalog for VS Code: manage and share chat modes, instructions, prompts, tasks - plus upcoming MCP server orchestration - across all your repositories from one consistent UI. Reduce prompt drift, standardize team AI usage, and reuse curated presets. ## ✨ Feature Highlights @@ -19,7 +19,7 @@ Core value: a single, structured, multi-catalog layer for AI assistant resources - Prompts (templated starting points) - Tasks (JSON task configs) - MCP Integration (coming soon) -- Hats (presets bundling multiple resources) +- Presets (presets bundling multiple resources) - Real‑time sync & state tracking (INACTIVE / ACTIVE / MODIFIED) - Safe activation (never overwrites user‑created originals) - Secure: HTTPS-only remotes + path & filename sanitization @@ -32,8 +32,8 @@ Core value: a single, structured, multi-catalog layer for AI assistant resources | Instructions | Shared guideline sets | `.github/instructions/` | `*.instructions.md` (legacy *.instruction.md) | ✅ | | Prompts | Prompt templates / starters | `.github/prompts/` | `*.prompt.md` | ✅ | | Tasks | Automation / action configurations | `.github/tasks/` | `*.task.json` | ✅ | -| MCP Servers | Model Context Protocol sources | `.vscode/mcp.json` | Merged composite file | � Coming soon | -| Hats | Declarative preset bundles | `.github/hats/` | `*.json` (see example below) | ✅ | +| MCP Servers | Model Context Protocol sources | `.vscode/mcp.json` | Merged composite file | Coming soon | +| Presets | Declarative preset bundles | `.github/presets/` | `*.json` (see example below) | ✅ | State logic: ACTIVE resources are copied to runtime; MODIFIED indicates the runtime file diverged from its source (e.g., team-local customization). @@ -46,11 +46,11 @@ Install from the VS Code Marketplace: [ContextShare](https://marketplace.visuals 1. Open the ContextShare Activity Bar view. 2. Add a catalog (Options → Add Catalog Directory… or add a remote HTTPS URL). 3. Activate a resource (right‑click → Activate). -4. (Optional) Apply a Hat preset to activate multiple at once. +4. (Optional) Apply a Preset to activate multiple at once. 5. Edit runtime copies under `.github/**` if you need local tweaks (they'll show as MODIFIED). -### Hats (Presets) -Hats are small JSON descriptors bundling chosen chat mode + instructions + prompts + tasks (+ soon MCP servers) into a one‑click activation set. Great for role or workflow switching (e.g., "Full Stack Review", "Security Audit"). +### Presets +Presets are small JSON descriptors bundling chosen chat mode + instructions + prompts + tasks (+ soon MCP servers) into a one‑click activation set. Great for role or workflow switching (e.g., "Full Stack Review", "Security Audit"). ### Example Catalog Structure @@ -66,7 +66,7 @@ example-catalog/ │ └── automated-testing.task.json ├── mcp/ │ └── development-servers.mcp.json -└── hats/ +└── presets/ └── full-stack-dev.json ``` @@ -78,7 +78,7 @@ example-catalog/ - [Changelog](./CHANGELOG.md) - Version history and release notes - [Catalog Display Names](./CATALOG_DISPLAY_NAMES_EXAMPLE.md) - Naming conventions and examples -## � Configuration Examples +## Configuration Examples Add catalogs in `.vscode/settings.json`: @@ -104,7 +104,7 @@ Remote `index.json` (HTTPS only): } ``` -Sample Hat (`.github/hats/full-stack-dev.json`): +Sample Preset (`.github/presets/full-stack-dev.json`): ```json { @@ -167,11 +167,10 @@ Report vulnerabilities privately via the Security Policy (responsible disclosure | Remote catalog empty | `index.json` not reachable / HTTP error | Open URL in browser; ensure HTTPS and correct raw path | | Resource shows MODIFIED unexpectedly | Local edit vs source catalog | Diff runtime file in `.github/**` with original source | | Removed MCP server persists | (Upcoming MCP feature) cached merged entry | After MCP feature release: remove from source & reload window | -| Hat not applying all items | Missing referenced filenames | Check JSON fields and ensure each resource exists | +| Preset not applying all items | Missing referenced filenames | Check JSON fields and ensure each resource exists | If stuck, enable verbose logging (future setting) or open an issue with a minimal reproduction. - ## 🏷️ Versioning We use [Semantic Versioning](https://semver.org/) for release management: @@ -186,4 +185,3 @@ This project adheres to the [Contributor Covenant Code of Conduct](./CODE_OF_CON ## Trademarks All product names, logos, and brands are property of their respective owners. Use of any third-party trademarks or logos does not imply endorsement. - diff --git a/package.json b/package.json index bd7f946..d7dc6a8 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "contributes": { "submenus": [ { - "id": "copilotCatalog.hatsMenu", - "label": "Hats", + "id": "copilotCatalog.presetsMenu", + "label": "Presets", "icon": "$(kebab-vertical)" }, { @@ -227,26 +227,26 @@ "title": "ContextShare: Edit Activated Copy" }, { - "command": "copilotCatalog.hats.apply", - "title": "ContextShare: Apply Hat (Preset)", + "command": "copilotCatalog.presets.apply", + "title": "ContextShare: Apply Preset", "icon": { - "light": "resources/icons/hat-light.svg", - "dark": "resources/icons/hat-dark.svg" + "light": "resources/icons/preset-light.svg", + "dark": "resources/icons/preset-dark.svg" } }, { - "command": "copilotCatalog.hats.createWorkspace", - "title": "ContextShare: Save Hat from Active (Workspace)", + "command": "copilotCatalog.presets.createWorkspace", + "title": "ContextShare: Save Preset from Active (Workspace)", "icon": "$(save)" }, { - "command": "copilotCatalog.hats.createUser", - "title": "ContextShare: Save Hat from Active (User)", + "command": "copilotCatalog.presets.createUser", + "title": "ContextShare: Save Preset from Active (User)", "icon": "$(account)" }, { - "command": "copilotCatalog.hats.delete", - "title": "ContextShare: Delete Hat (Workspace/User)", + "command": "copilotCatalog.presets.delete", + "title": "ContextShare: Delete Preset (Workspace/User)", "icon": "$(trash)" }, { @@ -343,7 +343,7 @@ "group": "navigation" }, { - "submenu": "copilotCatalog.hatsMenu", + "submenu": "copilotCatalog.presetsMenu", "when": "view == copilotCatalogOverview || view == copilotCatalogChatmodes", "group": "navigation@b" }, @@ -353,24 +353,24 @@ "group": "navigation@c" } ], - "copilotCatalog.hatsMenu": [ + "copilotCatalog.presetsMenu": [ { - "command": "copilotCatalog.hats.apply", + "command": "copilotCatalog.presets.apply", "when": "view == copilotCatalogOverview || view == copilotCatalogChatmodes", "group": "1" }, { - "command": "copilotCatalog.hats.createWorkspace", + "command": "copilotCatalog.presets.createWorkspace", "when": "view == copilotCatalogOverview || view == copilotCatalogChatmodes", "group": "2" }, { - "command": "copilotCatalog.hats.createUser", + "command": "copilotCatalog.presets.createUser", "when": "view == copilotCatalogOverview || view == copilotCatalogChatmodes", "group": "2" }, { - "command": "copilotCatalog.hats.delete", + "command": "copilotCatalog.presets.delete", "when": "view == copilotCatalogOverview || view == copilotCatalogChatmodes", "group": "3" } diff --git a/src/extension.ts b/src/extension.ts index 8f4454a..64959d7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,10 +6,10 @@ import * as os from 'os'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as vscode from 'vscode'; -import { Repository, Resource, ResourceCategory, ResourceState } from './models'; +import { Preset, Repository, Resource, ResourceCategory, ResourceState } from './models'; import { FileService } from './services/fileService'; import { GitCatalogService, RemoteGitSpec } from './services/gitCatalogService'; -import { HatService } from './services/hatService'; +import { PresetService } from './services/presetService'; import { ResourceService } from './services/resourceService'; import { CategoryTreeProvider } from './tree/categoryTreeProvider'; import { OptionsTreeProvider } from './tree/optionsTreeProvider'; @@ -141,7 +141,7 @@ const promptsTree = new CategoryTreeProvider(ResourceCategory.PROMPTS); const tasksTree = new CategoryTreeProvider(ResourceCategory.TASKS); const mcpTree = new CategoryTreeProvider(ResourceCategory.MCP); const optionsTree = new OptionsTreeProvider(); -const hatService = new HatService(fileService, resourceService, context.globalStorageUri.fsPath); +const presetService = new PresetService(fileService, resourceService, context.globalStorageUri.fsPath); // Track whether we've warned user about read-only catalog views let shownReadonlyNotice = false; @@ -421,7 +421,7 @@ await logger.info('No repositories detected after refresh.'); } } await loadResources(); -// No-op: hats are discovered on demand when command is invoked +// No-op: presets are discovered on demand when command is invoked logger.info(`Refresh complete. Repo count=${repositories.length} resources=${resources.length}`); try { updateStatus(); } catch (error) { logger.warn(`Failed to update status: ${error}`); @@ -656,7 +656,7 @@ await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'instr await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'prompts'))); await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'tasks'))); await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'mcp'))); -await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'hats'))); +await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(root, 'presets'))); // Seed sample files (best-effort) – catalog setup only await fs.writeFile( path.join(root, 'chatmodes', 'catalog-manager-agent.chatmode.md'), @@ -666,7 +666,7 @@ path.join(root, 'chatmodes', 'catalog-manager-agent.chatmode.md'), 'This chatmode must ONLY help the user set up and manage a ContextShare catalog using this extension. It must NOT answer or perform any unrelated tasks.', '', 'Rules:', -'- Scope strictly to catalog setup: scaffolding folders, configuring settings, activating/deactivating resources, understanding Hats, and packaging/installation steps.', +'- Scope strictly to catalog setup: scaffolding folders, configuring settings, activating/deactivating resources, understanding Presets, and packaging/installation steps.', '- If asked anything outside catalog setup, politely refuse and redirect: "I can only help with ContextShare catalog setup and management. Please ask a catalog-related question."', '- Remind the user of their duties: they own repo structure, security reviews, versioning, and Marketplace publishing credentials.', '- Never run shell commands unless explicitly asked; provide minimal, copyable commands and explain effects.', @@ -674,7 +674,7 @@ path.join(root, 'chatmodes', 'catalog-manager-agent.chatmode.md'), '', 'Quick references:', '- Settings: "ContextShare" → rootCatalogPath, targetWorkspace, catalogDirectory, runtimeDirectory', -'- Hats: presets to activate/deactivate groups of resources', +'- Presets: presets to activate/deactivate groups of resources', ].join('\n') ); await fs.writeFile( @@ -703,7 +703,7 @@ path.join(root, 'prompts', 'init-catalog.prompt.md'), 'What I need now:', '1) How to create the template catalog and where to put it', '2) How to configure rootCatalogPath or per-category sources and targetWorkspace', -'3) How to activate/deactivate a resource and apply a Hat', +'3) How to activate/deactivate a resource and apply a Preset', '4) What I must own (security reviews, versioning, publishing)', ].join('\n') ); @@ -717,13 +717,13 @@ steps: [ 'Place the catalog in your desired folder and name it (default: copilot_catalog).', 'Configure catalog directories using Dev menu → Configure Settings, then add catalog directories.', 'Use Activate on a resource to copy it to your runtime (e.g., .github).', -'Create/apply a Hat to quickly activate a set of resources.', +'Create/apply a Preset to quickly activate a set of resources.', 'Remember: you own security reviews, repo layout, version bumps, and publishing.' ] }, null, 2) ); await fs.writeFile(path.join(root, 'mcp', 'catalog-servers.mcp.json'), '{"servers": {}}'); -await fs.writeFile(path.join(root, 'hats', 'Copilot-Catalog-Setup.json'), JSON.stringify({ name: 'ContextShare Setup', description: 'Only the example assets generated by the template', resources: [ +await fs.writeFile(path.join(root, 'presets', 'Copilot-Catalog-Setup.json'), JSON.stringify({ name: 'ContextShare Setup', description: 'Only the example assets generated by the template', resources: [ 'chatmodes/catalog-manager-agent.chatmode.md', 'instructions/catalog-setup-guardrails.instructions.md', 'prompts/init-catalog.prompt.md', @@ -1143,63 +1143,63 @@ vscode.window.showErrorMessage('Failed to update target workspace setting.'); await refresh(); }) , -// --- Hats (Presets) --- -vscode.commands.registerCommand('copilotCatalog.hats.apply', async () => { +// --- Presets (Presets) --- +vscode.commands.registerCommand('copilotCatalog.presets.apply', async () => { if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } -const hats = await hatService.discoverHats(currentRepo); -if(hats.length===0){ vscode.window.showInformationMessage('No hats found (check catalog hats/, workspace .vscode/copilot-hats.json, or user hats).'); return; } -const pick = await vscode.window.showQuickPick(hats.map(h=> ({ label: h.name, description: h.description || h.source, detail: `${h.resources.length} items`, hat: h })), { placeHolder: 'Select a Hat to apply' }); +const presets = await presetService.discoverPresets(currentRepo); +if(presets.length===0){ vscode.window.showInformationMessage('No presets found (check catalog presets/, workspace .vscode/copilot-presets.json, or user presets).'); return; } +const pick = await vscode.window.showQuickPick(presets.map(p=> ({ label: p.name, description: p.description || p.source, detail: `${p.resources.length} items`, preset: p })), { placeHolder: 'Select a Preset to apply' }); if(!pick) return; -// Ask whether to enforce exclusivity (deactivate non-hat resources) +// Ask whether to enforce exclusivity (deactivate non-preset resources) const mode = await vscode.window.showQuickPick([ { label: 'Apply (keep others active)', value: 'nonExclusive' }, { label: 'Apply Exclusively (deactivate others)', value: 'exclusive' } -], { placeHolder: 'How should the Hat be applied?' }); +], { placeHolder: 'How should the Preset be applied?' }); if(!mode) return; const exclusive = mode.value === 'exclusive'; const currentResources = catalogFilter ? allResources.filter(r => r.catalogName === catalogFilter) : allResources; -const res = await hatService.applyHat(currentRepo, currentResources, pick.hat, { exclusive }); -await logger.info(`Applied hat ${pick.hat.name}: activated=${res.activated} deactivated=${res.deactivated} missing=${res.missing.length} errors=${res.errors.length}`); -if(res.errors.length){ vscode.window.showWarningMessage(`Hat applied with errors. Activated ${res.activated}, Deactivated ${res.deactivated}. Missing: ${res.missing.length}.`); } else { vscode.window.showInformationMessage(`Hat applied. Activated ${res.activated}, Deactivated ${res.deactivated}. Missing: ${res.missing.length}.`); } +const res = await presetService.applyPreset(currentRepo, currentResources, (pick as any).preset, { exclusive }); +await logger.info(`Applied preset ${(pick as any).preset.name}: activated=${res.activated} deactivated=${res.deactivated} missing=${res.missing.length} errors=${res.errors.length}`); +if(res.errors.length){ vscode.window.showWarningMessage(`Preset applied with errors. Activated ${res.activated}, Deactivated ${res.deactivated}. Missing: ${res.missing.length}.`); } else { vscode.window.showInformationMessage(`Preset applied. Activated ${res.activated}, Deactivated ${res.deactivated}. Missing: ${res.missing.length}.`); } await loadResources(); updateStatus(); }), -vscode.commands.registerCommand('copilotCatalog.hats.createWorkspace', async () => { +vscode.commands.registerCommand('copilotCatalog.presets.createWorkspace', async () => { if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } -const name = await vscode.window.showInputBox({ prompt: 'Name for the workspace Hat', placeHolder: 'My Hat' }); +const name = await vscode.window.showInputBox({ prompt: 'Name for the workspace Preset', placeHolder: 'My Preset' }); if(!name) return; const desc = await vscode.window.showInputBox({ prompt: 'Optional description' }); const currentResources = catalogFilter ? allResources.filter(r => r.catalogName === catalogFilter) : allResources; -const hat = await hatService.createHatFromActive(name, desc, currentResources, 'workspace', currentRepo); -await logger.info(`Created workspace hat ${hat.name} with ${hat.resources.length} resources`); -vscode.window.showInformationMessage(`Saved Hat "${hat.name}" to workspace (.vscode/copilot-hats.json).`); +const preset = await presetService.createPresetFromActive(name, desc, currentResources, 'workspace', currentRepo); +await logger.info(`Created workspace preset ${preset.name} with ${preset.resources.length} resources`); +vscode.window.showInformationMessage(`Saved Preset "${preset.name}" to workspace (.vscode/copilot-presets.json).`); }), -vscode.commands.registerCommand('copilotCatalog.hats.createUser', async () => { -const name = await vscode.window.showInputBox({ prompt: 'Name for the user Hat', placeHolder: 'My Hat' }); +vscode.commands.registerCommand('copilotCatalog.presets.createUser', async () => { +const name = await vscode.window.showInputBox({ prompt: 'Name for the user Preset', placeHolder: 'My Preset' }); if(!name) return; const desc = await vscode.window.showInputBox({ prompt: 'Optional description' }); const currentResources = catalogFilter ? allResources.filter(r => r.catalogName === catalogFilter) : allResources; -const hat = await hatService.createHatFromActive(name, desc, currentResources, 'user'); -await logger.info(`Created user hat ${hat.name} with ${hat.resources.length} resources`); -vscode.window.showInformationMessage(`Saved Hat "${hat.name}" to user settings (global storage).`); +const preset = await presetService.createPresetFromActive(name, desc, currentResources, 'user'); +await logger.info(`Created user preset ${preset.name} with ${preset.resources.length} resources`); +vscode.window.showInformationMessage(`Saved Preset "${preset.name}" to user settings (global storage).`); }), -vscode.commands.registerCommand('copilotCatalog.hats.delete', async () => { +vscode.commands.registerCommand('copilotCatalog.presets.delete', async () => { if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } -const hats = await hatService.discoverHats(currentRepo); -const deletable = hats.filter(h=> h.source === 'workspace' || h.source === 'user'); -if(deletable.length === 0){ vscode.window.showInformationMessage('No workspace/user hats to delete.'); return; } -const pick = await vscode.window.showQuickPick(deletable.map(h=> ({ label: h.name, description: h.description || h.source, detail: `${h.source} hat`, hat: h })), { placeHolder: 'Select a Hat to delete' }); +const presets = await presetService.discoverPresets(currentRepo); +const deletable = presets.filter(p=> p.source === 'workspace' || p.source === 'user'); +if(deletable.length === 0){ vscode.window.showInformationMessage('No workspace/user presets to delete.'); return; } +const pick = await vscode.window.showQuickPick(deletable.map(p=> ({ label: p.name, description: p.description || p.source, detail: `${p.source} preset`, preset: p })), { placeHolder: 'Select a Preset to delete' }); if(!pick) return; -const confirm = await vscode.window.showWarningMessage(`Delete Hat "${pick.hat.name}" from ${pick.hat.source}?`, { modal: true }, 'Delete'); +const confirm = await vscode.window.showWarningMessage(`Delete Preset "${(pick as any).preset.name}" from ${(pick as any).preset.source}?`, { modal: true }, 'Delete'); if(confirm !== 'Delete') return; -const ok = await hatService.deleteHat(pick.hat, currentRepo); -if(ok) vscode.window.showInformationMessage(`Deleted Hat "${pick.hat.name}" (${pick.hat.source}).`); -else vscode.window.showWarningMessage('Hat not found or could not be deleted.'); +const ok = await presetService.deletePreset((pick as any).preset, currentRepo); +if(ok) vscode.window.showInformationMessage(`Deleted Preset "${(pick as any).preset.name}" (${(pick as any).preset.source}).`); +else vscode.window.showWarningMessage('Preset not found or could not be deleted.'); }) , // User resource enable/disable diff --git a/src/models.ts b/src/models.ts index f92187b..1bb2c05 100644 --- a/src/models.ts +++ b/src/models.ts @@ -15,7 +15,7 @@ export enum ResourceCategory { export enum ResourceState { INACTIVE = 0, ACTIVE = 1, MODIFIED = 2 } export type ResourceOrigin = 'catalog' | 'user' | 'remote'; -export type HatSource = 'catalog' | 'workspace' | 'user'; +export type PresetSource = 'catalog' | 'workspace' | 'user'; // Configuration for multiple catalog sources export interface CatalogSource { @@ -56,14 +56,14 @@ export interface OperationResult { success: boolean; resource: Resource; message export interface ActivateOptions { merge?: boolean } -// Hat (preset) definition: a named set of catalog resource relative paths -export interface Hat { - id: string; // unique id for tree/commands - name: string; // display name - description?: string; // optional description - resources: string[]; // list of resource.relativePath entries to activate - source: HatSource; // where it came from - definitionPath?: string; // absolute file path where the hat is defined (for catalog/workspace), when applicable +// Preset (preset) definition: a named set of catalog resource relative paths +export interface Preset { +id: string; // unique id for tree/commands +name: string; // display name +description?: string; // optional description +resources: string[]; // list of resource.relativePath entries to activate +source: PresetSource; // where it came from +definitionPath?: string; // absolute file path where the preset is defined (for catalog/workspace), when applicable } export interface IFileService { diff --git a/src/services/presetService.ts b/src/services/presetService.ts index bcaa519..ea78c1a 100644 --- a/src/services/presetService.ts +++ b/src/services/presetService.ts @@ -1,36 +1,36 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import * as path from 'path'; -import { Hat, HatSource, IFileService, Repository, Resource } from '../models'; +import { Preset, PresetSource, IFileService, Repository, Resource } from '../models'; import { ResourceService } from './resourceService'; import { logger } from '../utils/logger'; -export class HatService { +export class PresetService { constructor(private fileService: IFileService, private resourceService: ResourceService, private userStorageRoot: string){ } - // Discover hats from catalog (repo.catalogPath/hats/*.json), workspace (.vscode/copilot-hats.json), and user storage (/hats.json) - async discoverHats(repo: Repository): Promise{ - const hats: Hat[] = []; - // Catalog hats directory - hats.push(...await this.readHatsFromDirectory(path.join(repo.catalogPath, 'hats'), 'catalog')); - // Workspace hats file - const workspaceFile = path.join(repo.rootPath, '.vscode', 'copilot-hats.json'); - hats.push(...await this.readHatsFromFile(workspaceFile, 'workspace')); - // User hats file - const userFile = path.join(this.userStorageRoot, 'hats.json'); - hats.push(...await this.readHatsFromFile(userFile, 'user')); + // Discover presets from catalog (repo.catalogPath/presets/*.json), workspace (.vscode/copilot-presets.json), and user storage (/presets.json) + async discoverPresets(repo: Repository): Promise{ + const presets: Preset[] = []; + // Catalog presets directory + presets.push(...await this.readPresetsFromDirectory(path.join(repo.catalogPath, 'presets'), 'catalog')); + // Workspace presets file + const workspaceFile = path.join(repo.rootPath, '.vscode', 'copilot-presets.json'); + presets.push(...await this.readPresetsFromFile(workspaceFile, 'workspace')); + // User presets file + const userFile = path.join(this.userStorageRoot, 'presets.json'); + presets.push(...await this.readPresetsFromFile(userFile, 'user')); // Ensure unique ids: prefix by source and filename when available const seen = new Set(); - for(const h of hats){ + for(const h of presets){ if(!h.id){ h.id = `${h.source}:${h.name}`; } let id = h.id; let i=1; while(seen.has(id)){ id = `${h.id}-${i++}`; } h.id = id; seen.add(id); } - return hats; + return presets; } - async applyHat(repo: Repository, resources: Resource[], hat: Hat, options?: { exclusive?: boolean }): Promise<{success:boolean; activated:number; deactivated:number; missing:string[]; errors:string[]}>{ + async applyPreset(repo: Repository, resources: Resource[], preset: Preset, options?: { exclusive?: boolean }): Promise<{success:boolean; activated:number; deactivated:number; missing:string[]; errors:string[]}>{ const missing: string[] = []; const errors: string[] = []; let activated = 0; @@ -39,7 +39,7 @@ export class HatService { const byRel = new Map(); for(const r of resources){ byRel.set(toSlash(r.relativePath), r); } // Resolve desired resource relative paths to Resource objects - const desiredSet = new Set(hat.resources.map(r=> toSlash(r))); + const desiredSet = new Set(preset.resources.map(r=> toSlash(r))); if(options?.exclusive){ // Deactivate any currently active (non-user) resource not in desired set for(const r of resources){ @@ -52,7 +52,7 @@ export class HatService { } } } - for(const rel of hat.resources){ + for(const rel of preset.resources){ const key = toSlash(rel); let target = byRel.get(key); if(!target){ @@ -69,44 +69,44 @@ export class HatService { return { success: errors.length===0, activated, deactivated, missing, errors }; } - async createHatFromActive(name: string, description: string|undefined, resources: Resource[], source: HatSource, repo?: Repository): Promise{ + async createPresetFromActive(name: string, description: string|undefined, resources: Resource[], source: PresetSource, repo?: Repository): Promise{ const rels = resources.filter(r=> r.state === 1 /* ACTIVE */ || (r as any).origin === 'user').map(r=> r.relativePath); - const hat: Hat = { id: `${source}:${name}`, name, description, resources: Array.from(new Set(rels)), source }; + const preset: Preset = { id: `${source}:${name}`, name, description, resources: Array.from(new Set(rels)), source }; if(source === 'workspace' && repo){ - await this.saveHatToWorkspace(repo, hat); + await this.savePresetToWorkspace(repo, preset); } else if(source === 'user'){ - await this.saveHatToUser(hat); + await this.savePresetToUser(preset); } - return hat; + return preset; } - async listWorkspaceHats(repo: Repository): Promise{ - const wsFile = path.join(repo.rootPath, '.vscode', 'copilot-hats.json'); - return this.readHatsFromFile(wsFile, 'workspace'); + async listWorkspacePresets(repo: Repository): Promise{ + const wsFile = path.join(repo.rootPath, '.vscode', 'copilot-presets.json'); + return this.readPresetsFromFile(wsFile, 'workspace'); } - async listUserHats(): Promise{ - const userFile = path.join(this.userStorageRoot, 'hats.json'); - return this.readHatsFromFile(userFile, 'user'); + async listUserPresets(): Promise{ + const userFile = path.join(this.userStorageRoot, 'presets.json'); + return this.readPresetsFromFile(userFile, 'user'); } - async deleteHat(hat: Hat, repo?: Repository): Promise{ - if(hat.source === 'workspace'){ + async deletePreset(preset: Preset, repo?: Repository): Promise{ + if(preset.source === 'workspace'){ if(!repo) return false; const wsDir = path.join(repo.rootPath, '.vscode'); - const wsFile = path.join(wsDir, 'copilot-hats.json'); - const list = await this.readHatArrayFile(wsFile); + const wsFile = path.join(wsDir, 'copilot-presets.json'); + const list = await this.readPresetArrayFile(wsFile); const before = list.length; - const filtered = list.filter(h=> (h?.name||'') !== hat.name); + const filtered = list.filter(h=> (h?.name||'') !== preset.name); if(filtered.length === before) return false; await this.fileService.ensureDirectory(wsDir); await this.fileService.writeFile(wsFile, JSON.stringify(filtered, null, 2)); return true; } - if(hat.source === 'user'){ - const userFile = path.join(this.userStorageRoot, 'hats.json'); - const list = await this.readHatArrayFile(userFile); + if(preset.source === 'user'){ + const userFile = path.join(this.userStorageRoot, 'presets.json'); + const list = await this.readPresetArrayFile(userFile); const before = list.length; - const filtered = list.filter(h=> (h?.name||'') !== hat.name); + const filtered = list.filter(h=> (h?.name||'') !== preset.name); if(filtered.length === before) return false; await this.fileService.ensureDirectory(this.userStorageRoot); await this.fileService.writeFile(userFile, JSON.stringify(filtered, null, 2)); @@ -115,27 +115,27 @@ export class HatService { return false; } - async saveHatToUser(hat: Hat): Promise{ - const userFile = path.join(this.userStorageRoot, 'hats.json'); - const list = await this.readHatArrayFile(userFile); - const filtered = list.filter(h=> h.name !== hat.name); - filtered.push({ name: hat.name, description: hat.description, resources: hat.resources }); + async savePresetToUser(preset: Preset): Promise{ + const userFile = path.join(this.userStorageRoot, 'presets.json'); + const list = await this.readPresetArrayFile(userFile); + const filtered = list.filter(h=> h.name !== preset.name); + filtered.push({ name: preset.name, description: preset.description, resources: preset.resources }); await this.fileService.ensureDirectory(this.userStorageRoot); await this.fileService.writeFile(userFile, JSON.stringify(filtered, null, 2)); } - async saveHatToWorkspace(repo: Repository, hat: Hat): Promise{ + async savePresetToWorkspace(repo: Repository, preset: Preset): Promise{ const wsDir = path.join(repo.rootPath, '.vscode'); - const wsFile = path.join(wsDir, 'copilot-hats.json'); - const list = await this.readHatArrayFile(wsFile); - const filtered = list.filter(h=> h.name !== hat.name); - filtered.push({ name: hat.name, description: hat.description, resources: hat.resources }); + const wsFile = path.join(wsDir, 'copilot-presets.json'); + const list = await this.readPresetArrayFile(wsFile); + const filtered = list.filter(h=> h.name !== preset.name); + filtered.push({ name: preset.name, description: preset.description, resources: preset.resources }); await this.fileService.ensureDirectory(wsDir); await this.fileService.writeFile(wsFile, JSON.stringify(filtered, null, 2)); } - private async readHatsFromDirectory(dir: string, source: HatSource): Promise{ - const out: Hat[] = []; + private async readPresetsFromDirectory(dir: string, source: PresetSource): Promise{ + const out: Preset[] = []; try{ const entries = await this.fileService.listDirectory(dir); for(const name of entries){ @@ -143,19 +143,19 @@ export class HatService { const st = await this.fileService.stat(full).catch(()=> 'missing'); if(st !== 'file') continue; if(!name.toLowerCase().endsWith('.json')) continue; - const parsed = await this.tryParseHatFile(full); + const parsed = await this.tryParsePresetFile(full); if(parsed){ out.push({ ...parsed, source, id: `${source}:${parsed.name}`, definitionPath: full }); } } }catch{ /* no dir */ } return out; } - private async readHatsFromFile(file: string, source: HatSource): Promise{ - const list = await this.readHatArrayFile(file); + private async readPresetsFromFile(file: string, source: PresetSource): Promise{ + const list = await this.readPresetArrayFile(file); return list.map(h=> ({ id: `${source}:${h.name}` , name: h.name, description: h.description, resources: h.resources||[], source, definitionPath: file })); } - private async tryParseHatFile(file: string): Promise<{name:string; description?:string; resources:string[]} | undefined>{ + private async tryParsePresetFile(file: string): Promise<{name:string; description?:string; resources:string[]} | undefined>{ try{ const raw = await this.fileService.readFile(file); const obj = JSON.parse(raw); @@ -165,12 +165,12 @@ export class HatService { return { name: obj.name, description, resources }; } }catch(error) { - await logger.warn(`[HatService] Failed to parse hat file: ${error}`); + await logger.warn(`[PresetService] Failed to parse preset file: ${error}`); } return undefined; } - private async readHatArrayFile(file: string): Promise>{ + private async readPresetArrayFile(file: string): Promise>{ try{ const raw = await this.fileService.readFile(file); const arr = JSON.parse(raw); diff --git a/src/tree/optionsTreeProvider.ts b/src/tree/optionsTreeProvider.ts index 4b5b26b..0194ca7 100644 --- a/src/tree/optionsTreeProvider.ts +++ b/src/tree/optionsTreeProvider.ts @@ -57,14 +57,14 @@ export class OptionsTreeProvider { ] }, { - id: 'hats', - label: 'Hats', + id: 'presets', + label: 'Presets', icon: 'kebab-vertical', children: [ - { id: 'hats-apply', label: 'Apply Hat (Preset)', icon: 'play', command: 'copilotCatalog.hats.apply' }, - { id: 'hats-save-workspace', label: 'Save Hat from Active (Workspace)', icon: 'save', command: 'copilotCatalog.hats.createWorkspace' }, - { id: 'hats-save-user', label: 'Save Hat from Active (User)', icon: 'account', command: 'copilotCatalog.hats.createUser' }, - { id: 'hats-delete', label: 'Delete Hat (Workspace/User)', icon: 'trash', command: 'copilotCatalog.hats.delete' } + { id: 'presets-apply', label: 'Apply Preset', icon: 'play', command: 'copilotCatalog.presets.apply' }, + { id: 'presets-save-workspace', label: 'Save Preset from Active (Workspace)', icon: 'save', command: 'copilotCatalog.presets.createWorkspace' }, + { id: 'presets-save-user', label: 'Save Preset from Active (User)', icon: 'account', command: 'copilotCatalog.presets.createUser' }, + { id: 'presets-delete', label: 'Delete Preset (Workspace/User)', icon: 'trash', command: 'copilotCatalog.presets.delete' } ] }, { diff --git a/test/presets.test.ts b/test/presets.test.ts index 0d36bc5..62abc24 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { MockFileService } from './fileService.mock'; import { ResourceService } from '../src/services/resourceService'; -import { HatService } from '../src/services/hatService'; +import { PresetService } from '../src/services/presetService'; import { Repository, ResourceCategory } from '../src/models'; import * as path from 'path'; @@ -15,39 +15,39 @@ function repoFor(root: string): Repository { const files: Record = { [path.join(root, 'copilot_catalog', 'chatmodes', 'a.chatmode.md')]: 'A', [path.join(root, 'copilot_catalog', 'instructions', 'b.instructions.md')]: 'B', - [path.join(root, 'copilot_catalog', 'hats', 'ab.json')]: JSON.stringify({ name: 'AB', resources: [ 'chatmodes/a.chatmode.md', 'instructions/b.instructions.md' ]}, null, 2) + [path.join(root, 'copilot_catalog', 'presets', 'ab.json')]: JSON.stringify({ name: 'AB', resources: [ 'chatmodes/a.chatmode.md', 'instructions/b.instructions.md' ]}, null, 2) }; const fs = new MockFileService(files); const rs = new ResourceService(fs); - const hats = new HatService(fs, rs, path.join(root, '.user')); + const presets = new PresetService(fs, rs, path.join(root, '.user')); const repo = repoFor(root); const resources = await rs.discoverResources(repo); - const discovered = await hats.discoverHats(repo); - if(discovered.length !== 1 || discovered[0].name !== 'AB') throw new Error('Hat discovery failed'); - const applyRes = await hats.applyHat(repo, resources, discovered[0]); + const discovered = await presets.discoverPresets(repo); + if(discovered.length !== 1 || discovered[0].name !== 'AB') throw new Error('Preset discovery failed'); + const applyRes = await presets.applyPreset(repo, resources, discovered[0]); if(!applyRes.success || applyRes.activated !== 2){ console.error('applyRes', applyRes); - throw new Error('Hat apply failed'); + throw new Error('Preset apply failed'); } - // Exclusive apply should deactivate any active not in hat (none at this moment because only two are active); still fine to call - const applyExclusive = await hats.applyHat(repo, resources, discovered[0], { exclusive: true }); - if(!applyExclusive.success){ throw new Error('Exclusive hat apply failed'); } + // Exclusive apply should deactivate any active not in preset (none at this moment because only two are active); still fine to call + const applyExclusive = await presets.applyPreset(repo, resources, discovered[0], { exclusive: true }); + if(!applyExclusive.success){ throw new Error('Exclusive preset apply failed'); } // Save from active to workspace - const created = await hats.createHatFromActive('WS', 'desc', resources, 'workspace', repo); - if(created.name !== 'WS') throw new Error('Create hat failed'); - const wsFile = path.join(root, '.vscode', 'copilot-hats.json'); + const created = await presets.createPresetFromActive('WS', 'desc', resources, 'workspace', repo); + if(created.name !== 'WS') throw new Error('Create preset failed'); + const wsFile = path.join(root, '.vscode', 'copilot-presets.json'); const wsExists = await fs.pathExists(wsFile); - if(!wsExists) throw new Error('Workspace hats file not written'); - // Create a user hat as well - await hats.createHatFromActive('USR', undefined, resources, 'user'); - const userFile = path.join(root, '.user', 'hats.json'); + if(!wsExists) throw new Error('Workspace presets file not written'); + // Create a user preset as well + await presets.createPresetFromActive('USR', undefined, resources, 'user'); + const userFile = path.join(root, '.user', 'presets.json'); const userExists = await fs.pathExists(userFile); - if(!userExists) throw new Error('User hats file not written'); + if(!userExists) throw new Error('User presets file not written'); // Delete both - const wsHats = await hats.listWorkspaceHats(repo); - const usrHats = await hats.listUserHats(); - const delWs = await hats.deleteHat(wsHats.find(h=> h.name==='WS')!, repo); - const delUsr = await hats.deleteHat(usrHats.find(h=> h.name==='USR')!); - if(!delWs || !delUsr) throw new Error('Delete hat failed'); - console.log('hats.test PASS'); -})().catch(e=>{ console.error('hats.test FAIL', e); process.exit(1); }); + const wsPresets = await presets.listWorkspacePresets(repo); + const usrPresets = await presets.listUserPresets(); + const delWs = await presets.deletePreset(wsPresets.find(h=> h.name==='WS')!, repo); + const delUsr = await presets.deletePreset(usrPresets.find(h=> h.name==='USR')!); + if(!delWs || !delUsr) throw new Error('Delete preset failed'); + console.log('presets.test PASS'); +})().catch(e=>{ console.error('presets.test FAIL', e); process.exit(1); }); diff --git a/test/testUtils.ts b/test/testUtils.ts index 3bb291d..34e0a0e 100644 --- a/test/testUtils.ts +++ b/test/testUtils.ts @@ -1,5 +1,5 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. import * as os from 'os'; import * as path from 'path'; @@ -192,7 +192,7 @@ export function createExpectedTargets(workspaceRoot: string) { prompt: path.join(workspaceRoot, '.github', 'prompts'), task: path.join(workspaceRoot, '.github', 'tasks'), instruction: path.join(workspaceRoot, '.github', 'instructions'), - hat: path.join(workspaceRoot, '.github', 'hats'), + preset: path.join(workspaceRoot, '.github', 'presets'), mcp: path.join(workspaceRoot, '.vscode', 'mcp.json') }; } From 333ac43c80858c0239918a5c12b00d7dba83f5a0 Mon Sep 17 00:00:00 2001 From: Jonathan Nafta Date: Mon, 22 Sep 2025 15:44:20 -0700 Subject: [PATCH 4/9] feat(tree): Group Git commands under Catalog submenu This change reorganizes the options tree for better usability by moving Git-related commands into a dedicated submenu. - Creates a new 'Git Repositories' submenu under the 'Catalog' section. - Moves 'Scan Git Repository', 'Refresh Git Repositories', and 'List Git Remotes' from the 'Developer' section into the new submenu. - Renames 'Refresh' to 'Refresh All Catalogs' for clarity. --- src/tree/optionsTreeProvider.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/tree/optionsTreeProvider.ts b/src/tree/optionsTreeProvider.ts index 0194ca7..da18c40 100644 --- a/src/tree/optionsTreeProvider.ts +++ b/src/tree/optionsTreeProvider.ts @@ -52,8 +52,18 @@ export class OptionsTreeProvider { label: 'Catalog', icon: 'library', children: [ - { id: 'catalog-refresh', label: 'Refresh', icon: 'refresh', command: 'copilotCatalog.refresh' }, - { id: 'catalog-filter', label: 'Filter by Catalog', icon: 'filter', command: 'copilotCatalog.filterCatalog' } + { id: 'catalog-refresh', label: 'Refresh All Catalogs', icon: 'refresh', command: 'copilotCatalog.refresh' }, + { id: 'catalog-filter', label: 'Filter by Catalog', icon: 'filter', command: 'copilotCatalog.filterCatalog' }, + { + id: 'git', + label: 'Git Repositories', + icon: 'repo', + children: [ + { id: 'git-discover', label: 'Discover Git Repository…', icon: 'search', command: 'copilotCatalog.dev.scanGitRepository' }, + { id: 'git-refresh', label: 'Refresh Git Repositories', icon: 'sync', command: 'copilotCatalog.dev.refreshGitRepositories' }, + { id: 'git-list', label: 'List Git Remotes', icon: 'list-unordered', command: 'copilotCatalog.dev.listGitRemotes' } + ] + } ] }, { @@ -74,10 +84,7 @@ export class OptionsTreeProvider { children: [ { id: 'dev-open-settings', label: 'Open Settings', icon: 'gear', command: 'copilotCatalog.openSettings' }, { id: 'dev-create-template', label: 'Create Template Catalog', icon: 'new-folder', command: 'copilotCatalog.dev.createTemplateCatalog' }, - { id: 'dev-add-dir', label: 'Add Catalog Directory…', icon: 'folder-opened', command: 'copilotCatalog.addCatalogDirectory' }, - { id: 'dev-scan-git', label: 'Scan Git Repository…', icon: 'repo', command: 'copilotCatalog.dev.scanGitRepository' }, - { id: 'dev-refresh-git', label: 'Refresh Git Repositories', icon: 'sync', command: 'copilotCatalog.dev.refreshGitRepositories' }, - { id: 'dev-list-git', label: 'List Git Remotes', icon: 'list-unordered', command: 'copilotCatalog.dev.listGitRemotes' } + { id: 'dev-add-dir', label: 'Add Catalog Directory…', icon: 'folder-opened', command: 'copilotCatalog.addCatalogDirectory' } ] } ]; From b251ae8dc05b6327d2d32c65d7c8376b732ab527 Mon Sep 17 00:00:00 2001 From: Jonathan Nafta Date: Mon, 22 Sep 2025 17:38:14 -0700 Subject: [PATCH 5/9] feat: Add real integration test suite and update version to 0.4.0 --- CHANGELOG.md | 9 +++ package-lock.json | 4 +- package.json | 4 +- src/tree/optionsTreeProvider.ts | 30 ++++++---- test/real.integration.test.ts | 98 +++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 test/real.integration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a710044..6dd11e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning when feasible. ## [Unreleased] + +## [0.4.0] - 2025-09-22 +### Added +- New integration test suite (`test/real.integration.test.ts`) that uses the actual `FileService` to verify file system operations. +- The `test:integration` script in `package.json` now runs the new test suite. + +### Changed +- Bumped version to 0.4.0. + ### Changed - Renamed "Hats" to "Presets" throughout the extension, including UI, commands, and documentation. diff --git a/package-lock.json b/package-lock.json index 9dc687c..1f64dfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "contextshare", - "version": "0.3.6", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "contextshare", - "version": "0.3.6", + "version": "0.4.0", "license": "MIT", "devDependencies": { "@types/glob": "^8.1.0", diff --git a/package.json b/package.json index d7dc6a8..d30e975 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "contextshare", "displayName": "ContextShare", "description": "Manage AI assistant catalog resources (chat modes, instructions, prompts, tasks, MCP) across multiple repositories inside VS Code.", - "version": "0.3.6", + "version": "0.4.0", "publisher": "ContextShare", "engines": { "vscode": "^1.90.0" @@ -419,7 +419,7 @@ "compile": "tsc -p ./", "test:unit": "node dist/test/runTest.js", "test:all": "npm run test && npm run test:integration && npm run test:performance && npm run test:security && npm run test:ux", - "test:integration": "node dist/test/integration.test.js", + "test:integration": "node dist/test/integration.test.js && node dist/test/real.integration.test.js", "test:performance": "node dist/test/performance.test.js", "test:security": "node dist/test/security.integration.test.js", "test:ux": "node dist/test/userExperience.test.js", diff --git a/src/tree/optionsTreeProvider.ts b/src/tree/optionsTreeProvider.ts index da18c40..9e8c5fa 100644 --- a/src/tree/optionsTreeProvider.ts +++ b/src/tree/optionsTreeProvider.ts @@ -11,6 +11,7 @@ type OptionNode = { icon?: string; command?: string; children?: OptionNode[]; + tooltip?: string; }; export class OptionsTreeProvider { @@ -40,6 +41,7 @@ export class OptionsTreeProvider { (ti as any).__node = n; if (n.icon && vscode) (ti as any).iconPath = new vscode.ThemeIcon(n.icon); if (n.command && vscode) (ti as any).command = { command: n.command, title: n.label }; + if (n.tooltip) (ti as any).tooltip = n.tooltip; ti.contextValue = collapsible ? 'options-group' : 'options-command'; return ti; }); @@ -51,17 +53,19 @@ export class OptionsTreeProvider { id: 'catalog', label: 'Catalog', icon: 'library', + tooltip: 'Manage catalogs and resources', children: [ - { id: 'catalog-refresh', label: 'Refresh All Catalogs', icon: 'refresh', command: 'copilotCatalog.refresh' }, - { id: 'catalog-filter', label: 'Filter by Catalog', icon: 'filter', command: 'copilotCatalog.filterCatalog' }, + { id: 'catalog-refresh', label: 'Refresh All Catalogs', icon: 'refresh', command: 'copilotCatalog.refresh', tooltip: 'Reload all resources from all configured catalogs' }, + { id: 'catalog-filter', label: 'Filter by Catalog', icon: 'filter', command: 'copilotCatalog.filterCatalog', tooltip: 'Show resources from selected catalogs only' }, { id: 'git', label: 'Git Repositories', icon: 'repo', + tooltip: 'Manage Git-based catalogs', children: [ - { id: 'git-discover', label: 'Discover Git Repository…', icon: 'search', command: 'copilotCatalog.dev.scanGitRepository' }, - { id: 'git-refresh', label: 'Refresh Git Repositories', icon: 'sync', command: 'copilotCatalog.dev.refreshGitRepositories' }, - { id: 'git-list', label: 'List Git Remotes', icon: 'list-unordered', command: 'copilotCatalog.dev.listGitRemotes' } + { id: 'git-discover', label: 'Discover Git Repository…', icon: 'search', command: 'copilotCatalog.dev.scanGitRepository', tooltip: 'Scan a Git repository for a catalog' }, + { id: 'git-refresh', label: 'Refresh Git Repositories', icon: 'sync', command: 'copilotCatalog.dev.refreshGitRepositories', tooltip: 'Refresh all discovered Git repositories' }, + { id: 'git-list', label: 'List Git Remotes', icon: 'list-unordered', command: 'copilotCatalog.dev.listGitRemotes', tooltip: 'List all configured Git remotes' } ] } ] @@ -70,21 +74,23 @@ export class OptionsTreeProvider { id: 'presets', label: 'Presets', icon: 'kebab-vertical', + tooltip: 'Manage resource presets', children: [ - { id: 'presets-apply', label: 'Apply Preset', icon: 'play', command: 'copilotCatalog.presets.apply' }, - { id: 'presets-save-workspace', label: 'Save Preset from Active (Workspace)', icon: 'save', command: 'copilotCatalog.presets.createWorkspace' }, - { id: 'presets-save-user', label: 'Save Preset from Active (User)', icon: 'account', command: 'copilotCatalog.presets.createUser' }, - { id: 'presets-delete', label: 'Delete Preset (Workspace/User)', icon: 'trash', command: 'copilotCatalog.presets.delete' } + { id: 'presets-apply', label: 'Apply Preset', icon: 'play', command: 'copilotCatalog.presets.apply', tooltip: 'Activate a collection of resources from a preset' }, + { id: 'presets-save-workspace', label: 'Save Preset from Active (Workspace)', icon: 'save', command: 'copilotCatalog.presets.createWorkspace', tooltip: 'Save the currently active resources as a new workspace preset' }, + { id: 'presets-save-user', label: 'Save Preset from Active (User)', icon: 'account', command: 'copilotCatalog.presets.createUser', tooltip: 'Save the currently active resources as a new user preset' }, + { id: 'presets-delete', label: 'Delete Preset (Workspace/User)', icon: 'trash', command: 'copilotCatalog.presets.delete', tooltip: 'Delete a workspace or user preset' } ] }, { id: 'dev', label: 'Dev', icon: 'tools', + tooltip: 'Developer and advanced options', children: [ - { id: 'dev-open-settings', label: 'Open Settings', icon: 'gear', command: 'copilotCatalog.openSettings' }, - { id: 'dev-create-template', label: 'Create Template Catalog', icon: 'new-folder', command: 'copilotCatalog.dev.createTemplateCatalog' }, - { id: 'dev-add-dir', label: 'Add Catalog Directory…', icon: 'folder-opened', command: 'copilotCatalog.addCatalogDirectory' } + { id: 'dev-open-settings', label: 'Open Settings', icon: 'gear', command: 'copilotCatalog.openSettings', tooltip: 'Open the settings for this extension' }, + { id: 'dev-create-template', label: 'Create Template Catalog', icon: 'new-folder', command: 'copilotCatalog.dev.createTemplateCatalog', tooltip: 'Create a new, empty catalog from a template' }, + { id: 'dev-add-dir', label: 'Add Catalog Directory…', icon: 'folder-opened', command: 'copilotCatalog.addCatalogDirectory', tooltip: 'Add a local directory as a catalog' } ] } ]; diff --git a/test/real.integration.test.ts b/test/real.integration.test.ts new file mode 100644 index 0000000..2ffeb7a --- /dev/null +++ b/test/real.integration.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { FileService } from '../src/services/fileService'; +import { ResourceService } from '../src/services/resourceService'; +import { createTestPaths, logTestSuccess, logTestStep } from './testUtils'; + +/** + * Real integration tests for file system operations using the actual FileService. + */ +async function testRealFileSystemOperations() { + console.log('Testing REAL file system operations...'); + + const testDir = path.join(os.tmpdir(), 'contextshare-real-integration-test'); + await fs.rm(testDir, { recursive: true, force: true }); + await fs.mkdir(testDir, { recursive: true }); + + const fileService = new FileService(); + + // Test 1: Directory creation + logTestStep('Directory creation'); + const newDir = path.join(testDir, 'new-dir'); + await fileService.ensureDirectory(newDir); + const stats = await fs.stat(newDir); + if (!stats.isDirectory()) { + throw new Error('Directory was not created'); + } + + // Test 2: File write and read + logTestStep('File write and read'); + const testFile = path.join(testDir, 'test.txt'); + const content = 'hello world'; + await fileService.writeFile(testFile, content); + const readContent = await fileService.readFile(testFile); + if (readContent !== content) { + throw new Error('File content mismatch'); + } + + // Test 3: File copy + logTestStep('File copy'); + const copyDest = path.join(testDir, 'test-copy.txt'); + await fileService.copyFile(testFile, copyDest); + const copiedContent = await fileService.readFile(copyDest); + if (copiedContent !== content) { + throw new Error('File copy failed'); + } + + // Test 4: File deletion + logTestStep('File deletion'); + await fileService.deleteFile(copyDest); + try { + await fs.stat(copyDest); + throw new Error('File was not deleted'); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + // Test 5: Path exists + logTestStep('Path exists'); + if (!await fileService.pathExists(testDir)) { + throw new Error('pathExists failed for directory'); + } + if (!await fileService.pathExists(testFile)) { + throw new Error('pathExists failed for file'); + } + if (await fileService.pathExists(path.join(testDir, 'non-existent-file'))) { + throw new Error('pathExists failed for non-existent file'); + } + + await fs.rm(testDir, { recursive: true, force: true }); + logTestSuccess('REAL file system operations'); +} + + +async function runRealIntegrationTests() { + console.log('Running REAL integration tests...'); + + try { + await testRealFileSystemOperations(); + + console.log('🎉 All REAL integration tests passed!'); + } catch (error) { + console.error('REAL integration tests failed:', error); + process.exit(1); + } +} + +// Run tests if this file is executed directly +if (require.main === module) { + runRealIntegrationTests(); +} + +export { runRealIntegrationTests }; From 0efa7840fa68c5f1e91d1a4643839941b85a4151 Mon Sep 17 00:00:00 2001 From: Jonathan Nafta Date: Mon, 22 Sep 2025 17:47:25 -0700 Subject: [PATCH 6/9] fix: Update version in manifest and sync with package.json --- .vscodeignore | 1 - package-lock.json | 18 +++++++++--------- package.json | 2 +- scripts/verify-version-sync.js | 11 ++++++----- vsix/extension.vsixmanifest | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.vscodeignore b/.vscodeignore index bbad001..5c83203 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -18,7 +18,6 @@ dist/** !dist/extension.js # 2. Exclude documentation & misc (non-runtime) -CHANGELOG.md SECURITY.md SOFTWARE_ARCHITECTURE_SPECIFICATION.md TESTPLAN.md diff --git a/package-lock.json b/package-lock.json index 1f64dfe..86b8045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@types/node": "^20.14.10", "@types/vscode": "^1.90.0", "@vscode/test-electron": "^2.3.9", - "@vscode/vsce": "^3.0.0", + "@vscode/vsce": "^3.6.1", "esbuild": "^0.25.9", "glob": "^11.0.3", "mocha": "^11.7.2", @@ -1152,17 +1152,17 @@ } }, "node_modules/@vscode/vsce": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz", - "integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.1.tgz", + "integrity": "sha512-UXtMgeCBl/t5zjn1TX1v1sl5L/oIv3Xc3pkKPGzaqeFCIkp5+wfFFDBXTWDt3d5uUulHnZKORHkMIsKNe9+k5A==", "dev": true, "license": "MIT", "dependencies": { "@azure/identity": "^4.1.0", - "@secretlint/node": "^10.1.1", - "@secretlint/secretlint-formatter-sarif": "^10.1.1", - "@secretlint/secretlint-rule-no-dotenv": "^10.1.1", - "@secretlint/secretlint-rule-preset-recommend": "^10.1.1", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", "chalk": "^4.1.2", @@ -1179,7 +1179,7 @@ "minimatch": "^3.0.3", "parse-semver": "^1.1.1", "read": "^1.0.7", - "secretlint": "^10.1.1", + "secretlint": "^10.1.2", "semver": "^7.5.2", "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", diff --git a/package.json b/package.json index d30e975..ba61bb5 100644 --- a/package.json +++ b/package.json @@ -439,7 +439,7 @@ "@types/node": "^20.14.10", "@types/vscode": "^1.90.0", "@vscode/test-electron": "^2.3.9", - "@vscode/vsce": "^3.0.0", + "@vscode/vsce": "^3.6.1", "esbuild": "^0.25.9", "glob": "^11.0.3", "mocha": "^11.7.2", diff --git a/scripts/verify-version-sync.js b/scripts/verify-version-sync.js index 98b08ee..69cf5f1 100644 --- a/scripts/verify-version-sync.js +++ b/scripts/verify-version-sync.js @@ -20,9 +20,10 @@ if(!manifestVersion){ } if(pkg.version !== manifestVersion){ - console.error(`ERROR: Version mismatch. package.json=${pkg.version}, manifest=${manifestVersion}`); - console.error('Hint: run: npm version then regenerate the VSIX with npx @vscode/vsce package'); - process.exit(1); + console.log(`Version mismatch detected. package.json=${pkg.version}, manifest=${manifestVersion}. Updating manifest...`); + const updatedXml = xml.replace(/(]*Version=")([^"]+)(")/i, `$1${pkg.version}$3`); + fs.writeFileSync(manifestPath, updatedXml, 'utf8'); + console.log(`Successfully updated ${manifestPath} to version ${pkg.version}`); +} else { + console.log(`Version sync OK: ${pkg.version}`); } - -console.log(`Version sync OK: ${pkg.version}`); \ No newline at end of file diff --git a/vsix/extension.vsixmanifest b/vsix/extension.vsixmanifest index ad81b3c..8044bc6 100644 --- a/vsix/extension.vsixmanifest +++ b/vsix/extension.vsixmanifest @@ -2,7 +2,7 @@ - + ContextShare Manage AI assistant catalog resources (chat modes, instructions, prompts, tasks) across multiple repositories. copilot,catalog,ai From fd1feba49a2f68ef8a36ee0dcce5e1b7840dd3d2 Mon Sep 17 00:00:00 2001 From: Jonathan Nafta Date: Mon, 22 Sep 2025 17:58:25 -0700 Subject: [PATCH 7/9] refactor(fileService): Improve directory handling and error management --- src/services/fileService.ts | 22 ++++++++++++++++++---- src/services/gitCatalogService.ts | 4 ++-- test/gitCatalog.integration.test.ts | 6 +++--- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/services/fileService.ts b/src/services/fileService.ts index dc3e36a..0b5db45 100644 --- a/src/services/fileService.ts +++ b/src/services/fileService.ts @@ -4,12 +4,17 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { IFileService } from '../models'; +async function ensureParentDirectory(p: string): Promise { + await fs.mkdir(path.dirname(p), { recursive: true }); +} + export class FileService implements IFileService { async readFile(p: string): Promise { - return fs.readFile(p, 'utf-8'); + return fs.readFile(p, 'utf8'); } async writeFile(p: string, content: string): Promise { - return fs.writeFile(p, content, 'utf-8'); + await ensureParentDirectory(p); + return fs.writeFile(p, content, 'utf8'); } async ensureDirectory(p: string): Promise { await fs.mkdir(p, { recursive: true }); @@ -23,7 +28,11 @@ export class FileService implements IFileService { } } async listDirectory(p: string): Promise { - return fs.readdir(p); + try { + return await fs.readdir(p); + } catch { + return []; + } } async stat(p: string): Promise<'file' | 'dir' | 'other' | 'missing'> { try { @@ -36,10 +45,15 @@ export class FileService implements IFileService { } } async copyFile(src: string, dest: string): Promise { + await ensureParentDirectory(dest); return fs.copyFile(src, dest); } async deleteFile(p: string): Promise { - return fs.unlink(p); + try { + await fs.unlink(p); + } catch { + // ignore + } } async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { return fs.rm(path, options); diff --git a/src/services/gitCatalogService.ts b/src/services/gitCatalogService.ts index ebb2522..1c75c9c 100644 --- a/src/services/gitCatalogService.ts +++ b/src/services/gitCatalogService.ts @@ -781,8 +781,8 @@ export class GitCatalogService { .filter(l => l.length > 0) .map(line => { const [commit, ref] = line.split(/\s+/); - const m = ref?.match(/^refs\/heads\/(.+)$/); - return m ? { name: m[1], commit } : undefined; + const m = ref?.match(/^refs\/heads\/(.+)$/); + return m ? { name: m[1], commit } : undefined; }) .filter((b): b is BranchInfo => !!b); return branches; diff --git a/test/gitCatalog.integration.test.ts b/test/gitCatalog.integration.test.ts index b89c6e4..445b873 100644 --- a/test/gitCatalog.integration.test.ts +++ b/test/gitCatalog.integration.test.ts @@ -159,9 +159,9 @@ suite('Git Catalog Integration Tests', () => { remotes: [ { url: 'https://github.com/test/repo1.git', - branch: 'main', - clonePath: repo1Clone, - lastUpdated: new Date().toISOString(), + branch: 'main', + clonePath: repo1Clone, + lastUpdated: new Date().toISOString(), specsHash: 'abc123', catalogs: [] } From c095beb3f6780e18df2e033c9cb2ac4f81b2851b Mon Sep 17 00:00:00 2001 From: Jonathan Nafta Date: Mon, 22 Sep 2025 18:08:43 -0700 Subject: [PATCH 8/9] feat(catalog): Validate locked commits and improve preset types Introduces the `PresetQuickPickItem` interface to provide strong typing for the preset selection UI. This removes the need for `any` type assertions when applying or deleting presets, improving type safety and code readability. Additionally, this change adds a validation step in the `GitCatalogService` to ensure that a `lockedCommit` in a remote catalog's configuration points to a valid commit object. This prevents errors if the `lockedCommit` references a tag that is not a commit or an invalid object. --- src/extension.ts | 18 +++++++++++------- src/services/gitCatalogService.ts | 5 +++++ src/utils/security.ts | 5 ++++- test/fileService.mock.ts | 10 ++++++---- test/real.integration.test.ts | 5 ++++- 5 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 64959d7..1e05bd3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -231,6 +231,10 @@ let repositories: Repository[] = await discoverRepositories(runtimeDirName); let currentRepo: Repository | undefined = repositories[0]; let resources: Resource[] = []; +interface PresetQuickPickItem extends vscode.QuickPickItem { + preset: Preset; +} + // Helper function to refresh all tree providers function refreshAllTrees() { overviewTree.refresh(); @@ -1148,7 +1152,7 @@ vscode.commands.registerCommand('copilotCatalog.presets.apply', async () => { if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } const presets = await presetService.discoverPresets(currentRepo); if(presets.length===0){ vscode.window.showInformationMessage('No presets found (check catalog presets/, workspace .vscode/copilot-presets.json, or user presets).'); return; } -const pick = await vscode.window.showQuickPick(presets.map(p=> ({ label: p.name, description: p.description || p.source, detail: `${p.resources.length} items`, preset: p })), { placeHolder: 'Select a Preset to apply' }); +const pick = await vscode.window.showQuickPick(presets.map(p=> ({ label: p.name, description: p.description || p.source, detail: `${p.resources.length} items`, preset: p })), { placeHolder: 'Select a Preset to apply' }); if(!pick) return; // Ask whether to enforce exclusivity (deactivate non-preset resources) const mode = await vscode.window.showQuickPick([ @@ -1160,8 +1164,8 @@ const exclusive = mode.value === 'exclusive'; const currentResources = catalogFilter ? allResources.filter(r => r.catalogName === catalogFilter) : allResources; -const res = await presetService.applyPreset(currentRepo, currentResources, (pick as any).preset, { exclusive }); -await logger.info(`Applied preset ${(pick as any).preset.name}: activated=${res.activated} deactivated=${res.deactivated} missing=${res.missing.length} errors=${res.errors.length}`); +const res = await presetService.applyPreset(currentRepo, currentResources, pick.preset, { exclusive }); +await logger.info(`Applied preset ${pick.preset.name}: activated=${res.activated} deactivated=${res.deactivated} missing=${res.missing.length} errors=${res.errors.length}`); if(res.errors.length){ vscode.window.showWarningMessage(`Preset applied with errors. Activated ${res.activated}, Deactivated ${res.deactivated}. Missing: ${res.missing.length}.`); } else { vscode.window.showInformationMessage(`Preset applied. Activated ${res.activated}, Deactivated ${res.deactivated}. Missing: ${res.missing.length}.`); } await loadResources(); updateStatus(); }), @@ -1193,12 +1197,12 @@ if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); const presets = await presetService.discoverPresets(currentRepo); const deletable = presets.filter(p=> p.source === 'workspace' || p.source === 'user'); if(deletable.length === 0){ vscode.window.showInformationMessage('No workspace/user presets to delete.'); return; } -const pick = await vscode.window.showQuickPick(deletable.map(p=> ({ label: p.name, description: p.description || p.source, detail: `${p.source} preset`, preset: p })), { placeHolder: 'Select a Preset to delete' }); +const pick = await vscode.window.showQuickPick(deletable.map(p=> ({ label: p.name, description: p.description || p.source, detail: `${p.source} preset`, preset: p })), { placeHolder: 'Select a Preset to delete' }); if(!pick) return; -const confirm = await vscode.window.showWarningMessage(`Delete Preset "${(pick as any).preset.name}" from ${(pick as any).preset.source}?`, { modal: true }, 'Delete'); +const confirm = await vscode.window.showWarningMessage(`Delete Preset "${pick.preset.name}" from ${pick.preset.source}?`, { modal: true }, 'Delete'); if(confirm !== 'Delete') return; -const ok = await presetService.deletePreset((pick as any).preset, currentRepo); -if(ok) vscode.window.showInformationMessage(`Deleted Preset "${(pick as any).preset.name}" (${(pick as any).preset.source}).`); +const ok = await presetService.deletePreset(pick.preset, currentRepo); +if(ok) vscode.window.showInformationMessage(`Deleted Preset "${pick.preset.name}" (${pick.preset.source}).`); else vscode.window.showWarningMessage('Preset not found or could not be deleted.'); }) , diff --git a/src/services/gitCatalogService.ts b/src/services/gitCatalogService.ts index 1c75c9c..d6bb9b5 100644 --- a/src/services/gitCatalogService.ts +++ b/src/services/gitCatalogService.ts @@ -344,6 +344,11 @@ export class GitCatalogService { } catch { // Ignore fetch failure; commit may already exist } + try { + await this.runGit(`git -C "${remote.clonePath}" cat-file -e ${remote.lockedCommit}^{commit}`); + } catch { + throw new Error(`Locked commit ${remote.lockedCommit} does not exist locally and could not be fetched.`); + } await this.runGit(`git -C "${remote.clonePath}" checkout ${remote.lockedCommit}`); } const { stdout: newHeadStdout } = await this.runGit(`git -C "${remote.clonePath}" rev-parse HEAD`); diff --git a/src/utils/security.ts b/src/utils/security.ts index cd9cf3b..a8c832f 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -67,7 +67,10 @@ export function sanitizeErrorMessage(error: any): string { // Remove potential file paths (Windows and Unix) const cleaned = message .replace(/[A-Za-z]:\\[^\\/:*?"<>|\r\n]*\\[^\\/:*?"<>|\r\n]*/g, '[REDACTED_PATH]') - .replace(/(?|\r\n]*\/[^/\s:*?"<>|\r\n]*/g, '/[REDACTED_PATH]/') + .replace(/\b(file|directory|path|folder):\s*[^s]+/gi, '$1: [REDACTED]') + .replace(/\b[A-Za-z]:[\/\\][^\s]*[\/\\]/g, '[REDACTED_PATH]') + .replace(/\b\/[^\s]*\/[^\s]*/g, '[REDACTED_PATH]'); return cleaned.substring(0, 200); // Limit length } diff --git a/test/fileService.mock.ts b/test/fileService.mock.ts index 6e89afc..c64dba2 100644 --- a/test/fileService.mock.ts +++ b/test/fileService.mock.ts @@ -27,18 +27,20 @@ export class MockFileService implements IFileService { async rm(p: string, options?: { recursive?: boolean; force?: boolean }): Promise { const norm = path.resolve(p); if (options?.recursive) { + const normWithSep = norm + path.sep; for (const file of this.files.keys()) { - if (file.startsWith(norm)) { + if (file === norm || file.startsWith(normWithSep)) { this.files.delete(file); } } for (const dir of this.dirs.keys()) { - if (dir.startsWith(norm)) { + if (dir === norm || dir.startsWith(normWithSep)) { this.dirs.delete(dir); } } + } else { + this.files.delete(norm); + this.dirs.delete(norm); } - this.files.delete(norm); - this.dirs.delete(norm); } } diff --git a/test/real.integration.test.ts b/test/real.integration.test.ts index 2ffeb7a..e96a2f1 100644 --- a/test/real.integration.test.ts +++ b/test/real.integration.test.ts @@ -92,7 +92,10 @@ async function runRealIntegrationTests() { // Run tests if this file is executed directly if (require.main === module) { - runRealIntegrationTests(); + runRealIntegrationTests().catch(err => { + console.error(err); + process.exit(1); + }); } export { runRealIntegrationTests }; From 7a895fd8c849de5439faaf8190db8a52d5f0981e Mon Sep 17 00:00:00 2001 From: Jonathan Nafta Date: Mon, 22 Sep 2025 18:29:38 -0700 Subject: [PATCH 9/9] feat(scripts): Make version sync script error by default The `verify-version-sync.js` script now errors and exits with a non-zero status code if a version mismatch is detected between `package.json` and `source.extension.vsixmanifest`. Previously, the script would automatically update the manifest, which could hide versioning issues in CI environments. The new behavior ensures that verification steps fail as expected. To retain the auto-fixing functionality, a `--fix` flag has been introduced. A corresponding `npm run fix:version` script has been added to `package.json` for convenience. Additionally, this commit includes: - Refactoring in `MockFileService` to use a more robust path comparison method. - Improved error handling in the integration test runner. --- package.json | 1 + scripts/verify-version-sync.js | 16 ++++++++++++---- test/fileService.mock.ts | 10 +++++++--- test/real.integration.test.ts | 15 +++++++++++---- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index ba61bb5..662e65c 100644 --- a/package.json +++ b/package.json @@ -415,6 +415,7 @@ "bundle": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --platform=node --format=cjs --external:vscode --sourcemap", "package": "echo 'vsce not installed in offline env'", "verify:version": "node scripts/verify-version-sync.js", + "fix:version": "node scripts/verify-version-sync.js --fix", "test": "npm run compile && node ./dist/test/runVscodeTests.js", "compile": "tsc -p ./", "test:unit": "node dist/test/runTest.js", diff --git a/scripts/verify-version-sync.js b/scripts/verify-version-sync.js index 69cf5f1..6b1f92f 100644 --- a/scripts/verify-version-sync.js +++ b/scripts/verify-version-sync.js @@ -19,11 +19,19 @@ if(!manifestVersion){ process.exit(2); } +const shouldFix = process.argv.includes('--fix'); + if(pkg.version !== manifestVersion){ - console.log(`Version mismatch detected. package.json=${pkg.version}, manifest=${manifestVersion}. Updating manifest...`); - const updatedXml = xml.replace(/(]*Version=")([^"]+)(")/i, `$1${pkg.version}$3`); - fs.writeFileSync(manifestPath, updatedXml, 'utf8'); - console.log(`Successfully updated ${manifestPath} to version ${pkg.version}`); + if (shouldFix) { + console.log(`Version mismatch detected. package.json=${pkg.version}, manifest=${manifestVersion}. Updating manifest...`); + const updatedXml = xml.replace(/(]*Version=")([^"]+)(")/i, `$1${pkg.version}$3`); + fs.writeFileSync(manifestPath, updatedXml, 'utf8'); + console.log(`Successfully updated ${manifestPath} to version ${pkg.version}`); + } else { + console.error(`ERROR: Version mismatch. package.json is ${pkg.version}, but vsixmanifest is ${manifestVersion}.`); + console.error('Run with --fix to automatically update the manifest.'); + process.exit(1); + } } else { console.log(`Version sync OK: ${pkg.version}`); } diff --git a/test/fileService.mock.ts b/test/fileService.mock.ts index c64dba2..edd68e9 100644 --- a/test/fileService.mock.ts +++ b/test/fileService.mock.ts @@ -27,14 +27,13 @@ export class MockFileService implements IFileService { async rm(p: string, options?: { recursive?: boolean; force?: boolean }): Promise { const norm = path.resolve(p); if (options?.recursive) { - const normWithSep = norm + path.sep; for (const file of this.files.keys()) { - if (file === norm || file.startsWith(normWithSep)) { + if (file === norm || this.isChildPath(file, norm)) { this.files.delete(file); } } for (const dir of this.dirs.keys()) { - if (dir === norm || dir.startsWith(normWithSep)) { + if (dir === norm || this.isChildPath(dir, norm)) { this.dirs.delete(dir); } } @@ -43,4 +42,9 @@ export class MockFileService implements IFileService { this.dirs.delete(norm); } } + + private isChildPath(childPath: string, parentPath: string): boolean { + const relative = path.relative(parentPath, childPath); + return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative); + } } diff --git a/test/real.integration.test.ts b/test/real.integration.test.ts index e96a2f1..6701c68 100644 --- a/test/real.integration.test.ts +++ b/test/real.integration.test.ts @@ -92,10 +92,17 @@ async function runRealIntegrationTests() { // Run tests if this file is executed directly if (require.main === module) { - runRealIntegrationTests().catch(err => { - console.error(err); - process.exit(1); - }); + (async () => { + try { + await runRealIntegrationTests(); + } catch (err) { + console.error('Unhandled error in real integration tests:', err); + process.exit(1); + } + })().catch(err => { + console.error('Critical error in test execution:', err); + process.exit(1); + }); } export { runRealIntegrationTests };