diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 29c772833e..c7bc472c68 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -622,6 +622,7 @@ export interface WebviewMessage { | "hooksSetAllEnabled" | "hooksOpenConfigFolder" | "hooksDeleteHook" + | "hooksOpenHookFile" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" @@ -681,6 +682,7 @@ export interface WebviewMessage { hookEnabled?: boolean // For hooksSetEnabled hooksEnabled?: boolean // For hooksSetAllEnabled hooksSource?: "global" | "project" | "mode" // For hooksOpenConfigFolder, hooksDeleteHook + filePath?: string // For hooksOpenHookFile codeIndexSettings?: { // Global state settings codebaseIndexEnabled: boolean diff --git a/src/core/webview/__tests__/webviewMessageHandler.hooks.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.hooks.spec.ts index 804fb2a927..5faf340898 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.hooks.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.hooks.spec.ts @@ -7,14 +7,18 @@ vi.mock("vscode", () => { const executeCommand = vi.fn().mockResolvedValue(undefined) const showInformationMessage = vi.fn() const showErrorMessage = vi.fn() + const showTextDocument = vi.fn().mockResolvedValue(undefined) + const openTextDocument = vi.fn().mockResolvedValue({ uri: { fsPath: "/mock/file" } }) return { window: { showInformationMessage, showErrorMessage, + showTextDocument, }, workspace: { workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + openTextDocument, }, commands: { executeCommand, @@ -505,6 +509,55 @@ describe("webviewMessageHandler - hooks commands", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to delete hook") }) }) + + describe("hooksOpenHookFile", () => { + it("should open hook file in editor when filePath is provided and file exists", async () => { + const hookFilePath = "/mock/workspace/.roo/hooks/hooks.json" + + await webviewMessageHandler(mockClineProvider, { + type: "hooksOpenHookFile", + filePath: hookFilePath, + } as any) + + expect(vscode.Uri.file).toHaveBeenCalledWith(hookFilePath) + expect(vscode.workspace.openTextDocument).toHaveBeenCalled() + expect(vscode.window.showTextDocument).toHaveBeenCalled() + }) + + it("should show error message when file does not exist", async () => { + const hookFilePath = "/mock/workspace/.roo/hooks/missing.json" + vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValueOnce(false) + + await webviewMessageHandler(mockClineProvider, { + type: "hooksOpenHookFile", + filePath: hookFilePath, + } as any) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(`Hook file not found: ${hookFilePath}`) + }) + + it("should not attempt to open when filePath is missing", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "hooksOpenHookFile", + } as any) + + expect(vscode.workspace.openTextDocument).not.toHaveBeenCalled() + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + }) + + it("should show error message when open fails", async () => { + const hookFilePath = "/mock/workspace/.roo/hooks/hooks.json" + vi.mocked(vscode.workspace.openTextDocument).mockRejectedValueOnce(new Error("Cannot open file")) + + await webviewMessageHandler(mockClineProvider, { + type: "hooksOpenHookFile", + filePath: hookFilePath, + } as any) + + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Failed to open hook file")) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to open hook configuration file") + }) + }) }) describe("webviewMessageHandler - hooks state integration", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b5867f0449..8015a500d5 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3535,6 +3535,29 @@ export const webviewMessageHandler = async ( break } + case "hooksOpenHookFile": { + const { filePath: hookFilePath } = message + if (!hookFilePath) { + return + } + + try { + const exists = await fileExistsAtPath(hookFilePath) + if (exists) { + // Open the file in the editor + const uri = vscode.Uri.file(hookFilePath) + const doc = await vscode.workspace.openTextDocument(uri) + await vscode.window.showTextDocument(doc) + } else { + vscode.window.showErrorMessage(`Hook file not found: ${hookFilePath}`) + } + } catch (error) { + provider.log(`Failed to open hook file: ${error}`) + vscode.window.showErrorMessage("Failed to open hook configuration file") + } + break + } + default: { // console.log(`Unhandled message type: ${message.type}`) // diff --git a/webview-ui/src/components/settings/HooksSettings.tsx b/webview-ui/src/components/settings/HooksSettings.tsx index febae3228a..4282a7576c 100644 --- a/webview-ui/src/components/settings/HooksSettings.tsx +++ b/webview-ui/src/components/settings/HooksSettings.tsx @@ -276,6 +276,14 @@ const HookItem: React.FC = ({ hook, onToggle }) => { }) } + const handleOpenHookFile = () => { + if (!hook.filePath) return + vscode.postMessage({ + type: "hooksOpenHookFile", + filePath: hook.filePath, + }) + } + const getEnabledDotColor = () => { return hook.enabled ? "var(--vscode-testing-iconPassed)" : "var(--vscode-descriptionForeground)" } @@ -311,10 +319,26 @@ const HookItem: React.FC = ({ hook, onToggle }) => { size="icon" onClick={handleDeleteHook} data-testid={`hook-delete-${hook.id}`} - aria-label={t("settings:hooks.deleteHook")} - style={{ marginRight: "6px" }}> + aria-label={t("settings:hooks.deleteHook")}> + + +
{ }) }) + it("sends hooksOpenHookFile message when open file button is clicked", async () => { + const { vscode } = await import("@src/utils/vscode") + const mockHook: HookInfo = { + id: "hook-1", + event: "before_execute_command", + commandPreview: "echo test", + enabled: true, + source: "project", + timeout: 30, + filePath: "/path/to/hooks.json", + } + + currentHooksState = { + enabledHooks: [mockHook], + executionHistory: [], + hasProjectHooks: false, + } + + render() + + fireEvent.click(screen.getByTestId("hook-open-file-hook-1")) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "hooksOpenHookFile", + filePath: "/path/to/hooks.json", + }) + }) + + it("shows correct tooltip for open file button when file path is available", () => { + const mockHook: HookInfo = { + id: "hook-1", + event: "before_execute_command", + commandPreview: "echo test", + enabled: true, + source: "project", + timeout: 30, + filePath: "/path/to/hooks.json", + } + + currentHooksState = { + enabledHooks: [mockHook], + executionHistory: [], + hasProjectHooks: false, + } + + render() + + const button = screen.getByTestId("hook-open-file-hook-1") + // In the mock, StandardTooltip wraps the button with a div having the title + expect(button.parentElement).toHaveAttribute("title", "settings:hooks.openHookFileTooltip") + }) + + it("shows correct tooltip for open file button when file path is unavailable", () => { + const mockHook: HookInfo = { + id: "hook-1", + event: "before_execute_command", + commandPreview: "echo test", + enabled: true, + source: "project", + timeout: 30, + // No filePath + } + + currentHooksState = { + enabledHooks: [mockHook], + executionHistory: [], + hasProjectHooks: false, + } + + render() + + const button = screen.getByTestId("hook-open-file-hook-1") + expect(button.parentElement).toHaveAttribute("title", "settings:hooks.openHookFileUnavailableTooltip") + }) + it("sends hooksSetAllEnabled message when top-level Enable Hooks toggle is changed", async () => { const { vscode } = await import("@src/utils/vscode") diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index de095f8727..a0533aec16 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -56,6 +56,8 @@ "projectHooksWarningTitle": "Project-level hooks detected", "projectHooksWarningMessage": "This project includes hook configurations that will execute shell commands. Only enable hooks from sources you trust.", "reloadNote": "Changes to hook configuration files require clicking Reload to take effect.", + "openHookFileTooltip": "Open hook file in editor", + "openHookFileUnavailableTooltip": "Hook file location unavailable", "matcherNote": "Matchers are evaluated against Roo Code's internal tool IDs (e.g. write_to_file, edit_file, apply_diff, apply_patch), not UI labels like Write/Edit.", "matcherExamplesLabel": "Examples:", "matcherExamples": {