Skip to content

Commit 50b0bd8

Browse files
authored
Merge pull request #231 from TETEFFF/wsl-compatibility-bug-fix
Wsl compatibility bug fix
2 parents aa7abd6 + 64b1af5 commit 50b0bd8

3 files changed

Lines changed: 420 additions & 31 deletions

File tree

src/services/user-scope-service.ts

Lines changed: 129 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
* Requirements: 9.1-9.5
1616
*/
1717

18+
import {
19+
execSync,
20+
} from 'node:child_process';
1821
import * as fs from 'node:fs';
1922
import * as os from 'node:os';
2023
import * as path from 'node:path';
@@ -60,17 +63,78 @@ export interface CopilotFile {
6063
targetPath: string;
6164
}
6265

66+
type CodeFlavourFolder = 'Code' | 'Code - Insiders';
67+
6368
/**
6469
* Service to sync bundle prompts to GitHub Copilot's native directories at user level.
6570
* Implements IScopeService for consistent scope handling.
6671
*/
6772
export class UserScopeService implements IScopeService {
6873
private readonly logger: Logger;
74+
private readonly appNameMap: Map<string, CodeFlavourFolder> = new Map([
75+
['vscode', 'Code'],
76+
['vscode-insiders', 'Code - Insiders']
77+
]);
78+
79+
private windowsHomeInWSL: string | undefined;
80+
private cachedPromptsDir: string | undefined;
6981

7082
constructor(private readonly context: vscode.ExtensionContext) {
7183
this.logger = Logger.getInstance();
7284
}
7385

86+
/**
87+
* Function to detect if the extension is running in a WSL remote context
88+
* We need this to determine if we should sync to Windows filesystem instead of WSL filesystem
89+
* @returns True if running in WSL, false otherwise
90+
*/
91+
private isRunningInWSL(): boolean {
92+
return vscode.env.remoteName === 'wsl';
93+
}
94+
95+
/**
96+
* When running on wsl, get the Windows home directory
97+
* @returns The Windows home directory path in WSL as mnt/c/Users/<User>
98+
*/
99+
private getWindowsHomeDirectoryInWSL(): string | undefined {
100+
if (this.windowsHomeInWSL) {
101+
return this.windowsHomeInWSL;
102+
}
103+
try {
104+
const wslWindowsHome = execSync(`wslpath -u "$(cmd.exe /c echo %USERPROFILE% 2>/dev/null)"`, { encoding: 'utf8', timeout: 5000 }).trim();
105+
this.logger.info(`[UserScopeService] Detected Windows home directory in WSL: ${wslWindowsHome}`);
106+
this.windowsHomeInWSL = wslWindowsHome;
107+
return wslWindowsHome;
108+
} catch (error) {
109+
this.logger.error('Failed to get Windows home directory in WSL', error as Error);
110+
return undefined;
111+
}
112+
}
113+
114+
/**
115+
* Get the Windows User directory when running in WSL.
116+
* Since we construct the path ourselves, we return the User dir directly
117+
* instead of globalStorage (avoiding redundant path parsing downstream).
118+
* @returns The User directory path in WSL, or undefined if not in WSL or detection fails.
119+
*/
120+
private getWindowsWslUserDir(): string | undefined {
121+
if (!this.isRunningInWSL()) {
122+
return undefined;
123+
}
124+
try {
125+
const windowsHome = this.getWindowsHomeDirectoryInWSL();
126+
if (windowsHome) {
127+
const folderName = this.appNameMap.get(vscode.env.uriScheme) || 'Code';
128+
const userDir = path.join(windowsHome, 'AppData', 'Roaming', folderName, 'User');
129+
this.logger.debug(`[UserScopeService] Resolved WSL User directory: ${userDir}`);
130+
return userDir;
131+
}
132+
} catch {
133+
this.logger.warn('[UserScopeService] Failed to resolve Windows path, falling back to WSL path');
134+
}
135+
return undefined;
136+
}
137+
74138
/**
75139
* Get the Copilot prompts directory for current VSCode flavor
76140
* Uses the extension's globalStorageUri to dynamically determine the IDE's data directory
@@ -86,44 +150,66 @@ export class UserScopeService implements IScopeService {
86150
* so we need to sync prompts to the Windows filesystem, not the WSL filesystem.
87151
*/
88152
private getCopilotPromptsDirectory(): string {
89-
const globalStoragePath = this.context.globalStorageUri.fsPath;
153+
if (this.cachedPromptsDir) {
154+
return this.cachedPromptsDir;
155+
}
90156

91-
// Find the User directory by looking for '/User/' or '\User\' in the path
92-
const userIndex = globalStoragePath.lastIndexOf(path.sep + 'User' + path.sep);
93-
const escapedSep = escapeRegex(path.sep);
157+
const resolved = this.resolveCopilotPromptsDirectory();
94158

95-
if (userIndex === -1) {
96-
// Fallback: Custom user-data-dir without 'User' directory
97-
// Navigate up from globalStorage/publisher.extension
98-
const baseDir = path.dirname(path.dirname(globalStoragePath));
99-
100-
// Check if we're in a profiles structure
101-
const [, profileId] = baseDir.match(new RegExp(`profiles${escapedSep}([^${escapedSep}]+)`)) || [];
102-
if (profileId) {
103-
const profileName = this.getActiveProfileName(baseDir) || profileId;
104-
this.logger.info(`[UserScopeService] Using profile: ${profileName}`);
105-
return path.join(baseDir, 'prompts');
106-
}
159+
// Sanity check: the resolved path should end with 'prompts' and not be a filesystem root
160+
const basename = path.basename(resolved);
161+
if (basename !== 'prompts' || resolved === path.dirname(resolved)) {
162+
this.logger.warn(`[UserScopeService] Resolved prompts directory looks suspicious: ${resolved}`);
163+
}
164+
165+
this.cachedPromptsDir = resolved;
166+
return resolved;
167+
}
168+
169+
private resolveCopilotPromptsDirectory(): string {
170+
const globalStoragePath = this.context.globalStorageUri.fsPath;
171+
this.logger.debug(`[UserScopeService] Original globalStorage path: ${globalStoragePath}`);
172+
173+
// WSL: we construct the path ourselves, so we know the User dir directly
174+
const wslUserDir = this.getWindowsWslUserDir();
175+
if (wslUserDir) {
176+
return this.resolvePromptsFromUserDir(wslUserDir);
177+
}
107178

108-
return path.join(baseDir, 'prompts');
179+
if (this.isRunningInWSL()) {
180+
this.logger.warn('[UserScopeService] Unable to resolve Windows path from WSL. Prompts may not be visible to Copilot.');
181+
vscode.window.showWarningMessage('Prompt Registry: Unable to resolve Windows path from WSL. Prompts may not be visible to Copilot.');
109182
}
110183

111-
// Extract path up to and including 'User'
112-
const userDir = globalStoragePath.substring(0, userIndex + path.sep.length + 'User'.length);
184+
// Non-WSL: parse the User dir from the globalStorage path
185+
const userIndex = globalStoragePath.lastIndexOf(path.sep + 'User' + path.sep);
186+
const userDir = userIndex === -1
187+
? path.dirname(path.dirname(globalStoragePath))
188+
: globalStoragePath.substring(0, userIndex + path.sep.length + 'User'.length);
113189

114-
// Check if this is a profile-based path by looking for '/profiles/' after User
190+
// Check if the globalStorage path itself contains a profile segment
115191
// Path structure: .../User/profiles/<profile-id>/globalStorage/...
116192
const remainingPath = globalStoragePath.substring(userDir.length);
193+
const escapedSep = escapeRegex(path.sep);
117194
const profilesMatch = remainingPath.match(new RegExp(`^${escapedSep}profiles${escapedSep}([^${escapedSep}]+)`));
118-
119195
if (profilesMatch) {
120-
// Profile-based path: include the profile directory
121196
const profileId = profilesMatch[1];
122197
const profileName = this.getActiveProfileName(userDir) || profileId;
123198
this.logger.info(`[UserScopeService] Using profile: ${profileName}`);
124199
return path.join(userDir, 'profiles', profileId, 'prompts');
125200
}
126201

202+
return this.resolvePromptsFromUserDir(userDir);
203+
}
204+
205+
/**
206+
* Given a known User directory, resolve the prompts directory
207+
* (handles profile detection for both WSL and non-WSL paths).
208+
* @param userDir
209+
*/
210+
private resolvePromptsFromUserDir(userDir: string): string {
211+
this.logger.debug(`[UserScopeService] Resolved User directory: ${userDir}`);
212+
127213
// Extension installed globally but user might be in a profile
128214
// Use combined detection method (storage.json + filesystem heuristic)
129215
const detectedProfile = this.detectActiveProfile(userDir);
@@ -353,8 +439,12 @@ export class UserScopeService implements IScopeService {
353439
// Always remove existing symlink and recreate - simpler and more robust
354440
await unlink(file.targetPath);
355441
this.logger.debug(`Removed existing symlink: ${file.targetPath}`);
442+
} else if (this.isRunningInWSL()) {
443+
// WSL uses copies (not symlinks), so existing regular files are ours — overwrite
444+
await unlink(file.targetPath);
445+
this.logger.debug(`Removed existing copy for re-sync (WSL): ${file.targetPath}`);
356446
} else {
357-
// It's a regular file - might be user's custom file, skip
447+
// It's a regular file on non-WSL - might be user's custom file, skip
358448
this.logger.warn(`File already exists (not managed): ${file.targetPath}`);
359449
return;
360450
}
@@ -364,16 +454,23 @@ export class UserScopeService implements IScopeService {
364454
const targetDir = path.dirname(file.targetPath);
365455
await this.ensureDirectory(targetDir);
366456

367-
// Try to create symlink first (preferred)
368-
try {
369-
await symlink(file.sourcePath, file.targetPath, 'file');
370-
this.logger.debug(`Created symlink: ${path.basename(file.targetPath)}`);
371-
} catch {
372-
// Symlink failed (maybe Windows or permissions), fall back to copy
373-
this.logger.debug('Symlink failed, copying file instead');
457+
// WSL: symlinks from Windows → WSL paths are broken from Windows' perspective,
458+
// so always copy when running in WSL. On non-WSL, prefer symlinks.
459+
if (this.isRunningInWSL()) {
374460
const content = await readFile(file.sourcePath, 'utf8');
375461
await writeFile(file.targetPath, content, 'utf8');
376-
this.logger.debug(`Copied file: ${path.basename(file.targetPath)}`);
462+
this.logger.debug(`Copied file (WSL): ${path.basename(file.targetPath)}`);
463+
} else {
464+
try {
465+
await symlink(file.sourcePath, file.targetPath, 'file');
466+
this.logger.debug(`Created symlink: ${path.basename(file.targetPath)}`);
467+
} catch {
468+
// Symlink failed (maybe Windows or permissions), fall back to copy
469+
this.logger.debug('Symlink failed, copying file instead');
470+
const content = await readFile(file.sourcePath, 'utf8');
471+
await writeFile(file.targetPath, content, 'utf8');
472+
this.logger.debug(`Copied file: ${path.basename(file.targetPath)}`);
473+
}
377474
}
378475

379476
this.logger.info(`✅ Synced ${file.type}: ${file.name}${path.basename(file.targetPath)}`);
@@ -578,6 +675,7 @@ export class UserScopeService implements IScopeService {
578675
// Check if file content matches source before deleting
579676
try {
580677
if (fs.existsSync(copilotFile.sourcePath)) {
678+
this.logger.debug(`Target is a regular file, checking content before removal: ${path.basename(copilotFile.targetPath)}`);
581679
const targetContent = await readFile(copilotFile.targetPath, 'utf8');
582680
const sourceContent = await readFile(copilotFile.sourcePath, 'utf8');
583681

test/mocha.setup.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ const vscode = {
240240
machineId: 'mock-machine-id',
241241
sessionId: 'mock-session-id',
242242
remoteName: undefined,
243+
uriScheme: 'vscode',
243244
shell: '/bin/bash',
244245
isTelemetryEnabled: true,
245246
openExternal: (uri) => Promise.resolve(true),

0 commit comments

Comments
 (0)