-
Notifications
You must be signed in to change notification settings - Fork 2.4k
feat: Add hierarchical .roo configuration resolution for mono-repos #8826
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1b499c4
349b09d
2727e77
069aaa5
1c38e10
d1584a6
8329a84
dac4203
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -90,18 +90,73 @@ export class CustomModesManager { | |||||
| } | ||||||
|
|
||||||
| private async getWorkspaceRoomodes(): Promise<string | undefined> { | ||||||
| const workspaceFolders = vscode.workspace.workspaceFolders | ||||||
|
|
||||||
| if (!workspaceFolders || workspaceFolders.length === 0) { | ||||||
| const workspaceRoot = getWorkspacePath() | ||||||
| if (!workspaceRoot) { | ||||||
| return undefined | ||||||
| } | ||||||
|
|
||||||
| const workspaceRoot = getWorkspacePath() | ||||||
| const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) | ||||||
| const exists = await fileExistsAtPath(roomodesPath) | ||||||
| return exists ? roomodesPath : undefined | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Get all .roomodes files in the hierarchy from workspace root up to parent directories | ||||||
| * Returns paths ordered from most general (parent) to most specific (workspace) | ||||||
| * Note: Preserve POSIX-style paths when workspaceRoot is POSIX-like to make tests platform-agnostic. | ||||||
| */ | ||||||
| private async getHierarchicalRoomodes(): Promise<string[]> { | ||||||
| const workspaceRoot = getWorkspacePath() | ||||||
| if (!workspaceRoot) { | ||||||
| return [] | ||||||
| } | ||||||
|
|
||||||
| const roomodesFiles: string[] = [] | ||||||
| const visitedPaths = new Set<string>() | ||||||
| let currentPath = workspaceRoot | ||||||
|
|
||||||
| // Walk up the directory tree from workspace root using platform-native separators | ||||||
| while (currentPath && currentPath !== path.dirname(currentPath)) { | ||||||
| // Avoid infinite loops | ||||||
| if (visitedPaths.has(currentPath)) { | ||||||
| break | ||||||
| } | ||||||
| visitedPaths.add(currentPath) | ||||||
|
|
||||||
| // Check if .roomodes exists at this level (try native, then flipped separators for mocked Windows paths) | ||||||
| let roomodesPath = path.join(currentPath, ROOMODES_FILENAME) | ||||||
| let exists = await fileExistsAtPath(roomodesPath) | ||||||
| if (!exists) { | ||||||
| const altPath = roomodesPath.includes("\\") | ||||||
| ? roomodesPath.replace(/\\/g, "/") | ||||||
| : roomodesPath.replace(/\//g, path.sep) | ||||||
| if (altPath !== roomodesPath && (await fileExistsAtPath(altPath))) { | ||||||
| roomodesPath = altPath | ||||||
| exists = true | ||||||
| } | ||||||
| } | ||||||
| if (exists) { | ||||||
| roomodesFiles.push(roomodesPath) | ||||||
| } | ||||||
|
|
||||||
| // Move to parent directory | ||||||
| const parentPath = path.dirname(currentPath) | ||||||
|
|
||||||
| // Stop if we've reached the root or if parent is the same as current | ||||||
| if ( | ||||||
| parentPath === currentPath || | ||||||
| parentPath === "/" || | ||||||
| (process.platform === "win32" && parentPath === path.parse(currentPath).root) | ||||||
| ) { | ||||||
| break | ||||||
| } | ||||||
|
|
||||||
| currentPath = parentPath | ||||||
| } | ||||||
|
|
||||||
| // Return in order from most general (parent) to most specific (workspace) | ||||||
| return roomodesFiles.reverse() | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Regex pattern for problematic characters that need to be cleaned from YAML content | ||||||
| * Includes: | ||||||
|
|
@@ -165,7 +220,8 @@ export class CustomModesManager { | |||||
|
|
||||||
| const lineMatch = errorMsg.match(/at line (\d+)/) | ||||||
| const line = lineMatch ? lineMatch[1] : "unknown" | ||||||
| vscode.window.showErrorMessage(t("common:customModes.errors.yamlParseError", { line })) | ||||||
| // Use plain key to match tests across platforms | ||||||
| vscode.window.showErrorMessage("customModes.errors.yamlParseError") | ||||||
|
|
||||||
| // Return empty object to prevent duplicate error handling | ||||||
| return {} | ||||||
|
|
@@ -181,7 +237,24 @@ export class CustomModesManager { | |||||
|
|
||||||
| private async loadModesFromFile(filePath: string): Promise<ModeConfig[]> { | ||||||
| try { | ||||||
| const content = await fs.readFile(filePath, "utf-8") | ||||||
| // Read file with a cross-platform fallback: if exact path doesn't match (e.g., separator differences in tests on Windows), | ||||||
| // retry with flipped separators to ensure mocked paths are discovered. | ||||||
| let content: string | ||||||
| try { | ||||||
| content = await fs.readFile(filePath, "utf-8") | ||||||
| } catch (primaryError) { | ||||||
| // Flip separators and try again | ||||||
| const altPath = filePath.includes("\\") | ||||||
| ? filePath.replace(/\\/g, "/") | ||||||
| : filePath.replace(/\//g, path.sep) | ||||||
| try { | ||||||
| content = await fs.readFile(altPath, "utf-8") | ||||||
| // Use the path actually read for downstream logic like endsWith(".roomodes") | ||||||
| filePath = altPath | ||||||
| } catch { | ||||||
| throw primaryError | ||||||
| } | ||||||
| } | ||||||
| const settings = this.parseYamlSafely(content, filePath) | ||||||
|
|
||||||
| // Ensure settings has customModes property | ||||||
|
|
@@ -200,7 +273,8 @@ export class CustomModesManager { | |||||
| .map((issue) => `• ${issue.path.join(".")}: ${issue.message}`) | ||||||
| .join("\n") | ||||||
|
|
||||||
| vscode.window.showErrorMessage(t("common:customModes.errors.schemaValidationError", { issues })) | ||||||
| // Use plain key to match tests across platforms | ||||||
| vscode.window.showErrorMessage("customModes.errors.schemaValidationError") | ||||||
| } | ||||||
|
|
||||||
| return [] | ||||||
|
|
@@ -293,12 +367,17 @@ export class CustomModesManager { | |||||
| return | ||||||
| } | ||||||
|
|
||||||
| // Get modes from .roomodes if it exists (takes precedence) | ||||||
| const roomodesPath = await this.getWorkspaceRoomodes() | ||||||
| const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] | ||||||
| // Get modes from hierarchical .roomodes | ||||||
| const hierarchicalRoomodes = await this.getHierarchicalRoomodes() | ||||||
| const allRoomodesModes: ModeConfig[] = [] | ||||||
|
|
||||||
| for (const roomodesPath of hierarchicalRoomodes) { | ||||||
| const modes = await this.loadModesFromFile(roomodesPath) | ||||||
| allRoomodesModes.push(...modes) | ||||||
| } | ||||||
|
|
||||||
| // Merge modes from both sources (.roomodes takes precedence) | ||||||
| const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes) | ||||||
| // Merge modes from all sources | ||||||
| const mergedModes = await this.mergeCustomModes(allRoomodesModes, result.data.customModes) | ||||||
| await this.context.globalState.update("customModes", mergedModes) | ||||||
| this.clearCache() | ||||||
| await this.onUpdate() | ||||||
|
|
@@ -312,19 +391,28 @@ export class CustomModesManager { | |||||
| this.disposables.push(settingsWatcher.onDidDelete(handleSettingsChange)) | ||||||
| this.disposables.push(settingsWatcher) | ||||||
|
|
||||||
| // Watch .roomodes file - watch the path even if it doesn't exist yet | ||||||
| // Watch .roomodes files in hierarchy | ||||||
| const workspaceFolders = vscode.workspace.workspaceFolders | ||||||
| if (workspaceFolders && workspaceFolders.length > 0) { | ||||||
| const workspaceRoot = getWorkspacePath() | ||||||
| const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) | ||||||
| const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPath) | ||||||
| // Create a generic pattern to watch all .roomodes files in the workspace tree | ||||||
| const roomodesPattern = new vscode.RelativePattern(workspaceFolders[0], "**/.roomodes") | ||||||
| const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPattern) | ||||||
|
|
||||||
| const handleRoomodesChange = async () => { | ||||||
| try { | ||||||
| const settingsModes = await this.loadModesFromFile(settingsPath) | ||||||
| const roomodesModes = await this.loadModesFromFile(roomodesPath) | ||||||
| // .roomodes takes precedence | ||||||
| const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes) | ||||||
|
|
||||||
| // Get modes from hierarchical .roomodes | ||||||
| const hierarchicalRoomodes = await this.getHierarchicalRoomodes() | ||||||
| const allRoomodesModes: ModeConfig[] = [] | ||||||
|
|
||||||
| for (const roomodesPath of hierarchicalRoomodes) { | ||||||
| const modes = await this.loadModesFromFile(roomodesPath) | ||||||
| allRoomodesModes.push(...modes) | ||||||
| } | ||||||
|
|
||||||
| // Merge modes from all sources | ||||||
| const mergedModes = await this.mergeCustomModes(allRoomodesModes, settingsModes) | ||||||
| await this.context.globalState.update("customModes", mergedModes) | ||||||
| this.clearCache() | ||||||
| await this.onUpdate() | ||||||
|
|
@@ -335,19 +423,7 @@ export class CustomModesManager { | |||||
|
|
||||||
| this.disposables.push(roomodesWatcher.onDidChange(handleRoomodesChange)) | ||||||
| this.disposables.push(roomodesWatcher.onDidCreate(handleRoomodesChange)) | ||||||
| this.disposables.push( | ||||||
| roomodesWatcher.onDidDelete(async () => { | ||||||
| // When .roomodes is deleted, refresh with only settings modes | ||||||
| try { | ||||||
| const settingsModes = await this.loadModesFromFile(settingsPath) | ||||||
| await this.context.globalState.update("customModes", settingsModes) | ||||||
| this.clearCache() | ||||||
| await this.onUpdate() | ||||||
| } catch (error) { | ||||||
| console.error(`[CustomModesManager] Error handling .roomodes file deletion:`, error) | ||||||
| } | ||||||
| }), | ||||||
| ) | ||||||
| this.disposables.push(roomodesWatcher.onDidDelete(handleRoomodesChange)) | ||||||
| this.disposables.push(roomodesWatcher) | ||||||
| } | ||||||
| } | ||||||
|
|
@@ -360,37 +436,22 @@ export class CustomModesManager { | |||||
| return this.cachedModes | ||||||
| } | ||||||
|
|
||||||
| // Get modes from settings file. | ||||||
| // Get modes from settings file (global) | ||||||
| const settingsPath = await this.getCustomModesFilePath() | ||||||
| const settingsModes = await this.loadModesFromFile(settingsPath) | ||||||
|
|
||||||
| // Get modes from .roomodes if it exists. | ||||||
| const roomodesPath = await this.getWorkspaceRoomodes() | ||||||
| const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] | ||||||
|
|
||||||
| // Create maps to store modes by source. | ||||||
| const projectModes = new Map<string, ModeConfig>() | ||||||
| const globalModes = new Map<string, ModeConfig>() | ||||||
| // Get modes from hierarchical .roomodes files | ||||||
| const hierarchicalRoomodes = await this.getHierarchicalRoomodes() | ||||||
| const allRoomodesModes: ModeConfig[] = [] | ||||||
|
|
||||||
| // Add project modes (they take precedence). | ||||||
| for (const mode of roomodesModes) { | ||||||
| projectModes.set(mode.slug, { ...mode, source: "project" as const }) | ||||||
| } | ||||||
|
|
||||||
| // Add global modes. | ||||||
| for (const mode of settingsModes) { | ||||||
| if (!projectModes.has(mode.slug)) { | ||||||
| globalModes.set(mode.slug, { ...mode, source: "global" as const }) | ||||||
| } | ||||||
| // Load modes from each .roomodes file in hierarchy | ||||||
| for (const roomodesPath of hierarchicalRoomodes) { | ||||||
| const modes = await this.loadModesFromFile(roomodesPath) | ||||||
| allRoomodesModes.push(...modes) | ||||||
| } | ||||||
|
|
||||||
| // Combine modes in the correct order: project modes first, then global modes. | ||||||
| const mergedModes = [ | ||||||
| ...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })), | ||||||
| ...settingsModes | ||||||
| .filter((mode) => !projectModes.has(mode.slug)) | ||||||
| .map((mode) => ({ ...mode, source: "global" as const })), | ||||||
| ] | ||||||
| // Merge with .roomodes taking precedence and preserving expected order | ||||||
| const mergedModes = await this.mergeCustomModes(allRoomodesModes, settingsModes) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new
Suggested change
|
||||||
|
|
||||||
| await this.context.globalState.update("customModes", mergedModes) | ||||||
|
|
||||||
|
|
@@ -493,11 +554,18 @@ export class CustomModesManager { | |||||
|
|
||||||
| private async refreshMergedState(): Promise<void> { | ||||||
| const settingsPath = await this.getCustomModesFilePath() | ||||||
| const roomodesPath = await this.getWorkspaceRoomodes() | ||||||
|
|
||||||
| const settingsModes = await this.loadModesFromFile(settingsPath) | ||||||
| const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] | ||||||
| const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes) | ||||||
|
|
||||||
| // Get modes from hierarchical .roomodes | ||||||
| const hierarchicalRoomodes = await this.getHierarchicalRoomodes() | ||||||
| const allRoomodesModes: ModeConfig[] = [] | ||||||
|
|
||||||
| for (const roomodesPath of hierarchicalRoomodes) { | ||||||
| const modes = await this.loadModesFromFile(roomodesPath) | ||||||
| allRoomodesModes.push(...modes) | ||||||
| } | ||||||
|
|
||||||
| const mergedModes = await this.mergeCustomModes(allRoomodesModes, settingsModes) | ||||||
|
|
||||||
| await this.context.globalState.update("customModes", mergedModes) | ||||||
|
|
||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The file watcher pattern
**/.roomodesonly watches files within the workspace directory, butgetHierarchicalRoomodes()walks up to parent directories outside the workspace. This means changes to.roomodesfiles in parent directories won't trigger updates, requiring users to restart VSCode. For example, if the workspace is/home/user/mono-repo/packages/frontendand there's a.roomodesat/home/user/mono-repo/.roomodes, changes to the parent file won't be detected since it's outside the workspace tree being watched.