Skip to content

Commit 487081f

Browse files
MOAR RULES (RooCodeInc#2973)
* add create new rule row to modal * changeset * fix missing boolean check * fix merge issues causing duplicates * remove commented out code * update placeholder * tighten validation
1 parent 9a39cbd commit 487081f

File tree

8 files changed

+231
-16
lines changed

8 files changed

+231
-16
lines changed

.changeset/fresh-cups-tap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"claude-dev": minor
3+
---
4+
5+
Add a row to the Cline Rules modal to create a new rule file

src/core/context/instructions/user-instructions/cline-rules.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,32 @@ export async function refreshClineRulesToggles(
174174
}
175175
}
176176

177+
export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: string) => {
178+
try {
179+
let filePath: string
180+
if (isGlobal) {
181+
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
182+
filePath = path.join(globalClineRulesFilePath, filename)
183+
} else {
184+
const localClineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
185+
await fs.mkdir(localClineRulesFilePath, { recursive: true })
186+
filePath = path.join(localClineRulesFilePath, filename)
187+
}
188+
189+
const fileExists = await fileExistsAtPath(filePath)
190+
191+
if (fileExists) {
192+
return { filePath, fileExists }
193+
}
194+
195+
await fs.writeFile(filePath, "", "utf8")
196+
197+
return { filePath, fileExists: false }
198+
} catch (error) {
199+
return { filePath: null, fileExists: false }
200+
}
201+
}
202+
177203
export async function deleteRuleFile(
178204
context: vscode.ExtensionContext,
179205
rulePath: string,

src/core/controller/index.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
} from "../storage/state"
5050
import { Task, cwd } from "../task"
5151
import { ClineRulesToggles } from "../../shared/cline-rules"
52-
import { deleteRuleFile, refreshClineRulesToggles } from "../context/instructions/user-instructions/cline-rules"
52+
import { createRuleFile, deleteRuleFile, refreshClineRulesToggles } from "../context/instructions/user-instructions/cline-rules"
5353

5454
/*
5555
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -449,6 +449,36 @@ export class Controller {
449449
break
450450
case "openFile":
451451
openFile(message.text!)
452+
break
453+
case "createRuleFile":
454+
if (typeof message.isGlobal !== "boolean" || typeof message.filename !== "string" || !message.filename) {
455+
console.error("createRuleFile: Missing or invalid parameters", {
456+
isGlobal:
457+
typeof message.isGlobal === "boolean" ? message.isGlobal : `Invalid: ${typeof message.isGlobal}`,
458+
filename: typeof message.filename === "string" ? message.filename : `Invalid: ${typeof message.filename}`,
459+
})
460+
return
461+
}
462+
const { filePath, fileExists } = await createRuleFile(message.isGlobal, message.filename, cwd)
463+
if (fileExists && filePath) {
464+
vscode.window.showWarningMessage(`Rule file "${message.filename}" already exists.`)
465+
// Still open it for editing
466+
openFile(filePath)
467+
return
468+
} else if (filePath && !fileExists) {
469+
await refreshClineRulesToggles(this.context, cwd)
470+
await this.postStateToWebview()
471+
472+
openFile(filePath)
473+
474+
vscode.window.showInformationMessage(
475+
`Created new ${message.isGlobal ? "global" : "workspace"} rule file: ${message.filename}`,
476+
)
477+
} else {
478+
// null filePath
479+
vscode.window.showErrorMessage(`Failed to create rule file.`)
480+
}
481+
452482
break
453483
case "openMention":
454484
openMention(message.text)

src/shared/WebviewMessage.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface WebviewMessage {
2727
| "openImage"
2828
| "openInBrowser"
2929
| "openFile"
30+
| "createRuleFile"
3031
| "openMention"
3132
| "cancelTask"
3233
| "showChatView"
@@ -126,10 +127,12 @@ export interface WebviewMessage {
126127
message: any // JSON serialized protobuf message
127128
request_id: string // For correlating requests and responses
128129
}
129-
// For toggleClineRule
130+
// For cline rules
130131
isGlobal?: boolean
131132
rulePath?: string
132133
enabled?: boolean
134+
filename?: string
135+
133136
offset?: number
134137
}
135138

webview-ui/src/components/cline-rules/ClineRulesToggleModal.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,7 @@ const ClineRulesToggleModal: React.FC = () => {
100100
type: "openExtensionSettings",
101101
})
102102
setIsVisible(false)
103-
}}>
104-
{/* <span className="codicon codicon-gear text-[10px]"></span> */}
105-
</VSCodeButton>
103+
}}></VSCodeButton>
106104
</div>
107105

108106
{/* Global Rules Section */}
@@ -111,8 +109,8 @@ const ClineRulesToggleModal: React.FC = () => {
111109
<RulesToggleList
112110
rules={globalRules}
113111
toggleRule={(rulePath, enabled) => toggleRule(true, rulePath, enabled)}
114-
isGlobal={true}
115112
listGap="small"
113+
isGlobal={true}
116114
/>
117115
</div>
118116

@@ -122,8 +120,8 @@ const ClineRulesToggleModal: React.FC = () => {
122120
<RulesToggleList
123121
rules={localRules}
124122
toggleRule={(rulePath, enabled) => toggleRule(false, rulePath, enabled)}
125-
isGlobal={false}
126123
listGap="small"
124+
isGlobal={false}
127125
/>
128126
</div>
129127
</div>
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { useState, useRef, useEffect } from "react"
2+
import { vscode } from "@/utils/vscode"
3+
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
4+
5+
interface NewRuleRowProps {
6+
isGlobal: boolean // To determine where to create the file
7+
}
8+
9+
const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal }) => {
10+
const [isExpanded, setIsExpanded] = useState(false)
11+
const [filename, setFilename] = useState("")
12+
const inputRef = useRef<HTMLInputElement>(null)
13+
const [error, setError] = useState<string | null>(null)
14+
15+
// Focus the input when expanded
16+
useEffect(() => {
17+
if (isExpanded && inputRef.current) {
18+
inputRef.current.focus()
19+
}
20+
}, [isExpanded])
21+
22+
const getExtension = (filename: string): string => {
23+
if (filename.startsWith(".") && !filename.includes(".", 1)) return ""
24+
const match = filename.match(/\.[^.]+$/)
25+
return match ? match[0].toLowerCase() : ""
26+
}
27+
28+
const isValidExtension = (ext: string): boolean => {
29+
// Valid if it's empty (no extension) or .md or .txt
30+
return ext === "" || ext === ".md" || ext === ".txt"
31+
}
32+
33+
const handleCreateRule = () => {
34+
if (filename.trim()) {
35+
const trimmedFilename = filename.trim()
36+
const extension = getExtension(trimmedFilename)
37+
38+
if (!isValidExtension(extension)) {
39+
setError("Only .md, .txt, or no file extension allowed")
40+
return
41+
}
42+
43+
let finalFilename = trimmedFilename
44+
if (extension === "") {
45+
finalFilename = `${trimmedFilename}.md`
46+
}
47+
48+
vscode.postMessage({
49+
type: "createRuleFile",
50+
isGlobal,
51+
filename: finalFilename,
52+
})
53+
54+
setFilename("")
55+
setError(null)
56+
setIsExpanded(false)
57+
}
58+
}
59+
60+
const handleBlur = () => {
61+
setIsExpanded(false)
62+
setError(null)
63+
setFilename("")
64+
}
65+
66+
const handleKeyDown = (e: React.KeyboardEvent) => {
67+
if (e.key === "Enter") {
68+
handleCreateRule()
69+
} else if (e.key === "Escape") {
70+
setIsExpanded(false)
71+
setFilename("")
72+
}
73+
}
74+
75+
return (
76+
<div
77+
className={`mb-2.5 transition-all duration-300 ease-in-out ${isExpanded ? "opacity-100" : "opacity-70 hover:opacity-100"}`}
78+
onClick={() => !isExpanded && setIsExpanded(true)}>
79+
<div
80+
className={`flex items-center p-2 rounded bg-[var(--vscode-input-background)] transition-all duration-300 ease-in-out h-[18px] ${
81+
isExpanded ? "shadow-sm" : ""
82+
}`}>
83+
{isExpanded ? (
84+
<>
85+
<input
86+
ref={inputRef}
87+
type="text"
88+
placeholder="rule-name (.md, .txt, or no extension)"
89+
value={filename}
90+
onChange={(e) => setFilename(e.target.value)}
91+
onBlur={handleBlur}
92+
onKeyDown={handleKeyDown}
93+
className="flex-1 bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] border-0 outline-0 rounded focus:outline-none focus:ring-0 focus:border-transparent"
94+
style={{
95+
outline: "none",
96+
}}
97+
/>
98+
99+
<div className="flex items-center ml-2 space-x-2">
100+
<VSCodeButton
101+
appearance="icon"
102+
aria-label="Create rule file"
103+
title="Create rule file"
104+
onClick={handleCreateRule}
105+
style={{ padding: "0px" }}>
106+
<span className="codicon codicon-add text-[14px]" />
107+
</VSCodeButton>
108+
</div>
109+
</>
110+
) : (
111+
<>
112+
<span className="flex-1 text-[var(--vscode-descriptionForeground)] bg-[var(--vscode-input-background)] italic text-xs">
113+
New rule file...
114+
</span>
115+
<div className="flex items-center ml-2 space-x-2">
116+
<VSCodeButton
117+
appearance="icon"
118+
aria-label="New rule file"
119+
title="New rule file"
120+
onClick={(e) => {
121+
e.stopPropagation()
122+
setIsExpanded(true)
123+
}}
124+
style={{ padding: "0px" }}>
125+
<span className="codicon codicon-add text-[14px]" />
126+
</VSCodeButton>
127+
</div>
128+
</>
129+
)}
130+
</div>
131+
{isExpanded && error && <div className="text-[var(--vscode-errorForeground)] text-xs mt-1 ml-2">{error}</div>}
132+
</div>
133+
)
134+
}
135+
136+
export default NewRuleRow

webview-ui/src/components/cline-rules/RuleRow.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const RuleRow: React.FC<{
2828
return (
2929
<div className="mb-2.5">
3030
<div
31-
className={`flex items-center p-2 rounded bg-[var(--vscode-textCodeBlock-background)] ${
31+
className={`flex items-center p-2 rounded bg-[var(--vscode-textCodeBlock-background)] h-[18px] ${
3232
enabled ? "opacity-100" : "opacity-60"
3333
}`}>
3434
<span className="flex-1 overflow-hidden break-all whitespace-normal flex items-center mr-1" title={rulePath}>

webview-ui/src/components/cline-rules/RulesToggleList.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1+
import NewRuleRow from "./NewRuleRow"
12
import RuleRow from "./RuleRow"
23

34
const RulesToggleList = ({
45
rules,
56
toggleRule,
6-
isGlobal,
77
listGap = "medium",
8+
isGlobal,
89
}: {
910
rules: [string, boolean][]
1011
toggleRule: (rulePath: string, enabled: boolean) => void
11-
isGlobal: boolean
1212
listGap?: "small" | "medium" | "large"
13+
isGlobal: boolean
1314
}) => {
1415
const gapClasses = {
1516
small: "gap-0",
@@ -19,14 +20,30 @@ const RulesToggleList = ({
1920

2021
const gapClass = gapClasses[listGap]
2122

22-
return rules.length > 0 ? (
23+
return (
2324
<div className={`flex flex-col ${gapClass}`}>
24-
{rules.map(([rulePath, enabled]) => (
25-
<RuleRow key={rulePath} rulePath={rulePath} enabled={enabled} isGlobal={isGlobal} toggleRule={toggleRule} />
26-
))}
25+
{rules.length > 0 ? (
26+
<>
27+
{rules.map(([rulePath, enabled]) => (
28+
<RuleRow
29+
key={rulePath}
30+
rulePath={rulePath}
31+
enabled={enabled}
32+
isGlobal={isGlobal}
33+
toggleRule={toggleRule}
34+
/>
35+
))}
36+
<NewRuleRow isGlobal={isGlobal} />
37+
</>
38+
) : (
39+
<>
40+
<div className="flex flex-col items-center gap-3 my-3 text-[var(--vscode-descriptionForeground)]">
41+
No rules found
42+
</div>
43+
<NewRuleRow isGlobal={isGlobal} />
44+
</>
45+
)}
2746
</div>
28-
) : (
29-
<div className="flex flex-col items-center gap-3 my-5 text-[var(--vscode-descriptionForeground)]">No rules found</div>
3047
)
3148
}
3249

0 commit comments

Comments
 (0)