Merge pull request #656 from samhvw8/feat/add-to-context-code-action

Feat add to context code action & Fix some code action error
This commit is contained in:
Matt Rubens
2025-01-31 00:24:38 -05:00
committed by GitHub
10 changed files with 372 additions and 201 deletions

View File

@@ -1,113 +1,27 @@
import * as vscode from "vscode"
import * as path from "path"
import { ClineProvider } from "./webview/ClineProvider"
import { EditorUtils } from "./EditorUtils"
export const ACTION_NAMES = {
EXPLAIN: "Roo Code: Explain Code",
FIX: "Roo Code: Fix Code",
FIX_LOGIC: "Roo Code: Fix Logic",
IMPROVE: "Roo Code: Improve Code",
ADD_TO_CONTEXT: "Roo Code: Add to Context",
} as const
const COMMAND_IDS = {
EXPLAIN: "roo-cline.explainCode",
FIX: "roo-cline.fixCode",
IMPROVE: "roo-cline.improveCode",
ADD_TO_CONTEXT: "roo-cline.addToContext",
} as const
interface DiagnosticData {
message: string
severity: vscode.DiagnosticSeverity
code?: string | number | { value: string | number; target: vscode.Uri }
source?: string
range: vscode.Range
}
interface EffectiveRange {
range: vscode.Range
text: string
}
export class CodeActionProvider implements vscode.CodeActionProvider {
public static readonly providedCodeActionKinds = [
vscode.CodeActionKind.QuickFix,
vscode.CodeActionKind.RefactorRewrite,
]
// Cache file paths for performance
private readonly filePathCache = new WeakMap<vscode.TextDocument, string>()
private getEffectiveRange(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
): EffectiveRange | null {
try {
const selectedText = document.getText(range)
if (selectedText) {
return { range, text: selectedText }
}
const currentLine = document.lineAt(range.start.line)
if (!currentLine.text.trim()) {
return null
}
// Optimize range creation by checking bounds first
const startLine = Math.max(0, currentLine.lineNumber - 1)
const endLine = Math.min(document.lineCount - 1, currentLine.lineNumber + 1)
// Only create new positions if needed
const effectiveRange = new vscode.Range(
startLine === currentLine.lineNumber ? range.start : new vscode.Position(startLine, 0),
endLine === currentLine.lineNumber
? range.end
: new vscode.Position(endLine, document.lineAt(endLine).text.length),
)
return {
range: effectiveRange,
text: document.getText(effectiveRange),
}
} catch (error) {
console.error("Error getting effective range:", error)
return null
}
}
private getFilePath(document: vscode.TextDocument): string {
// Check cache first
let filePath = this.filePathCache.get(document)
if (filePath) {
return filePath
}
try {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)
if (!workspaceFolder) {
filePath = document.uri.fsPath
} else {
const relativePath = path.relative(workspaceFolder.uri.fsPath, document.uri.fsPath)
filePath = !relativePath || relativePath.startsWith("..") ? document.uri.fsPath : relativePath
}
// Cache the result
this.filePathCache.set(document, filePath)
return filePath
} catch (error) {
console.error("Error getting file path:", error)
return document.uri.fsPath
}
}
private createDiagnosticData(diagnostic: vscode.Diagnostic): DiagnosticData {
return {
message: diagnostic.message,
severity: diagnostic.severity,
code: diagnostic.code,
source: diagnostic.source,
range: diagnostic.range, // Reuse the range object
}
}
private createAction(title: string, kind: vscode.CodeActionKind, command: string, args: any[]): vscode.CodeAction {
const action = new vscode.CodeAction(title, kind)
action.command = { command, title, arguments: args }
@@ -126,32 +40,20 @@ export class CodeActionProvider implements vscode.CodeActionProvider {
]
}
private hasIntersectingRange(range1: vscode.Range, range2: vscode.Range): boolean {
// Optimize range intersection check
return !(
range2.end.line < range1.start.line ||
range2.start.line > range1.end.line ||
(range2.end.line === range1.start.line && range2.end.character < range1.start.character) ||
(range2.start.line === range1.end.line && range2.start.character > range1.end.character)
)
}
public provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
context: vscode.CodeActionContext,
): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> {
try {
const effectiveRange = this.getEffectiveRange(document, range)
const effectiveRange = EditorUtils.getEffectiveRange(document, range)
if (!effectiveRange) {
return []
}
const filePath = this.getFilePath(document)
const filePath = EditorUtils.getFilePath(document)
const actions: vscode.CodeAction[] = []
// Create actions using helper method
// Add explain actions
actions.push(
...this.createActionPair(ACTION_NAMES.EXPLAIN, vscode.CodeActionKind.QuickFix, COMMAND_IDS.EXPLAIN, [
filePath,
@@ -159,14 +61,13 @@ export class CodeActionProvider implements vscode.CodeActionProvider {
]),
)
// Only process diagnostics if they exist
if (context.diagnostics.length > 0) {
const relevantDiagnostics = context.diagnostics.filter((d) =>
this.hasIntersectingRange(effectiveRange.range, d.range),
EditorUtils.hasIntersectingRange(effectiveRange.range, d.range),
)
if (relevantDiagnostics.length > 0) {
const diagnosticMessages = relevantDiagnostics.map(this.createDiagnosticData)
const diagnosticMessages = relevantDiagnostics.map(EditorUtils.createDiagnosticData)
actions.push(
...this.createActionPair(ACTION_NAMES.FIX, vscode.CodeActionKind.QuickFix, COMMAND_IDS.FIX, [
filePath,
@@ -175,9 +76,15 @@ export class CodeActionProvider implements vscode.CodeActionProvider {
]),
)
}
} else {
actions.push(
...this.createActionPair(ACTION_NAMES.FIX_LOGIC, vscode.CodeActionKind.QuickFix, COMMAND_IDS.FIX, [
filePath,
effectiveRange.text,
]),
)
}
// Add improve actions
actions.push(
...this.createActionPair(
ACTION_NAMES.IMPROVE,
@@ -187,6 +94,15 @@ export class CodeActionProvider implements vscode.CodeActionProvider {
),
)
actions.push(
this.createAction(
ACTION_NAMES.ADD_TO_CONTEXT,
vscode.CodeActionKind.QuickFix,
COMMAND_IDS.ADD_TO_CONTEXT,
[filePath, effectiveRange.text],
),
)
return actions
} catch (error) {
console.error("Error providing code actions:", error)

141
src/core/EditorUtils.ts Normal file
View File

@@ -0,0 +1,141 @@
import * as vscode from "vscode"
import * as path from "path"
export interface EffectiveRange {
range: vscode.Range
text: string
}
export interface DiagnosticData {
message: string
severity: vscode.DiagnosticSeverity
code?: string | number | { value: string | number; target: vscode.Uri }
source?: string
range: vscode.Range
}
export interface EditorContext {
filePath: string
selectedText: string
diagnostics?: DiagnosticData[]
}
export class EditorUtils {
// Cache file paths for performance
private static readonly filePathCache = new WeakMap<vscode.TextDocument, string>()
static getEffectiveRange(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
): EffectiveRange | null {
try {
const selectedText = document.getText(range)
if (selectedText) {
return { range, text: selectedText }
}
const currentLine = document.lineAt(range.start.line)
if (!currentLine.text.trim()) {
return null
}
// Optimize range creation by checking bounds first
const startLine = Math.max(0, currentLine.lineNumber - 1)
const endLine = Math.min(document.lineCount - 1, currentLine.lineNumber + 1)
// Only create new positions if needed
const effectiveRange = new vscode.Range(
startLine === currentLine.lineNumber ? range.start : new vscode.Position(startLine, 0),
endLine === currentLine.lineNumber
? range.end
: new vscode.Position(endLine, document.lineAt(endLine).text.length),
)
return {
range: effectiveRange,
text: document.getText(effectiveRange),
}
} catch (error) {
console.error("Error getting effective range:", error)
return null
}
}
static getFilePath(document: vscode.TextDocument): string {
// Check cache first
let filePath = this.filePathCache.get(document)
if (filePath) {
return filePath
}
try {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)
if (!workspaceFolder) {
filePath = document.uri.fsPath
} else {
const relativePath = path.relative(workspaceFolder.uri.fsPath, document.uri.fsPath)
filePath = !relativePath || relativePath.startsWith("..") ? document.uri.fsPath : relativePath
}
// Cache the result
this.filePathCache.set(document, filePath)
return filePath
} catch (error) {
console.error("Error getting file path:", error)
return document.uri.fsPath
}
}
static createDiagnosticData(diagnostic: vscode.Diagnostic): DiagnosticData {
return {
message: diagnostic.message,
severity: diagnostic.severity,
code: diagnostic.code,
source: diagnostic.source,
range: diagnostic.range,
}
}
static hasIntersectingRange(range1: vscode.Range, range2: vscode.Range): boolean {
return !(
range2.end.line < range1.start.line ||
range2.start.line > range1.end.line ||
(range2.end.line === range1.start.line && range2.end.character < range1.start.character) ||
(range2.start.line === range1.end.line && range2.start.character > range1.end.character)
)
}
static getEditorContext(editor?: vscode.TextEditor): EditorContext | null {
try {
if (!editor) {
editor = vscode.window.activeTextEditor
}
if (!editor) {
return null
}
const document = editor.document
const selection = editor.selection
const effectiveRange = this.getEffectiveRange(document, selection)
if (!effectiveRange) {
return null
}
const filePath = this.getFilePath(document)
const diagnostics = vscode.languages
.getDiagnostics(document.uri)
.filter((d) => this.hasIntersectingRange(effectiveRange.range, d.range))
.map(this.createDiagnosticData)
return {
filePath,
selectedText: effectiveRange.text,
...(diagnostics.length > 0 ? { diagnostics } : {}),
}
} catch (error) {
console.error("Error getting editor context:", error)
return null
}
}
}

View File

@@ -1,5 +1,6 @@
import * as vscode from "vscode"
import { CodeActionProvider, ACTION_NAMES } from "../CodeActionProvider"
import { EditorUtils } from "../EditorUtils"
// Mock VSCode API
jest.mock("vscode", () => ({
@@ -16,13 +17,6 @@ jest.mock("vscode", () => ({
start: { line: startLine, character: startChar },
end: { line: endLine, character: endChar },
})),
Position: jest.fn().mockImplementation((line, character) => ({
line,
character,
})),
workspace: {
getWorkspaceFolder: jest.fn(),
},
DiagnosticSeverity: {
Error: 0,
Warning: 1,
@@ -31,6 +25,16 @@ jest.mock("vscode", () => ({
},
}))
// Mock EditorUtils
jest.mock("../EditorUtils", () => ({
EditorUtils: {
getEffectiveRange: jest.fn(),
getFilePath: jest.fn(),
hasIntersectingRange: jest.fn(),
createDiagnosticData: jest.fn(),
},
}))
describe("CodeActionProvider", () => {
let provider: CodeActionProvider
let mockDocument: any
@@ -55,68 +59,32 @@ describe("CodeActionProvider", () => {
mockContext = {
diagnostics: [],
}
})
describe("getEffectiveRange", () => {
it("should return selected text when available", () => {
mockDocument.getText.mockReturnValue("selected text")
const result = (provider as any).getEffectiveRange(mockDocument, mockRange)
expect(result).toEqual({
range: mockRange,
text: "selected text",
})
})
it("should return null for empty line", () => {
mockDocument.getText.mockReturnValue("")
mockDocument.lineAt.mockReturnValue({ text: "", lineNumber: 0 })
const result = (provider as any).getEffectiveRange(mockDocument, mockRange)
expect(result).toBeNull()
})
})
describe("getFilePath", () => {
it("should return relative path when in workspace", () => {
const mockWorkspaceFolder = {
uri: { fsPath: "/test" },
}
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(mockWorkspaceFolder)
const result = (provider as any).getFilePath(mockDocument)
expect(result).toBe("file.ts")
})
it("should return absolute path when not in workspace", () => {
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(null)
const result = (provider as any).getFilePath(mockDocument)
expect(result).toBe("/test/file.ts")
// Setup default EditorUtils mocks
;(EditorUtils.getEffectiveRange as jest.Mock).mockReturnValue({
range: mockRange,
text: "test code",
})
;(EditorUtils.getFilePath as jest.Mock).mockReturnValue("/test/file.ts")
;(EditorUtils.hasIntersectingRange as jest.Mock).mockReturnValue(true)
;(EditorUtils.createDiagnosticData as jest.Mock).mockImplementation((d) => d)
})
describe("provideCodeActions", () => {
beforeEach(() => {
mockDocument.getText.mockReturnValue("test code")
mockDocument.lineAt.mockReturnValue({ text: "test code", lineNumber: 0 })
})
it("should provide explain and improve actions by default", () => {
it("should provide explain, improve, fix logic, and add to context actions by default", () => {
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
expect(actions).toHaveLength(4)
expect(actions).toHaveLength(7) // 2 explain + 2 fix logic + 2 improve + 1 add to context
expect((actions as any)[0].title).toBe(`${ACTION_NAMES.EXPLAIN} in New Task`)
expect((actions as any)[1].title).toBe(`${ACTION_NAMES.EXPLAIN} in Current Task`)
expect((actions as any)[2].title).toBe(`${ACTION_NAMES.IMPROVE} in New Task`)
expect((actions as any)[3].title).toBe(`${ACTION_NAMES.IMPROVE} in Current Task`)
expect((actions as any)[2].title).toBe(`${ACTION_NAMES.FIX_LOGIC} in New Task`)
expect((actions as any)[3].title).toBe(`${ACTION_NAMES.FIX_LOGIC} in Current Task`)
expect((actions as any)[4].title).toBe(`${ACTION_NAMES.IMPROVE} in New Task`)
expect((actions as any)[5].title).toBe(`${ACTION_NAMES.IMPROVE} in Current Task`)
expect((actions as any)[6].title).toBe(ACTION_NAMES.ADD_TO_CONTEXT)
})
it("should provide fix action when diagnostics exist", () => {
it("should provide fix action instead of fix logic when diagnostics exist", () => {
mockContext.diagnostics = [
{
message: "test error",
@@ -127,22 +95,33 @@ describe("CodeActionProvider", () => {
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
expect(actions).toHaveLength(6)
expect(actions).toHaveLength(7) // 2 explain + 2 fix + 2 improve + 1 add to context
expect((actions as any).some((a: any) => a.title === `${ACTION_NAMES.FIX} in New Task`)).toBe(true)
expect((actions as any).some((a: any) => a.title === `${ACTION_NAMES.FIX} in Current Task`)).toBe(true)
expect((actions as any).some((a: any) => a.title === `${ACTION_NAMES.FIX_LOGIC} in New Task`)).toBe(false)
expect((actions as any).some((a: any) => a.title === `${ACTION_NAMES.FIX_LOGIC} in Current Task`)).toBe(
false,
)
})
it("should handle errors gracefully", () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
mockDocument.getText.mockImplementation(() => {
throw new Error("Test error")
})
mockDocument.lineAt.mockReturnValue({ text: "test", lineNumber: 0 })
it("should return empty array when no effective range", () => {
;(EditorUtils.getEffectiveRange as jest.Mock).mockReturnValue(null)
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
expect(actions).toEqual([])
expect(consoleErrorSpy).toHaveBeenCalledWith("Error getting effective range:", expect.any(Error))
})
it("should handle errors gracefully", () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
;(EditorUtils.getEffectiveRange as jest.Mock).mockImplementation(() => {
throw new Error("Test error")
})
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext)
expect(actions).toEqual([])
expect(consoleErrorSpy).toHaveBeenCalledWith("Error providing code actions:", expect.any(Error))
consoleErrorSpy.mockRestore()
})

View File

@@ -0,0 +1,75 @@
import * as vscode from "vscode"
import { EditorUtils } from "../EditorUtils"
// Mock VSCode API
jest.mock("vscode", () => ({
Range: jest.fn().mockImplementation((startLine, startChar, endLine, endChar) => ({
start: { line: startLine, character: startChar },
end: { line: endLine, character: endChar },
})),
Position: jest.fn().mockImplementation((line, character) => ({
line,
character,
})),
workspace: {
getWorkspaceFolder: jest.fn(),
},
}))
describe("EditorUtils", () => {
let mockDocument: any
beforeEach(() => {
mockDocument = {
getText: jest.fn(),
lineAt: jest.fn(),
lineCount: 10,
uri: { fsPath: "/test/file.ts" },
}
})
describe("getEffectiveRange", () => {
it("should return selected text when available", () => {
const mockRange = new vscode.Range(0, 0, 0, 10)
mockDocument.getText.mockReturnValue("selected text")
const result = EditorUtils.getEffectiveRange(mockDocument, mockRange)
expect(result).toEqual({
range: mockRange,
text: "selected text",
})
})
it("should return null for empty line", () => {
const mockRange = new vscode.Range(0, 0, 0, 10)
mockDocument.getText.mockReturnValue("")
mockDocument.lineAt.mockReturnValue({ text: "", lineNumber: 0 })
const result = EditorUtils.getEffectiveRange(mockDocument, mockRange)
expect(result).toBeNull()
})
})
describe("getFilePath", () => {
it("should return relative path when in workspace", () => {
const mockWorkspaceFolder = {
uri: { fsPath: "/test" },
}
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(mockWorkspaceFolder)
const result = EditorUtils.getFilePath(mockDocument)
expect(result).toBe("file.ts")
})
it("should return absolute path when not in workspace", () => {
;(vscode.workspace.getWorkspaceFolder as jest.Mock).mockReturnValue(null)
const result = EditorUtils.getFilePath(mockDocument)
expect(result).toBe("/test/file.ts")
})
})
})

View File

@@ -238,6 +238,16 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const prompt = supportPrompt.create(promptType, params, customSupportPrompts)
if (command.endsWith("addToContext")) {
await visibleProvider.postMessageToWebview({
type: "invoke",
invoke: "setChatBoxMessage",
text: prompt,
})
return
}
if (visibleProvider.cline && command.endsWith("InCurrentTask")) {
await visibleProvider.postMessageToWebview({
type: "invoke",