CustomModesManager.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import * as vscode from "vscode"
  2. import * as path from "path"
  3. import * as fs from "fs/promises"
  4. import { CustomModesSettingsSchema } from "./CustomModesSchema"
  5. import { ModeConfig } from "../../shared/modes"
  6. import { fileExistsAtPath } from "../../utils/fs"
  7. import { arePathsEqual } from "../../utils/path"
  8. import { logger } from "../../utils/logging"
  9. const ROOMODES_FILENAME = ".roomodes"
  10. export class CustomModesManager {
  11. private disposables: vscode.Disposable[] = []
  12. private isWriting = false
  13. private writeQueue: Array<() => Promise<void>> = []
  14. constructor(
  15. private readonly context: vscode.ExtensionContext,
  16. private readonly onUpdate: () => Promise<void>,
  17. ) {
  18. this.watchCustomModesFiles()
  19. }
  20. private async queueWrite(operation: () => Promise<void>): Promise<void> {
  21. this.writeQueue.push(operation)
  22. if (!this.isWriting) {
  23. await this.processWriteQueue()
  24. }
  25. }
  26. private async processWriteQueue(): Promise<void> {
  27. if (this.isWriting || this.writeQueue.length === 0) {
  28. return
  29. }
  30. this.isWriting = true
  31. try {
  32. while (this.writeQueue.length > 0) {
  33. const operation = this.writeQueue.shift()
  34. if (operation) {
  35. await operation()
  36. }
  37. }
  38. } finally {
  39. this.isWriting = false
  40. }
  41. }
  42. private async getWorkspaceRoomodes(): Promise<string | undefined> {
  43. const workspaceFolders = vscode.workspace.workspaceFolders
  44. if (!workspaceFolders || workspaceFolders.length === 0) {
  45. return undefined
  46. }
  47. const workspaceRoot = workspaceFolders[0].uri.fsPath
  48. const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
  49. const exists = await fileExistsAtPath(roomodesPath)
  50. return exists ? roomodesPath : undefined
  51. }
  52. private async loadModesFromFile(filePath: string): Promise<ModeConfig[]> {
  53. try {
  54. const content = await fs.readFile(filePath, "utf-8")
  55. const settings = JSON.parse(content)
  56. const result = CustomModesSettingsSchema.safeParse(settings)
  57. if (!result.success) {
  58. return []
  59. }
  60. // Determine source based on file path
  61. const isRoomodes = filePath.endsWith(ROOMODES_FILENAME)
  62. const source = isRoomodes ? ("project" as const) : ("global" as const)
  63. // Add source to each mode
  64. return result.data.customModes.map((mode) => ({
  65. ...mode,
  66. source,
  67. }))
  68. } catch (error) {
  69. const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
  70. console.error(`[CustomModesManager] ${errorMsg}`)
  71. return []
  72. }
  73. }
  74. private async mergeCustomModes(projectModes: ModeConfig[], globalModes: ModeConfig[]): Promise<ModeConfig[]> {
  75. const slugs = new Set<string>()
  76. const merged: ModeConfig[] = []
  77. // Add project mode (takes precedence)
  78. for (const mode of projectModes) {
  79. if (!slugs.has(mode.slug)) {
  80. slugs.add(mode.slug)
  81. merged.push({
  82. ...mode,
  83. source: "project",
  84. })
  85. }
  86. }
  87. // Add non-duplicate global modes
  88. for (const mode of globalModes) {
  89. if (!slugs.has(mode.slug)) {
  90. slugs.add(mode.slug)
  91. merged.push({
  92. ...mode,
  93. source: "global",
  94. })
  95. }
  96. }
  97. return merged
  98. }
  99. async getCustomModesFilePath(): Promise<string> {
  100. const settingsDir = await this.ensureSettingsDirectoryExists()
  101. const filePath = path.join(settingsDir, "cline_custom_modes.json")
  102. const fileExists = await fileExistsAtPath(filePath)
  103. if (!fileExists) {
  104. await this.queueWrite(async () => {
  105. await fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2))
  106. })
  107. }
  108. return filePath
  109. }
  110. private async watchCustomModesFiles(): Promise<void> {
  111. const settingsPath = await this.getCustomModesFilePath()
  112. // Watch settings file
  113. this.disposables.push(
  114. vscode.workspace.onDidSaveTextDocument(async (document) => {
  115. if (arePathsEqual(document.uri.fsPath, settingsPath)) {
  116. const content = await fs.readFile(settingsPath, "utf-8")
  117. const errorMessage =
  118. "Invalid custom modes format. Please ensure your settings follow the correct JSON format."
  119. let config: any
  120. try {
  121. config = JSON.parse(content)
  122. } catch (error) {
  123. console.error(error)
  124. vscode.window.showErrorMessage(errorMessage)
  125. return
  126. }
  127. const result = CustomModesSettingsSchema.safeParse(config)
  128. if (!result.success) {
  129. vscode.window.showErrorMessage(errorMessage)
  130. return
  131. }
  132. // Get modes from .roomodes if it exists (takes precedence)
  133. const roomodesPath = await this.getWorkspaceRoomodes()
  134. const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
  135. // Merge modes from both sources (.roomodes takes precedence)
  136. const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes)
  137. await this.context.globalState.update("customModes", mergedModes)
  138. await this.onUpdate()
  139. }
  140. }),
  141. )
  142. // Watch .roomodes file if it exists
  143. const roomodesPath = await this.getWorkspaceRoomodes()
  144. if (roomodesPath) {
  145. this.disposables.push(
  146. vscode.workspace.onDidSaveTextDocument(async (document) => {
  147. if (arePathsEqual(document.uri.fsPath, roomodesPath)) {
  148. const settingsModes = await this.loadModesFromFile(settingsPath)
  149. const roomodesModes = await this.loadModesFromFile(roomodesPath)
  150. // .roomodes takes precedence
  151. const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
  152. await this.context.globalState.update("customModes", mergedModes)
  153. await this.onUpdate()
  154. }
  155. }),
  156. )
  157. }
  158. }
  159. async getCustomModes(): Promise<ModeConfig[]> {
  160. // Get modes from settings file
  161. const settingsPath = await this.getCustomModesFilePath()
  162. const settingsModes = await this.loadModesFromFile(settingsPath)
  163. // Get modes from .roomodes if it exists
  164. const roomodesPath = await this.getWorkspaceRoomodes()
  165. const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
  166. // Create maps to store modes by source
  167. const projectModes = new Map<string, ModeConfig>()
  168. const globalModes = new Map<string, ModeConfig>()
  169. // Add project modes (they take precedence)
  170. for (const mode of roomodesModes) {
  171. projectModes.set(mode.slug, { ...mode, source: "project" as const })
  172. }
  173. // Add global modes
  174. for (const mode of settingsModes) {
  175. if (!projectModes.has(mode.slug)) {
  176. globalModes.set(mode.slug, { ...mode, source: "global" as const })
  177. }
  178. }
  179. // Combine modes in the correct order: project modes first, then global modes
  180. const mergedModes = [
  181. ...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })),
  182. ...settingsModes
  183. .filter((mode) => !projectModes.has(mode.slug))
  184. .map((mode) => ({ ...mode, source: "global" as const })),
  185. ]
  186. await this.context.globalState.update("customModes", mergedModes)
  187. return mergedModes
  188. }
  189. async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
  190. try {
  191. const isProjectMode = config.source === "project"
  192. let targetPath: string
  193. if (isProjectMode) {
  194. const workspaceFolders = vscode.workspace.workspaceFolders
  195. if (!workspaceFolders || workspaceFolders.length === 0) {
  196. logger.error("Failed to update project mode: No workspace folder found", { slug })
  197. throw new Error("No workspace folder found for project-specific mode")
  198. }
  199. const workspaceRoot = workspaceFolders[0].uri.fsPath
  200. targetPath = path.join(workspaceRoot, ROOMODES_FILENAME)
  201. const exists = await fileExistsAtPath(targetPath)
  202. logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, {
  203. slug,
  204. workspace: workspaceRoot,
  205. })
  206. } else {
  207. targetPath = await this.getCustomModesFilePath()
  208. }
  209. await this.queueWrite(async () => {
  210. // Ensure source is set correctly based on target file
  211. const modeWithSource = {
  212. ...config,
  213. source: isProjectMode ? ("project" as const) : ("global" as const),
  214. }
  215. await this.updateModesInFile(targetPath, (modes) => {
  216. const updatedModes = modes.filter((m) => m.slug !== slug)
  217. updatedModes.push(modeWithSource)
  218. return updatedModes
  219. })
  220. await this.refreshMergedState()
  221. })
  222. } catch (error) {
  223. const errorMessage = error instanceof Error ? error.message : String(error)
  224. logger.error("Failed to update custom mode", { slug, error: errorMessage })
  225. vscode.window.showErrorMessage(`Failed to update custom mode: ${errorMessage}`)
  226. }
  227. }
  228. private async updateModesInFile(filePath: string, operation: (modes: ModeConfig[]) => ModeConfig[]): Promise<void> {
  229. let content = "{}"
  230. try {
  231. content = await fs.readFile(filePath, "utf-8")
  232. } catch (error) {
  233. // File might not exist yet
  234. content = JSON.stringify({ customModes: [] })
  235. }
  236. let settings
  237. try {
  238. settings = JSON.parse(content)
  239. } catch (error) {
  240. console.error(`[CustomModesManager] Failed to parse JSON from ${filePath}:`, error)
  241. settings = { customModes: [] }
  242. }
  243. settings.customModes = operation(settings.customModes || [])
  244. await fs.writeFile(filePath, JSON.stringify(settings, null, 2), "utf-8")
  245. }
  246. private async refreshMergedState(): Promise<void> {
  247. const settingsPath = await this.getCustomModesFilePath()
  248. const roomodesPath = await this.getWorkspaceRoomodes()
  249. const settingsModes = await this.loadModesFromFile(settingsPath)
  250. const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
  251. const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
  252. await this.context.globalState.update("customModes", mergedModes)
  253. await this.onUpdate()
  254. }
  255. async deleteCustomMode(slug: string): Promise<void> {
  256. try {
  257. const settingsPath = await this.getCustomModesFilePath()
  258. const roomodesPath = await this.getWorkspaceRoomodes()
  259. const settingsModes = await this.loadModesFromFile(settingsPath)
  260. const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
  261. // Find the mode in either file
  262. const projectMode = roomodesModes.find((m) => m.slug === slug)
  263. const globalMode = settingsModes.find((m) => m.slug === slug)
  264. if (!projectMode && !globalMode) {
  265. throw new Error("Write error: Mode not found")
  266. }
  267. await this.queueWrite(async () => {
  268. // Delete from project first if it exists there
  269. if (projectMode && roomodesPath) {
  270. await this.updateModesInFile(roomodesPath, (modes) => modes.filter((m) => m.slug !== slug))
  271. }
  272. // Delete from global settings if it exists there
  273. if (globalMode) {
  274. await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug))
  275. }
  276. await this.refreshMergedState()
  277. })
  278. } catch (error) {
  279. vscode.window.showErrorMessage(
  280. `Failed to delete custom mode: ${error instanceof Error ? error.message : String(error)}`,
  281. )
  282. }
  283. }
  284. private async ensureSettingsDirectoryExists(): Promise<string> {
  285. const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings")
  286. await fs.mkdir(settingsDir, { recursive: true })
  287. return settingsDir
  288. }
  289. async resetCustomModes(): Promise<void> {
  290. try {
  291. const filePath = await this.getCustomModesFilePath()
  292. await fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2))
  293. await this.context.globalState.update("customModes", [])
  294. await this.onUpdate()
  295. } catch (error) {
  296. vscode.window.showErrorMessage(
  297. `Failed to reset custom modes: ${error instanceof Error ? error.message : String(error)}`,
  298. )
  299. }
  300. }
  301. dispose(): void {
  302. for (const disposable of this.disposables) {
  303. disposable.dispose()
  304. }
  305. this.disposables = []
  306. }
  307. }