Skip to content

Commit 329690c

Browse files
hannesrudolphdaniel-lxsellipsis-dev[bot]
authored
feat: Delete .roo/rules-{mode} folder when custom mode is deleted (#5210) (#5317)
* feat: Add folder deletion when custom mode is deleted (#5210) * fix: address PR feedback - fix Korean translation typo and add missing German translation * fix: use os.homedir() instead of vscode.env.userHome * fix: use dynamic home directory in webview tests for cross-platform compatibility * fix: normalize path separators in tests for Windows compatibility * feat: implement DeleteModeDialog component for mode deletion confirmation * fix: use ref to store current modeToDelete value for consistent state handling * feat: add error handling for rules folder deletion and localize error messages * Update src/i18n/locales/ja/common.json Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Update src/i18n/locales/zh-CN/common.json Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * fix: remove redundant rulesFolderExists from deleteCustomModeCheck response * fix: update webviewMessageHandler tests to match fs import style --------- Co-authored-by: Daniel Riccio <[email protected]> Co-authored-by: Daniel <[email protected]> Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
1 parent 7e8c1d7 commit 329690c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+797
-43
lines changed

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,82 @@ const mockGetModels = getModels as Mock<typeof getModels>
1414
const mockClineProvider = {
1515
getState: vi.fn(),
1616
postMessageToWebview: vi.fn(),
17+
customModesManager: {
18+
getCustomModes: vi.fn(),
19+
deleteCustomMode: vi.fn(),
20+
},
21+
context: {
22+
extensionPath: "/mock/extension/path",
23+
globalStorageUri: { fsPath: "/mock/global/storage" },
24+
},
25+
contextProxy: {
26+
context: {
27+
extensionPath: "/mock/extension/path",
28+
globalStorageUri: { fsPath: "/mock/global/storage" },
29+
},
30+
setValue: vi.fn(),
31+
},
32+
log: vi.fn(),
33+
postStateToWebview: vi.fn(),
1734
} as unknown as ClineProvider
1835

36+
import { t } from "../../../i18n"
37+
38+
vi.mock("vscode", () => ({
39+
window: {
40+
showInformationMessage: vi.fn(),
41+
showErrorMessage: vi.fn(),
42+
},
43+
workspace: {
44+
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
45+
},
46+
}))
47+
48+
vi.mock("../../../i18n", () => ({
49+
t: vi.fn((key: string, args?: Record<string, any>) => {
50+
// For the delete confirmation with rules, we need to return the interpolated string
51+
if (key === "common:confirmation.delete_custom_mode_with_rules" && args) {
52+
return `Are you sure you want to delete this ${args.scope} mode?\n\nThis will also delete the associated rules folder at:\n${args.rulesFolderPath}`
53+
}
54+
// Return the translated value for "Yes"
55+
if (key === "common:answers.yes") {
56+
return "Yes"
57+
}
58+
// Return the translated value for "Cancel"
59+
if (key === "common:answers.cancel") {
60+
return "Cancel"
61+
}
62+
return key
63+
}),
64+
}))
65+
66+
vi.mock("fs/promises", () => {
67+
const mockRm = vi.fn().mockResolvedValue(undefined)
68+
const mockMkdir = vi.fn().mockResolvedValue(undefined)
69+
70+
return {
71+
default: {
72+
rm: mockRm,
73+
mkdir: mockMkdir,
74+
},
75+
rm: mockRm,
76+
mkdir: mockMkdir,
77+
}
78+
})
79+
80+
import * as vscode from "vscode"
81+
import * as fs from "fs/promises"
82+
import * as os from "os"
83+
import * as path from "path"
84+
import * as fsUtils from "../../../utils/fs"
85+
import { getWorkspacePath } from "../../../utils/path"
86+
import { ensureSettingsDirectoryExists } from "../../../utils/globalContext"
87+
import type { ModeConfig } from "@roo-code/types"
88+
89+
vi.mock("../../../utils/fs")
90+
vi.mock("../../../utils/path")
91+
vi.mock("../../../utils/globalContext")
92+
1993
describe("webviewMessageHandler - requestRouterModels", () => {
2094
beforeEach(() => {
2195
vi.clearAllMocks()
@@ -295,3 +369,116 @@ describe("webviewMessageHandler - requestRouterModels", () => {
295369
})
296370
})
297371
})
372+
373+
describe("webviewMessageHandler - deleteCustomMode", () => {
374+
beforeEach(() => {
375+
vi.clearAllMocks()
376+
vi.mocked(getWorkspacePath).mockReturnValue("/mock/workspace")
377+
vi.mocked(vscode.window.showErrorMessage).mockResolvedValue(undefined)
378+
vi.mocked(ensureSettingsDirectoryExists).mockResolvedValue("/mock/global/storage/.roo")
379+
})
380+
381+
it("should delete a project mode and its rules folder", async () => {
382+
const slug = "test-project-mode"
383+
const rulesFolderPath = path.join("/mock/workspace", ".roo", `rules-${slug}`)
384+
385+
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
386+
{
387+
name: "Test Project Mode",
388+
slug,
389+
roleDefinition: "Test Role",
390+
groups: [],
391+
source: "project",
392+
} as ModeConfig,
393+
])
394+
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
395+
vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
396+
397+
await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
398+
399+
// The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
400+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
401+
expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
402+
expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
403+
})
404+
405+
it("should delete a global mode and its rules folder", async () => {
406+
const slug = "test-global-mode"
407+
const homeDir = os.homedir()
408+
const rulesFolderPath = path.join(homeDir, ".roo", `rules-${slug}`)
409+
410+
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
411+
{
412+
name: "Test Global Mode",
413+
slug,
414+
roleDefinition: "Test Role",
415+
groups: [],
416+
source: "global",
417+
} as ModeConfig,
418+
])
419+
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
420+
vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
421+
422+
await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
423+
424+
// The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
425+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
426+
expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
427+
expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
428+
})
429+
430+
it("should only delete the mode when rules folder does not exist", async () => {
431+
const slug = "test-mode-no-rules"
432+
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
433+
{
434+
name: "Test Mode No Rules",
435+
slug,
436+
roleDefinition: "Test Role",
437+
groups: [],
438+
source: "project",
439+
} as ModeConfig,
440+
])
441+
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(false)
442+
vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
443+
444+
await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
445+
446+
// The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
447+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
448+
expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
449+
expect(fs.rm).not.toHaveBeenCalled()
450+
})
451+
452+
it("should handle errors when deleting rules folder", async () => {
453+
const slug = "test-mode-error"
454+
const rulesFolderPath = path.join("/mock/workspace", ".roo", `rules-${slug}`)
455+
const error = new Error("Permission denied")
456+
457+
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
458+
{
459+
name: "Test Mode Error",
460+
slug,
461+
roleDefinition: "Test Role",
462+
groups: [],
463+
source: "project",
464+
} as ModeConfig,
465+
])
466+
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
467+
vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
468+
vi.mocked(fs.rm).mockRejectedValue(error)
469+
470+
await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
471+
472+
expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
473+
expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
474+
// Verify error message is shown to the user
475+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
476+
t("common:errors.delete_rules_folder_failed", {
477+
rulesFolderPath,
478+
error: error.message,
479+
}),
480+
)
481+
// No error response is sent anymore - we just continue with deletion
482+
expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled()
483+
})
484+
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { safeWriteJson } from "../../utils/safeWriteJson"
22
import * as path from "path"
3+
import * as os from "os"
34
import * as fs from "fs/promises"
45
import pWaitFor from "p-wait-for"
56
import * as vscode from "vscode"
@@ -35,6 +36,7 @@ import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
3536
import { openMention } from "../mentions"
3637
import { TelemetrySetting } from "../../shared/TelemetrySetting"
3738
import { getWorkspacePath } from "../../utils/path"
39+
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
3840
import { Mode, defaultModeSlug } from "../../shared/modes"
3941
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
4042
import { GetModelsOptions } from "../../shared/api"
@@ -1494,17 +1496,66 @@ export const webviewMessageHandler = async (
14941496
break
14951497
case "deleteCustomMode":
14961498
if (message.slug) {
1497-
const answer = await vscode.window.showInformationMessage(
1498-
t("common:confirmation.delete_custom_mode"),
1499-
{ modal: true },
1500-
t("common:answers.yes"),
1501-
)
1499+
// Get the mode details to determine source and rules folder path
1500+
const customModes = await provider.customModesManager.getCustomModes()
1501+
const modeToDelete = customModes.find((mode) => mode.slug === message.slug)
15021502

1503-
if (answer !== t("common:answers.yes")) {
1503+
if (!modeToDelete) {
1504+
break
1505+
}
1506+
1507+
// Determine the scope based on source (project or global)
1508+
const scope = modeToDelete.source || "global"
1509+
1510+
// Determine the rules folder path
1511+
let rulesFolderPath: string
1512+
if (scope === "project") {
1513+
const workspacePath = getWorkspacePath()
1514+
if (workspacePath) {
1515+
rulesFolderPath = path.join(workspacePath, ".roo", `rules-${message.slug}`)
1516+
} else {
1517+
rulesFolderPath = path.join(".roo", `rules-${message.slug}`)
1518+
}
1519+
} else {
1520+
// Global scope - use OS home directory
1521+
const homeDir = os.homedir()
1522+
rulesFolderPath = path.join(homeDir, ".roo", `rules-${message.slug}`)
1523+
}
1524+
1525+
// Check if the rules folder exists
1526+
const rulesFolderExists = await fileExistsAtPath(rulesFolderPath)
1527+
1528+
// If this is a check request, send back the folder info
1529+
if (message.checkOnly) {
1530+
await provider.postMessageToWebview({
1531+
type: "deleteCustomModeCheck",
1532+
slug: message.slug,
1533+
rulesFolderPath: rulesFolderExists ? rulesFolderPath : undefined,
1534+
})
15041535
break
15051536
}
15061537

1538+
// Delete the mode
15071539
await provider.customModesManager.deleteCustomMode(message.slug)
1540+
1541+
// Delete the rules folder if it exists
1542+
if (rulesFolderExists) {
1543+
try {
1544+
await fs.rm(rulesFolderPath, { recursive: true, force: true })
1545+
provider.log(`Deleted rules folder for mode ${message.slug}: ${rulesFolderPath}`)
1546+
} catch (error) {
1547+
provider.log(`Failed to delete rules folder for mode ${message.slug}: ${error}`)
1548+
// Notify the user about the failure
1549+
vscode.window.showErrorMessage(
1550+
t("common:errors.delete_rules_folder_failed", {
1551+
rulesFolderPath,
1552+
error: error instanceof Error ? error.message : String(error),
1553+
}),
1554+
)
1555+
// Continue with mode deletion even if folder deletion fails
1556+
}
1557+
}
1558+
15081559
// Switch back to default mode after deletion
15091560
await updateGlobalState("mode", defaultModeSlug)
15101561
await provider.postStateToWebview()

src/i18n/locales/ca/common.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"confirmation": {
2222
"reset_state": "Estàs segur que vols restablir tots els estats i emmagatzematge secret a l'extensió? Això no es pot desfer.",
2323
"delete_config_profile": "Estàs segur que vols eliminar aquest perfil de configuració?",
24-
"delete_custom_mode": "Estàs segur que vols eliminar aquest mode personalitzat?",
24+
"delete_custom_mode_with_rules": "Esteu segur que voleu suprimir aquest mode {scope}?\n\nAixò també suprimirà la carpeta de regles associada a:\n{rulesFolderPath}",
2525
"delete_message": "Què vols eliminar?",
2626
"just_this_message": "Només aquest missatge",
2727
"this_and_subsequent": "Aquest i tots els missatges posteriors"
@@ -74,6 +74,7 @@
7474
"share_auth_required": "Es requereix autenticació. Si us plau, inicia sessió per compartir tasques.",
7575
"share_not_enabled": "La compartició de tasques no està habilitada per a aquesta organització.",
7676
"share_task_not_found": "Tasca no trobada o accés denegat.",
77+
"delete_rules_folder_failed": "Error en eliminar la carpeta de regles: {{rulesFolderPath}}. Error: {{error}}",
7778
"claudeCode": {
7879
"processExited": "El procés Claude Code ha sortit amb codi {{exitCode}}.",
7980
"errorOutput": "Sortida d'error: {{output}}",
@@ -144,6 +145,10 @@
144145
"resetFailed": "Error en restablir els modes personalitzats: {{error}}",
145146
"modeNotFound": "Error d'escriptura: Mode no trobat",
146147
"noWorkspaceForProject": "No s'ha trobat cap carpeta d'espai de treball per al mode específic del projecte"
148+
},
149+
"scope": {
150+
"project": "projecte",
151+
"global": "global"
147152
}
148153
},
149154
"mdm": {
@@ -152,5 +157,14 @@
152157
"organization_mismatch": "Has d'estar autenticat amb el compte de Roo Code Cloud de la teva organització.",
153158
"verification_failed": "No s'ha pogut verificar l'autenticació de l'organització."
154159
}
160+
},
161+
"prompts": {
162+
"deleteMode": {
163+
"title": "Suprimeix el mode personalitzat",
164+
"description": "Esteu segur que voleu suprimir aquest mode {{scope}}? Això també suprimirà la carpeta de regles associada a: {{rulesFolderPath}}",
165+
"descriptionNoRules": "Esteu segur que voleu suprimir aquest mode personalitzat?",
166+
"cancel": "Cancel·la",
167+
"confirm": "Suprimeix"
168+
}
155169
}
156170
}

src/i18n/locales/de/common.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"confirmation": {
1818
"reset_state": "Möchtest du wirklich alle Zustände und geheimen Speicher in der Erweiterung zurücksetzen? Dies kann nicht rückgängig gemacht werden.",
1919
"delete_config_profile": "Möchtest du dieses Konfigurationsprofil wirklich löschen?",
20-
"delete_custom_mode": "Möchtest du diesen benutzerdefinierten Modus wirklich löschen?",
20+
"delete_custom_mode_with_rules": "Bist du sicher, dass du diesen {scope}-Modus löschen möchtest?\n\nDadurch wird auch der zugehörige Regelordner unter folgender Adresse gelöscht:\n{rulesFolderPath}",
2121
"delete_message": "Was möchtest du löschen?",
2222
"just_this_message": "Nur diese Nachricht",
2323
"this_and_subsequent": "Diese und alle nachfolgenden Nachrichten"
@@ -71,6 +71,7 @@
7171
"share_not_enabled": "Aufgabenfreigabe ist für diese Organisation nicht aktiviert.",
7272
"share_task_not_found": "Aufgabe nicht gefunden oder Zugriff verweigert.",
7373
"mode_import_failed": "Fehler beim Importieren des Modus: {{error}}",
74+
"delete_rules_folder_failed": "Fehler beim Löschen des Regelordners: {{rulesFolderPath}}. Fehler: {{error}}",
7475
"claudeCode": {
7576
"processExited": "Claude Code Prozess wurde mit Code {{exitCode}} beendet.",
7677
"errorOutput": "Fehlerausgabe: {{output}}",
@@ -144,6 +145,10 @@
144145
"resetFailed": "Fehler beim Zurücksetzen der benutzerdefinierten Modi: {{error}}",
145146
"modeNotFound": "Schreibfehler: Modus nicht gefunden",
146147
"noWorkspaceForProject": "Kein Arbeitsbereich-Ordner für projektspezifischen Modus gefunden"
148+
},
149+
"scope": {
150+
"project": "projekt",
151+
"global": "global"
147152
}
148153
},
149154
"mdm": {
@@ -152,5 +157,14 @@
152157
"organization_mismatch": "Du musst mit dem Roo Code Cloud-Konto deiner Organisation authentifiziert sein.",
153158
"verification_failed": "Die Organisationsauthentifizierung konnte nicht verifiziert werden."
154159
}
160+
},
161+
"prompts": {
162+
"deleteMode": {
163+
"title": "Benutzerdefinierten Modus löschen",
164+
"description": "Bist du sicher, dass du diesen {{scope}}-Modus löschen möchtest? Dadurch wird auch der zugehörige Regelordner unter {{rulesFolderPath}} gelöscht",
165+
"descriptionNoRules": "Bist du sicher, dass du diesen benutzerdefinierten Modus löschen möchtest?",
166+
"cancel": "Abbrechen",
167+
"confirm": "Löschen"
168+
}
155169
}
156170
}

0 commit comments

Comments
 (0)