Skip to content

Commit 86b051d

Browse files
committed
feat: implement code action provider for VS Code integration
1 parent 7c875f1 commit 86b051d

File tree

2 files changed

+326
-0
lines changed

2 files changed

+326
-0
lines changed

src/core/CodeActionProvider.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import * as vscode from 'vscode';
2+
import * as path from 'path';
3+
4+
const ACTION_NAMES = {
5+
EXPLAIN: 'Roo Cline: Explain Code',
6+
FIX: 'Roo Cline: Fix Code',
7+
IMPROVE: 'Roo Cline: 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 ? range.end : new vscode.Position(endLine, document.lineAt(endLine).text.length)
61+
);
62+
63+
return {
64+
range: effectiveRange,
65+
text: document.getText(effectiveRange)
66+
};
67+
} catch (error) {
68+
console.error('Error getting effective range:', error);
69+
return null;
70+
}
71+
}
72+
73+
private getFilePath(document: vscode.TextDocument): string {
74+
// Check cache first
75+
let filePath = this.filePathCache.get(document);
76+
if (filePath) {
77+
return filePath;
78+
}
79+
80+
try {
81+
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri);
82+
if (!workspaceFolder) {
83+
filePath = document.uri.fsPath;
84+
} else {
85+
const relativePath = path.relative(workspaceFolder.uri.fsPath, document.uri.fsPath);
86+
filePath = (!relativePath || relativePath.startsWith('..')) ? document.uri.fsPath : relativePath;
87+
}
88+
89+
// Cache the result
90+
this.filePathCache.set(document, filePath);
91+
return filePath;
92+
} catch (error) {
93+
console.error('Error getting file path:', error);
94+
return document.uri.fsPath;
95+
}
96+
}
97+
98+
private createDiagnosticData(diagnostic: vscode.Diagnostic): DiagnosticData {
99+
return {
100+
message: diagnostic.message,
101+
severity: diagnostic.severity,
102+
code: diagnostic.code,
103+
source: diagnostic.source,
104+
range: diagnostic.range // Reuse the range object
105+
};
106+
}
107+
108+
private createAction(
109+
title: string,
110+
kind: vscode.CodeActionKind,
111+
command: string,
112+
args: any[]
113+
): vscode.CodeAction {
114+
const action = new vscode.CodeAction(title, kind);
115+
action.command = { command, title, arguments: args };
116+
return action;
117+
}
118+
119+
private hasIntersectingRange(range1: vscode.Range, range2: vscode.Range): boolean {
120+
// Optimize range intersection check
121+
return !(
122+
range2.end.line < range1.start.line ||
123+
range2.start.line > range1.end.line ||
124+
(range2.end.line === range1.start.line && range2.end.character < range1.start.character) ||
125+
(range2.start.line === range1.end.line && range2.start.character > range1.end.character)
126+
);
127+
}
128+
129+
public provideCodeActions(
130+
document: vscode.TextDocument,
131+
range: vscode.Range | vscode.Selection,
132+
context: vscode.CodeActionContext
133+
): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> {
134+
try {
135+
const effectiveRange = this.getEffectiveRange(document, range);
136+
if (!effectiveRange) {
137+
return [];
138+
}
139+
140+
const filePath = this.getFilePath(document);
141+
const actions: vscode.CodeAction[] = [];
142+
143+
// Create actions using helper method
144+
actions.push(this.createAction(
145+
ACTION_NAMES.EXPLAIN,
146+
vscode.CodeActionKind.QuickFix,
147+
COMMAND_IDS.EXPLAIN,
148+
[filePath, effectiveRange.text]
149+
));
150+
151+
// Only process diagnostics if they exist
152+
if (context.diagnostics.length > 0) {
153+
const relevantDiagnostics = context.diagnostics.filter(d =>
154+
this.hasIntersectingRange(effectiveRange.range, d.range)
155+
);
156+
157+
if (relevantDiagnostics.length > 0) {
158+
const diagnosticMessages = relevantDiagnostics.map(this.createDiagnosticData);
159+
actions.push(this.createAction(
160+
ACTION_NAMES.FIX,
161+
vscode.CodeActionKind.QuickFix,
162+
COMMAND_IDS.FIX,
163+
[filePath, effectiveRange.text, diagnosticMessages]
164+
));
165+
}
166+
}
167+
168+
actions.push(this.createAction(
169+
ACTION_NAMES.IMPROVE,
170+
vscode.CodeActionKind.RefactorRewrite,
171+
COMMAND_IDS.IMPROVE,
172+
[filePath, effectiveRange.text]
173+
));
174+
175+
return actions;
176+
} catch (error) {
177+
console.error('Error providing code actions:', error);
178+
return [];
179+
}
180+
}
181+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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 Cline: Explain Code');
114+
expect((actions as any)[1].title).toBe('Roo Cline: Improve Code');
115+
});
116+
117+
it('should provide fix action when diagnostics exist', () => {
118+
mockContext.diagnostics = [{
119+
message: 'test error',
120+
severity: vscode.DiagnosticSeverity.Error,
121+
range: mockRange
122+
}];
123+
124+
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext);
125+
126+
expect(actions).toHaveLength(3);
127+
expect((actions as any).some((a: any) => a.title === 'Roo Cline: Fix Code')).toBe(true);
128+
});
129+
130+
it('should handle errors gracefully', () => {
131+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
132+
mockDocument.getText.mockImplementation(() => {
133+
throw new Error('Test error');
134+
});
135+
mockDocument.lineAt.mockReturnValue({ text: 'test', lineNumber: 0 });
136+
137+
const actions = provider.provideCodeActions(mockDocument, mockRange, mockContext);
138+
139+
expect(actions).toEqual([]);
140+
expect(consoleErrorSpy).toHaveBeenCalledWith('Error getting effective range:', expect.any(Error));
141+
142+
consoleErrorSpy.mockRestore();
143+
});
144+
});
145+
});

0 commit comments

Comments
 (0)