feat: implement core code review extension

This commit is contained in:
Developer
2026-07-14 21:18:19 +08:00
parent cafe67db6d
commit 1144c9b5db
39 changed files with 7541 additions and 132 deletions
+293
View File
@@ -0,0 +1,293 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import { Orchestrator } from '../orchestrator/orchestrator';
import { runAIReview } from '../ai/engine';
import { loadActiveRules } from '../rules/yaml-parser';
import { mergeResults, MergedReport } from '../merger/merger';
import { reportToMarkdown } from '../utils/report';
import { getAIConfig, setApiKey, getApiKey, isApiKeyConfigured } from '../config';
import { createProvider } from '../ai/factory';
import { SetupViewProvider } from '../views/setupView';
import { ReviewPanel } from '../panel/webview';
let currentReport: MergedReport | null = null;
export function registerCommands(
context: vscode.ExtensionContext,
orchestrator: Orchestrator,
setupProvider: SetupViewProvider
): void {
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.review', async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage('请先打开一个文件');
return;
}
const document = editor.document;
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: '正在审查...',
cancellable: false,
}, async (progress) => {
progress.report({ message: '运行静态分析...' });
const startTime = Date.now();
const staticResult = await orchestrator.runStaticAnalysis(document, workingDir);
progress.report({ message: '运行 AI 审查...' });
const customRules = loadActiveRules(workspaceRoot);
const code = document.getText();
const aiResult = await runAIReview(context, code, staticResult.diagnostics, customRules);
currentReport = mergeResults({
staticDiagnostics: staticResult.diagnostics,
customRuleResults: aiResult.customRuleResults,
translatedDiagnostics: aiResult.translatedDiagnostics,
aiFindings: aiResult.findings,
errors: [...staticResult.errors, ...(aiResult.error ? [aiResult.error] : [])],
degraded: aiResult.degraded,
startTime,
filePath: document.uri.fsPath,
language: document.languageId,
adapterIds: staticResult.adapterIds,
});
const panel = ReviewPanel.createOrShow(context.extensionUri);
panel.update(currentReport);
});
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.reviewSelection', async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) { return; }
const selection = editor.selection;
if (selection.isEmpty) {
vscode.window.showWarningMessage('请先选中要审查的代码');
return;
}
const code = editor.document.getText(selection);
const apiKey = await getApiKey(context);
if (!apiKey) {
vscode.window.showWarningMessage('请先在设置面板中配置 API Key');
return;
}
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
const customRules = loadActiveRules(workspaceRoot);
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: '审查选中代码...',
cancellable: false,
}, async () => {
const aiResult = await runAIReview(context, code, [], customRules);
vscode.window.showInformationMessage(
`选中代码审查完成: ${aiResult.customRuleResults.length + aiResult.findings.length} 个问题`
);
});
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.openPanel', () => {
ReviewPanel.createOrShow(context.extensionUri);
if (currentReport) {
const panel = ReviewPanel.createOrShow(context.extensionUri);
panel.update(currentReport);
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.exportReport', async () => {
if (!currentReport) {
vscode.window.showWarningMessage('请先运行完整审查生成报告');
return;
}
const markdown = reportToMarkdown(currentReport);
const doc = await vscode.workspace.openTextDocument({ content: markdown, language: 'markdown' });
await vscode.window.showTextDocument(doc);
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.addCustomRule', () => {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) {
vscode.window.showWarningMessage('请先打开工作区');
return;
}
vscode.window.showInformationMessage('添加自定义规则功能开发中');
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.fixIssue', () => {
vscode.window.showInformationMessage('单条修复功能开发中');
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.fixAll', () => {
vscode.window.showInformationMessage('批量修复功能开发中');
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.openSetup', () => {
vscode.commands.executeCommand('workbench.view.extension.code-reviewer');
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.setApiKey', async () => {
const key = await vscode.window.showInputBox({
prompt: '请输入 API Key',
password: true,
placeHolder: 'sk-...',
});
if (key) {
await setApiKey(context, key);
setupProvider.refresh();
vscode.window.showInformationMessage('API Key 已保存');
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.focusApiKey', async () => {
const key = await vscode.window.showInputBox({
prompt: '请输入 API Key',
password: true,
placeHolder: 'sk-...',
});
if (key) {
await setApiKey(context, key);
setupProvider.refresh();
vscode.window.showInformationMessage('API Key 已保存');
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.selectProvider', async () => {
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
const selected = await vscode.window.showQuickPick(['deepseek', 'openai'], {
placeHolder: '选择模型提供商',
});
if (selected) {
await config.update('ai.provider', selected, vscode.ConfigurationTarget.Global);
setupProvider.refresh();
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.selectModel', async () => {
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
const current = config.get<string>('ai.model', '');
const selected = await vscode.window.showInputBox({
prompt: '输入模型名称',
value: current,
placeHolder: 'deepseek-chat',
});
if (selected) {
await config.update('ai.model', selected, vscode.ConfigurationTarget.Global);
setupProvider.refresh();
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.selectLanguage', async () => {
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
const selected = await vscode.window.showQuickPick(
[
{ label: '中文(简体)', description: 'zh-CN' },
{ label: 'English', description: 'en' },
{ label: '日本語', description: 'ja' },
],
{ placeHolder: '选择输出语言' }
);
if (selected) {
await config.update('ai.outputLanguage', selected.description, vscode.ConfigurationTarget.Global);
setupProvider.refresh();
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.saveAndTest', async () => {
const apiKey = await getApiKey(context);
if (!apiKey) {
vscode.window.showWarningMessage('请先设置 API Key');
return;
}
const config = getAIConfig();
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: '测试连接...',
cancellable: false,
}, async () => {
try {
const provider = createProvider(config.provider, apiKey, config.endpoint);
await provider.chat('回复 ok', 'ping', {
model: config.model,
temperature: 0,
timeoutMs: 15000,
});
setupProvider.connectionTested = true;
setupProvider.connectionSuccess = true;
setupProvider.refresh();
vscode.window.showInformationMessage('✓ 连接成功', { modal: false });
} catch (err) {
setupProvider.connectionTested = true;
setupProvider.connectionSuccess = false;
setupProvider.refresh();
const message = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`✗ 连接失败: ${message}`, { modal: false });
}
});
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.toggleRule', async (ruleId: string) => {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) { return; }
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
if (!fs.existsSync(configPath)) { return; }
let content = fs.readFileSync(configPath, 'utf-8');
const enabledPattern = new RegExp(`^(\\s*${ruleId}\\s*:\\s*\\n\\s*enabled\\s*:\\s*)(true|false)`, 'm');
if (enabledPattern.test(content)) {
const match = enabledPattern.exec(content);
if (match) {
const newValue = match[2] === 'true' ? 'false' : 'true';
content = content.replace(enabledPattern, `$1${newValue}`);
}
} else {
content += `\n ${ruleId}:\n enabled: false\n`;
}
fs.writeFileSync(configPath, content, 'utf-8');
setupProvider.refresh();
})
);
}
+7
View File
@@ -0,0 +1,7 @@
export type {
Severity,
AdapterStatus,
LinterDiagnostic,
AdapterResult,
LinterAdapter,
} from '../types';
+49
View File
@@ -0,0 +1,49 @@
import * as vscode from 'vscode';
import { ESLint } from 'eslint';
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
export class ESLintAdapter implements LinterAdapter {
id = 'eslint';
supportedLanguages = ['javascript', 'typescript'];
isAvailable(): boolean {
return true;
}
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
try {
const engine = new ESLint({ cwd: workingDir });
const results = await engine.lintText(document.getText(), {
filePath: document.fileName || 'untitled.ts',
});
const diagnostics: LinterDiagnostic[] = [];
for (const result of results) {
for (const msg of result.messages) {
if (msg.ruleId === null) { continue; }
diagnostics.push({
severity: msg.severity === 2 ? 'error' : 'warning',
ruleId: `eslint:${msg.ruleId}`,
message: msg.message,
range: new vscode.Range(
msg.line - 1,
msg.column - 1,
(msg.endLine ?? msg.line) - 1,
(msg.endColumn ?? msg.column) - 1
),
suggestion: msg.fix?.text,
});
}
}
return { diagnostics, status: 'ok' };
} catch (error) {
return {
diagnostics: [],
status: 'execution-failed',
errorMessage: error instanceof Error ? error.message : String(error),
};
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import * as vscode from 'vscode';
import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
import { PmdAdapter } from './pmd';
import { ESLintAdapter } from './eslint';
import { StylelintAdapter } from './stylelint';
import { extractJspSections } from '../jsp/jsp-extractor';
import { getLinterForLanguage } from '../config';
export class JspAdapter implements LinterAdapter {
id = 'jsp';
supportedLanguages = ['jsp'];
private pmdAdapter = new PmdAdapter();
private eslintAdapter = new ESLintAdapter();
private stylelintAdapter = new StylelintAdapter();
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
const allDiagnostics: LinterDiagnostic[] = [];
const errors: string[] = [];
const jsEnabled = getLinterForLanguage('javascript') !== '';
const cssEnabled = getLinterForLanguage('css') !== '';
const javaEnabled = getLinterForLanguage('java') !== '';
const pmdResult = await this.pmdAdapter.check(document, workingDir);
allDiagnostics.push(...pmdResult.diagnostics);
if (pmdResult.status !== 'ok') {
errors.push(`PMD: ${pmdResult.errorMessage ?? pmdResult.status}`);
}
const sections = extractJspSections(document.getText());
for (const section of sections) {
const isEnabled = (section.language === 'javascript' && jsEnabled)
|| (section.language === 'css' && cssEnabled)
|| (section.language === 'java' && javaEnabled);
if (!isEnabled) { continue; }
const adapter = this.getAdapter(section.language);
if (!adapter) { continue; }
try {
const virtualDoc = await vscode.workspace.openTextDocument({
content: section.code,
language: section.language,
});
const result = await adapter.check(virtualDoc, workingDir);
for (const diag of result.diagnostics) {
const adjustedRange = new vscode.Range(
diag.range.start.line + section.lineOffset,
diag.range.start.character,
diag.range.end.line + section.lineOffset,
diag.range.end.character,
);
allDiagnostics.push({ ...diag, range: adjustedRange });
}
if (result.status !== 'ok') {
errors.push(`${section.language}: ${result.errorMessage ?? result.status}`);
}
} catch (err) {
errors.push(`${section.language}: ${err instanceof Error ? err.message : String(err)}`);
}
}
const hasErrors = errors.length > 0;
const hasUnavailable = errors.some(e => e.includes('未安装') || e.includes('tool-unavailable'));
return {
diagnostics: allDiagnostics,
status: hasErrors ? (hasUnavailable ? 'tool-unavailable' : 'execution-failed') : 'ok',
errorMessage: errors.join('; '),
};
}
private getAdapter(language: string): LinterAdapter | null {
switch (language) {
case 'javascript': return this.eslintAdapter;
case 'css': return this.stylelintAdapter;
case 'java': return this.pmdAdapter;
default: return null;
}
}
isAvailable(): boolean {
return true;
}
}
+114
View File
@@ -0,0 +1,114 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { execSync, spawn } from 'child_process';
import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
import { getPMDRulesetPath } from '../config';
export class PmdAdapter implements LinterAdapter {
id = 'pmd';
supportedLanguages = ['java'];
private getPmdLibClasspath(): string {
const extRoot = this.getExtensionRoot();
const pmdLib = path.join(extRoot, 'jars', 'pmd', 'lib');
return path.join(pmdLib, '*');
}
private getPmdRunnerClasspath(): string {
const extRoot = this.getExtensionRoot();
return path.join(extRoot, 'jars', 'pmd');
}
private getExtensionRoot(): string {
try {
const extPath = vscode.extensions.getExtension?.('vscode-code-reviewer')?.extensionPath;
if (extPath) { return extPath; }
} catch { /* extension not available */ }
return path.join(__dirname, '..', '..');
}
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
try {
const ruleset = getPMDRulesetPath()
|| path.join(this.getPmdRunnerClasspath(), 'pmd-java-ruleset.xml');
const classpath = `${this.getPmdLibClasspath()};${this.getPmdRunnerClasspath()}`;
const isVirtual = document.uri.scheme === 'untitled';
const fileArg = isVirtual ? '-' : document.uri.fsPath;
const javaArgs = ['-cp', classpath, 'PmdRunner', fileArg, ruleset];
const result = await this.execPmd(javaArgs, isVirtual ? document.getText() : null, workingDir);
const diagnostics = this.parsePmdOutput(result);
return { diagnostics, status: 'ok' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('ENOENT') || message.includes('java')) {
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'Java 11+ 未安装或不在 PATH 中' };
}
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
}
}
private execPmd(args: string[], stdinInput: string | null, cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn('java', args, { cwd });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0 || code === 4 || stdout.length > 0) {
resolve(stdout);
} else {
reject(new Error(stderr || `PMD exited with code ${code}`));
}
});
proc.on('error', reject);
if (stdinInput !== null) {
proc.stdin.write(stdinInput);
proc.stdin.end();
}
});
}
private parsePmdOutput(output: string): LinterDiagnostic[] {
if (!output.trim()) { return []; }
try {
const data = JSON.parse(output);
const diagnostics: LinterDiagnostic[] = [];
for (const file of data.files ?? []) {
for (const violation of file.violations ?? []) {
const line = Math.max(0, (violation.beginline ?? 1) - 1);
const col = Math.max(0, (violation.begincolumn ?? 1) - 1);
const endCol = Math.max(col, (violation.endcolumn ?? col + 1) - 1);
const range = new vscode.Range(line, col, line, endCol);
diagnostics.push({
severity: this.mapPriority(violation.priority),
ruleId: `pmd:${violation.rule}`,
message: violation.description ?? '',
range,
});
}
}
return diagnostics;
} catch {
return [];
}
}
private mapPriority(priority: number): 'error' | 'warning' | 'info' {
if (priority <= 2) { return 'error'; }
if (priority === 3) { return 'warning'; }
return 'info';
}
isAvailable(): boolean {
try {
execSync('java -version 2>&1', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import * as vscode from 'vscode';
import { spawn } from 'child_process';
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
const DIALECT_MAP: Record<string, string> = {
sql: 'ansi',
plsql: 'postgres',
};
interface SqlFluffViolation {
line_no: number;
line_pos: number;
rule_code: string;
description: string;
}
interface SqlFluffResult {
filepath: string;
violations: SqlFluffViolation[];
}
function runSqlfluff(dialect: string, code: string, cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('sqlfluff', ['lint', '--dialect', dialect, '--format', 'json', '-'], {
cwd,
timeout: 30000,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
child.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') {
reject(new Error('tool-unavailable'));
} else {
reject(err);
}
});
child.on('close', (code: number | null) => {
if (stdout) {
resolve(stdout);
} else {
reject(new Error(stderr || `sqlfluff exited with code ${code}`));
}
});
child.stdin.write(code);
child.stdin.end();
});
}
export class SqlLintAdapter implements LinterAdapter {
id = 'sql-lint';
supportedLanguages = ['sql', 'plsql'];
isAvailable(): boolean {
return true;
}
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
const languageId = document.languageId;
const dialect = DIALECT_MAP[languageId] || 'ansi';
try {
const stdout = await runSqlfluff(dialect, document.getText(), workingDir);
const results: SqlFluffResult[] = JSON.parse(stdout);
const diagnostics: LinterDiagnostic[] = [];
for (const result of results) {
for (const v of result.violations) {
diagnostics.push({
severity: 'warning',
ruleId: `sql-lint:${v.rule_code}`,
message: v.description,
range: new vscode.Range(
v.line_no - 1,
v.line_pos - 1,
v.line_no - 1,
v.line_pos - 1
),
});
}
}
return { diagnostics, status: 'ok' };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === 'tool-unavailable') {
return {
diagnostics: [],
status: 'tool-unavailable',
errorMessage: 'sqlfluff 未安装,请执行 pip install sqlfluff',
};
}
return {
diagnostics: [],
status: 'execution-failed',
errorMessage: message,
};
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import * as vscode from 'vscode';
import stylelint from 'stylelint';
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
export class StylelintAdapter implements LinterAdapter {
id = 'stylelint';
supportedLanguages = ['css'];
isAvailable(): boolean {
return true;
}
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
try {
const result = await stylelint.lint({
code: document.getText(),
codeFilename: document.fileName,
cwd: workingDir,
});
const diagnostics: LinterDiagnostic[] = [];
for (const res of result.results) {
for (const w of res.warnings) {
diagnostics.push({
severity: w.severity,
ruleId: `stylelint:${w.rule}`,
message: w.text,
range: new vscode.Range(
w.line - 1,
w.column - 1,
(w.endLine ?? w.line) - 1,
(w.endColumn ?? w.column) - 1
),
});
}
}
return { diagnostics, status: 'ok' };
} catch (error) {
return {
diagnostics: [],
status: 'execution-failed',
errorMessage: error instanceof Error ? error.message : String(error),
};
}
}
}
+158
View File
@@ -0,0 +1,158 @@
import * as vscode from 'vscode';
import type { AIProvider } from './providers/base';
import { createProvider } from './factory';
import { getAIProvider, getAIModel, getAIEndpoint, getAITemperature, getAITimeout, getAIOutputLanguage, getApiKey } from '../config';
import type { LinterDiagnostic, CustomRule } from '../types';
import type {
AIEngineResult,
CustomRuleResult,
TranslatedDiagnostic,
AIFinding,
} from './schema';
function buildCustomRulePrompt(rules: CustomRule[]): string {
return rules.map(r =>
`- [${r.id}] (${r.severity}) ${r.description}`
).join('\n');
}
function buildLinterDiagnosticsPrompt(diagnostics: LinterDiagnostic[]): string {
return diagnostics.map(d =>
`- [${d.ruleId}] L${d.range.start.line + 1}: ${d.message}`
).join('\n');
}
function addLineNumbers(code: string): string {
return code.split('\n').map((line, i) => `${String(i + 1).padStart(4, ' ')}| ${line}`).join('\n');
}
function parseJsonResponse(raw: string): object {
const trimmed = raw.trim();
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start === -1 || end === -1) {
throw new Error('响应中未找到 JSON');
}
return JSON.parse(trimmed.substring(start, end + 1));
}
const CUSTOM_RULE_SYSTEM_PROMPT = `你是代码规则审查员,只评估以下自定义规则是否被违反。
理解语义而非文本匹配。
仅输出 JSON,格式:
{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述" }] }
如果没有违反任何规则,返回空数组。`;
const DEEP_REVIEW_SYSTEM_PROMPT = `你是资深代码审查专家,完成两个任务:
1. 将英文静态分析结果翻译为输出语言,并补充修复建议
2. 深度审查代码,发现静态分析未覆盖的问题
重点:安全漏洞、逻辑错误、性能问题、设计缺陷
不要重复静态分析已报告的问题。
仅输出 JSON,格式:
{
"translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议", "codeDiff": "可选" }],
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "codeDiff": "可选", "line": 行号 }]
}`;
export async function runAIReview(
context: vscode.ExtensionContext,
code: string,
staticDiagnostics: LinterDiagnostic[],
customRules: CustomRule[]
): Promise<AIEngineResult> {
const apiKey = await getApiKey(context);
if (!apiKey) {
return {
customRuleResults: [],
translatedDiagnostics: [],
findings: [],
degraded: true,
error: '未配置 API Key',
};
}
const providerId = getAIProvider();
const endpoint = getAIEndpoint();
let provider: AIProvider;
try {
provider = createProvider(providerId, apiKey, endpoint);
} catch (err) {
return {
customRuleResults: [],
translatedDiagnostics: [],
findings: [],
degraded: true,
error: `创建 Provider 失败: ${err instanceof Error ? err.message : String(err)}`,
};
}
const options = {
model: getAIModel(),
temperature: getAITemperature(),
timeoutMs: getAITimeout() * 1000,
};
const numberedCode = addLineNumbers(code);
const requestA =
customRules.length > 0
? provider.chat(
CUSTOM_RULE_SYSTEM_PROMPT,
`## 自定义规则\n${buildCustomRulePrompt(customRules)}\n\n## 代码(带行号)\n${numberedCode}`,
options
)
: Promise.resolve('{}');
const requestB = provider.chat(
`${DEEP_REVIEW_SYSTEM_PROMPT}\n输出语言:${getAIOutputLanguage()}`,
`## 代码(带行号)\n${numberedCode}\n\n## 静态分析结果(英文)\n${buildLinterDiagnosticsPrompt(staticDiagnostics)}`,
options
);
const [resultA, resultB] = await Promise.allSettled([requestA, requestB]);
const errors: string[] = [];
let customRuleResults: CustomRuleResult[] = [];
if (resultA.status === 'fulfilled') {
try {
const parsed = parseJsonResponse(resultA.value) as { customRuleResults?: CustomRuleResult[] };
customRuleResults = (parsed.customRuleResults ?? []).map(r => ({
...r,
ruleId: `custom:${r.ruleId}`,
}));
} catch {
errors.push('自定义规则响应解析失败');
}
} else {
errors.push(`自定义规则请求失败: ${resultA.reason}`);
}
let translatedDiagnostics: TranslatedDiagnostic[] = [];
let findings: AIFinding[] = [];
if (resultB.status === 'fulfilled') {
try {
const parsed = parseJsonResponse(resultB.value) as {
translatedDiagnostics?: TranslatedDiagnostic[];
findings?: AIFinding[];
};
translatedDiagnostics = parsed.translatedDiagnostics ?? [];
findings = parsed.findings ?? [];
} catch {
errors.push('AI 审查响应解析失败');
}
} else {
errors.push(`AI 审查请求失败: ${resultB.reason}`);
}
const degraded = errors.length > 0;
return {
customRuleResults,
translatedDiagnostics,
findings,
degraded,
error: errors.join('; '),
};
}
+22
View File
@@ -0,0 +1,22 @@
import { AIProvider } from './providers/base';
import { DeepSeekProvider } from './providers/deepseek';
import { OpenAIProvider } from './providers/openai';
type ProviderConstructor = new (apiKey: string, endpoint: string) => AIProvider;
const registry: Record<string, ProviderConstructor> = {
deepseek: DeepSeekProvider,
openai: OpenAIProvider,
};
export function createProvider(providerId: string, apiKey: string, endpoint: string): AIProvider {
const Cls = registry[providerId];
if (!Cls) {
throw new Error(`未知的 Provider: ${providerId}`);
}
return new Cls(apiKey, endpoint);
}
export function getProviderIds(): string[] {
return Object.keys(registry);
}
+21
View File
@@ -0,0 +1,21 @@
export interface ChatOptions {
model: string;
temperature: number;
timeoutMs: number;
}
export abstract class AIProvider {
abstract id: string;
abstract name: string;
constructor(
protected apiKey: string,
protected endpoint: string
) {}
abstract chat(
systemPrompt: string,
userPrompt: string,
options: ChatOptions
): Promise<string>;
}
+49
View File
@@ -0,0 +1,49 @@
import { AIProvider, ChatOptions } from './base';
export class DeepSeekProvider extends AIProvider {
id = 'deepseek';
name = 'DeepSeek';
async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
const url = `${this.endpoint}/chat/completions`;
const body = JSON.stringify({
model: options.model,
temperature: options.temperature,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body,
signal: controller.signal,
});
if (!response.ok) {
const errorText = await response.text();
if (response.status === 401) {
throw new Error('API Key 无效,请重新设置');
}
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
}
const data = await response.json() as {
choices: Array<{ message: { content: string } }>;
};
return data.choices[0]?.message?.content ?? '';
} finally {
clearTimeout(timeout);
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import { AIProvider, ChatOptions } from './base';
export class OpenAIProvider extends AIProvider {
id = 'openai';
name = 'OpenAI';
async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
const url = `${this.endpoint}/chat/completions`;
const body = JSON.stringify({
model: options.model,
temperature: options.temperature,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body,
signal: controller.signal,
});
if (!response.ok) {
const errorText = await response.text();
if (response.status === 401) {
throw new Error('API Key 无效,请重新设置');
}
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
}
const data = await response.json() as {
choices: Array<{ message: { content: string } }>;
};
return data.choices[0]?.message?.content ?? '';
} finally {
clearTimeout(timeout);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
export interface TranslatedDiagnostic {
originalRuleId: string;
translatedMessage: string;
translatedSuggestion: string;
codeDiff?: string;
}
export interface CustomRuleResult {
ruleId: string;
line: number;
severity: 'error' | 'warning' | 'info';
message: string;
}
export interface AIFinding {
ruleId: string;
severity: 'error' | 'warning' | 'info';
category: 'bug' | 'performance' | 'security' | 'style' | 'design';
title: string;
description: string;
suggestion: string;
codeDiff?: string;
line: number;
}
export interface AIResponse {
translatedDiagnostics: TranslatedDiagnostic[];
customRuleResults: CustomRuleResult[];
findings: AIFinding[];
}
export interface AIEngineResult {
customRuleResults: CustomRuleResult[];
translatedDiagnostics: TranslatedDiagnostic[];
findings: AIFinding[];
degraded: boolean;
error?: string;
}
+36
View File
@@ -0,0 +1,36 @@
import * as vscode from 'vscode';
const ROOT = 'vscode-code-reviewer';
export function getAIProvider(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('ai.provider', 'deepseek');
}
export function getAIModel(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('ai.model', 'deepseek-chat');
}
export function getAIEndpoint(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('ai.endpoint', 'https://api.deepseek.com/v1');
}
export function getAITemperature(): number {
return vscode.workspace.getConfiguration(ROOT).get<number>('ai.temperature', 0.2);
}
export function getAITimeout(): number {
return vscode.workspace.getConfiguration(ROOT).get<number>('ai.timeout', 300);
}
export function getAIOutputLanguage(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('ai.outputLanguage', 'zh-CN');
}
export function getAIConfig() {
return {
provider: getAIProvider(),
model: getAIModel(),
endpoint: getAIEndpoint(),
outputLanguage: getAIOutputLanguage(),
};
}
+7
View File
@@ -0,0 +1,7 @@
import * as vscode from 'vscode';
const ROOT = 'vscode-code-reviewer';
export function getContextLines(): number {
return vscode.workspace.getConfiguration(ROOT).get<number>('fixer.contextLines', 5);
}
+4
View File
@@ -0,0 +1,4 @@
export * from './ai';
export * from './linter';
export * from './fixer';
export * from './secret';
+23
View File
@@ -0,0 +1,23 @@
import * as vscode from 'vscode';
const ROOT = 'vscode-code-reviewer';
export function getLinterForLanguage(language: string): string {
return vscode.workspace.getConfiguration(ROOT).get<string>(`linters.${language}`, '');
}
export function getPMDJarPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.jarPath', '');
}
export function getPMDRulesetPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.rulesetPath', '');
}
export function getPMDJspRulesetPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.jspRulesetPath', '');
}
export function getSqlLintConfigFile(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('sql-lint.configFile', '');
}
+20
View File
@@ -0,0 +1,20 @@
import * as vscode from 'vscode';
const SECRET_KEY = 'vscode-code-reviewer.apiKey';
export async function getApiKey(context: vscode.ExtensionContext): Promise<string | undefined> {
return context.secrets.get(SECRET_KEY);
}
export async function setApiKey(context: vscode.ExtensionContext, value: string): Promise<void> {
await context.secrets.store(SECRET_KEY, value);
}
export async function deleteApiKey(context: vscode.ExtensionContext): Promise<void> {
await context.secrets.delete(SECRET_KEY);
}
export async function isApiKeyConfigured(context: vscode.ExtensionContext): Promise<boolean> {
const key = await getApiKey(context);
return !!key;
}
+32 -18
View File
@@ -1,26 +1,40 @@
// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
import * as vscode from 'vscode';
import { Orchestrator } from './orchestrator/orchestrator';
import { registerCommands } from './activation/commands';
import { SetupViewProvider } from './views/setupView';
let orchestrator: Orchestrator;
// This method is called when your extension is activated
// Your extension is activated the very first time the command is executed
export function activate(context: vscode.ExtensionContext) {
console.log('CodeGuard 代码审查插件已激活');
// Use the console to output diagnostic information (console.log) and errors (console.error)
// This line of code will only be executed once when your extension is activated
console.log('Congratulations, your extension "vscode-code-reviewer" is now active!');
orchestrator = new Orchestrator();
// The command has been defined in the package.json file
// Now provide the implementation of the command with registerCommand
// The commandId parameter must match the command field in package.json
const disposable = vscode.commands.registerCommand('vscode-code-reviewer.helloWorld', () => {
// The code you place here will be executed every time your command is executed
// Display a message box to the user
vscode.window.showInformationMessage('Hello World from vscode-code-reviewer!');
});
const setupProvider = new SetupViewProvider(context);
vscode.window.registerTreeDataProvider('codeReviewer.setupView', setupProvider);
context.subscriptions.push(disposable);
registerCommands(context, orchestrator, setupProvider);
const debounceTimers = new Map<string, NodeJS.Timeout>();
context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument((document) => {
const key = document.uri.toString();
const existing = debounceTimers.get(key);
if (existing) { clearTimeout(existing); }
const timer = setTimeout(() => {
debounceTimers.delete(key);
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
orchestrator.runStaticAnalysis(document, workingDir);
}, 500);
debounceTimers.set(key, timer);
})
);
}
// This method is called when your extension is deactivated
export function deactivate() {}
export function deactivate() {
orchestrator = undefined!;
}
+225
View File
@@ -0,0 +1,225 @@
import * as vscode from 'vscode';
import type { LinterDiagnostic } from '../types';
import type { AIProvider } from '../ai/providers/base';
export type FixCategory = 'naming' | 'style' | 'bug' | 'security' | 'performance';
export interface FixableDiagnostic {
ruleId: string;
message: string;
line: number;
severity: string;
codeContext: string;
source: 'linter' | 'custom';
category: FixCategory;
}
export interface CodeFix {
startLine: number;
endLine: number;
originalText: string;
newText: string;
matched: boolean;
actualRange?: vscode.Range;
}
const FIX_SYSTEM_PROMPT = `你是代码修复专家。根据提供的问题和代码上下文,输出修复后的代码。
仅输出 JSON{ "originalText": "需要替换的原文", "newText": "修复后的新代码" }`;
function detectCategory(diagnostic: LinterDiagnostic): FixCategory {
if (diagnostic.ruleId.includes('naming') || diagnostic.ruleId.includes('Name')) { return 'naming'; }
if (diagnostic.ruleId.includes('security') || diagnostic.ruleId.includes('injection') || diagnostic.ruleId.includes('secret')) { return 'security'; }
if (diagnostic.ruleId.includes('perf')) { return 'performance'; }
return 'style';
}
function getContextRange(document: vscode.TextDocument, line: number, category: FixCategory): { startLine: number; endLine: number } {
switch (category) {
case 'naming':
return {
startLine: Math.max(0, line - 2),
endLine: Math.min(document.lineCount - 1, line + 2),
};
case 'style':
return {
startLine: Math.max(0, line - 5),
endLine: Math.min(document.lineCount - 1, line + 5),
};
case 'bug':
case 'security':
case 'performance': {
const funcRange = findEnclosingFunction(document, line);
return {
startLine: funcRange?.start.line ?? Math.max(0, line - 10),
endLine: funcRange?.end.line ?? Math.min(document.lineCount - 1, line + 10),
};
}
default:
return {
startLine: Math.max(0, line - 5),
endLine: Math.min(document.lineCount - 1, line + 5),
};
}
}
function findEnclosingFunction(document: vscode.TextDocument, line: number): { start: vscode.Position; end: vscode.Position } | null {
const text = document.getText();
const lines = text.split('\n');
let braceDepth = 0;
let funcStart = line;
let funcEnd = line;
for (let i = line; i >= 0; i--) {
const l = lines[i];
braceDepth += (l.match(/\}/g) || []).length;
braceDepth -= (l.match(/\{/g) || []).length;
const isFunctionLine = /\b(function|def|class|method|public|private|protected|void|int|String|boolean|var|let|const|async)\s/.test(l);
if (braceDepth < 0 && isFunctionLine) {
funcStart = i;
break;
}
}
braceDepth = 0;
for (let i = funcStart; i < lines.length; i++) {
const l = lines[i];
braceDepth += (l.match(/\{/g) || []).length;
braceDepth -= (l.match(/\}/g) || []).length;
if (braceDepth === 0 && (l.match(/\{/g) || []).length > 0) {
funcEnd = i;
break;
}
}
return {
start: new vscode.Position(funcStart, 0),
end: new vscode.Position(funcEnd, lines[funcEnd]?.length ?? 0),
};
}
function extractLines(document: vscode.TextDocument, startLine: number, endLine: number): string {
const lines: string[] = [];
for (let i = startLine; i <= endLine; i++) {
const lineText = document.lineAt(i).text;
lines.push(`${String(i + 1).padStart(4, ' ')}| ${lineText}`);
}
return lines.join('\n');
}
export function prepareContext(document: vscode.TextDocument, diagnostic: LinterDiagnostic, source: 'linter' | 'custom'): FixableDiagnostic | null {
const line = diagnostic.range.start.line;
const category = detectCategory(diagnostic);
const { startLine, endLine } = getContextRange(document, line, category);
const codeContext = extractLines(document, startLine, endLine);
return {
ruleId: diagnostic.ruleId,
message: diagnostic.message,
line,
severity: diagnostic.severity,
codeContext,
source,
category,
};
}
export async function generateFix(
provider: AIProvider,
model: string,
temperature: number,
timeoutMs: number,
diagnostic: FixableDiagnostic
): Promise<CodeFix | null> {
const userPrompt = `问题: [${diagnostic.ruleId}] ${diagnostic.message}\n代码上下文:\n${diagnostic.codeContext}`;
try {
const response = await provider.chat(FIX_SYSTEM_PROMPT, userPrompt, {
model,
temperature,
timeoutMs,
});
const trimmed = response.trim();
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start === -1 || end === -1) { return null; }
const parsed = JSON.parse(trimmed.substring(start, end + 1));
return {
startLine: diagnostic.line,
endLine: diagnostic.line,
originalText: parsed.originalText ?? '',
newText: parsed.newText ?? '',
matched: false,
};
} catch {
return null;
}
}
export function matchAndValidate(document: vscode.TextDocument, fix: CodeFix): { matched: boolean; actualRange?: vscode.Range } {
const lineContent = document.lineAt(fix.startLine).text;
if (lineContent === fix.originalText.split('\n')[0]) {
const range = new vscode.Range(fix.startLine, 0, fix.endLine, document.lineAt(fix.endLine).text.length);
if (document.getText(range) === fix.originalText) {
return { matched: true, actualRange: range };
}
}
const index = document.getText().indexOf(fix.originalText);
if (index !== -1) {
return {
matched: true,
actualRange: new vscode.Range(
document.positionAt(index),
document.positionAt(index + fix.originalText.length)
),
};
}
return { matched: false };
}
export async function applySingleFix(editor: vscode.TextEditor, fix: CodeFix): Promise<boolean> {
if (!fix.actualRange || !fix.matched) { return false; }
return editor.edit(editBuilder => {
editBuilder.replace(fix.actualRange!, fix.newText);
});
}
const snapshotStack: Map<string, string[]> = new Map();
export function saveSnapshot(document: vscode.TextDocument): void {
const filePath = document.uri.fsPath;
if (!snapshotStack.has(filePath)) { snapshotStack.set(filePath, []); }
snapshotStack.get(filePath)!.push(document.getText());
}
export async function undoLastFix(document: vscode.TextDocument): Promise<boolean> {
const stack = snapshotStack.get(document.uri.fsPath);
if (!stack || stack.length === 0) { return false; }
const previousContent = stack.pop()!;
const edit = new vscode.WorkspaceEdit();
edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), previousContent);
return vscode.workspace.applyEdit(edit);
}
export function hasSnapshot(document: vscode.TextDocument): boolean {
const stack = snapshotStack.get(document.uri.fsPath);
return !!(stack && stack.length > 0);
}
export async function applyBatchFixes(document: vscode.TextDocument, fixes: CodeFix[]): Promise<number> {
saveSnapshot(document);
const validFixes = fixes.filter(f => f.matched);
const sorted = [...validFixes].sort((a, b) => b.startLine - a.startLine);
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document.uri.toString() !== document.uri.toString()) { return 0; }
let applied = 0;
for (const fix of sorted) {
if (await applySingleFix(editor, fix)) { applied++; }
}
return applied;
}
+56
View File
@@ -0,0 +1,56 @@
export interface JspSection {
language: 'javascript' | 'css' | 'java';
code: string;
lineOffset: number;
sourceStart: number;
sourceEnd: number;
}
export function extractJspSections(content: string): JspSection[] {
const sections: JspSection[] = [];
const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
let match: RegExpExecArray | null;
while ((match = scriptRegex.exec(content)) !== null) {
const code = match[1];
const beforeMatch = content.substring(0, match.index);
const lineOffset = beforeMatch.split('\n').length - 1;
sections.push({
language: 'javascript',
code,
lineOffset,
sourceStart: match.index,
sourceEnd: match.index + match[0].length,
});
}
const styleRegex = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
while ((match = styleRegex.exec(content)) !== null) {
const code = match[1];
const beforeMatch = content.substring(0, match.index);
const lineOffset = beforeMatch.split('\n').length - 1;
sections.push({
language: 'css',
code,
lineOffset,
sourceStart: match.index,
sourceEnd: match.index + match[0].length,
});
}
const scriptletRegex = /<%=?([\s\S]*?)%>/g;
while ((match = scriptletRegex.exec(content)) !== null) {
const code = match[1];
const beforeMatch = content.substring(0, match.index);
const lineOffset = beforeMatch.split('\n').length - 1;
sections.push({
language: 'java',
code,
lineOffset,
sourceStart: match.index,
sourceEnd: match.index + match[0].length,
});
}
return sections;
}
+72
View File
@@ -0,0 +1,72 @@
import * as vscode from 'vscode';
import type { LinterDiagnostic, Severity } from '../types';
import type { TranslatedDiagnostic, CustomRuleResult, AIFinding } from '../ai/schema';
export interface MergedReport {
linterDiagnostics: LinterDiagnostic[];
customRuleDiagnostics: LinterDiagnostic[];
translatedDiagnostics: TranslatedDiagnostic[];
aiFindings: AIFinding[];
linterCount: number;
customRuleCount: number;
aiCount: number;
errors: string[];
degraded: boolean;
duration: number;
filePath: string;
language: string;
adapterNames: string[];
fixableLinterIndices: number[];
fixableCustomIndices: number[];
}
interface MergeInput {
staticDiagnostics: LinterDiagnostic[];
customRuleResults: CustomRuleResult[];
translatedDiagnostics: TranslatedDiagnostic[];
aiFindings: AIFinding[];
errors: string[];
degraded: boolean;
startTime: number;
filePath: string;
language: string;
adapterIds: string[];
}
export function mergeResults(input: MergeInput): MergedReport {
const customRuleDiagnostics: LinterDiagnostic[] = input.customRuleResults.map(r => ({
severity: r.severity as Severity,
ruleId: r.ruleId,
message: r.message,
range: new vscode.Range(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1),
}));
const linterCount = input.staticDiagnostics.length;
const customRuleCount = customRuleDiagnostics.length;
const aiCount = input.aiFindings.length;
const fixableLinterIndices = input.staticDiagnostics
.map((_, i) => i)
.filter(i => input.staticDiagnostics[i].suggestion);
const fixableCustomIndices = customRuleDiagnostics
.map((_, i) => i);
return {
linterDiagnostics: input.staticDiagnostics,
customRuleDiagnostics,
translatedDiagnostics: input.translatedDiagnostics,
aiFindings: input.aiFindings,
linterCount,
customRuleCount,
aiCount,
errors: input.errors,
degraded: input.degraded,
duration: Date.now() - input.startTime,
filePath: input.filePath,
language: input.language,
adapterNames: input.adapterIds,
fixableLinterIndices,
fixableCustomIndices,
};
}
+79
View File
@@ -0,0 +1,79 @@
import * as vscode from 'vscode';
import type { LinterAdapter, LinterDiagnostic } from '../types';
import { getLinterForLanguage } from '../config';
import { ESLintAdapter } from '../adapters/eslint';
import { PmdAdapter } from '../adapters/pmd';
import { StylelintAdapter } from '../adapters/stylelint';
import { SqlLintAdapter } from '../adapters/sql-lint';
import { JspAdapter } from '../adapters/jsp';
export interface StaticAnalysisResult {
diagnostics: LinterDiagnostic[];
errors: string[];
adapterIds: string[];
duration: number;
}
export class Orchestrator {
private adapters: LinterAdapter[];
constructor() {
this.adapters = [
new ESLintAdapter(),
new PmdAdapter(),
new StylelintAdapter(),
new SqlLintAdapter(),
new JspAdapter(),
];
}
getAdapterMap(): Map<string, LinterAdapter> {
const map = new Map<string, LinterAdapter>();
for (const adapter of this.adapters) {
for (const lang of adapter.supportedLanguages) {
map.set(lang, adapter);
}
}
return map;
}
async runStaticAnalysis(
document: vscode.TextDocument,
workingDir: string
): Promise<StaticAnalysisResult> {
const startTime = Date.now();
const languageId = document.languageId;
const selectedLinter = getLinterForLanguage(languageId);
if (!selectedLinter) {
return { diagnostics: [], errors: [], adapterIds: [], duration: 0 };
}
const adapter = this.adapters.find(a => a.id === selectedLinter);
if (!adapter) {
return {
diagnostics: [],
errors: [`未找到适配器: ${selectedLinter}`],
adapterIds: [],
duration: Date.now() - startTime,
};
}
const result = await adapter.check(document, workingDir);
const errors: string[] = [];
if (result.status !== 'ok') {
errors.push(`[${adapter.id}] ${result.errorMessage ?? result.status}`);
}
return {
diagnostics: result.diagnostics,
errors,
adapterIds: [adapter.id],
duration: Date.now() - startTime,
};
}
getAdaptersByIds(ids: string[]): LinterAdapter[] {
return ids.map(id => this.adapters.find(a => a.id === id)).filter(Boolean) as LinterAdapter[];
}
}
+282
View File
@@ -0,0 +1,282 @@
import * as vscode from 'vscode';
import { MergedReport } from '../merger/merger';
interface PanelMessage {
type: 'navigate' | 'rerun' | 'export' | 'settings' | 'fix' | 'fixAll';
line?: number;
ruleId?: string;
source?: 'linter' | 'custom' | 'ai';
}
export class ReviewPanel {
public static currentPanel: ReviewPanel | undefined;
private readonly panel: vscode.WebviewPanel;
private disposables: vscode.Disposable[] = [];
private constructor(
private readonly extensionUri: vscode.Uri,
column: vscode.ViewColumn
) {
this.panel = vscode.window.createWebviewPanel(
'codeReviewer.reviewPanel',
'代码审查报告',
column,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [],
}
);
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
this.panel.webview.onDidReceiveMessage(
(message: PanelMessage) => this.handleMessage(message),
null,
this.disposables
);
}
static createOrShow(extensionUri: vscode.Uri, column?: vscode.ViewColumn): ReviewPanel {
if (ReviewPanel.currentPanel) {
ReviewPanel.currentPanel.panel.reveal(column);
return ReviewPanel.currentPanel;
}
ReviewPanel.currentPanel = new ReviewPanel(extensionUri, column ?? vscode.ViewColumn.Two);
return ReviewPanel.currentPanel;
}
update(report: MergedReport): void {
this.panel.webview.html = this.buildHtml(report);
}
private buildHtml(report: MergedReport): string {
const total = report.linterCount + report.customRuleCount + report.aiCount;
const errorCount = report.linterDiagnostics.filter(d => d.severity === 'error').length
+ report.customRuleDiagnostics.filter(d => d.severity === 'error').length
+ report.aiFindings.filter(f => f.severity === 'error').length;
const warnCount = report.linterDiagnostics.filter(d => d.severity === 'warning').length
+ report.customRuleDiagnostics.filter(d => d.severity === 'warning').length
+ report.aiFindings.filter(f => f.severity === 'warning').length;
const infoCount = total - errorCount - warnCount;
const fileName = report.filePath.split(/[/\\]/).pop() ?? '';
const degradedBanner = report.degraded
? `<div class="banner ${report.errors.length > 0 ? 'banner-error' : 'banner-warn'}">
${report.errors.length > 0 ? '⚠ AI 审查失败' : '⚠ 部分 AI 功能不可用'}
${report.errors.join('; ')}
</div>`
: '';
return `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>代码审查报告</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 16px; }
.header { margin-bottom: 16px; }
.header h1 { font-size: 18px; margin-bottom: 4px; }
.header .meta { font-size: 12px; color: var(--vscode-descriptionForeground); }
.banner { padding: 8px 12px; border-radius: 4px; margin-bottom: 12px; font-size: 13px; }
.banner-warn { background: #332B00; border: 1px solid #665C00; color: #FFF3B0; }
.banner-error { background: #330000; border: 1px solid #660000; color: #FFB0B0; }
.stats { display: flex; gap: 12px; margin-bottom: 16px; }
.stat-card { flex: 1; padding: 12px; border-radius: 6px; text-align: center; background: var(--vscode-sideBar-background); }
.stat-card .num { font-size: 24px; font-weight: 600; }
.stat-card .label { font-size: 12px; color: var(--vscode-descriptionForeground); margin-top: 2px; }
.stat-total .num { color: var(--vscode-foreground); }
.stat-error .num { color: #E06C75; }
.stat-warn .num { color: #D19A66; }
.stat-info .num { color: #61AFEF; }
.tabs { display: flex; gap: 0; margin-bottom: 12px; border-bottom: 1px solid var(--vscode-panel-border); }
.tab { padding: 8px 16px; cursor: pointer; border: none; background: none; color: var(--vscode-descriptionForeground); font-family: var(--vscode-font-family); font-size: 13px; border-bottom: 2px solid transparent; }
.tab.active { color: var(--vscode-foreground); border-bottom-color: #7C3AED; }
.tab .count { margin-left: 6px; font-size: 11px; opacity: 0.7; }
.issue-list { display: none; }
.issue-list.active { display: block; }
.issue { padding: 8px 12px; border-radius: 4px; margin-bottom: 6px; background: var(--vscode-sideBar-background); cursor: pointer; display: flex; justify-content: space-between; align-items: flex-start; }
.issue:hover { background: var(--vscode-list-hoverBackground); }
.issue-left { flex: 1; }
.issue-title { font-size: 13px; margin-bottom: 2px; }
.issue-detail { font-size: 12px; color: var(--vscode-descriptionForeground); }
.issue-actions { display: flex; gap: 4px; flex-shrink: 0; }
.btn { padding: 2px 8px; border-radius: 3px; border: 1px solid var(--vscode-button-border); background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); cursor: pointer; font-size: 11px; }
.btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
.btn-primary { background: #7C3AED; border-color: #7C3AED; color: #fff; }
.btn-primary:hover { background: #6D28D9; }
.severity { display: inline-block; width: 16px; text-align: center; }
.actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--vscode-panel-border); }
.empty { text-align: center; padding: 24px; color: var(--vscode-descriptionForeground); font-size: 13px; }
</style>
</head>
<body>
<div class="header">
<h1>📋 代码审查报告</h1>
<div class="meta">${fileName} · ${report.language} · ${(report.duration / 1000).toFixed(1)}s</div>
</div>
${degradedBanner}
<div class="stats">
<div class="stat-card stat-total"><div class="num">${total}</div><div class="label">总计</div></div>
<div class="stat-card stat-error"><div class="num">${errorCount}</div><div class="label">错误</div></div>
<div class="stat-card stat-warn"><div class="num">${warnCount}</div><div class="label">警告</div></div>
<div class="stat-card stat-info"><div class="num">${infoCount}</div><div class="label">建议</div></div>
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('linter')">🔧 静态分析 <span class="count">${report.linterCount}</span></button>
<button class="tab" onclick="switchTab('custom')">📋 自定义规则 <span class="count">${report.customRuleCount}</span></button>
<button class="tab" onclick="switchTab('ai')">🤖 AI 审查 <span class="count">${report.aiCount}</span></button>
</div>
<div id="tab-linter" class="issue-list active">
${this.buildLinterList(report)}
</div>
<div id="tab-custom" class="issue-list">
${this.buildCustomList(report)}
</div>
<div id="tab-ai" class="issue-list">
${this.buildAIList(report)}
</div>
<div class="actions">
<button class="btn btn-primary" onclick="send('rerun')">🔄 重新审查</button>
<button class="btn" onclick="send('export')">📄 导出</button>
<button class="btn" onclick="send('settings')">⚙️ 设置</button>
<button class="btn" onclick="send('fixAll')">🔧 批量修复</button>
</div>
<script>
const vscode = acquireVsCodeApi();
function send(type, line, ruleId, source) {
vscode.postMessage({ type, line, ruleId, source });
}
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.issue-list').forEach(l => l.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
}
</script>
</body>
</html>`;
}
private buildLinterList(report: MergedReport): string {
if (report.linterDiagnostics.length === 0) {
return '<div class="empty">✅ 静态分析未发现问题</div>';
}
return report.linterDiagnostics.map((d, i) => `
<div class="issue" onclick="send('navigate', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">
<div class="issue-left">
<div class="issue-title"><span class="severity">${this.sevIcon(d.severity)}</span> <code>${this.escape(d.ruleId)}</code> L${d.range.start.line + 1}</div>
<div class="issue-detail">${this.escape(d.message)}</div>
</div>
<div class="issue-actions">
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">修复</button>
</div>
</div>`).join('');
}
private buildCustomList(report: MergedReport): string {
if (report.customRuleDiagnostics.length === 0) {
return '<div class="empty">✅ 自定义规则未发现问题</div>';
}
return report.customRuleDiagnostics.map((d, i) => `
<div class="issue" onclick="send('navigate', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'custom')">
<div class="issue-left">
<div class="issue-title"><span class="severity">${this.sevIcon(d.severity)}</span> <code>${this.escape(d.ruleId)}</code> L${d.range.start.line + 1}</div>
<div class="issue-detail">${this.escape(d.message)}</div>
</div>
<div class="issue-actions">
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'custom')">修复</button>
</div>
</div>`).join('');
}
private buildAIList(report: MergedReport): string {
const total = report.translatedDiagnostics.length + report.aiFindings.length;
if (total === 0) {
return '<div class="empty">🤖 AI 审查未发现新问题</div>';
}
const parts: string[] = [];
for (const td of report.translatedDiagnostics) {
parts.push(`
<div class="issue">
<div class="issue-left">
<div class="issue-title"><span class="severity">🔵</span> <code>${this.escape(td.originalRuleId)}</code></div>
<div class="issue-detail">${this.escape(td.translatedMessage)}</div>
${td.translatedSuggestion ? `<div class="issue-detail">建议: ${this.escape(td.translatedSuggestion)}</div>` : ''}
</div>
</div>`);
}
for (const f of report.aiFindings) {
parts.push(`
<div class="issue" onclick="send('navigate', ${f.line}, '${this.escape(f.ruleId)}', 'ai')">
<div class="issue-left">
<div class="issue-title"><span class="severity">${this.sevIcon(f.severity)}</span> [${f.category}] <strong>${this.escape(f.title)}</strong></div>
<div class="issue-detail">${this.escape(f.description)}</div>
${f.suggestion ? `<div class="issue-detail">建议: ${this.escape(f.suggestion)}</div>` : ''}
</div>
<div class="issue-actions">
<button class="btn" onclick="event.stopPropagation();send('fix', ${f.line}, '${this.escape(f.ruleId)}', 'ai')">修复</button>
</div>
</div>`);
}
return parts.join('');
}
private sevIcon(severity: string): string {
switch (severity) {
case 'error': return '🔴';
case 'warning': return '🟡';
case 'info': return '🔵';
default: return '⚪';
}
}
private escape(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
private handleMessage(message: PanelMessage): void {
switch (message.type) {
case 'navigate':
if (message.line !== undefined) {
const editor = vscode.window.activeTextEditor;
if (editor) {
const line = Math.max(0, message.line);
const range = new vscode.Range(line, 0, line, 0);
editor.selection = new vscode.Selection(range.start, range.end);
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
}
}
break;
case 'rerun':
vscode.commands.executeCommand('codeReviewer.review');
break;
case 'export':
vscode.commands.executeCommand('codeReviewer.exportReport');
break;
case 'settings':
vscode.commands.executeCommand('codeReviewer.openSetup');
break;
case 'fix':
vscode.commands.executeCommand('codeReviewer.fixIssue', message);
break;
case 'fixAll':
vscode.commands.executeCommand('codeReviewer.fixAll');
break;
}
}
dispose(): void {
ReviewPanel.currentPanel = undefined;
this.panel.dispose();
for (const d of this.disposables) { d.dispose(); }
this.disposables = [];
}
}
+157
View File
@@ -0,0 +1,157 @@
import * as fs from 'fs';
import * as path from 'path';
import type { CustomRule, Severity } from '../types';
interface RuleYamlItem {
id: string;
severity: string;
description: string;
message: string;
languages?: string[];
}
interface RuleConfig {
enabled?: string[];
rules?: Record<string, { enabled: boolean }>;
}
function parseYamlSimple(content: string): object[] {
const items: Array<Record<string, unknown>> = [];
let current: Record<string, unknown> | null = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) { continue; }
if (trimmed.startsWith('- ')) {
if (current) { items.push(current); }
current = {};
const indentMatch = trimmed.match(/^- (\w[\w-]*)\s*:\s*(.*)$/);
if (indentMatch) {
const key = indentMatch[1];
const raw = indentMatch[2].trim();
if (raw.startsWith('[') && raw.endsWith(']')) {
current[key] = raw.slice(1, -1).split(',').map(s =>
s.trim().replace(/^['"]|['"]$/g, '')
);
} else {
current[key] = raw;
}
}
} else if (current) {
const propMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
if (propMatch) {
const key = propMatch[1];
const raw = propMatch[2].trim();
if (!raw || raw === '[]') {
current[key] = [];
} else if (raw.startsWith('[') && raw.endsWith(']')) {
current[key] = raw.slice(1, -1).split(',').map(s =>
s.trim().replace(/^['"]|['"]$/g, '')
);
} else {
current[key] = raw;
}
}
}
}
if (current) { items.push(current); }
return items;
}
function parseConfigYaml(content: string): RuleConfig {
const config: RuleConfig = { enabled: [], rules: {} };
let section: string | null = null;
let currentKey = '';
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) { continue; }
if (trimmed === 'enabled:') {
section = 'enabled';
continue;
}
if (trimmed === 'rules:') {
section = 'rules';
continue;
}
if (section === 'enabled' && trimmed.startsWith('- ')) {
const name = trimmed.substring(2).trim();
if (!config.enabled) { config.enabled = []; }
config.enabled!.push(name);
}
if (section === 'rules') {
const ruleMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*$/);
if (ruleMatch) {
currentKey = ruleMatch[1];
if (!config.rules) { config.rules = {}; }
config.rules[currentKey] = { enabled: true };
} else if (currentKey) {
const propMatch = trimmed.match(/^(\w+)\s*:\s*(.*)$/);
if (propMatch) {
const key = propMatch[1];
const value = propMatch[2].trim();
if (!config.rules) { config.rules = {}; }
if (!config.rules[currentKey]) { config.rules[currentKey] = { enabled: true }; }
(config.rules[currentKey] as Record<string, unknown>)[key] =
value === 'false' ? false : value === 'true' ? true : value;
}
}
}
}
return config;
}
export function loadActiveRules(workspaceRoot: string): CustomRule[] {
const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
if (!fs.existsSync(rulesDir)) { return []; }
let ruleConfig: RuleConfig = {};
if (fs.existsSync(configPath)) {
const configContent = fs.readFileSync(configPath, 'utf-8');
ruleConfig = parseConfigYaml(configContent);
}
const enabledFiles = new Set(ruleConfig.enabled ?? []);
const disabledRules = new Set(
Object.entries(ruleConfig.rules ?? {})
.filter(([, v]) => v.enabled === false)
.map(([k]) => k)
);
const allRules: CustomRule[] = [];
const files = fs.readdirSync(rulesDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
for (const file of files) {
if (enabledFiles.size > 0 && !enabledFiles.has(file)) { continue; }
const content = fs.readFileSync(path.join(rulesDir, file), 'utf-8');
const items = parseYamlSimple(content) as RuleYamlItem[];
for (const item of items) {
if (disabledRules.has(item.id)) { continue; }
if (!item.id || !item.severity || !item.description || !item.message) { continue; }
const severity = (['error', 'warning', 'info'].includes(item.severity)
? (item.severity as Severity)
: 'warning');
allRules.push({
id: item.id,
severity,
description: item.description,
message: item.message,
languages: item.languages,
});
}
}
return allRules;
}
+34
View File
@@ -0,0 +1,34 @@
import * as vscode from 'vscode';
export interface CustomRule {
id: string;
severity: Severity;
description: string;
message: string;
languages?: string[];
}
export type Severity = 'error' | 'warning' | 'info';
export type AdapterStatus = 'ok' | 'tool-unavailable' | 'execution-failed';
export interface LinterDiagnostic {
severity: Severity;
ruleId: string;
message: string;
range: vscode.Range;
suggestion?: string;
}
export interface AdapterResult {
diagnostics: LinterDiagnostic[];
status: AdapterStatus;
errorMessage?: string;
}
export interface LinterAdapter {
id: string;
supportedLanguages: string[];
check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult>;
isAvailable(): boolean;
}
+7
View File
@@ -0,0 +1,7 @@
export function debounce<T extends (...args: unknown[]) => unknown>(fn: T, ms: number): (...args: Parameters<T>) => void {
let timer: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
+103
View File
@@ -0,0 +1,103 @@
import { MergedReport } from '../merger/merger';
function severityEmoji(severity: string): string {
switch (severity) {
case 'error': return '🔴';
case 'warning': return '🟡';
case 'info': return '🔵';
default: return '⚪';
}
}
function formatLine(line: number): string {
return `L${line + 1}`;
}
export function reportToMarkdown(report: MergedReport): string {
const lines: string[] = [];
lines.push('# 代码审查报告');
lines.push('');
lines.push(`**文件:** \`${report.filePath}\``);
lines.push(`**语言:** ${report.language}`);
lines.push(`**耗时:** ${(report.duration / 1000).toFixed(1)}s`);
if (report.adapterNames.length > 0) {
lines.push(`**分析工具:** ${report.adapterNames.join(', ')}`);
}
if (report.degraded) {
lines.push('');
lines.push('> ⚠️ 部分 AI 功能不可用,报告已降级');
}
if (report.errors.length > 0) {
lines.push('');
lines.push('## 错误');
for (const err of report.errors) {
lines.push(`- ${err}`);
}
}
lines.push('');
lines.push('---');
lines.push('');
const total = report.linterCount + report.customRuleCount + report.aiCount;
const errors = report.linterDiagnostics.filter(d => d.severity === 'error').length
+ report.customRuleDiagnostics.filter(d => d.severity === 'error').length
+ report.aiFindings.filter(f => f.severity === 'error').length;
const warnings = report.linterDiagnostics.filter(d => d.severity === 'warning').length
+ report.customRuleDiagnostics.filter(d => d.severity === 'warning').length
+ report.aiFindings.filter(f => f.severity === 'warning').length;
const infos = total - errors - warnings;
lines.push(`**总计:** ${total} | **错误:** ${errors} | **警告:** ${warnings} | **建议:** ${infos}`);
lines.push('');
if (report.linterDiagnostics.length > 0) {
lines.push(`## 🔧 静态分析 · ${report.linterCount} 个问题`);
lines.push('');
for (const diag of report.linterDiagnostics) {
lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
lines.push(` ${diag.message}`);
if (diag.suggestion) {
lines.push(` 建议: ${diag.suggestion}`);
}
}
lines.push('');
}
if (report.customRuleDiagnostics.length > 0) {
lines.push(`## 📋 自定义规则 · ${report.customRuleCount} 个问题`);
lines.push('');
for (const diag of report.customRuleDiagnostics) {
lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
lines.push(` ${diag.message}`);
}
lines.push('');
}
if (report.aiFindings.length > 0) {
lines.push(`## 🤖 AI 审查 · ${report.aiCount} 条建议`);
lines.push('');
for (const finding of report.aiFindings) {
lines.push(`- ${severityEmoji(finding.severity)} [AI] [${finding.category}] \`${finding.ruleId}\` ${formatLine(finding.line)}`);
lines.push(` **${finding.title}**`);
lines.push(` ${finding.description}`);
if (finding.suggestion) {
lines.push(` 建议: ${finding.suggestion}`);
}
if (finding.codeDiff) {
lines.push(' ```diff');
lines.push(` ${finding.codeDiff.split('\n').join('\n ')}`);
lines.push(' ```');
}
}
lines.push('');
}
if (total === 0) {
lines.push('✅ 未发现问题');
lines.push('');
}
return lines.join('\n');
}
+232
View File
@@ -0,0 +1,232 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import { getAIConfig, setApiKey, getApiKey, isApiKeyConfigured } from '../config';
import { createProvider } from '../ai/factory';
import { loadActiveRules } from '../rules/yaml-parser';
import type { CustomRule } from '../types';
type SetupItemType = 'section' | 'step' | 'providerGroup' | 'provider' | 'model' | 'apiKey' | 'language' | 'rule' | 'ruleAdd' | 'action';
class SetupItem extends vscode.TreeItem {
constructor(
public readonly label: string,
public readonly itemType: SetupItemType,
public readonly collapsibleState: vscode.TreeItemCollapsibleState,
public readonly command?: vscode.Command,
public readonly iconPath?: vscode.ThemeIcon,
public readonly description?: string,
public readonly contextValue?: string,
) {
super(label, collapsibleState);
}
}
export class SetupViewProvider implements vscode.TreeDataProvider<SetupItem> {
private _onDidChangeTreeData = new vscode.EventEmitter<SetupItem | undefined>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private customRules: CustomRule[] = [];
private apiKeyConfigured = false;
public connectionTested = false;
public connectionSuccess = false;
constructor(private context: vscode.ExtensionContext) {
this.refresh();
}
async refresh(): Promise<void> {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
this.customRules = loadActiveRules(workspaceRoot);
this.apiKeyConfigured = await isApiKeyConfigured(this.context);
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(element: SetupItem): vscode.TreeItem {
return element;
}
async getChildren(element?: SetupItem): Promise<SetupItem[]> {
if (!element) {
return this.getRootItems();
}
switch (element.itemType) {
case 'providerGroup': return this.getProviderItems();
case 'apiKey': return this.getApiKeyItems();
case 'language': return this.getLanguageItems();
default: return [];
}
}
private getRootItems(): SetupItem[] {
const items: SetupItem[] = [];
const step1Done = this.apiKeyConfigured;
const step2Done = this.customRules.some(r => r.id);
const step3Done = this.connectionTested && this.connectionSuccess;
items.push(new SetupItem(
'快速开始',
'section',
vscode.TreeItemCollapsibleState.Expanded,
undefined,
undefined,
undefined,
'section'
));
items.push(new SetupItem(
step1Done ? '① 完成 AI 模型配置' : '① 配置 AI 模型及 API Key',
'step',
vscode.TreeItemCollapsibleState.None,
step1Done ? undefined : {
command: 'codeReviewer.focusApiKey',
title: '配置 API Key',
},
step1Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
));
items.push(new SetupItem(
step2Done ? '② 完成规则启用' : '② 启用自定义规则',
'step',
vscode.TreeItemCollapsibleState.None,
undefined,
step2Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
));
items.push(new SetupItem(
step3Done ? '③ 完成连接测试' : '③ 保存并测试连接',
'step',
vscode.TreeItemCollapsibleState.None,
step3Done ? undefined : {
command: 'codeReviewer.saveAndTest',
title: '测试连接',
},
step3Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
));
items.push(new SetupItem(
'审核引擎',
'section',
vscode.TreeItemCollapsibleState.Collapsed
));
items.push(new SetupItem(
'AI 模型配置',
'providerGroup',
vscode.TreeItemCollapsibleState.Collapsed
));
items.push(new SetupItem(
'API Key',
'apiKey',
vscode.TreeItemCollapsibleState.Collapsed
));
items.push(new SetupItem(
'输出语言',
'language',
vscode.TreeItemCollapsibleState.Collapsed
));
items.push(new SetupItem(
`自定义规则 [${this.customRules.length} 条]`,
'section',
vscode.TreeItemCollapsibleState.Expanded
));
for (const rule of this.customRules) {
items.push(new SetupItem(
rule.id,
'rule',
vscode.TreeItemCollapsibleState.None,
{
command: 'codeReviewer.toggleRule',
title: '切换规则',
arguments: [rule.id],
},
undefined,
rule.severity,
'rule'
));
}
items.push(new SetupItem(
'输入规则名称... [+ 添加]',
'ruleAdd',
vscode.TreeItemCollapsibleState.None,
{
command: 'codeReviewer.addCustomRule',
title: '添加规则',
}
));
const connectionLabel = this.connectionTested
? (this.connectionSuccess ? '✓ 已连接' : '✗ 重试')
: '保存并测试连接';
items.push(new SetupItem(
connectionLabel,
'action',
vscode.TreeItemCollapsibleState.None,
{
command: 'codeReviewer.saveAndTest',
title: '测试连接',
}
));
return items;
}
private getProviderItems(): SetupItem[] {
const config = getAIConfig();
return [
new SetupItem(`提供商: ${config.provider}`,
'provider',
vscode.TreeItemCollapsibleState.None,
{
command: 'codeReviewer.selectProvider',
title: '选择提供商',
}
),
new SetupItem(`模型: ${config.model}`,
'model',
vscode.TreeItemCollapsibleState.None,
{
command: 'codeReviewer.selectModel',
title: '选择模型',
}
),
];
}
private getApiKeyItems(): SetupItem[] {
return [
new SetupItem(
'设置 API Key...',
'apiKey',
vscode.TreeItemCollapsibleState.None,
{
command: 'codeReviewer.setApiKey',
title: '设置 API Key',
}
),
];
}
private getLanguageItems(): SetupItem[] {
const config = getAIConfig();
return [
new SetupItem(
`当前: ${config.outputLanguage === 'zh-CN' ? '中文(简体)' : config.outputLanguage}`,
'language',
vscode.TreeItemCollapsibleState.None,
{
command: 'codeReviewer.selectLanguage',
title: '选择输出语言',
}
),
];
}
}