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/.vscodeignore b/.vscodeignore index bbad001..966746a 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 @@ -51,6 +50,7 @@ tsconfig.json .gitattributes dev-sandbox/ node_modules/ +.env # 5. Re-include required assets (order matters) !package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c29d2d..6dd11e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,40 @@ 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. + +### 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 +158,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/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..cdde493 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,68 @@ +# Publishing the ContextShare Extension + +This document outlines the steps to publish new versions of the ContextShare extension to the Visual Studio Marketplace. + +## Prerequisites + +1. **Node.js and npm**: Ensure you have Node.js (which includes npm) installed. +2. **Azure DevOps Account**: You need an Azure DevOps account to create a Personal Access Token (PAT). +3. **Publisher Account**: You must be a member of the "ContextShare" publisher account on the VS Code Marketplace. + +## One-Time Setup: Personal Access Token (PAT) + +To publish to the Marketplace, you need a Personal Access Token (PAT) with publishing rights. + +1. **Create a PAT**: + * Navigate to your Azure DevOps organization (e.g., `https://dev.azure.com/{your_organization_name}`). + * Go to **User settings** (the gear icon) > **Personal Access Tokens**. + * Click **+ New Token**. + * Give it a descriptive name (e.g., `vsce-publish-contextshare`). + * Set the **Organization** to "All accessible organizations". + * Set the **Scope** to **Custom defined** and click **Show all scopes**. + * Find and select **Marketplace (Manage)**. + * Click **Create**. + +2. **Store the PAT**: + * **IMPORTANT**: Copy the generated token immediately. You will not be able to see it again. + * Create a new file named `.env` in the root of this project. + * Add the following line to your `.env` file, replacing `{your_pat}` with the token you just copied: + ``` + VSCE_PAT={your_pat} + ``` + * The `.env` file is already listed in `.gitignore`, so it will not be committed to source control. + +## Publishing a Pre-Release Version + +Use this for testing new features before a stable release. The version in `package.json` (e.g., `0.4.0`) will be published as a pre-release. + +1. **Ensure `package.json` has the correct version number.** +2. Run the following command: + + ```bash + npm run publish:prerelease + ``` + +This will build, test, and publish the extension as a pre-release (e.g., `0.4.0 (Pre-release)`). + +## Publishing a Stable Version + +Once a pre-release version has been tested and is ready for general availability, you can publish it as a stable release. + +1. **Ensure `package.json` has the correct version number.** +2. Run the following command: + + ```bash + npm run publish:stable + ``` + +This will publish the version as a stable release, making it the default for all users. + +## Packaging without Publishing + +If you only want to create the `.vsix` file without publishing it to the Marketplace, run: + +```bash +npm run package +``` + +The generated `.vsix` file will be in the root of the project directory. 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/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/package-lock.json b/package-lock.json index dde59cf..7e14192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "contextshare", - "version": "0.3.5", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "contextshare", - "version": "0.3.5", + "version": "0.4.0", "license": "MIT", "devDependencies": { "@types/glob": "^8.1.0", @@ -14,7 +14,9 @@ "@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", + "dotenv": "^17.2.2", + "dotenv-cli": "^10.0.0", "esbuild": "^0.25.9", "glob": "^11.0.3", "mocha": "^11.7.2", @@ -1152,17 +1154,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 +1181,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", @@ -2133,6 +2135,64 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", + "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-cli": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-10.0.0.tgz", + "integrity": "sha512-lnOnttzfrzkRx2echxJHQRB6vOAMSCzzZg79IxpC00tU42wZPuZkQxNNrrwVAxaQZIIh001l4PxVlCrBxngBzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^17.1.0", + "dotenv-expand": "^11.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3558,7 +3618,6 @@ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", - "optional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } diff --git a/package.json b/package.json index bd7f946..9354d93 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" @@ -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" } @@ -409,17 +409,21 @@ }, "license": "MIT", "scripts": { - "vscode:prepublish": "npm install && npm run build", + "vscode:prepublish": "npm run build", + "prepublishOnly": "npm run test && npm run build", "build": "tsc -p . && npm run bundle", "watch": "tsc -w -p .", "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'", + "package": "vsce package", + "publish:prerelease": "dotenv -e .env -- vsce publish --pre-release", + "publish:stable": "dotenv -e .env -- vsce publish", "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", "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", @@ -439,7 +443,9 @@ "@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", + "dotenv": "^17.2.2", + "dotenv-cli": "^10.0.0", "esbuild": "^0.25.9", "glob": "^11.0.3", "mocha": "^11.7.2", 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/scripts/verify-version-sync.js b/scripts/verify-version-sync.js index 98b08ee..6b1f92f 100644 --- a/scripts/verify-version-sync.js +++ b/scripts/verify-version-sync.js @@ -19,10 +19,19 @@ if(!manifestVersion){ process.exit(2); } +const shouldFix = process.argv.includes('--fix'); + 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); + 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}`); } - -console.log(`Version sync OK: ${pkg.version}`); \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 6f8354a..1e05bd3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,9 +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 { HatService } from './services/hatService'; +import { GitCatalogService, RemoteGitSpec } from './services/gitCatalogService'; +import { PresetService } from './services/presetService'; import { ResourceService } from './services/resourceService'; import { CategoryTreeProvider } from './tree/categoryTreeProvider'; import { OptionsTreeProvider } from './tree/optionsTreeProvider'; @@ -25,1121 +26,1388 @@ 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.`); - }; - - preflightLog('Activating...'); - - try { - const fileService = new FileService(); - const resourceService = new ResourceService(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.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); - } +// 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...'); + +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 presetService = new PresetService(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[] = []; + +interface PresetQuickPickItem extends vscode.QuickPickItem { + preset: Preset; +} + +// 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: 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}`); +} +} 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, 'presets'))); +// 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 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.', +'- Be concise, concrete, and avoid speculative answers.', +'', +'Quick references:', +'- Settings: "ContextShare" → rootCatalogPath, targetWorkspace, catalogDirectory, runtimeDirectory', +'- Presets: 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 Preset', +'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 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, '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', +'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; + } + + // 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(); +}) +, +// --- Presets (Presets) --- +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' }); +if(!pick) return; +// 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 Preset be applied?' }); +if(!mode) return; +const exclusive = mode.value === 'exclusive'; +const currentResources = catalogFilter ? +allResources.filter(r => r.catalogName === catalogFilter) : +allResources; +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(); +}), +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 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 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.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 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.presets.delete', async () => { +if(!currentRepo){ vscode.window.showWarningMessage('No repository available.'); return; } +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 Preset "${pick.preset.name}" from ${pick.preset.source}?`, { modal: true }, 'Delete'); +if(confirm !== 'Delete') return; +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.'); +}) +, +// 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..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,25 +56,27 @@ 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 { - 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..0b5db45 100644 --- a/src/services/fileService.ts +++ b/src/services/fileService.ts @@ -4,28 +4,21 @@ 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 }); +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, 'utf8'); } - async writeFile(p: string, content: string): Promise { await ensureParentDirectory(p); - await fs.writeFile(p, content, 'utf8'); + return fs.writeFile(p, content, 'utf8'); } - async ensureDirectory(p: string): Promise { await fs.mkdir(p, { recursive: true }); } - async pathExists(p: string): Promise { try { await fs.access(p); @@ -34,7 +27,6 @@ export class FileService implements IFileService { return false; } } - async listDirectory(p: string): Promise { try { return await fs.readdir(p); @@ -42,28 +34,28 @@ export class FileService implements IFileService { return []; } } - 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 + // 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 new file mode 100644 index 0000000..d6bb9b5 --- /dev/null +++ b/src/services/gitCatalogService.ts @@ -0,0 +1,851 @@ +// 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' + 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 +} + +// 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; + lockedCommit?: string; // when set, repo is pinned to this commit (no automatic fetch/reset) + specsHash: string; + catalogs: CatalogCandidate[]; + includePaths?: string[]; + displayNamePrefix?: string; +} + +interface GitCatalogMeta { + version: number; + remotes: RemoteMeta[]; +} + +interface BranchInfo { + name: string; + commit: string; +} + +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 || 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: 2, remotes: [] }; + } + + private async saveMeta(metaPath: string): Promise { + const meta: GitCatalogMeta = { + version: 2, + 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 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 + } + 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`); + 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) { + this.log(`Cloning ${remote.url}@${remote.branch} to ${remote.clonePath}`); + 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}`); + } catch (err) { + throw new Error(`Git clone failed: ${sanitizeErrorMessage(err)}`); + } + } else { + // 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`); + 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)}`); + } + } + + try { + const { stdout } = await this.runGit(`git -C "${remote.clonePath}" rev-parse HEAD`); + const currentCommit = stdout.trim(); + 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', + lockedCommit?: string, + 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, + lockedCommit, + 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(), + lastCommit: undefined, + lockedCommit, + 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); + + // Delete clone directory + try { + 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 (err) { + this.log(`Failed to delete clone directory ${remote.clonePath}: ${getErrorMessage(err)}`); + } + + // 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(); + } + + 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]; + } + + 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, + lockedCommit: spec.lockedCommit, + 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); + } + + // 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('/'); + 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/services/hatService.ts b/src/services/presetService.ts similarity index 56% rename from src/services/hatService.ts rename to src/services/presetService.ts index bcaa519..ea78c1a 100644 --- a/src/services/hatService.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 4263d95..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,31 +53,45 @@ export class OptionsTreeProvider { id: 'catalog', label: 'Catalog', icon: 'library', + tooltip: 'Manage catalogs and resources', 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', 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', 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' } + ] + } ] }, { - id: 'hats', - label: 'Hats', + id: 'presets', + label: 'Presets', icon: 'kebab-vertical', + tooltip: 'Manage resource presets', 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', 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/src/utils/security.ts b/src/utils/security.ts index 1cad8be..a8c832f 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -66,10 +66,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(/\/[^\/\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(/[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]'); return cleaned.substring(0, 200); // Limit length diff --git a/test/fileService.mock.ts b/test/fileService.mock.ts index 709484e..edd68e9 100644 --- a/test/fileService.mock.ts +++ b/test/fileService.mock.ts @@ -23,4 +23,28 @@ export class MockFileService implements IFileService { async stat(p: string){ const norm = path.resolve(p); if(this.files.has(norm)) return 'file'; if(this.dirs.has(norm)) return 'dir'; return 'missing'; } async copyFile(src: string, dest: string){ const s = path.resolve(src); const d = path.resolve(dest); if(!this.files.has(s)) throw new Error('missing src'); this.files.set(d, this.files.get(s)!); this.dirs.add(path.dirname(d)); } async deleteFile(p: string){ const norm = path.resolve(p); this.files.delete(norm); } + async deleteDirectory(p: string){ const norm = path.resolve(p); this.dirs.delete(norm); } + async rm(p: string, options?: { recursive?: boolean; force?: boolean }): Promise { + const norm = path.resolve(p); + if (options?.recursive) { + for (const file of this.files.keys()) { + if (file === norm || this.isChildPath(file, norm)) { + this.files.delete(file); + } + } + for (const dir of this.dirs.keys()) { + if (dir === norm || this.isChildPath(dir, norm)) { + this.dirs.delete(dir); + } + } + } else { + this.files.delete(norm); + 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/gitCatalog.integration.test.ts b/test/gitCatalog.integration.test.ts new file mode 100644 index 0000000..445b873 --- /dev/null +++ b/test/gitCatalog.integration.test.ts @@ -0,0 +1,322 @@ +// 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'; + +// 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: '' }; + }; +} + +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({}); + 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.ok(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.ok(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.ok(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'); + }); + + // 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'); + }); + + 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(); + + 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/hats.test.ts deleted file mode 100644 index 0d36bc5..0000000 --- a/test/hats.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -import { MockFileService } from './fileService.mock'; -import { ResourceService } from '../src/services/resourceService'; -import { HatService } from '../src/services/hatService'; -import { Repository, ResourceCategory } from '../src/models'; -import * as path from 'path'; - -function repoFor(root: string): Repository { - return { id: 'r', name: 'r', rootPath: root, catalogPath: path.join(root,'copilot_catalog'), runtimePath: path.join(root,'.github'), isActive: true }; -} - -(async function run(){ - const root = path.resolve('/ws'); - 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) - }; - const fs = new MockFileService(files); - const rs = new ResourceService(fs); - const hats = new HatService(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]); - if(!applyRes.success || applyRes.activated !== 2){ - console.error('applyRes', applyRes); - throw new Error('Hat 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'); } - // 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 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'); - const userExists = await fs.pathExists(userFile); - if(!userExists) throw new Error('User hats 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); }); diff --git a/test/presets.test.ts b/test/presets.test.ts new file mode 100644 index 0000000..62abc24 --- /dev/null +++ b/test/presets.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { MockFileService } from './fileService.mock'; +import { ResourceService } from '../src/services/resourceService'; +import { PresetService } from '../src/services/presetService'; +import { Repository, ResourceCategory } from '../src/models'; +import * as path from 'path'; + +function repoFor(root: string): Repository { + return { id: 'r', name: 'r', rootPath: root, catalogPath: path.join(root,'copilot_catalog'), runtimePath: path.join(root,'.github'), isActive: true }; +} + +(async function run(){ + const root = path.resolve('/ws'); + 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', '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 presets = new PresetService(fs, rs, path.join(root, '.user')); + const repo = repoFor(root); + const resources = await rs.discoverResources(repo); + 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('Preset 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 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 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 presets file not written'); + // Delete both + 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/real.integration.test.ts b/test/real.integration.test.ts new file mode 100644 index 0000000..6701c68 --- /dev/null +++ b/test/real.integration.test.ts @@ -0,0 +1,108 @@ +// 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) { + (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 }; 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') }; } 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