Skip to content

Commit 0a32e24

Browse files
authored
Merge pull request #329 from samhvw8/feat/roo-cline-code-action
New Feature code action
2 parents 030575a + 3257dff commit 0a32e24

File tree

21 files changed

+1088
-286
lines changed

21 files changed

+1088
-286
lines changed

package.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,41 @@
101101
"command": "roo-cline.openInNewTab",
102102
"title": "Open In New Tab",
103103
"category": "Roo Code"
104+
},
105+
{
106+
"command": "roo-cline.explainCode",
107+
"title": "Roo Code: Explain Code",
108+
"category": "Roo Code"
109+
},
110+
{
111+
"command": "roo-cline.fixCode",
112+
"title": "Roo Code: Fix Code",
113+
"category": "Roo Code"
114+
},
115+
{
116+
"command": "roo-cline.improveCode",
117+
"title": "Roo Code: Improve Code",
118+
"category": "Roo Code"
104119
}
105120
],
106121
"menus": {
122+
"editor/context": [
123+
{
124+
"command": "roo-cline.explainCode",
125+
"when": "editorHasSelection",
126+
"group": "Roo Code@1"
127+
},
128+
{
129+
"command": "roo-cline.fixCode",
130+
"when": "editorHasSelection",
131+
"group": "Roo Code@2"
132+
},
133+
{
134+
"command": "roo-cline.improveCode",
135+
"when": "editorHasSelection",
136+
"group": "Roo Code@3"
137+
}
138+
],
107139
"view/title": [
108140
{
109141
"command": "roo-cline.plusButtonClicked",

src/core/Cline.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,7 @@ export class Cline {
809809
})
810810
}
811811

812-
const { browserViewportSize, mode, customPrompts, preferredLanguage } =
812+
const { browserViewportSize, mode, customModePrompts, preferredLanguage } =
813813
(await this.providerRef.deref()?.getState()) ?? {}
814814
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
815815
const systemPrompt = await (async () => {
@@ -825,7 +825,7 @@ export class Cline {
825825
this.diffStrategy,
826826
browserViewportSize,
827827
mode,
828-
customPrompts,
828+
customModePrompts,
829829
customModes,
830830
this.customInstructions,
831831
preferredLanguage,

src/core/CodeActionProvider.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import * as vscode from "vscode"
2+
import * as path from "path"
3+
4+
export const ACTION_NAMES = {
5+
EXPLAIN: "Roo Code: Explain Code",
6+
FIX: "Roo Code: Fix Code",
7+
IMPROVE: "Roo Code: Improve Code",
8+
} as const
9+
10+
const COMMAND_IDS = {
11+
EXPLAIN: "roo-cline.explainCode",
12+
FIX: "roo-cline.fixCode",
13+
IMPROVE: "roo-cline.improveCode",
14+
} as const
15+
16+
interface DiagnosticData {
17+
message: string
18+
severity: vscode.DiagnosticSeverity
19+
code?: string | number | { value: string | number; target: vscode.Uri }
20+
source?: string
21+
range: vscode.Range
22+
}
23+
24+
interface EffectiveRange {
25+
range: vscode.Range
26+
text: string
27+
}
28+
29+
export class CodeActionProvider implements vscode.CodeActionProvider {
30+
public static readonly providedCodeActionKinds = [
31+
vscode.CodeActionKind.QuickFix,
32+
vscode.CodeActionKind.RefactorRewrite,
33+
]
34+
35+
// Cache file paths for performance
36+
private readonly filePathCache = new WeakMap<vscode.TextDocument, string>()
37+
38+
private getEffectiveRange(
39+
document: vscode.TextDocument,
40+
range: vscode.Range | vscode.Selection,
41+
): EffectiveRange | null {
42+
try {
43+
const selectedText = document.getText(range)
44+
if (selectedText) {
45+
return { range, text: selectedText }
46+
}
47+
48+
const currentLine = document.lineAt(range.start.line)
49+
if (!currentLine.text.trim()) {
50+
return null
51+
}
52+
53+
// Optimize range creation by checking bounds first
54+
const startLine = Math.max(0, currentLine.lineNumber - 1)
55+
const endLine = Math.min(document.lineCount - 1, currentLine.lineNumber + 1)
56+
57+
// Only create new positions if needed
58+
const effectiveRange = new vscode.Range(
59+
startLine === currentLine.lineNumber ? range.start : new vscode.Position(startLine, 0),
60+
endLine === currentLine.lineNumber
61+
? range.end
62+
: new vscode.Position(endLine, document.lineAt(endLine).text.length),
63+
)
64+
65+
return {
66+
range: effectiveRange,
67+
text: document.getText(effectiveRange),
68+
}
69+
} catch (error) {
70+
console.error("Error getting effective range:", error)
71+
return null
72+
}
73+
}
74+
75+
private getFilePath(document: vscode.TextDocument): string {
76+
// Check cache first
77+
let filePath = this.filePathCache.get(document)
78+
if (filePath) {
79+
return filePath
80+
}
81+
82+
try {
83+
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)
84+
if (!workspaceFolder) {
85+
filePath = document.uri.fsPath
86+
} else {
87+
const relativePath = path.relative(workspaceFolder.uri.fsPath, document.uri.fsPath)
88+
filePath = !relativePath || relativePath.startsWith("..") ? document.uri.fsPath : relativePath
89+
}
90+
91+
// Cache the result
92+
this.filePathCache.set(document, filePath)
93+
return filePath
94+
} catch (error) {
95+
console.error("Error getting file path:", error)
96+
return document.uri.fsPath
97+
}
98+
}
99+
100+
private createDiagnosticData(diagnostic: vscode.Diagnostic): DiagnosticData {
101+
return {
102+
message: diagnostic.message,
103+
severity: diagnostic.severity,
104+
code: diagnostic.code,
105+
source: diagnostic.source,
106+
range: diagnostic.range, // Reuse the range object
107+
}
108+
}
109+
110+
private createAction(title: string, kind: vscode.CodeActionKind, command: string, args: any[]): vscode.CodeAction {
111+
const action = new vscode.CodeAction(title, kind)
112+
action.command = { command, title, arguments: args }
113+
return action
114+
}
115+
116+
private hasIntersectingRange(range1: vscode.Range, range2: vscode.Range): boolean {
117+
// Optimize range intersection check
118+
return !(
119+
range2.end.line < range1.start.line ||
120+
range2.start.line > range1.end.line ||
121+
(range2.end.line === range1.start.line && range2.end.character < range1.start.character) ||
122+
(range2.start.line === range1.end.line && range2.start.character > range1.end.character)
123+
)
124+
}
125+
126+
public provideCodeActions(
127+
document: vscode.TextDocument,
128+
range: vscode.Range | vscode.Selection,
129+
context: vscode.CodeActionContext,
130+
): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> {
131+
try {
132+
const effectiveRange = this.getEffectiveRange(document, range)
133+
if (!effectiveRange) {
134+
return []
135+
}
136+
137+
const filePath = this.getFilePath(document)
138+
const actions: vscode.CodeAction[] = []
139+
140+
// Create actions using helper method
141+
actions.push(
142+
this.createAction(ACTION_NAMES.EXPLAIN, vscode.CodeActionKind.QuickFix, COMMAND_IDS.EXPLAIN, [
143+
filePath,
144+
effectiveRange.text,
145+
]),
146+
)
147+
148+
// Only process diagnostics if they exist
149+
if (context.diagnostics.length > 0) {
150+
const relevantDiagnostics = context.diagnostics.filter((d) =>
151+
this.hasIntersectingRange(effectiveRange.range, d.range),
152+
)
153+
154+
if (relevantDiagnostics.length > 0) {
155+
const diagnosticMessages = relevantDiagnostics.map(this.createDiagnosticData)
156+
actions.push(
157+
this.createAction(ACTION_NAMES.FIX, vscode.CodeActionKind.QuickFix, COMMAND_IDS.FIX, [
158+
filePath,
159+
effectiveRange.text,
160+
diagnosticMessages,
161+
]),
162+
)
163+
}
164+
}
165+
166+
actions.push(
167+
this.createAction(ACTION_NAMES.IMPROVE, vscode.CodeActionKind.RefactorRewrite, COMMAND_IDS.IMPROVE, [
168+
filePath,
169+
effectiveRange.text,
170+
]),
171+
)
172+
173+
return actions
174+
} catch (error) {
175+
console.error("Error providing code actions:", error)
176+
return []
177+
}
178+
}
179+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import * as vscode from "vscode"
2+
import { CodeActionProvider } from "../CodeActionProvider"
3+
4+
// Mock VSCode API
5+
jest.mock("vscode", () => ({
6+
CodeAction: jest.fn().mockImplementation((title, kind) => ({
7+
title,
8+
kind,
9+
command: undefined,
10+
})),
11+
CodeActionKind: {
12+
QuickFix: { value: "quickfix" },
13+
RefactorRewrite: { value: "refactor.rewrite" },
14+
},
15+
Range: jest.fn().mockImplementation((startLine, startChar, endLine, endChar) => ({
16+
start: { line: startLine, character: startChar },
17+
end: { line: endLine, character: endChar },
18+
})),
19+
Position: jest.fn().mockImplementation((line, character) => ({
20+
line,
21+
character,
22+
})),
23+
workspace: {
24+
getWorkspaceFolder: jest.fn(),
25+
},
26+
DiagnosticSeverity: {
27+
Error: 0,
28+
Warning: 1,
29+
Information: 2,
30+
Hint: 3,
31+
},
32+
}))
33+
34+
describe("CodeActionProvider", () => {
35+
let provider: CodeActionProvider
36+
let mockDocument: any
37+
let mockRange: any
38+
let mockContext: any
39+
40+
beforeEach(() => {
41+
provider = new CodeActionProvider()
42+
43+
// Mock document
44+
mockDocument = {
45+
getText: jest.fn(),
46+
lineAt: jest.fn(),
47+
lineCount: 10,
48+
uri: { fsPath: "/test/file.ts" },
49+
}
50+
51+
// Mock range
52+
mockRange = new vscode.Range(0, 0, 0, 10)
53+
54+
// Mock context
55+
mockContext = {
56+
diagnostics: [],
57+
}
58+
})
59+
60+
describe("getEffectiveRange", () => {
61+
it("should return selected text when available", () => {
62+
mockDocument.getText.mockReturnValue("selected text")
63+
64+
const result = (provider as any).getEffectiveRange(mockDocument, mockRange)
65+
66+
expect(result).toEqual({
67+
range: mockRange,
68+
text: "selected text",
69+
})
70+
})
71+
72+
it("should return null for empty line", () => {
73+
mockDocument.getText.mockReturnValue("")
74+
mockDocument.lineAt.mockReturnValue({ text: "", lineNumber: 0 })
75+
76+
const result = (provider as any).getEffectiveRange(mockDocument, mockRange)
77+
78+
expect(result).toBeNull()
79+
})
80+
})
81+
82+
describe("getFilePath", () => {
83+
it("should return relative path when in workspace", () => {
84+
const mockWorkspaceFolder = {
85+
uri: { fsPath: "/test" },
86+
}
87+
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(mockWorkspaceFolder)
88+
89+
const result = (provider as any).getFilePath(mockDocument)
90+
91+
expect(result).toBe("file.ts")
92+
})
93+
94+
it("should return absolute path when not in workspace", () => {
95+
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(null)
96+
97+
const result = (provider as any).getFilePath(mockDocument)
98+
99+
expect(result).toBe("/test/file.ts")
100+
})
101+
})
102+
103+
describe("provideCodeActions", () => {
104+
beforeEach(() => {
105+
mockDocument.getText.mockReturnValue("test code")
106+
mockDocument.lineAt.mockReturnValue({ text: "test code", lineNumber: 0 })
107+
})
108+
109+
it("should provide explain and improve actions by default", () => {
110+
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
111+
112+
expect(actions).toHaveLength(2)
113+
expect((actions as any)[0].title).toBe("Roo Code: Explain Code")
114+
expect((actions as any)[1].title).toBe("Roo Code: Improve Code")
115+
})
116+
117+
it("should provide fix action when diagnostics exist", () => {
118+
mockContext.diagnostics = [
119+
{
120+
message: "test error",
121+
severity: vscode.DiagnosticSeverity.Error,
122+
range: mockRange,
123+
},
124+
]
125+
126+
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
127+
128+
expect(actions).toHaveLength(3)
129+
expect((actions as any).some((a: any) => a.title === "Roo Code: Fix Code")).toBe(true)
130+
})
131+
132+
it("should handle errors gracefully", () => {
133+
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
134+
mockDocument.getText.mockImplementation(() => {
135+
throw new Error("Test error")
136+
})
137+
mockDocument.lineAt.mockReturnValue({ text: "test", lineNumber: 0 })
138+
139+
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
140+
141+
expect(actions).toEqual([])
142+
expect(consoleErrorSpy).toHaveBeenCalledWith("Error getting effective range:", expect.any(Error))
143+
144+
consoleErrorSpy.mockRestore()
145+
})
146+
})
147+
})

0 commit comments

Comments
 (0)