CustomModesManager.ts 12 KB

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