feat: open hook config file from UI

This commit is contained in:
Toray Altas 2026-01-16 21:11:49 -05:00
parent ff4d057f1e
commit a13fba4c88
6 changed files with 181 additions and 2 deletions

View file

@ -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

View file

@ -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", () => {

View file

@ -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}`)
//

View file

@ -276,6 +276,14 @@ const HookItem: React.FC<HookItemProps> = ({ 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<HookItemProps> = ({ 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")}>
<span className="codicon codicon-trash" style={{ fontSize: "14px" }}></span>
</Button>
<StandardTooltip
content={
hook.filePath
? t("settings:hooks.openHookFileTooltip")
: t("settings:hooks.openHookFileUnavailableTooltip")
}>
<Button
variant="ghost"
size="icon"
onClick={handleOpenHookFile}
disabled={!hook.filePath}
data-testid={`hook-open-file-${hook.id}`}
aria-label={t("settings:hooks.openHookFile")}
style={{ marginRight: "6px" }}>
<span className="codicon codicon-link-external" style={{ fontSize: "14px" }}></span>
</Button>
</StandardTooltip>
<div
data-testid={`hook-status-dot-${hook.id}`}
style={{

View file

@ -463,6 +463,81 @@ describe("HooksSettings", () => {
})
})
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(<HooksSettings />)
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(<HooksSettings />)
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(<HooksSettings />)
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")

View file

@ -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": {