@@ -6,6 +6,8 @@ import { ModeConfig } from "../../shared/modes"
66import { fileExistsAtPath } from "../../utils/fs"
77import { arePathsEqual } from "../../utils/path"
88
9+ const ROOMODES_FILENAME = ".roomodes"
10+
911export class CustomModesManager {
1012 private disposables : vscode . Disposable [ ] = [ ]
1113 private isWriting = false
@@ -15,7 +17,7 @@ export class CustomModesManager {
1517 private readonly context : vscode . ExtensionContext ,
1618 private readonly onUpdate : ( ) => Promise < void > ,
1719 ) {
18- this . watchCustomModesFile ( )
20+ this . watchCustomModesFiles ( )
1921 }
2022
2123 private async queueWrite ( operation : ( ) => Promise < void > ) : Promise < void > {
@@ -43,6 +45,73 @@ export class CustomModesManager {
4345 }
4446 }
4547
48+ private async getWorkspaceRoomodes ( ) : Promise < string | undefined > {
49+ const workspaceFolders = vscode . workspace . workspaceFolders
50+ if ( ! workspaceFolders || workspaceFolders . length === 0 ) {
51+ return undefined
52+ }
53+ const workspaceRoot = workspaceFolders [ 0 ] . uri . fsPath
54+ const roomodesPath = path . join ( workspaceRoot , ROOMODES_FILENAME )
55+ const exists = await fileExistsAtPath ( roomodesPath )
56+ return exists ? roomodesPath : undefined
57+ }
58+
59+ private async loadModesFromFile ( filePath : string ) : Promise < ModeConfig [ ] > {
60+ try {
61+ const content = await fs . readFile ( filePath , "utf-8" )
62+ const settings = JSON . parse ( content )
63+ const result = CustomModesSettingsSchema . safeParse ( settings )
64+ if ( ! result . success ) {
65+ const errorMsg = `Schema validation failed for ${ filePath } `
66+ console . error ( `[CustomModesManager] ${ errorMsg } :` , result . error )
67+ return [ ]
68+ }
69+
70+ // Determine source based on file path
71+ const isRoomodes = filePath . endsWith ( ROOMODES_FILENAME )
72+ const source = isRoomodes ? ( "project" as const ) : ( "global" as const )
73+
74+ // Add source to each mode
75+ return result . data . customModes . map ( ( mode ) => ( {
76+ ...mode ,
77+ source,
78+ } ) )
79+ } catch ( error ) {
80+ const errorMsg = `Failed to load modes from ${ filePath } : ${ error instanceof Error ? error . message : String ( error ) } `
81+ console . error ( `[CustomModesManager] ${ errorMsg } ` )
82+ return [ ]
83+ }
84+ }
85+
86+ private async mergeCustomModes ( projectModes : ModeConfig [ ] , globalModes : ModeConfig [ ] ) : Promise < ModeConfig [ ] > {
87+ const slugs = new Set < string > ( )
88+ const merged : ModeConfig [ ] = [ ]
89+
90+ // Add project mode (takes precedence)
91+ for ( const mode of projectModes ) {
92+ if ( ! slugs . has ( mode . slug ) ) {
93+ slugs . add ( mode . slug )
94+ merged . push ( {
95+ ...mode ,
96+ source : "project" ,
97+ } )
98+ }
99+ }
100+
101+ // Add non-duplicate global modes
102+ for ( const mode of globalModes ) {
103+ if ( ! slugs . has ( mode . slug ) ) {
104+ slugs . add ( mode . slug )
105+ merged . push ( {
106+ ...mode ,
107+ source : "global" ,
108+ } )
109+ }
110+ }
111+
112+ return merged
113+ }
114+
46115 async getCustomModesFilePath ( ) : Promise < string > {
47116 const settingsDir = await this . ensureSettingsDirectoryExists ( )
48117 const filePath = path . join ( settingsDir , "cline_custom_modes.json" )
@@ -55,14 +124,17 @@ export class CustomModesManager {
55124 return filePath
56125 }
57126
58- private async watchCustomModesFile ( ) : Promise < void > {
127+ private async watchCustomModesFiles ( ) : Promise < void > {
59128 const settingsPath = await this . getCustomModesFilePath ( )
129+
130+ // Watch settings file
60131 this . disposables . push (
61132 vscode . workspace . onDidSaveTextDocument ( async ( document ) => {
62133 if ( arePathsEqual ( document . uri . fsPath , settingsPath ) ) {
63134 const content = await fs . readFile ( settingsPath , "utf-8" )
64135 const errorMessage =
65136 "Invalid custom modes format. Please ensure your settings follow the correct JSON format."
137+
66138 let config : any
67139 try {
68140 config = JSON . parse ( content )
@@ -71,86 +143,170 @@ export class CustomModesManager {
71143 vscode . window . showErrorMessage ( errorMessage )
72144 return
73145 }
146+
74147 const result = CustomModesSettingsSchema . safeParse ( config )
75148 if ( ! result . success ) {
76149 vscode . window . showErrorMessage ( errorMessage )
77150 return
78151 }
79- await this . context . globalState . update ( "customModes" , result . data . customModes )
152+
153+ // Get modes from .roomodes if it exists (takes precedence)
154+ const roomodesPath = await this . getWorkspaceRoomodes ( )
155+ const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
156+
157+ // Merge modes from both sources (.roomodes takes precedence)
158+ const mergedModes = await this . mergeCustomModes ( roomodesModes , result . data . customModes )
159+ await this . context . globalState . update ( "customModes" , mergedModes )
80160 await this . onUpdate ( )
81161 }
82162 } ) ,
83163 )
164+
165+ // Watch .roomodes file if it exists
166+ const roomodesPath = await this . getWorkspaceRoomodes ( )
167+ if ( roomodesPath ) {
168+ this . disposables . push (
169+ vscode . workspace . onDidSaveTextDocument ( async ( document ) => {
170+ if ( arePathsEqual ( document . uri . fsPath , roomodesPath ) ) {
171+ const settingsModes = await this . loadModesFromFile ( settingsPath )
172+ const roomodesModes = await this . loadModesFromFile ( roomodesPath )
173+ // .roomodes takes precedence
174+ const mergedModes = await this . mergeCustomModes ( roomodesModes , settingsModes )
175+ await this . context . globalState . update ( "customModes" , mergedModes )
176+ await this . onUpdate ( )
177+ }
178+ } ) ,
179+ )
180+ }
84181 }
85182
86183 async getCustomModes ( ) : Promise < ModeConfig [ ] > {
87- const modes = await this . context . globalState . get < ModeConfig [ ] > ( "customModes" )
184+ // Get modes from settings file
185+ const settingsPath = await this . getCustomModesFilePath ( )
186+ const settingsModes = await this . loadModesFromFile ( settingsPath )
88187
89- // Always read from file to ensure we have the latest
90- try {
91- const settingsPath = await this . getCustomModesFilePath ( )
92- const content = await fs . readFile ( settingsPath , "utf-8" )
188+ // Get modes from .roomodes if it exists
189+ const roomodesPath = await this . getWorkspaceRoomodes ( )
190+ const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
93191
94- const settings = JSON . parse ( content )
95- const result = CustomModesSettingsSchema . safeParse ( settings )
96- if ( result . success ) {
97- await this . context . globalState . update ( "customModes" , result . data . customModes )
98- return result . data . customModes
192+ // Create maps to store modes by source
193+ const projectModes = new Map < string , ModeConfig > ( )
194+ const globalModes = new Map < string , ModeConfig > ( )
195+
196+ // Add project modes (they take precedence)
197+ for ( const mode of roomodesModes ) {
198+ projectModes . set ( mode . slug , { ...mode , source : "project" as const } )
199+ }
200+
201+ // Add global modes
202+ for ( const mode of settingsModes ) {
203+ if ( ! projectModes . has ( mode . slug ) ) {
204+ globalModes . set ( mode . slug , { ...mode , source : "global" as const } )
99205 }
100- return modes ?? [ ]
101- } catch ( error ) {
102- // Return empty array if there's an error reading the file
103206 }
104207
105- return modes ?? [ ]
208+ // Combine modes in the correct order: project modes first, then global modes
209+ const mergedModes = [
210+ ...roomodesModes . map ( ( mode ) => ( { ...mode , source : "project" as const } ) ) ,
211+ ...settingsModes
212+ . filter ( ( mode ) => ! projectModes . has ( mode . slug ) )
213+ . map ( ( mode ) => ( { ...mode , source : "global" as const } ) ) ,
214+ ]
215+
216+ await this . context . globalState . update ( "customModes" , mergedModes )
217+ return mergedModes
106218 }
107219
108220 async updateCustomMode ( slug : string , config : ModeConfig ) : Promise < void > {
109221 try {
110- const settingsPath = await this . getCustomModesFilePath ( )
222+ const isProjectMode = config . source === "project"
223+ const targetPath = isProjectMode ? await this . getWorkspaceRoomodes ( ) : await this . getCustomModesFilePath ( )
111224
112- await this . queueWrite ( async ( ) => {
113- // Read and update file
114- const content = await fs . readFile ( settingsPath , "utf-8" )
115- const settings = JSON . parse ( content )
116- const currentModes = settings . customModes || [ ]
117- const updatedModes = currentModes . filter ( ( m : ModeConfig ) => m . slug !== slug )
118- updatedModes . push ( config )
119- settings . customModes = updatedModes
120-
121- const newContent = JSON . stringify ( settings , null , 2 )
225+ if ( isProjectMode && ! targetPath ) {
226+ throw new Error ( "No workspace folder found for project-specific mode" )
227+ }
122228
123- // Write to file
124- await fs . writeFile ( settingsPath , newContent )
229+ await this . queueWrite ( async ( ) => {
230+ // Ensure source is set correctly based on target file
231+ const modeWithSource = {
232+ ...config ,
233+ source : isProjectMode ? ( "project" as const ) : ( "global" as const ) ,
234+ }
125235
126- // Update global state
127- await this . context . globalState . update ( "customModes" , updatedModes )
236+ await this . updateModesInFile ( targetPath ! , ( modes ) => {
237+ const updatedModes = modes . filter ( ( m ) => m . slug !== slug )
238+ updatedModes . push ( modeWithSource )
239+ return updatedModes
240+ } )
128241
129- // Notify about the update
130- await this . onUpdate ( )
242+ await this . refreshMergedState ( )
131243 } )
132-
133- // Success, no need for message
134244 } catch ( error ) {
135245 vscode . window . showErrorMessage (
136246 `Failed to update custom mode: ${ error instanceof Error ? error . message : String ( error ) } ` ,
137247 )
138248 }
139249 }
250+ private async updateModesInFile ( filePath : string , operation : ( modes : ModeConfig [ ] ) => ModeConfig [ ] ) : Promise < void > {
251+ let content = "{}"
252+ try {
253+ content = await fs . readFile ( filePath , "utf-8" )
254+ } catch ( error ) {
255+ // File might not exist yet
256+ content = JSON . stringify ( { customModes : [ ] } )
257+ }
258+
259+ let settings
260+ try {
261+ settings = JSON . parse ( content )
262+ } catch ( error ) {
263+ console . error ( `[CustomModesManager] Failed to parse JSON from ${ filePath } :` , error )
264+ settings = { customModes : [ ] }
265+ }
266+ settings . customModes = operation ( settings . customModes || [ ] )
267+ await fs . writeFile ( filePath , JSON . stringify ( settings , null , 2 ) , "utf-8" )
268+ }
269+
270+ private async refreshMergedState ( ) : Promise < void > {
271+ const settingsPath = await this . getCustomModesFilePath ( )
272+ const roomodesPath = await this . getWorkspaceRoomodes ( )
273+
274+ const settingsModes = await this . loadModesFromFile ( settingsPath )
275+ const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
276+ const mergedModes = await this . mergeCustomModes ( roomodesModes , settingsModes )
277+
278+ await this . context . globalState . update ( "customModes" , mergedModes )
279+ await this . onUpdate ( )
280+ }
140281
141282 async deleteCustomMode ( slug : string ) : Promise < void > {
142283 try {
143284 const settingsPath = await this . getCustomModesFilePath ( )
285+ const roomodesPath = await this . getWorkspaceRoomodes ( )
286+
287+ const settingsModes = await this . loadModesFromFile ( settingsPath )
288+ const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
289+
290+ // Find the mode in either file
291+ const projectMode = roomodesModes . find ( ( m ) => m . slug === slug )
292+ const globalMode = settingsModes . find ( ( m ) => m . slug === slug )
293+
294+ if ( ! projectMode && ! globalMode ) {
295+ throw new Error ( "Write error: Mode not found" )
296+ }
144297
145298 await this . queueWrite ( async ( ) => {
146- const content = await fs . readFile ( settingsPath , "utf-8" )
147- const settings = JSON . parse ( content )
299+ // Delete from project first if it exists there
300+ if ( projectMode && roomodesPath ) {
301+ await this . updateModesInFile ( roomodesPath , ( modes ) => modes . filter ( ( m ) => m . slug !== slug ) )
302+ }
148303
149- settings . customModes = ( settings . customModes || [ ] ) . filter ( ( m : ModeConfig ) => m . slug !== slug )
150- await fs . writeFile ( settingsPath , JSON . stringify ( settings , null , 2 ) )
304+ // Delete from global settings if it exists there
305+ if ( globalMode ) {
306+ await this . updateModesInFile ( settingsPath , ( modes ) => modes . filter ( ( m ) => m . slug !== slug ) )
307+ }
151308
152- await this . context . globalState . update ( "customModes" , settings . customModes )
153- await this . onUpdate ( )
309+ await this . refreshMergedState ( )
154310 } )
155311 } catch ( error ) {
156312 vscode . window . showErrorMessage (
@@ -165,9 +321,6 @@ export class CustomModesManager {
165321 return settingsDir
166322 }
167323
168- /**
169- * Delete the custom modes file and reset to default state
170- */
171324 async resetCustomModes ( ) : Promise < void > {
172325 try {
173326 const filePath = await this . getCustomModesFilePath ( )
0 commit comments