Skip to content
Closed
179 changes: 118 additions & 61 deletions src/core/config/CustomModesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,79 @@ 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 []
}

// Use posix semantics when input is POSIX-like to avoid Windows drive prefixes in tests
const posixInput = workspaceRoot.startsWith("/")
const ops = posixInput ? path.posix : path

const roomodesFiles: string[] = []
const visitedPaths = new Set<string>()

// Normalize home directory for comparison
let homeDir = os.homedir()
if (posixInput) homeDir = homeDir.replace(/\\/g, "/")
const homeResolved = ops.resolve(homeDir)

// Start from workspaceRoot (avoid resolve() injecting drive letters for POSIX-like inputs)
let currentPath = posixInput ? workspaceRoot : path.resolve(workspaceRoot)

// Walk up the directory tree from workspace root
while (currentPath && currentPath !== ops.dirname(currentPath)) {
// Avoid infinite loops
if (visitedPaths.has(currentPath)) {
break
}
visitedPaths.add(currentPath)

// Don't look for .roomodes in the home directory
if (ops.resolve(currentPath) === homeResolved) {
break
}

// Check if .roomodes exists at this level
const roomodesPath = ops.join(currentPath, ROOMODES_FILENAME)
if (await fileExistsAtPath(roomodesPath)) {
roomodesFiles.push(roomodesPath)
}

// Move to parent directory
const parentPath = ops.dirname(currentPath)

// Stop if we've reached the root or if parent is the same as current
if (
parentPath === currentPath ||
(!posixInput && (parentPath === "/" || parentPath === path.parse(currentPath).root)) ||
(posixInput && parentPath === "/")
) {
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:
Expand Down Expand Up @@ -165,7 +226,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 {}
Expand Down Expand Up @@ -200,7 +262,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 []
Expand Down Expand Up @@ -293,12 +356,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()
Expand All @@ -312,19 +380,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)
Comment on lines +397 to +399
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file watcher pattern **/.roomodes only watches files within the workspace directory, but getHierarchicalRoomodes() walks up to parent directories outside the workspace. This means changes to .roomodes files in parent directories won't trigger updates, requiring users to restart VSCode. For example, if the workspace is /home/user/mono-repo/packages/frontend and there's a .roomodes at /home/user/mono-repo/.roomodes, changes to the parent file won't be detected since it's outside the workspace tree being watched.


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()
Expand All @@ -335,19 +412,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)
}
}
Expand All @@ -360,37 +425,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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new mergeCustomModes call may not correctly override less specific modes. Since getHierarchicalRoomodes returns files from parent to workspace (i.e. more general first), iterating in order means the first (parent) mode wins even if a more specific config exists. Consider iterating the project modes in reverse (or always overriding) so that more specific (.roomodes) entries properly override less specific ones.

Suggested change
const mergedModes = await this.mergeCustomModes(allRoomodesModes, settingsModes)
const mergedModes = await this.mergeCustomModes(allRoomodesModes.reverse(), settingsModes)


await this.context.globalState.update("customModes", mergedModes)

Expand Down Expand Up @@ -493,11 +543,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)

Expand Down
Loading
Loading