From 2209afb7228a9a5b3f2bcdaece74a61b05950838 Mon Sep 17 00:00:00 2001 From: safwaneettih Date: Tue, 25 Nov 2025 16:51:28 +0100 Subject: [PATCH 01/11] feat(ui): rename chatmodes to agents and improve cross-platform compatibility - Renamed 'chatmodes' to 'agents' throughout UI to match VS Code terminology - Added support for legacy chatmodes/ and chatmode/ (singular) directory paths - Changed syncInstructions default to true for better UX - Fixed Windows symlink handling with automatic fallback to file copy - Enhanced debug logging throughout sync and UI components - Improved cross-platform path handling (Windows backslash support) - Updated documentation to reflect agents terminology and Windows compatibility - Condensed CHANGELOG for cleaner release notes Breaking Changes: - None (maintains backward compatibility with legacy chatmode paths) Fixes: - Fixed UI not displaying agents (was filtering for 'chatmode' type) - Fixed instructions not syncing by default - Fixed Windows path separator issues in symlink detection - Fixed activate/deactivate button behavior in webview - Fixed repository storage to use globalStorage instead of User/prompts --- .github/copilot-instructions.md | 9 +- CHANGELOG.md | 5 +- README.md | 25 +- media/main.css | 6 + package.json | 8 +- src/configManager.ts | 107 +++++-- src/constant.ts | 5 +- src/syncManager.ts | 503 ++++++++++++++++++++++++-------- src/ui/promptCardsWebview.ts | 82 ++++-- src/ui/promptCommands.ts | 110 +++++-- src/ui/promptDetailsWebview.ts | 2 +- src/ui/promptTreeProvider.ts | 376 +++++++++++++++++++----- 12 files changed, 941 insertions(+), 297 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ef71c11..0213264 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,10 +10,11 @@ Warn me about backward-incompatible changes. Architecture & key files - Entry/activation: src/extension.ts – activates on startup, wires ConfigManager, StatusBarManager, SyncManager; registers commands: promptitude.syncNow • promptitude.showStatus • promptitude.openPromptsFolder • promptitude.addAzureDevOpsPAT • promptitude.clearAzureDevOpsPAT • promptitude.clearAzureDevOpsCache -- Sync: src/syncManager.ts – schedules by promptitude.frequency; per-repo (url or url|branch, default main) select provider via GitProviderFactory, authenticate, fetch tree, filter, download files. Filters chatmode/, instructions/, prompts/ and .md/.txt; writes files flat to the prompts directory using the basename (last write wins). -- Configuration: src/configManager.ts – reads promptitude.*; repositoryConfigs parses url|branch; getPromptsDirectory returns OS-specific path; flags: enabled, syncOnStartup, showNotifications, debug, syncChatmode, syncInstructions, syncPrompt. +- Sync: src/syncManager.ts – schedules by promptitude.frequency; per-repo (url or url|branch, default main) select provider via GitProviderFactory, authenticate, fetch tree, filter, download files. Filters agents/ (and legacy chatmodes/), instructions/, prompts/ and .md/.txt; writes files to repository storage then creates symlinks (or copies on Windows without admin/dev mode) to active prompts directory using unique names when conflicts exist across repos. +- Configuration: src/configManager.ts – reads promptitude.*; repositoryConfigs parses url|branch; getPromptsDirectory returns OS-specific path; flags: enabled, syncOnStartup, showNotifications, debug, syncChatmode (supports agents/ and chatmodes/), syncInstructions, syncPrompt. - Providers: src/utils/github.ts (VS Code GitHub auth with scope repo; REST branches→sha→git/trees; contents for files). src/utils/azureDevOps.ts (PATs in SecretStorage; per-organization PAT index cached in globalState; supports dev.azure.com and legacy visualstudio.com; owner encoded as organization|project|baseUrl). - Utilities/UI: src/utils/fileSystem.ts (fs ops); src/utils/notifications.ts (messages + auth flows); src/utils/logger.ts (single "Promptitude" output channel; debug gated by setting); src/statusBarManager.ts (Idle/Syncing/Success/Error + last sync time; click triggers sync). +- Storage: Repository files stored in {globalStorageUri}/repos/{encoded_url}/; active prompts symlinked (or copied on Windows) to {globalStorageUri}/prompts/. Cross-platform path handling normalizes separators (/ vs \). Developer workflows - Build/package: npm install → npm run compile (or npm run watch) → npm run package (VSIX). Lint: npm run lint. Tests: npm run test. @@ -24,7 +25,9 @@ Conventions & patterns (repo-specific) - Always use FileSystemManager for IO and NotificationManager for UX/auth prompts; do not duplicate provider auth logic. - Settings drive behavior; avoid hard-coded paths/branches; use ConfigManager.repositoryConfigs for url|branch parsing. - Provider code lives in GitApiManager implementations; select via GitProviderFactory.createFromUrl(). -- Duplicate filenames across repos overwrite by last processed repo (flat output). Allowed file types: .md, .txt. +- Duplicate filenames across repos resolved with unique workspace names using repository identifiers. +- Allowed file types: .md, .txt. +- Cross-platform: Normalize path separators (replace \\ with / for comparisons); on Windows, symlink creation falls back to file copy if admin/dev mode unavailable; both symlinks and file copies tracked as "active" prompts. Examples - settings.json: diff --git a/CHANGELOG.md b/CHANGELOG.md index b99d1e1..bf350ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,7 @@ All notable changes to the "promptitude" extension will be documented in this fi ### Fixed -- Improved prompt management UI with bug fixes and stability improvements -- Fixed duplicate filename handling across multiple repositories -- Enhanced cross-platform compatibility -- Code quality improvements +- Fixed UI bugs including Windows path handling, activate/deactivate button behavior, and cross-platform compatibility issues ## [1.5.0] - 2025-11-12 diff --git a/README.md b/README.md index f2d4103..b436e8a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The Promptitude Extension automatically synchronizes the latest GitHub Copilot p - **🔄 Automatic Sync**: Configurable sync frequency (daily by default) - **📦 Multiple Repositories**: Support for syncing from multiple Git repositories simultaneously -- **🌍 Cross-Platform**: Works on macOS, Windows, and Linux +- **🌍 Cross-Platform**: Works on macOS, Windows, and Linux with intelligent symlink fallback - **⚙️ Configurable**: Customizable sync frequency and target directory - **🔐 Secure**: Uses your existing GitHub authentication from VS Code and secure PAT storage for Azure DevOps - **🌐 Multi-Provider**: Supports both GitHub and Azure DevOps repositories @@ -18,6 +18,7 @@ The Promptitude Extension automatically synchronizes the latest GitHub Copilot p - **🎨 User-Friendly**: Simple setup with minimal configuration required - **📊 Status Indicators**: Clear feedback on sync status and last update time - **🛡️ Error Handling**: Graceful handling of repository conflicts and partial failures +- **💻 Windows Compatibility**: Automatic fallback to file copies when symlinks aren't available (no admin rights needed) ## 🚀 Quick Start @@ -56,7 +57,7 @@ The Promptitude Extension automatically synchronizes the latest GitHub Copilot p | `promptitude.repositories` | Repositories with optional branch (use `url` or `url|branch`) | `[]` | array | | `promptitude.syncOnStartup` | Sync when VS Code starts | `true` | boolean | | `promptitude.showNotifications` | Show sync status notifications | `true` | boolean | -| `promptitude.syncChatmode` | Sync chatmode prompts | `true` | boolean | +| `promptitude.syncChatmode` | Sync agent prompts (supports both agents/ and legacy chatmodes/ directories) | `true` | boolean | | `promptitude.syncInstructions` | Sync instructions prompts | `false` | boolean | | `promptitude.syncPrompt` | Sync prompt files | `true` | boolean | @@ -80,6 +81,23 @@ You can override this by setting a custom path in `promptitude.customPath`. The extension will adapt this path automatically to function with non-default VS Code profiles. +### Platform-Specific Behavior + +#### Windows Symlink Handling + +On Windows, the extension intelligently handles file synchronization: + +- **With Developer Mode or Admin Rights**: Creates symlinks for optimal performance (files stay in sync automatically) +- **Without Special Permissions**: Automatically falls back to copying files (no admin rights required) +- Both approaches work seamlessly - the extension detects and manages active prompts correctly + +To enable symlinks on Windows 10/11 without admin rights: +1. Go to Settings → Update & Security → For developers +2. Enable "Developer Mode" +3. Restart VS Code + +**Note**: File copy mode works perfectly fine for most users and requires no special setup. + ### Multiple Repository Configuration The extension supports syncing from multiple Git repositories simultaneously. This is useful for organizations that maintain prompt collections across multiple repositories or for users who want to combine prompts from different sources. @@ -133,7 +151,8 @@ The extension adds a status bar item showing: The extension syncs all prompt files from the repository subdirectories into a flattened structure: ``` -chatmodes/*.md → User/prompts/ +agents/*.md → User/prompts/ +chatmodes/*.md → User/prompts/ (legacy support) instructions/*.md → User/prompts/ prompts/*.md → User/prompts/ ``` diff --git a/media/main.css b/media/main.css index d6f838e..c4215f4 100644 --- a/media/main.css +++ b/media/main.css @@ -69,6 +69,12 @@ text-transform: capitalize; } +.type-badge.type-agents { + background: var(--vscode-charts-blue); + color: white; +} + +/* Legacy support for chatmode class */ .type-badge.type-chatmode { background: var(--vscode-charts-blue); color: white; diff --git a/package.json b/package.json index f90284a..694524e 100644 --- a/package.json +++ b/package.json @@ -73,13 +73,13 @@ "promptitude.syncChatmode": { "type": "boolean", "default": true, - "description": "Sync chatmodes", + "description": "Sync agent prompts (supports both agents/ and legacy chatmodes/ directories)", "order": 2 }, "promptitude.syncInstructions": { "type": "boolean", - "default": false, - "description": "Sync instructions", + "default": true, + "description": "Sync instruction prompts", "order": 3 }, "promptitude.syncPrompt": { @@ -250,4 +250,4 @@ "@vscode/test-electron": "^2.3.0", "@vscode/vsce": "^2.19.0" } -} +} \ No newline at end of file diff --git a/src/configManager.ts b/src/configManager.ts index 65d1f02..1425b46 100644 --- a/src/configManager.ts +++ b/src/configManager.ts @@ -94,50 +94,109 @@ export class ConfigManager { return ConfigManager.SYNC_FREQUENCIES[this.frequency]; } + /** + * Get the VS Code User prompts directory where GitHub Copilot reads prompts from. + * This is where symlinks/copies should be created when activating prompts. + * + * - Default profile: /User/prompts + * - Named profile: /User/profiles//prompts + */ getPromptsDirectory(): string { if (this.customPath) { this.logger.info(`Using custom prompts path: ${this.customPath}`); return this.customPath; } - // Use profile-specific storage if context is available + // Use VS Code's storage path to determine the correct User directory if (this.context && this.context.globalStorageUri) { - // globalStorageUri is profile-specific in VS Code - // Example: /Users/username/Library/Application Support/Code/User/globalStorage/extension-id + // globalStorageUri path structure: + // - Default: /User/globalStorage/ + // - Profile: /User/profiles//globalStorage/ const globalStoragePath = this.context.globalStorageUri.fsPath; - return path.join(globalStoragePath, 'prompts'); - } - // Fallback to global user data directory (legacy behavior) - switch (process.platform) { - case 'win32': - return path.join(os.homedir(), 'AppData', 'Roaming', 'Code', 'User'); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User'); - case 'linux': - return path.join(os.homedir(), '.config', 'Code', 'User'); - default: - return path.join(os.homedir(), '.vscode', 'User'); + let userPromptsPath: string; + + // Check if we're in a profile + if (globalStoragePath.includes(`${path.sep}profiles${path.sep}`)) { + // Extract profile path: .../User/profiles//globalStorage/... -> .../User/profiles/ + const parts = globalStoragePath.split(path.sep); + const profilesIndex = parts.indexOf('profiles'); + + if (profilesIndex !== -1 && profilesIndex + 1 < parts.length) { + // Reconstruct path to profile's prompts directory + const profilePath = parts.slice(0, profilesIndex + 2).join(path.sep); + userPromptsPath = path.join(profilePath, 'prompts'); + } else { + // Fallback if parsing fails + this.logger.warn('Failed to parse profile path, using fallback'); + return this.getFallbackPromptsDirectory(); + } + } else { + // Default profile: .../User/globalStorage/... -> .../User + const parts = globalStoragePath.split(path.sep); + const userIndex = parts.indexOf('User'); + + if (userIndex !== -1) { + const userPath = parts.slice(0, userIndex + 1).join(path.sep); + userPromptsPath = path.join(userPath, 'prompts'); + } else { + // Fallback if parsing fails + this.logger.warn('Failed to parse User path, using fallback'); + return this.getFallbackPromptsDirectory(); + } + } + + if (this.debug) { + this.logger.debug(`VS Code User prompts directory: ${userPromptsPath}`); + } + + return userPromptsPath; } + + // Fallback to platform-specific user data directory + return this.getFallbackPromptsDirectory(); } /** - * Fallback to hardcoded paths (for backward compatibility when context is not available) + * Fallback to platform-specific VS Code User/prompts paths */ private getFallbackPromptsDirectory(): string { - this.logger.warn('Using fallback hardcoded prompts directory paths'); + this.logger.warn('Using fallback prompts directory paths (extension context not available)'); try { let promptsPath: string; switch (process.platform) { case 'win32': - promptsPath = path.join(os.homedir(), 'AppData', 'Roaming', 'Code', 'User', 'prompts'); + // Windows: C:\Users\username\AppData\Roaming\Code\User\prompts + promptsPath = path.join( + os.homedir(), + 'AppData', + 'Roaming', + 'Code', + 'User', + 'prompts' + ); break; case 'darwin': - promptsPath = path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'prompts'); + // macOS: /Users/username/Library/Application Support/Code/User/prompts + promptsPath = path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Code', + 'User', + 'prompts' + ); break; case 'linux': - promptsPath = path.join(os.homedir(), '.config', 'Code', 'User', 'prompts'); + // Linux: /home/username/.config/Code/User/prompts + promptsPath = path.join( + os.homedir(), + '.config', + 'Code', + 'User', + 'prompts' + ); break; default: promptsPath = path.join(os.homedir(), '.vscode', 'prompts'); @@ -149,8 +208,6 @@ export class ConfigManager { } return promptsPath; } catch (error) { - // If Node.js modules are not available, use a reasonable default - // This should not happen in a VS Code extension context, but provides safety throw new Error('Unable to determine prompts directory: Node.js environment not available'); } } @@ -168,7 +225,7 @@ export class ConfigManager { */ getUsedProviders(): Set { const providers = new Set(); - + // In getUsedProviders(): for (const repo of this.repositories) { const [url] = repo.split('|'); @@ -181,7 +238,7 @@ export class ConfigManager { // Ignore invalid URLs } } - + return providers; } @@ -204,7 +261,7 @@ export class ConfigManager { */ getRepositoriesByProvider(): Map { const providerMap = new Map(); - + // Sanitize branch suffix before detection for (const repo of this.repositories) { const [url] = repo.split('|'); diff --git a/src/constant.ts b/src/constant.ts index dce7dc4..b5f9830 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -1,3 +1,6 @@ -export const REPO_SYNC_CHAT_MODE_PATH = `chatmodes/`; +// Support both 'agents' and legacy 'chatmodes'/'chatmode' directories +export const REPO_SYNC_CHAT_MODE_PATH = `agents/`; +export const REPO_SYNC_CHAT_MODE_LEGACY_PATH = `chatmodes/`; +export const REPO_SYNC_CHAT_MODE_LEGACY_SINGULAR_PATH = `chatmode/`; export const REPO_SYNC_INSTRUCTIONS_PATH = `instructions/`; export const REPO_SYNC_PROMPT_PATH = `prompts/`; diff --git a/src/syncManager.ts b/src/syncManager.ts index f8f4888..c36b7ce 100644 --- a/src/syncManager.ts +++ b/src/syncManager.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import * as path from 'path'; +import * as os from 'os'; import { ConfigManager } from './configManager'; import { StatusBarManager, SyncStatus } from './statusBarManager'; import { Logger } from './utils/logger'; @@ -10,7 +11,7 @@ import { GitProviderFactory } from './utils/gitProviderFactory'; import { FileSystemManager } from './utils/fileSystem'; import { AzureDevOpsApiManager } from './utils/azureDevOps'; import { PromptTreeDataProvider } from './ui/promptTreeProvider'; -import { REPO_SYNC_CHAT_MODE_PATH, REPO_SYNC_INSTRUCTIONS_PATH, REPO_SYNC_PROMPT_PATH, } from './constant'; +import { REPO_SYNC_CHAT_MODE_PATH, REPO_SYNC_CHAT_MODE_LEGACY_PATH, REPO_SYNC_CHAT_MODE_LEGACY_SINGULAR_PATH, REPO_SYNC_INSTRUCTIONS_PATH, REPO_SYNC_PROMPT_PATH, } from './constant'; export interface SyncResult { success: boolean; itemsUpdated: number; @@ -52,10 +53,10 @@ export class SyncManager { async initialize(context: vscode.ExtensionContext): Promise { this.context = context; - + // Update notification manager with extension context this.notifications = new NotificationManager(this.config, this.context, this.logger); - + this.logger.info('Initializing SyncManager...'); // Migrate repository storage from old location to new location @@ -77,7 +78,7 @@ export class SyncManager { // Schedule periodic syncs this.scheduleNextSync(); - + this.logger.info('SyncManager initialized successfully'); } @@ -124,20 +125,20 @@ export class SyncManager { this.logger.error('All repositories failed to sync'); } } - + // Refresh tree provider after recreating symlinks if (this.treeProvider) { this.logger.debug('Refreshing tree provider after sync and symlink recreation'); this.treeProvider.refresh(); } - + // Schedule next sync this.scheduleNextSync(); - return { - success: result.overallSuccess, - itemsUpdated: result.totalItemsUpdated, - error: result.errors.length > 0 ? result.errors.join('; ') : undefined + return { + success: result.overallSuccess, + itemsUpdated: result.totalItemsUpdated, + error: result.errors.length > 0 ? result.errors.join('; ') : undefined }; } catch (error) { @@ -145,23 +146,28 @@ export class SyncManager { this.logger.error('Sync failed', error instanceof Error ? error : undefined); this.statusBar.setStatus(SyncStatus.Error, 'Sync failed'); await this.notifications.showSyncError(errorMessage); - + return { success: false, itemsUpdated: 0, error: errorMessage }; } } private filterRelevantFiles(tree: GitTreeItem[]): GitTreeItem[] { const allowedPaths: string[] = []; - + // Build list of allowed paths based on settings if (this.config.syncChatmode) { allowedPaths.push(REPO_SYNC_CHAT_MODE_PATH); + allowedPaths.push(REPO_SYNC_CHAT_MODE_LEGACY_PATH); // Support legacy chatmodes/ directory + allowedPaths.push(REPO_SYNC_CHAT_MODE_LEGACY_SINGULAR_PATH); // Support legacy chatmode/ directory (singular) + this.logger.debug(`Enabled sync for agents: ${REPO_SYNC_CHAT_MODE_PATH} and legacy: ${REPO_SYNC_CHAT_MODE_LEGACY_PATH}, ${REPO_SYNC_CHAT_MODE_LEGACY_SINGULAR_PATH}`); } if (this.config.syncInstructions) { allowedPaths.push(REPO_SYNC_INSTRUCTIONS_PATH); + this.logger.debug(`Enabled sync for instructions: ${REPO_SYNC_INSTRUCTIONS_PATH}`); } if (this.config.syncPrompt) { allowedPaths.push(REPO_SYNC_PROMPT_PATH); + this.logger.debug(`Enabled sync for prompts: ${REPO_SYNC_PROMPT_PATH}`); } // If no types are selected, return empty array @@ -170,27 +176,29 @@ export class SyncManager { return []; } + this.logger.debug(`Filtering ${tree.length} items, allowed paths: ${allowedPaths.join(', ')}`); + const filtered = tree.filter(item => { const isBlob = item.type === 'blob'; - + // Normalize path separators and remove leading slash for comparison const normalizedPath = item.path.replace(/\\/g, '/').replace(/^\/+/, ''); - + const matchesPath = allowedPaths.some(path => { const normalizedAllowedPath = path.replace(/\\/g, '/').replace(/^\/+/, ''); return normalizedPath.startsWith(normalizedAllowedPath); }); - + // Support more file extensions including .prompt.md - const isRelevantFile = item.path.endsWith('.md') || - item.path.endsWith('.txt'); - if(isRelevantFile) { + const isRelevantFile = item.path.endsWith('.md') || + item.path.endsWith('.txt'); + if (isRelevantFile) { this.logger.debug(` ${item.path}: blob=${isBlob}, matchesPath=${matchesPath}, (normalized: ${normalizedPath})`); } return isBlob && matchesPath && isRelevantFile; }); - + this.logger.debug(`Filtered result: ${filtered.length} files out of ${tree.length} total`); return filtered; } @@ -199,7 +207,7 @@ export class SyncManager { // Construct repository URL based on provider const providerName = gitApi.getProviderName(); let repositoryUrl: string; - + if (providerName === 'github') { repositoryUrl = `https://github.com/${owner}/${repo}`; } else if (providerName === 'azure') { @@ -207,10 +215,10 @@ export class SyncManager { } else { repositoryUrl = `${providerName}/${owner}/${repo}`; } - + const repoStoragePath = this.getRepositoryPath(repositoryUrl); await this.fileSystem.ensureDirectoryExists(repoStoragePath); - + let itemsUpdated = 0; for (const file of files) { @@ -230,26 +238,26 @@ export class SyncManager { // Save to repository storage directory const fileName = this.fileSystem.getBasename(file.path); const repoFilePath = this.fileSystem.joinPath(repoStoragePath, fileName); - + // Check if file needs updating - if(!content) { + if (!content) { this.logger.warn(`No content retrieved for ${file.path}, skipping`); continue; } const needsUpdate = await this.shouldUpdateFile(repoFilePath, content); - + if (needsUpdate) { await this.fileSystem.writeFileContent(repoFilePath, content); itemsUpdated++; this.logger.debug(`Updated file in repository storage: ${repoFilePath}`); - + // If this prompt is currently active, update the symlink in workspace await this.updateActivePromptSymlink(fileName, repositoryUrl); } else { this.logger.debug(`File unchanged in repository storage: ${repoFilePath}`); } - + } catch (error) { this.logger.warn(`Failed to sync file ${file.path}: ${error}`); // Continue with other files even if one fails @@ -266,10 +274,10 @@ export class SyncManager { try { const promptsDir = this.config.getPromptsDirectory(); - + // Ensure the prompts directory exists before trying to read it await this.fileSystem.ensureDirectoryExists(promptsDir); - + const existingFiles = await this.fileSystem.readDirectory(promptsDir); const allPrompts = this.treeProvider.getAllPrompts(); @@ -327,7 +335,7 @@ export class SyncManager { const branch = entry.branch; try { this.logger.debug(`Syncing repository: ${repoUrl}`); - + // Get or create Git API manager for this repository let gitApi = this.gitProviders.get(repoUrl); if (!gitApi) { @@ -354,7 +362,7 @@ export class SyncManager { } } } - + // Parse repository URL const { owner, repo } = gitApi.parseRepositoryUrl(repoUrl); this.logger.debug(`Syncing from ${owner}/${repo} branch ${branch}`); @@ -362,14 +370,14 @@ export class SyncManager { // Get repository tree const tree = await gitApi.getRepositoryTree(owner, repo, branch); this.logger.debug(`Retrieved repository tree with ${tree.tree.length} items for ${repoUrl}`); - + // Filter relevant files const relevantFiles = this.filterRelevantFiles(tree.tree); - - if(relevantFiles.length === 0) { + + if (relevantFiles.length === 0) { this.logger.warn(`No relevant files found to sync in ${repoUrl} based on current settings`); - const promptLocation = `${REPO_SYNC_CHAT_MODE_PATH}, ${REPO_SYNC_INSTRUCTIONS_PATH}, ${REPO_SYNC_PROMPT_PATH}`; + const promptLocation = `${REPO_SYNC_CHAT_MODE_PATH}, ${REPO_SYNC_CHAT_MODE_LEGACY_PATH}, ${REPO_SYNC_CHAT_MODE_LEGACY_SINGULAR_PATH}, ${REPO_SYNC_INSTRUCTIONS_PATH}, ${REPO_SYNC_PROMPT_PATH}`; results.push({ repository: repoUrl, success: false, @@ -383,7 +391,7 @@ export class SyncManager { // Sync files const itemsUpdated = await this.syncFiles(gitApi, owner, repo, relevantFiles, branch); - + results.push({ repository: repoUrl, success: true, @@ -396,7 +404,7 @@ export class SyncManager { } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; this.logger.warn(`Failed to sync repository ${repoUrl}: ${errorMessage}`); - + results.push({ repository: repoUrl, success: false, @@ -409,7 +417,7 @@ export class SyncManager { } const overallSuccess = results.every(r => r.success); - + return { overallSuccess, totalItemsUpdated, @@ -440,11 +448,11 @@ export class SyncManager { // Get the filename from the local path const fileName = this.fileSystem.getBasename(localPath); - + // Find the prompt in the tree provider by filename const allPrompts = this.treeProvider.getAllPrompts(); const matchingPrompt = allPrompts.find(prompt => prompt.name === fileName); - + if (!matchingPrompt) { // If prompt is not found in tree (new file), sync it this.logger.debug(`Prompt ${fileName} not found in tree, syncing as new file`); @@ -470,14 +478,14 @@ export class SyncManager { } const interval = this.config.getSyncInterval(); - + if (interval <= 0) { this.logger.debug('Manual sync mode, not scheduling automatic sync'); return; } this.logger.debug(`Scheduling next sync in ${interval}ms (${this.config.frequency})`); - + this.timer = setTimeout(() => { this.logger.info(`Automatic sync triggered (${this.config.frequency})`); this.syncNow(); @@ -487,7 +495,7 @@ export class SyncManager { async showStatus(): Promise { const syncTypes = []; if (this.config.syncChatmode) { - syncTypes.push('Chatmode'); + syncTypes.push('Agents'); } if (this.config.syncInstructions) { syncTypes.push('Instructions'); @@ -498,11 +506,11 @@ export class SyncManager { const repositories = this.config.repositories; const repoConfigs = this.config.repositoryConfigs; - + // Check authentication status for different providers const usedProviders = this.config.getUsedProviders(); const authStatus: string[] = []; - + if (usedProviders.has('github')) { try { const session = await vscode.authentication.getSession('github', ['repo'], { createIfNone: false }); @@ -511,7 +519,7 @@ export class SyncManager { authStatus.push('GitHub: ❌ Not authenticated'); } } - + if (usedProviders.has('azure') && this.context) { try { const azureManager = new AzureDevOpsApiManager(this.context); @@ -522,7 +530,7 @@ export class SyncManager { authStatus.push('Azure DevOps: ❌ No PATs configured'); } } - + const items = [ 'Sync Status', '──────────', @@ -535,7 +543,7 @@ export class SyncManager { `Debug Mode: ${this.config.debug ? '✅' : '❌'}`, `Active Prompts Only: ✅ ${this.getSyncStatistics()}`, ]; - + // Add authentication section if there are providers to show if (authStatus.length > 0) { items.push( @@ -562,7 +570,7 @@ export class SyncManager { '', 'Sync Types', '──────────', - `Chatmode: ${this.config.syncChatmode ? '✅' : '❌'}`, + `Agents: ${this.config.syncChatmode ? '✅' : '❌'}`, `Instructions: ${this.config.syncInstructions ? '✅' : '❌'}`, `Prompt: ${this.config.syncPrompt ? '✅' : '❌'}`, `Active Types: ${syncTypes.length > 0 ? syncTypes.join(', ') : 'None'}`, @@ -589,7 +597,7 @@ export class SyncManager { quickPick.title = 'Promptitude Extension Status'; quickPick.placeholder = 'Extension status and configuration'; quickPick.canSelectMany = false; - + quickPick.onDidAccept(() => { quickPick.hide(); }); @@ -600,18 +608,18 @@ export class SyncManager { async openPromptsFolder(): Promise { try { const promptsDir = this.config.getPromptsDirectory(); - + // Ensure directory exists await this.fileSystem.ensureDirectoryExists(promptsDir); - + // Open folder in system file explorer const folderUri = vscode.Uri.file(promptsDir); await vscode.commands.executeCommand('revealFileInOS', folderUri); - + this.logger.info(`Opened prompts folder: ${promptsDir}`); - + // Show info message - + } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; this.logger.error('Failed to open prompts folder', error instanceof Error ? error : undefined); @@ -623,11 +631,55 @@ export class SyncManager { * Get the directory where repositories are stored locally (separate from active prompts) */ private getRepositoryStorageDirectory(): string { - // Store repositories in a sibling directory to prompts - // This ensures profile-specific storage when using VS Code profiles - const promptsDir = this.config.getPromptsDirectory(); - const parentDir = path.dirname(promptsDir); - return this.fileSystem.joinPath(parentDir, 'repos'); + // Repository storage should be in globalStorage, not in User/prompts + // This keeps downloaded repository files separate from active prompts + if (this.context && this.context.globalStorageUri) { + return this.fileSystem.joinPath(this.context.globalStorageUri.fsPath, 'repos'); + } + + // Fallback: use platform-specific globalStorage path + let globalStoragePath: string; + const extensionId = 'logientnventive.promptitude-extension'; + + switch (process.platform) { + case 'win32': + globalStoragePath = path.join( + os.homedir(), + 'AppData', + 'Roaming', + 'Code', + 'User', + 'globalStorage', + extensionId + ); + break; + case 'darwin': + globalStoragePath = path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Code', + 'User', + 'globalStorage', + extensionId + ); + break; + case 'linux': + globalStoragePath = path.join( + os.homedir(), + '.config', + 'Code', + 'User', + 'globalStorage', + extensionId + ); + break; + default: + globalStoragePath = path.join(os.homedir(), '.vscode', 'globalStorage', extensionId); + break; + } + + return this.fileSystem.joinPath(globalStoragePath, 'repos'); } /** @@ -637,26 +689,26 @@ export class SyncManager { try { const fs = require('fs').promises; const promptsDir = this.config.getPromptsDirectory(); - + // Old location: inside prompts directory const oldRepoStorage = path.join(promptsDir, '.promptitude', 'repos'); - + // New location: outside prompts directory const newRepoStorage = this.repoStorageDir; - + // Check if old location exists and new location doesn't if (await this.fileSystem.directoryExists(oldRepoStorage)) { this.logger.info('Migrating repository storage to new location...'); - + // Ensure parent directory exists for new location const newParentDir = path.dirname(newRepoStorage); await this.fileSystem.ensureDirectoryExists(newParentDir); - + // Move the entire repos directory try { await fs.rename(oldRepoStorage, newRepoStorage); this.logger.info(`Successfully migrated repository storage from ${oldRepoStorage} to ${newRepoStorage}`); - + // Clean up old .promptitude directory if it's empty const oldPromptitudeDir = path.join(promptsDir, '.promptitude'); try { @@ -673,7 +725,7 @@ export class SyncManager { // If rename fails, try copy and delete this.logger.warn('Could not move repository storage, attempting copy...'); const ncp = require('child_process').spawnSync('cp', ['-R', oldRepoStorage, newRepoStorage]); - + if (ncp.status === 0) { // Delete old directory after successful copy await fs.rm(oldRepoStorage, { recursive: true, force: true }); @@ -703,43 +755,163 @@ export class SyncManager { } /** - * Create a symlink from repository storage to active prompts directory + * Generate a unique workspace filename for a prompt when there are conflicts across repositories + * @param fileName The original filename + * @param repositoryUrl The repository URL + * @returns Unique filename with repository identifier if needed */ - private async createPromptSymlink(sourcePath: string, targetPath: string): Promise { + private async getUniqueWorkspaceName(fileName: string, repositoryUrl: string): Promise { + // Check if this filename exists in multiple repositories + const repositories = this.config.repositoryConfigs; + const reposWithFile: string[] = []; + + for (const repoConfig of repositories) { + const repoPath = this.getRepositoryPath(repoConfig.url); + const filePath = this.fileSystem.joinPath(repoPath, fileName); + + if (await this.fileSystem.fileExists(filePath)) { + reposWithFile.push(repoConfig.url); + } + } + + // If file exists in only one repository, use original name + if (reposWithFile.length <= 1) { + return fileName; + } + + // File exists in multiple repos - need to make it unique + this.logger.debug(`File ${fileName} exists in ${reposWithFile.length} repositories, generating unique name`); + + // Extract a short identifier from the repository URL + const repoIdentifier = this.getRepositoryIdentifier(repositoryUrl); + + // Insert identifier before the file extension + const lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex > 0) { + const baseName = fileName.substring(0, lastDotIndex); + const extension = fileName.substring(lastDotIndex); + return `${baseName}@${repoIdentifier}${extension}`; + } else { + return `${fileName}@${repoIdentifier}`; + } + } + + /** + * Extract a short, readable identifier from a repository URL + * @param repositoryUrl The full repository URL + * @returns Short identifier (e.g., "org-repo" from "https://github.com/org/repo") + */ + private getRepositoryIdentifier(repositoryUrl: string): string { try { - // Ensure target directory exists - const targetDir = require('path').dirname(targetPath); - await this.fileSystem.ensureDirectoryExists(targetDir); + // Remove protocol and common prefixes + let identifier = repositoryUrl + .replace(/^https?:\/\//, '') + .replace(/^www\./, '') + .replace(/\.git$/, ''); + + // For GitHub URLs: github.com/org/repo -> org-repo + if (identifier.includes('github.com/')) { + const parts = identifier.split('github.com/')[1].split('/'); + if (parts.length >= 2) { + return `${parts[0]}-${parts[1]}`; + } + } - // Remove existing file/symlink if it exists - if (await this.fileSystem.fileExists(targetPath)) { - await vscode.workspace.fs.delete(vscode.Uri.file(targetPath)); + // For Azure DevOps: dev.azure.com/org/project/_git/repo -> org-project-repo + if (identifier.includes('dev.azure.com/')) { + const parts = identifier.split('/').filter(p => p && p !== '_git'); + if (parts.length >= 4) { + return `${parts[1]}-${parts[2]}-${parts[3]}`; + } } - // Create symlink using Node.js fs module - const fs = require('fs').promises; - await fs.symlink(sourcePath, targetPath, 'file'); - - this.logger.debug(`Created symlink: ${sourcePath} -> ${targetPath}`); + // Fallback: use last 2 path segments separated by dash + const pathParts = identifier.split('/').filter(p => p); + if (pathParts.length >= 2) { + return `${pathParts[pathParts.length - 2]}-${pathParts[pathParts.length - 1]}`; + } + + // Last resort: use the last path segment + return pathParts[pathParts.length - 1] || 'repo'; } catch (error) { - this.logger.error(`Failed to create symlink: ${sourcePath} -> ${targetPath}`, error instanceof Error ? error : undefined); - throw error; + this.logger.warn(`Failed to extract repository identifier from ${repositoryUrl}, using fallback`); + return 'repo'; + } + } + + /** + * Create a symlink from repository storage to active prompts directory + * On Windows, falls back to file copy if symlink creation fails (requires admin/developer mode) + */ + private async createPromptSymlink(sourcePath: string, targetPath: string): Promise { + this.logger.debug(`Creating symlink/copy: ${sourcePath} -> ${targetPath}`); + this.logger.debug(`Platform: ${process.platform}`); + + // Ensure target directory exists + const targetDir = require('path').dirname(targetPath); + await this.fileSystem.ensureDirectoryExists(targetDir); + + // Remove existing file/symlink if it exists + if (await this.fileSystem.fileExists(targetPath)) { + this.logger.debug(`Target already exists, removing: ${targetPath}`); + await vscode.workspace.fs.delete(vscode.Uri.file(targetPath)); + } + + // Try to create symlink + const fs = require('fs').promises; + try { + this.logger.debug(`Attempting to create symlink...`); + // On Windows, use 'file' type; on Unix, type parameter is optional but 'file' works + await fs.symlink(sourcePath, targetPath, 'file'); + this.logger.info(`✅ Created symlink: ${sourcePath} -> ${targetPath}`); + } catch (symlinkError: any) { + this.logger.debug(`Symlink creation failed with code: ${symlinkError?.code}`); + // Symlink creation failed (likely Windows without admin/dev mode) + // Fall back to copying the file + if (process.platform === 'win32' && symlinkError?.code === 'EPERM') { + this.logger.info(`Symlink not permitted on Windows, copying file instead: ${path.basename(targetPath)}`); + try { + const content = await fs.readFile(sourcePath, 'utf8'); + await fs.writeFile(targetPath, content, 'utf8'); + this.logger.info(`✅ Copied file as fallback: ${sourcePath} -> ${targetPath}`); + } catch (copyError) { + this.logger.error(`Failed to copy file as fallback: ${sourcePath} -> ${targetPath}`, copyError instanceof Error ? copyError : undefined); + throw copyError; + } + } else { + // On Unix systems or other Windows errors, symlink failure is more serious + this.logger.error(`Failed to create symlink: ${sourcePath} -> ${targetPath}`, symlinkError instanceof Error ? symlinkError : undefined); + throw symlinkError; + } + } + + // Verify the file was created + const targetExists = await this.fileSystem.fileExists(targetPath); + this.logger.debug(`Target file exists after operation: ${targetExists}`); + if (!targetExists) { + throw new Error(`Failed to create file at: ${targetPath}`); } } /** * Remove a symlink from the active prompts directory + * On Windows, also removes regular file copies (fallback when symlinks aren't available) */ private async removePromptSymlink(targetPath: string): Promise { + this.logger.debug(`Removing symlink/copy: ${targetPath}`); try { if (await this.fileSystem.fileExists(targetPath)) { // Check if it's actually a symlink before removing const fs = require('fs').promises; const stats = await fs.lstat(targetPath); - + if (stats.isSymbolicLink()) { await vscode.workspace.fs.delete(vscode.Uri.file(targetPath)); - this.logger.debug(`Removed symlink: ${targetPath}`); + this.logger.info(`✅ Removed symlink: ${targetPath}`); + } else if (process.platform === 'win32') { + // On Windows, we may have copied files instead of symlinks + await vscode.workspace.fs.delete(vscode.Uri.file(targetPath)); + this.logger.info(`✅ Removed file copy (Windows fallback): ${targetPath}`); } else { this.logger.warn(`File exists but is not a symlink: ${targetPath}`); } @@ -766,20 +938,24 @@ export class SyncManager { // Get all active prompts from tree provider const activePrompts = this.treeProvider.getSelectedPrompts(); let recreatedCount = 0; - + this.logger.debug(`Checking ${activePrompts.length} active prompts for missing symlinks`); for (const prompt of activePrompts) { try { const workspacePath = prompt.path; const exists = await this.fileSystem.fileExists(workspacePath); - + if (!exists && prompt.repositoryUrl) { // Symlink is missing, recreate it const fileName = this.fileSystem.getBasename(workspacePath); - await this.activatePrompt(fileName, prompt.repositoryUrl); + const actualWorkspaceName = await this.activatePrompt(prompt.name, prompt.repositoryUrl); + // Update the prompt's workspaceName if it changed + if (!prompt.workspaceName || actualWorkspaceName !== fileName) { + prompt.workspaceName = actualWorkspaceName; + } recreatedCount++; - this.logger.debug(`Recreated missing symlink for: ${prompt.name}`); + this.logger.debug(`Recreated missing symlink for: ${prompt.name} as ${actualWorkspaceName}`); } } catch (error) { this.logger.warn(`Failed to recreate symlink for ${prompt.name}: ${error}`); @@ -798,6 +974,7 @@ export class SyncManager { /** * Fix broken symlinks by checking if they point to valid targets, and recreating them if not + * On Windows, also checks file copies that may be outdated */ private async fixBrokenSymlinks(): Promise { const fs = require('fs').promises; @@ -807,7 +984,7 @@ export class SyncManager { try { // Ensure the prompts directory exists before trying to scan it await this.fileSystem.ensureDirectoryExists(promptsDir); - + const entries = await fs.readdir(promptsDir, { withFileTypes: true }); for (const entry of entries) { @@ -816,29 +993,32 @@ export class SyncManager { } const fullPath = path.join(promptsDir, entry.name); - + try { const stats = await fs.lstat(fullPath); - + // Check if it's a symlink if (stats.isSymbolicLink()) { const targetPath = await fs.readlink(fullPath); - + + // Normalize path separators for cross-platform comparison + const normalizedTargetPath = targetPath.replace(/\\/g, '/'); + // Check if target exists try { await fs.stat(fullPath); // This follows the symlink } catch (error) { // Target doesn't exist - broken symlink - this.logger.warn(`Found broken symlink: ${entry.name} -> ${targetPath}`); - + this.logger.warn(`Found broken symlink: ${entry.name} -> ${normalizedTargetPath}`); + // Try to extract repository URL from the target path - const repositoryUrl = this.extractRepositoryUrlFromTargetPath(targetPath); - + const repositoryUrl = this.extractRepositoryUrlFromTargetPath(normalizedTargetPath); + if (repositoryUrl) { // Check if file exists in new repository storage location const repoPath = this.getRepositoryPath(repositoryUrl); const newSourcePath = path.join(repoPath, entry.name); - + if (await this.fileSystem.fileExists(newSourcePath)) { // Remove broken symlink and recreate it await fs.unlink(fullPath); @@ -868,13 +1048,17 @@ export class SyncManager { /** * Extract repository URL from a symlink target path (including old location paths) + * Handles both Unix and Windows path separators */ private extractRepositoryUrlFromTargetPath(targetPath: string): string | undefined { try { + // Normalize path separators to forward slashes for consistent parsing + const normalizedPath = targetPath.replace(/\\/g, '/'); + // Split the path and look for the repos directory - const pathParts = targetPath.split(path.sep); + const pathParts = normalizedPath.split('/'); const reposIndex = pathParts.findIndex(part => part === 'repos'); - + if (reposIndex !== -1 && reposIndex + 1 < pathParts.length) { const encodedRepoUrl = pathParts[reposIndex + 1]; // Decode the repository URL @@ -883,7 +1067,7 @@ export class SyncManager { .replace(/^/, 'https://'); return decodedUrl; } - + return undefined; } catch (error) { this.logger.warn(`Failed to extract repository URL from target path: ${targetPath}`); @@ -896,23 +1080,25 @@ export class SyncManager { */ private async updateActivePromptSymlink(fileName: string, repositoryUrl: string): Promise { try { - const workspacePath = this.fileSystem.joinPath(this.config.getPromptsDirectory(), fileName); - + // Generate the unique workspace name for this prompt + const workspaceName = await this.getUniqueWorkspaceName(fileName, repositoryUrl); + const workspacePath = this.fileSystem.joinPath(this.config.getPromptsDirectory(), workspaceName); + // Check if there's a symlink for this prompt in the workspace if (await this.fileSystem.fileExists(workspacePath)) { const fs = require('fs').promises; const stats = await fs.lstat(workspacePath); - + if (stats.isSymbolicLink()) { // It's a symlink, update it to point to the new version const repoPath = this.getRepositoryPath(repositoryUrl); const newSourcePath = this.fileSystem.joinPath(repoPath, fileName); - + // Remove old symlink and create new one await this.removePromptSymlink(workspacePath); await this.createPromptSymlink(newSourcePath, workspacePath); - - this.logger.debug(`Updated symlink for active prompt: ${fileName}`); + + this.logger.debug(`Updated symlink for active prompt: ${fileName} -> ${workspaceName}`); } } } catch (error) { @@ -921,46 +1107,94 @@ export class SyncManager { } /** - * Activate a prompt by creating a symlink to it + * Activate a prompt by creating a symlink to it in the appropriate subdirectory */ - async activatePrompt(promptPath: string, repositoryUrl?: string): Promise { + async activatePrompt(promptPath: string, repositoryUrl?: string): Promise { if (!repositoryUrl) { - this.logger.warn(`Cannot activate prompt without repository URL: ${promptPath}`); - return; + const errorMsg = `Repository URL is required to activate prompt: ${promptPath}`; + this.logger.warn(errorMsg); + throw new Error(errorMsg); } + this.logger.info(`Activating prompt: ${promptPath} from repository: ${repositoryUrl}`); + try { const repoPath = this.getRepositoryPath(repositoryUrl); + this.logger.debug(`Repository storage path: ${repoPath}`); + const sourcePath = this.fileSystem.joinPath(repoPath, promptPath); - const targetPath = this.fileSystem.joinPath(this.config.getPromptsDirectory(), promptPath); + this.logger.debug(`Source file path: ${sourcePath}`); + + const sourceExists = await this.fileSystem.fileExists(sourcePath); + this.logger.debug(`Source file exists: ${sourceExists}`); - if (!(await this.fileSystem.fileExists(sourcePath))) { - throw new Error(`Source file does not exist in repository: ${sourcePath}`); + if (!sourceExists) { + const errorMsg = `Source file does not exist: ${sourcePath}`; + this.logger.error(errorMsg); + throw new Error(errorMsg); } + // Generate unique workspace name if there are conflicts + const workspaceName = await this.getUniqueWorkspaceName(promptPath, repositoryUrl); + this.logger.debug(`Workspace name: ${workspaceName}`); + + // Create target path directly in User/prompts/ (no subdirectories) + const promptsDir = this.config.getPromptsDirectory(); + const targetPath = this.fileSystem.joinPath(promptsDir, workspaceName); + this.logger.debug(`Target path: ${targetPath}`); + await this.createPromptSymlink(sourcePath, targetPath); - this.logger.info(`Activated prompt: ${promptPath}`); + this.logger.info(`✅ Successfully activated prompt: ${promptPath} as ${workspaceName}`); + + // Verify the file was created + const targetExists = await this.fileSystem.fileExists(targetPath); + if (!targetExists) { + throw new Error(`Target file was not created: ${targetPath}`); + } + + return workspaceName; // Return the actual workspace filename used } catch (error) { - this.logger.error(`Failed to activate prompt: ${promptPath}`, error instanceof Error ? error : undefined); + const errorMsg = error instanceof Error ? error.message : String(error); + this.logger.error(`❌ Failed to activate prompt: ${promptPath} - ${errorMsg}`, error instanceof Error ? error : undefined); throw error; } } + /** + * Determine prompt type from filename + */ + private determinePromptType(fileName: string): 'agents' | 'instructions' | 'prompts' { + const lowerName = fileName.toLowerCase(); + + // Support both 'agents' and legacy 'chatmode' naming + if (lowerName.includes('agent') || lowerName.includes('chatmode') || lowerName.includes('chat-mode')) { + return 'agents'; + } + + if (lowerName.includes('instruction') || lowerName.includes('guide')) { + return 'instructions'; + } + + return 'prompts'; + } + /** * Deactivate a prompt by removing its symlink + * @param promptPath The workspace filename (may include repository identifier) */ async deactivatePrompt(promptPath: string): Promise { try { - const targetPath = this.fileSystem.joinPath(this.config.getPromptsDirectory(), promptPath); + const promptsDir = this.config.getPromptsDirectory(); + const targetPath = this.fileSystem.joinPath(promptsDir, promptPath); + + this.logger.debug(`Deactivating prompt from: ${targetPath}`); await this.removePromptSymlink(targetPath); - this.logger.info(`Deactivated prompt: ${promptPath}`); + this.logger.info(`✅ Deactivated prompt: ${promptPath}`); } catch (error) { this.logger.error(`Failed to deactivate prompt: ${promptPath}`, error instanceof Error ? error : undefined); throw error; } - } - - /** + } /** * Clean up orphaned regular files in prompts directory that should be symlinks * Regular files that exist in repository storage should be removed from workspace */ @@ -972,10 +1206,10 @@ export class SyncManager { try { this.logger.info('Starting cleanup of orphaned prompts...'); - + // Ensure the prompts directory exists before trying to scan it await this.fileSystem.ensureDirectoryExists(promptsDir); - + const entries = await fs.readdir(promptsDir, { withFileTypes: true }); for (const entry of entries) { @@ -985,12 +1219,15 @@ export class SyncManager { } const fullPath = path.join(promptsDir, entry.name); - + try { const stats = await fs.lstat(fullPath); - + // Skip if it's already a symlink (these are the active prompts we want to keep) - if (stats.isSymbolicLink()) { + // On Windows with file copy fallback, we check differently + const isSymlink = stats.isSymbolicLink(); + + if (isSymlink) { continue; } @@ -1000,7 +1237,7 @@ export class SyncManager { // Use the base URL (without branch) for repository path computation const repoPath = this.getRepositoryPath(repoConfig.url); const repoFilePath = path.join(repoPath, entry.name); - + if (await this.fileSystem.fileExists(repoFilePath)) { existsInRepo = true; break; @@ -1008,7 +1245,19 @@ export class SyncManager { } // If the file exists in repository storage, it's an orphaned copy + // However, on Windows it might be an active file copy (not a symlink) + // So we need to be more careful if (existsInRepo) { + // On Windows, check if this is actually an active prompt (even if not a symlink) + if (process.platform === 'win32' && this.treeProvider) { + const allPrompts = this.treeProvider.getAllPrompts(); + const isActive = allPrompts.some(p => p.name === entry.name && p.active); + if (isActive) { + this.logger.debug(`Keeping active Windows file copy: ${entry.name}`); + continue; + } + } + await fs.unlink(fullPath); removed++; this.logger.info(`Removed orphaned file: ${entry.name}`); @@ -1033,7 +1282,7 @@ export class SyncManager { dispose(): void { this.logger.info('Disposing SyncManager...'); - + if (this.timer) { clearTimeout(this.timer); this.timer = null; diff --git a/src/ui/promptCardsWebview.ts b/src/ui/promptCardsWebview.ts index 55383b6..232e930 100644 --- a/src/ui/promptCardsWebview.ts +++ b/src/ui/promptCardsWebview.ts @@ -15,7 +15,7 @@ export class PromptCardsWebviewProvider implements vscode.WebviewViewProvider { private readonly promptTreeProvider: PromptTreeDataProvider ) { this.logger = Logger.get('PromptCardsWebview'); - + // Listen to tree provider changes and update webview this.promptTreeProvider.onDidChangeTreeData(() => { this.logger.debug('Tree data changed, updating webview'); @@ -42,21 +42,26 @@ export class PromptCardsWebviewProvider implements vscode.WebviewViewProvider { // Handle messages from the webview webviewView.webview.onDidReceiveMessage( message => { - this.logger.debug(`Received message from webview: ${message.command}`); + this.logger.debug(`[WebView] Received message: ${JSON.stringify(message)}`); switch (message.command) { case 'refresh': - this.logger.debug('Manual refresh requested from webview'); + this.logger.debug('[WebView] Manual refresh requested from webview'); this.refresh(); break; case 'togglePrompt': + this.logger.info(`[WebView] togglePrompt message received for: ${message.promptPath}`); this.togglePrompt(message.promptPath); break; case 'viewPrompt': + this.logger.debug(`[WebView] viewPrompt message received for: ${message.promptPath}`); this.viewPrompt(message.promptPath); break; case 'openRepository': + this.logger.debug(`[WebView] openRepository message received for: ${message.repositoryUrl}`); this.openRepository(message.repositoryUrl); break; + default: + this.logger.warn(`[WebView] Unknown message command: ${message.command}`); } }, undefined, @@ -88,15 +93,40 @@ export class PromptCardsWebviewProvider implements vscode.WebviewViewProvider { private async togglePrompt(promptPath: string) { try { - const promptInfo = this.promptTreeProvider.getAllPrompts().find(p => p.path === promptPath); + this.logger.info(`[WebView] togglePrompt called with path: ${promptPath}`); + + // Normalize paths for cross-platform comparison (Windows uses backslashes) + const normalizedPromptPath = promptPath.replace(/\\/g, '/'); + this.logger.debug(`[WebView] Normalized path: ${normalizedPromptPath}`); + + const allPrompts = this.promptTreeProvider.getAllPrompts(); + this.logger.debug(`[WebView] Total prompts available: ${allPrompts.length}`); + + const promptInfo = allPrompts.find(p => { + const normalizedPath = p.path.replace(/\\/g, '/'); + return normalizedPath === normalizedPromptPath; + }); + if (promptInfo) { + this.logger.info(`[WebView] Found prompt: ${promptInfo.name}, active: ${promptInfo.active}`); + this.logger.debug(`[WebView] Executing command: prompts.toggleSelection`); + // Use the command that handles symlink creation/removal await vscode.commands.executeCommand('prompts.toggleSelection', promptInfo); + + this.logger.debug(`[WebView] Command executed successfully, updating webview`); + // Update webview after successful toggle (command handles its own refresh) this.updateWebview(); + } else { + this.logger.warn(`[WebView] Prompt not found for path: ${promptPath}`); + this.logger.debug(`[WebView] Available paths: ${allPrompts.map(p => p.path).join(', ')}`); + vscode.window.showErrorMessage('Prompt not found'); } } catch (error) { - this.logger.error('Failed to toggle prompt:', error as Error); + this.logger.error('[WebView] Failed to toggle prompt:', error as Error); vscode.window.showErrorMessage(`Failed to toggle prompt: ${error}`); + // Refresh to ensure UI shows correct state + this.updateWebview(); } } @@ -117,9 +147,9 @@ export class PromptCardsWebviewProvider implements vscode.WebviewViewProvider { if (!repositoryUrl) { return; } - + this.logger.info(`Opening repository in browser: ${repositoryUrl}`); - + // Open the URL in the default browser await vscode.env.openExternal(vscode.Uri.parse(repositoryUrl)); } catch (error) { @@ -374,8 +404,8 @@ export class PromptCardsWebviewProvider implements vscode.WebviewViewProvider { border-radius: 10px; } - .category-section.chatmode .category-header { - border-bottom-color: var(--chatmode-color); + .category-section.agents .category-header { + border-bottom-color: var(--agents-color); } .category-section.instructions .category-header { @@ -432,8 +462,8 @@ export class PromptCardsWebviewProvider implements vscode.WebviewViewProvider { opacity: 0.6; } - .prompt-card.chatmode.active::before { - background-color: var(--chatmode-color); + .prompt-card.agents.active::before { + background-color: var(--agents-color); } .prompt-card.instructions.active::before { @@ -684,14 +714,14 @@ export class PromptCardsWebviewProvider implements vscode.WebviewViewProvider { const counts = { all: allPrompts.length, - chatmode: allPrompts.filter(p => p.type === 'chatmode').length, + agents: allPrompts.filter(p => p.type === 'agents').length, prompts: allPrompts.filter(p => p.type === 'prompts').length, instructions: allPrompts.filter(p => p.type === 'instructions').length }; const activeCounts = { all: allPrompts.filter(p => p.active).length, - chatmode: allPrompts.filter(p => p.type === 'chatmode' && p.active).length, + agents: allPrompts.filter(p => p.type === 'agents' && p.active).length, prompts: allPrompts.filter(p => p.type === 'prompts' && p.active).length, instructions: allPrompts.filter(p => p.type === 'instructions' && p.active).length }; @@ -768,9 +798,9 @@ export class PromptCardsWebviewProvider implements vscode.WebviewViewProvider { 📋 All \${activeCounts.all}/\${counts.all} - @@ -968,10 +1000,10 @@ export class PromptCardsWebviewProvider implements vscode.WebviewViewProvider { function cleanPromptName(filename) { // Remove common extensions: .prompt.md, .chatmode.md, .instructions.md, .md, .txt return filename - .replace(/\\.prompt\\.md$/, '') - .replace(/\\.chatmode\\.md$/, '') - .replace(/\\.instructions\\.md$/, '') - .replace(/\\.(md|txt)$/, ''); + .replace(/\\\\.prompt\\\\.md$/, '') + .replace(/\\\\.chatmode\\\\.md$/, '') + .replace(/\\\\.instructions\\\\.md$/, '') + .replace(/\\\\.(md|txt)$/, ''); } function getRepositoryName(repositoryUrl) { @@ -1010,10 +1042,12 @@ export class PromptCardsWebviewProvider implements vscode.WebviewViewProvider { } function togglePrompt(promptPath) { + console.log('[Promptitude WebView] togglePrompt called with:', promptPath); vscode.postMessage({ command: 'togglePrompt', promptPath: promptPath }); + console.log('[Promptitude WebView] Message posted to extension'); } function viewPrompt(promptPath) { diff --git a/src/ui/promptCommands.ts b/src/ui/promptCommands.ts index 3193748..dc07fab 100644 --- a/src/ui/promptCommands.ts +++ b/src/ui/promptCommands.ts @@ -26,8 +26,8 @@ export class PromptCommandManager { this.refreshPrompts(); }); - const toggleSelectionCommand = vscode.commands.registerCommand('prompts.toggleSelection', (item: PromptTreeItem | PromptInfo) => { - this.toggleSelection(item); + const toggleSelectionCommand = vscode.commands.registerCommand('prompts.toggleSelection', async (item: PromptTreeItem | PromptInfo) => { + await this.toggleSelection(item); }); const selectAllCommand = vscode.commands.registerCommand('prompts.selectAll', () => { @@ -39,20 +39,20 @@ export class PromptCommandManager { }); // Prompt action commands - const editPromptCommand = vscode.commands.registerCommand('prompts.editPrompt', (item: PromptTreeItem | PromptInfo) => { - this.editPrompt(item); + const editPromptCommand = vscode.commands.registerCommand('prompts.editPrompt', async (item: PromptTreeItem | PromptInfo) => { + await this.editPrompt(item); }); - const viewPromptCommand = vscode.commands.registerCommand('prompts.viewPrompt', (item: PromptTreeItem | PromptInfo) => { - this.viewPrompt(item); + const viewPromptCommand = vscode.commands.registerCommand('prompts.viewPrompt', async (item: PromptTreeItem | PromptInfo) => { + await this.viewPrompt(item); }); - const deletePromptCommand = vscode.commands.registerCommand('prompts.deletePrompt', (item: PromptTreeItem | PromptInfo) => { - this.deletePrompt(item); + const deletePromptCommand = vscode.commands.registerCommand('prompts.deletePrompt', async (item: PromptTreeItem | PromptInfo) => { + await this.deletePrompt(item); }); - const duplicatePromptCommand = vscode.commands.registerCommand('prompts.duplicatePrompt', (item: PromptTreeItem | PromptInfo) => { - this.duplicatePrompt(item); + const duplicatePromptCommand = vscode.commands.registerCommand('prompts.duplicatePrompt', async (item: PromptTreeItem | PromptInfo) => { + await this.duplicatePrompt(item); }); // Settings command @@ -92,42 +92,88 @@ export class PromptCommandManager { } this.logger.info(`Toggling selection for: ${promptInfo.name}, current active: ${promptInfo.active}, repositoryUrl: ${promptInfo.repositoryUrl || 'none'}`); + this.logger.debug(`Prompt info details - path: ${promptInfo.path}, workspaceName: ${promptInfo.workspaceName || 'none'}`); - // Toggle the selection in the tree provider first + // Store the current state before toggling + const wasActive = promptInfo.active; + this.logger.debug(`State before toggle: wasActive = ${wasActive}`); + + // Toggle the selection in the tree provider first (optimistic update) this.treeProvider.toggleSelection(promptInfo); - this.webviewProvider.updateSelectionStatus(promptInfo); - + this.logger.debug(`State after toggle: promptInfo.active = ${promptInfo.active}`); + // Handle symlink creation/removal if SyncManager is available if (this.syncManager) { + this.logger.debug(`SyncManager is available, proceeding with file operations`); if (promptInfo.active) { // Prompt was activated - create symlink const repositoryUrl = promptInfo.repositoryUrl; if (repositoryUrl) { this.logger.info(`Activating prompt: ${promptInfo.name} with URL: ${repositoryUrl}`); - await this.syncManager.activatePrompt(promptInfo.name, repositoryUrl); - this.logger.info(`Successfully created symlink for activated prompt: ${promptInfo.name}`); - vscode.window.showInformationMessage(`✅ Activated prompt: ${promptInfo.name}`); + + // Show immediate feedback + const activatingMsg = vscode.window.setStatusBarMessage(`$(sync~spin) Activating prompt: ${promptInfo.name}...`); + + try { + const actualWorkspaceName = await this.syncManager.activatePrompt(promptInfo.name, repositoryUrl); + // Update the workspace name in the prompt info + if (actualWorkspaceName !== promptInfo.name) { + promptInfo.workspaceName = actualWorkspaceName; + // Update the path to reflect the actual workspace filename + const promptsDir = this.config.getPromptsDirectory(); + promptInfo.path = this.fileSystem.joinPath(promptsDir, actualWorkspaceName); + } + this.logger.info(`Successfully created symlink/copy for activated prompt: ${promptInfo.name} as ${actualWorkspaceName}`); + activatingMsg.dispose(); + + // File operation succeeded - refresh tree and update webview + this.treeProvider.refresh(); + this.webviewProvider.updateSelectionStatus(promptInfo); + vscode.window.showInformationMessage(`✅ Activated prompt: ${promptInfo.name}`); + } catch (activationError) { + activatingMsg.dispose(); + // Revert the toggle since activation failed + this.treeProvider.toggleSelection(promptInfo); + this.webviewProvider.updateSelectionStatus(promptInfo); + throw activationError; + } } else { const errorMsg = `No repository URL found for prompt: ${promptInfo.name}. Cannot create symlink.`; this.logger.error(errorMsg); vscode.window.showErrorMessage(errorMsg); // Revert the toggle since we couldn't create the symlink this.treeProvider.toggleSelection(promptInfo); + this.webviewProvider.updateSelectionStatus(promptInfo); } } else { // Prompt was deactivated - remove symlink - this.logger.info(`Deactivating prompt: ${promptInfo.name}`); - await this.syncManager.deactivatePrompt(promptInfo.name); - this.logger.info(`Successfully removed symlink for deactivated prompt: ${promptInfo.name}`); - vscode.window.showInformationMessage(`✅ Deactivated prompt: ${promptInfo.name}`); + // Use workspaceName if available, otherwise fall back to name + const nameToDeactivate = promptInfo.workspaceName || promptInfo.name; + this.logger.info(`Deactivating prompt: ${nameToDeactivate}`); + + try { + await this.syncManager.deactivatePrompt(nameToDeactivate); + this.logger.info(`Successfully removed symlink/copy for deactivated prompt: ${nameToDeactivate}`); + + // File operation succeeded - refresh tree and update webview + this.treeProvider.refresh(); + this.webviewProvider.updateSelectionStatus(promptInfo); + vscode.window.showInformationMessage(`✅ Deactivated prompt: ${promptInfo.name}`); + } catch (deactivationError) { + // Revert the toggle since deactivation failed + this.treeProvider.toggleSelection(promptInfo); + this.webviewProvider.updateSelectionStatus(promptInfo); + throw deactivationError; + } } } else { this.logger.error('SyncManager not available - cannot create/remove symlinks'); vscode.window.showErrorMessage('SyncManager not available'); // Revert the toggle this.treeProvider.toggleSelection(promptInfo); + this.webviewProvider.updateSelectionStatus(promptInfo); } - + const status = promptInfo.active ? 'activated' : 'deactivated'; this.logger.info(`Prompt ${promptInfo.name} ${status}`); } catch (error) { @@ -162,12 +208,12 @@ export class PromptCommandManager { try { // Get all currently selected prompts before deselecting const selected = [...this.treeProvider.getSelectedPrompts()]; - + // Deactivate each prompt using toggleSelection to ensure symlinks are removed for (const prompt of selected) { await this.toggleSelection(prompt); } - + vscode.window.showInformationMessage('All prompts deactivated'); this.logger.debug('Deactivated all prompts'); } catch (error) { @@ -227,7 +273,7 @@ export class PromptCommandManager { await vscode.workspace.fs.delete(vscode.Uri.file(promptInfo.path)); this.treeProvider.refresh(); this.webviewProvider.clearPrompt(); - + vscode.window.showInformationMessage(`Prompt "${promptInfo.name}" deleted successfully`); this.logger.debug(`Deleted prompt: ${promptInfo.name}`); } @@ -249,7 +295,7 @@ export class PromptCommandManager { const baseName = promptInfo.name.replace(/\.[^/.]+$/, ''); const extension = promptInfo.name.substring(baseName.length); let newName = `${baseName}_copy${extension}`; - + // Ensure unique filename const promptsDir = this.config.getPromptsDirectory(); let counter = 1; @@ -259,14 +305,14 @@ export class PromptCommandManager { } const newPath = this.fileSystem.joinPath(promptsDir, newName); - + // Copy content const content = await this.fileSystem.readFileContent(promptInfo.path); await this.fileSystem.writeFileContent(newPath, content); - + // Refresh tree view this.treeProvider.refresh(); - + vscode.window.showInformationMessage(`Prompt duplicated as "${newName}"`); this.logger.debug(`Duplicated prompt ${promptInfo.name} as ${newName}`); } catch (error) { @@ -289,11 +335,11 @@ export class PromptCommandManager { if ('promptInfo' in item) { return item.promptInfo; } - + if ('name' in item && 'path' in item && 'type' in item) { return item as PromptInfo; } - + return undefined; } @@ -301,7 +347,7 @@ export class PromptCommandManager { async deleteSelectedPrompts(): Promise { try { const selectedPrompts = this.treeProvider.getSelectedPrompts(); - + if (selectedPrompts.length === 0) { vscode.window.showInformationMessage('No active prompts to delete'); return; @@ -349,7 +395,7 @@ export class PromptCommandManager { async exportSelectedPrompts(): Promise { try { const selectedPrompts = this.treeProvider.getSelectedPrompts(); - + if (selectedPrompts.length === 0) { vscode.window.showInformationMessage('No active prompts to export'); return; diff --git a/src/ui/promptDetailsWebview.ts b/src/ui/promptDetailsWebview.ts index c7a0d60..c67824e 100644 --- a/src/ui/promptDetailsWebview.ts +++ b/src/ui/promptDetailsWebview.ts @@ -446,7 +446,7 @@ export class PromptDetailsWebviewProvider implements vscode.WebviewViewProvider // Update source if from repository if (data.prompt.repositoryUrl) { const repoName = extractRepositoryName(data.prompt.repositoryUrl); - promptSource.innerHTML = \`\${repoName} 🔗\`; + promptSource.innerHTML = \`\${repoName}\`; // Add click handler for the link const repoLink = promptSource.querySelector('.repo-link'); diff --git a/src/ui/promptTreeProvider.ts b/src/ui/promptTreeProvider.ts index f32d87f..f619890 100644 --- a/src/ui/promptTreeProvider.ts +++ b/src/ui/promptTreeProvider.ts @@ -1,21 +1,23 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; +import * as os from 'os'; import { ConfigManager } from '../configManager'; import { FileSystemManager } from '../utils/fileSystem'; import { Logger } from '../utils/logger'; -import { decodeRepositorySlug } from '../storage/repositoryStorage'; +import { decodeRepositorySlug, encodeRepositorySlug } from '../storage/repositoryStorage'; export interface PromptInfo { - name: string; + name: string; // Original filename from repository path: string; - type: 'chatmode' | 'instructions' | 'prompts'; + type: 'agents' | 'instructions' | 'prompts'; size: number; lastModified: Date; lineCount: number; active: boolean; repositoryUrl?: string; // The repository URL this prompt came from description?: string; // Extracted description from prompt content + workspaceName?: string; // Unique name used in workspace (may differ from name if there are conflicts) } export class PromptTreeItem extends vscode.TreeItem { @@ -55,7 +57,7 @@ export class PromptTreeItem extends vscode.TreeItem { private getTypeIcon(): vscode.ThemeIcon { switch (this.promptInfo.type) { - case 'chatmode': + case 'agents': return new vscode.ThemeIcon('comment-discussion'); case 'instructions': return new vscode.ThemeIcon('book'); @@ -91,7 +93,7 @@ export class CategoryTreeItem extends vscode.TreeItem { private getIcon(): vscode.ThemeIcon { switch (this.category.toLowerCase()) { - case 'chatmode': + case 'agents': return new vscode.ThemeIcon('comment-discussion'); case 'instructions': return new vscode.ThemeIcon('book'); @@ -136,7 +138,7 @@ export class PromptTreeDataProvider implements vscode.TreeDataProvider p.active).length; categories.push(new CategoryTreeItem( @@ -196,7 +198,7 @@ export class PromptTreeDataProvider implements vscode.TreeDataProvider a.name.localeCompare(b.name)) .map(prompt => new PromptTreeItem(prompt, vscode.TreeItemCollapsibleState.None)); @@ -204,7 +206,7 @@ export class PromptTreeDataProvider implements vscode.TreeDataProvider