diff --git a/extensions/vscode/__mocks__/vscode.js b/extensions/vscode/__mocks__/vscode.js new file mode 100644 index 0000000..2d64845 --- /dev/null +++ b/extensions/vscode/__mocks__/vscode.js @@ -0,0 +1,19 @@ +module.exports = { + env: { language: 'en' }, + Uri: { file: (p) => ({ fsPath: p, scheme: 'file' }), joinPath: (...args) => args.join('/') }, + workspace: { workspaceFolders: [{ uri: { fsPath: '/mock' } }], openTextDocument: () => Promise.resolve({ lineCount: 10, lineAt: () => ({ text: '' }) }) }, + window: { showErrorMessage: () => {}, showWarningMessage: () => Promise.resolve(undefined), createOutputChannel: () => ({ appendLine: () => {}, dispose: () => {} }), createWebviewPanel: () => ({ webview: { html: '', onDidReceiveMessage: () => ({ dispose: () => {} }), asWebviewUri: (u) => u }, onDidDispose: () => ({ dispose: () => {} }), reveal: () => {}, dispose: () => {} }) }, + commands: { registerCommand: () => ({ dispose: () => {} }), executeCommand: () => Promise.resolve() }, + extensions: { getExtension: () => null }, + Disposable: { from: (...ds) => ({ dispose: () => ds.forEach((d) => d.dispose()) }) }, + CommentMode: { Preview: 1 }, + CommentThreadCollapsibleState: { Expanded: 0 }, + MarkdownString: function (v) { this.value = v; this.isTrusted = false; }, + Range: function (a, b, c, d) { this.start = { line: a, character: b }; this.end = { line: c, character: d }; }, + WorkspaceEdit: function () { this.replace = () => {}; this.delete = () => {}; }, + ThemeIcon: function () {}, + ViewColumn: { One: 1 }, + EventEmitter: function () { this.event = () => {}; this.fire = () => {}; }, + CommentController: function () { this.createCommentThread = () => ({ canReply: false, label: '', collapsibleState: 0, contextValue: '', comments: [], dispose: () => {} }); }, + WebviewViewProvider: function () {}, +}; \ No newline at end of file diff --git a/extensions/vscode/open-code-review-vscode-0.1.0.vsix b/extensions/vscode/open-code-review-vscode-0.1.0.vsix index 5f008a1..428b077 100644 Binary files a/extensions/vscode/open-code-review-vscode-0.1.0.vsix and b/extensions/vscode/open-code-review-vscode-0.1.0.vsix differ diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 9005778..6d8ebd5 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -1,7 +1,7 @@ { "name": "open-code-review-vscode", "displayName": "Open Code Review", - "description": "AI 代码审查 —— 基于 open-code-review CLI", + "description": "%ocr.description%", "version": "0.1.0", "publisher": "open-code-review", "license": "Apache-2.0", @@ -25,7 +25,7 @@ "activitybar": [ { "id": "ocr-container", - "title": "Open Code Review", + "title": "%ocr.activitybar.title%", "icon": "resources/icon.svg" } ] @@ -35,34 +35,34 @@ { "id": "ocr.sidebar", "type": "webview", - "name": "Code Review" + "name": "%ocr.sidebar.name%" } ] }, "commands": [ { "command": "ocr.review.start", - "title": "OCR: 开始代码审查" + "title": "%ocr.review.start%" }, { "command": "ocr.review.cancel", - "title": "OCR: 取消审查" + "title": "%ocr.review.cancel%" }, { "command": "ocr.config.open", - "title": "OCR: 打开配置" + "title": "%ocr.config.open%" }, { "command": "ocr.comment.apply", - "title": "应用" + "title": "%ocr.comment.apply%" }, { "command": "ocr.comment.discard", - "title": "忽略" + "title": "%ocr.comment.discard%" }, { "command": "ocr.comment.falsePositive", - "title": "误报" + "title": "%ocr.comment.falsePositive%" } ], "menus": { diff --git a/extensions/vscode/package.nls.json b/extensions/vscode/package.nls.json new file mode 100644 index 0000000..94c0761 --- /dev/null +++ b/extensions/vscode/package.nls.json @@ -0,0 +1,11 @@ +{ + "ocr.activitybar.title": "Open Code Review", + "ocr.sidebar.name": "Code Review", + "ocr.review.start": "OCR: Start Code Review", + "ocr.review.cancel": "OCR: Cancel Review", + "ocr.config.open": "OCR: Open Configuration", + "ocr.comment.apply": "Apply", + "ocr.comment.discard": "Discard", + "ocr.comment.falsePositive": "False Positive", + "ocr.description": "AI Code Review — powered by open-code-review CLI" +} \ No newline at end of file diff --git a/extensions/vscode/package.nls.zh-cn.json b/extensions/vscode/package.nls.zh-cn.json new file mode 100644 index 0000000..33766f7 --- /dev/null +++ b/extensions/vscode/package.nls.zh-cn.json @@ -0,0 +1,11 @@ +{ + "ocr.activitybar.title": "Open Code Review", + "ocr.sidebar.name": "Code Review", + "ocr.review.start": "OCR: 开始代码审查", + "ocr.review.cancel": "OCR: 取消审查", + "ocr.config.open": "OCR: 打开配置", + "ocr.comment.apply": "应用", + "ocr.comment.discard": "忽略", + "ocr.comment.falsePositive": "误报", + "ocr.description": "AI 代码审查 —— 基于 open-code-review CLI" +} \ No newline at end of file diff --git a/extensions/vscode/src/extension/providers/CommentProvider.ts b/extensions/vscode/src/extension/providers/CommentProvider.ts index bea8f30..02d1dba 100644 --- a/extensions/vscode/src/extension/providers/CommentProvider.ts +++ b/extensions/vscode/src/extension/providers/CommentProvider.ts @@ -1,3 +1,4 @@ +import { t, resolveLocale, SupportedLocale } from '../../shared/i18n'; import * as vscode from 'vscode'; import { ReviewComment, CommentStatus, CommentSyncState } from '../../shared/types'; import { COMMENT_CONTROLLER_ID } from '../../shared/constants'; @@ -13,8 +14,11 @@ export class CommentProvider { private offsets = new LineOffsetTracker(); private syncListeners: Array<(s: CommentSyncState[]) => void> = []; + private locale: SupportedLocale; + constructor(private extensionUri: vscode.Uri) { - this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, 'Open Code Review'); + this.locale = resolveLocale(vscode.env.language); + this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, t(this.locale, 'ext.commentController')); } onSync(fn: (s: CommentSyncState[]) => void): void { @@ -58,10 +62,10 @@ export class CommentProvider { const body = this.renderBody(c, i, 'pending'); const thread = this.controller.createCommentThread(doc.uri, range, [{ body, mode: vscode.CommentMode.Preview, - author: { name: '⏳ [未处理]' }, + author: { name: t(this.locale, 'ext.comment.pending') }, }]); thread.canReply = false; - thread.label = `Code Review (${i + 1} / ${this.comments.length})`; + thread.label = `${t(this.locale, 'ext.comment.threadLabel')} (${i + 1} / ${this.comments.length})`; // 有代码建议 → 'pending'(显示应用+忽略);无建议 → 'pendingNoSuggestion'(仅忽略) thread.contextValue = this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion'; thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; @@ -82,7 +86,7 @@ export class CommentProvider { if (this.hasSuggestion(c)) { md += `\n***\n\`\`\`diff\n${c.suggestionCode}\n\`\`\``; } else { - md += `\n***\n_💡 无代码建议,请手动处理_`; + md += `\n***\n${t(this.locale, 'ext.comment.noSuggestion')}`; } const s = new vscode.MarkdownString(md); s.isTrusted = true; @@ -100,7 +104,7 @@ export class CommentProvider { const start = Math.max(0, this.offsets.adjusted(c.path, c.startLine) - 1); const end = Math.min(doc.lineCount - 1, this.offsets.adjusted(c.path, c.endLine) - 1); if (end < start) { - vscode.window.showErrorMessage('应用失败:代码位置已失效,请刷新后重试。'); + vscode.window.showErrorMessage(t(this.locale, 'ext.comment.applyFailedStale')); return; } const range = new vscode.Range(start, 0, end, doc.lineAt(end).text.length); @@ -113,7 +117,7 @@ export class CommentProvider { else edit.delete(uri, range); const ok = await vscode.workspace.applyEdit(edit); if (!ok) { - vscode.window.showErrorMessage('应用失败:无法修改文件,请检查文件是否被占用或处于只读状态。'); + vscode.window.showErrorMessage(t(this.locale, 'ext.comment.applyFailedLocked')); return; } await doc.save(); @@ -129,7 +133,12 @@ export class CommentProvider { this.status.set(index, status); const thread = this.threads.get(index); if (thread) { - const label = { applied: '✅ [已应用]', discarded: '✅ [已忽略]', falsePositive: '✅ [已误报]', pending: '⏳ [未处理]' }[status]; + const label = { + applied: t(this.locale, 'ext.comment.statusApplied'), + discarded: t(this.locale, 'ext.comment.statusDiscarded'), + falsePositive: t(this.locale, 'ext.comment.statusFalsePositive'), + pending: t(this.locale, 'ext.comment.pending'), + }[status]; thread.comments = [{ ...thread.comments[0], author: { name: label }, body: this.renderBody(this.comments[index], index, status) }] as any; thread.contextValue = status; thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; @@ -141,7 +150,7 @@ export class CommentProvider { const thread = this.threads.get(index); if (!thread) { const c = this.comments[index]; - if (c) vscode.window.showWarningMessage(`无法定位到 ${c.path}:该路径不是可打开的文件。`); + if (c) vscode.window.showWarningMessage(`${t(this.locale, 'ext.comment.jumpFailed')}${c.path}${t(this.locale, 'ext.comment.jumpNotAFile')}`); return; } await vscode.window.showTextDocument(thread.uri, { selection: thread.range, preview: false }); diff --git a/extensions/vscode/src/extension/providers/ConfigPanelProvider.ts b/extensions/vscode/src/extension/providers/ConfigPanelProvider.ts index 2be41d8..03dc13d 100644 --- a/extensions/vscode/src/extension/providers/ConfigPanelProvider.ts +++ b/extensions/vscode/src/extension/providers/ConfigPanelProvider.ts @@ -1,3 +1,4 @@ +import { resolveLocale, t, toHtmlLang } from '../../shared/i18n'; import * as vscode from 'vscode'; import { ConfigPanelFocus, isConfigReady } from '../../shared/configUtils'; import { ConfigPanelHostToWebview, WebviewToHost } from '../../shared/messages'; @@ -30,7 +31,7 @@ export class ConfigPanelProvider implements vscode.Disposable { this.panel = vscode.window.createWebviewPanel( PANEL_VIEW_TYPE, - '模型配置', + t(resolveLocale(vscode.env.language), 'ext.configPanelTitle'), vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [this.extensionUri] }, ); @@ -80,12 +81,14 @@ export class ConfigPanelProvider implements vscode.Disposable { const config = this.config.read(); const cached = this.cli.getCachedEnvironment(); const skipEnvCheck = focus?.step === 2 || isConfigReady(config); + const locale = resolveLocale(vscode.env.language); this.post({ type: 'configPanelInit', config, focus: focus ?? null, env: cached, skipEnvCheck, + locale, }); break; } @@ -106,12 +109,13 @@ export class ConfigPanelProvider implements vscode.Disposable { break; } case 'deleteCustomProvider': { + const locale = resolveLocale(vscode.env.language); const confirmed = await vscode.window.showWarningMessage( - `确定删除自定义 Provider「${msg.name}」?`, + t(locale, 'ext.deleteProviderConfirm').replace('{name}', msg.name), { modal: true }, - '删除', + t(locale, 'ext.deleteProviderConfirmBtn'), ); - if (confirmed !== '删除') break; + if (confirmed !== t(locale, 'ext.deleteProviderConfirmBtn')) break; this.notifyConfig(this.config.deleteCustomProvider(msg.name)); break; } @@ -144,8 +148,10 @@ export class ConfigPanelProvider implements vscode.Disposable { private html(webview: vscode.Webview): string { const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'out', 'configPanel.js')); const nonce = String(Date.now()); + const resolved = resolveLocale(vscode.env.language); + const lang = toHtmlLang(resolved); return ` - +
diff --git a/extensions/vscode/src/extension/providers/SidebarProvider.ts b/extensions/vscode/src/extension/providers/SidebarProvider.ts index f17de1d..f63ff58 100644 --- a/extensions/vscode/src/extension/providers/SidebarProvider.ts +++ b/extensions/vscode/src/extension/providers/SidebarProvider.ts @@ -1,3 +1,4 @@ +import { resolveLocale, toHtmlLang } from '../../shared/i18n'; import * as vscode from 'vscode'; import { ConfigPanelFocus } from '../../shared/configUtils'; import { HostToWebview, WebviewToHost } from '../../shared/messages'; @@ -48,7 +49,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider { case 'ready': { const config = this.config.read(); const gitState = await this.git.getState('workspace'); - this.post({ type: 'init', config, gitState }); + const locale = resolveLocale(vscode.env.language); + this.post({ type: 'init', config, gitState, locale }); break; } case 'getGitState': { @@ -108,8 +110,10 @@ export class SidebarProvider implements vscode.WebviewViewProvider { private html(webview: vscode.Webview): string { const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'out', 'webview.js')); const nonce = String(Date.now()); + const resolved = resolveLocale(vscode.env.language); + const lang = toHtmlLang(resolved); return ` - +
diff --git a/extensions/vscode/src/extension/services/CliService.ts b/extensions/vscode/src/extension/services/CliService.ts index 291385f..8de2994 100644 --- a/extensions/vscode/src/extension/services/CliService.ts +++ b/extensions/vscode/src/extension/services/CliService.ts @@ -1,3 +1,5 @@ +import { t, resolveLocale } from '../../shared/i18n'; +import * as vscode from 'vscode'; import { spawn } from 'child_process'; import { CliResult, CliRunOptions, EnvCheckResult, LogLine } from '../../shared/types'; import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from './cliParse'; @@ -90,7 +92,8 @@ export class CliService { proc.on('error', (err) => { onLog({ text: String(err), level: 'error' }); resolve(false); }); proc.on('close', (code) => { emitLines('', 'info', true); - onLog({ text: code === 0 ? '✓ 安装完成' : `✗ 安装失败 (exit ${code})`, level: code === 0 ? 'info' : 'error' }); + const locale = resolveLocale(vscode.env.language); + onLog({ text: code === 0 ? t(locale, 'ext.cli.installOk') : `${t(locale, 'ext.cli.installFail')}${code})`, level: code === 0 ? 'info' : 'error' }); if (code === 0) this.invalidateEnvironmentCache(); resolve(code === 0); }); diff --git a/extensions/vscode/src/extension/services/GitService.ts b/extensions/vscode/src/extension/services/GitService.ts index 2751035..a3b29f0 100644 --- a/extensions/vscode/src/extension/services/GitService.ts +++ b/extensions/vscode/src/extension/services/GitService.ts @@ -1,3 +1,4 @@ +import { t, resolveLocale } from '../../shared/i18n'; import * as vscode from 'vscode'; import { execFile } from 'child_process'; import { GitState, CommitInfo, FileChange, ReviewMode } from '../../shared/types'; @@ -167,7 +168,7 @@ export class GitService { if (opts.mode === 'workspace') { left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD'); right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri; - label = '工作区 ↔ HEAD'; + label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead'); } else if (opts.mode === 'commit' && opts.commit) { left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : `${opts.commit}^`); right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.commit); @@ -211,11 +212,13 @@ function runGit(cwd: string, args: string[]): Promise { function formatRelative(date?: Date): string { if (!date) return ''; + const locale = resolveLocale(vscode.env.language); const diff = Date.now() - date.getTime(); const h = Math.floor(diff / 3.6e6); - if (h < 1) return '刚刚'; - if (h < 24) return `${h} 小时前`; + if (h < 1) return t(locale, 'ext.git.justNow'); + if (h === 1) return t(locale, 'ext.git.hourAgo'); + if (h < 24) return t(locale, 'ext.git.hoursAgo').replace('{h}', String(h)); const d = Math.floor(h / 24); - if (d === 1) return '昨天'; - return `${d} 天前`; + if (d === 1) return t(locale, 'ext.git.yesterday'); + return t(locale, 'ext.git.daysAgo').replace('{d}', String(d)); } diff --git a/extensions/vscode/src/extension/services/shellEnv.ts b/extensions/vscode/src/extension/services/shellEnv.ts index 5bef761..7fb4b58 100644 --- a/extensions/vscode/src/extension/services/shellEnv.ts +++ b/extensions/vscode/src/extension/services/shellEnv.ts @@ -57,7 +57,7 @@ export function resolveBin(name: string): string { let resolved = name; try { const shell = process.env.SHELL || '/bin/zsh'; - if (!/^[a-zA-Z0-9._\/-]+$/.test(name)) return name; + if (!/^[a-zA-Z0-9._/-]+$/.test(name)) return name; const res = spawnSync(shell, ['-ilc', `command -v '${name.replace(/'/g, "'\\''")}'`], { encoding: 'utf8', timeout: 5000, diff --git a/extensions/vscode/src/shared/i18n.ts b/extensions/vscode/src/shared/i18n.ts new file mode 100644 index 0000000..346af78 --- /dev/null +++ b/extensions/vscode/src/shared/i18n.ts @@ -0,0 +1,332 @@ +/** + * Supported display locales for the extension UI. + * Add new entries here and in the `messages` dictionary below to extend. + * + * - `en` — English (default, fallback for all unrecognized locales) + * - `zh-cn` — Simplified Chinese (matches VS Code `zh-cn` / `zh-CN`) + */ +export type SupportedLocale = 'en' | 'zh-cn'; + +const messages: Record> = { + en: { + // ── IdleView ── + 'view.idle.configFirst': 'Configure model first', + 'view.idle.reviewing': 'Reviewing…', + 'view.idle.selectBranch': 'Select comparison branch', + 'view.idle.selectCommit': 'Select a commit', + 'view.idle.noFiles': 'No files to review', + 'view.idle.reviewAll': 'Review all changes', + 'view.idle.workspace': 'Workspace', + 'view.idle.branch': 'Branch Compare', + 'view.idle.commit': 'Single Commit', + 'view.idle.baseRef': 'Base ref', + 'view.idle.targetRef': 'Target ref', + 'view.idle.chooseBranch': 'Choose branch', + 'view.idle.commitHistory': 'Commit history', + 'view.idle.customPrompt': 'Custom review prompt (optional)', + 'view.idle.manageCustom': 'Manage custom providers', + 'view.idle.modelConfig': 'Model config', + + // ── RunningView ── + 'view.running.reviewLog': 'Review log', + 'view.running.cancel': 'Cancel', + + // ── DoneView ── + 'view.done.comments': 'comments', + 'view.done.files': 'files', + 'view.done.processLog': 'Process log', + + // ── EmptyView ── + 'view.empty.noIssues': 'No issues found · Passed', + 'view.empty.processLog': 'Process log', + + // ── CancelledView ── + 'view.cancelled.title': 'Review cancelled', + + // ── FailedView ── + 'view.failed.title': 'Review failed.', + 'view.failed.checkConfig': 'Please check model configuration and retry.', + 'view.failed.checkApiKey': 'Please check API key and network connection.', + 'view.failed.retry': 'Retry', + + // ── ConfigView ── + 'view.config.title': 'Model Configuration', + 'view.config.desc': 'Connect an LLM provider to start code review', + 'view.config.close': 'Close', + 'view.config.step1': 'Environment Setup', + 'view.config.step2': 'Provider Config', + 'view.config.checking': 'ocr checking…', + 'view.config.notInstalled': 'ocr not installed', + 'view.config.official': 'Official Provider', + 'view.config.custom': 'Custom Provider', + 'view.config.currentUse': 'Currently using', + 'view.config.notConfigured': 'No provider configured', + 'view.config.officialLabel': 'Official', + 'view.config.customLabel': 'Custom', + 'view.config.legacyLabel': 'Legacy', + 'view.config.model': 'Model', + 'view.config.customModel': 'Enter custom model…', + 'view.config.apiKey': 'API Key', + 'view.config.apiKeyEnvHint': 'Also available via env var', + 'view.config.apiKeySaved': 'Saved (leave blank to keep)', + 'view.config.testing': 'Testing connection…', + 'view.config.testOk': '✓ Connected', + 'view.config.testFail': '✗ Connection failed', + 'view.config.previous': 'Previous', + 'view.config.testFailDetail': '✗ Connection failed: {message}', + 'view.config.test': 'Test Connection', + 'view.config.save': 'Save', + 'view.config.continueProvider': 'Continue to Provider Config', + 'view.config.providerName': 'Provider Name', + 'view.config.protocol': 'Protocol', + 'view.config.baseUrl': 'Base URL', + 'view.config.modelList': 'Model list', + 'view.config.modelListPlaceholder': 'Comma-separated, e.g. model-a, model-b', + 'view.config.authHeader': 'Auth Header', + 'view.config.authHeaderHint': 'Optional x-api-key or authorization for Anthropic protocol', + 'view.config.authHeaderDefault': 'Default (Authorization)', + 'view.config.backToList': '← Back to list', + 'view.config.optional': '(optional)', + 'view.config.ocrVersionTooltip': 'Open Code Review CLI Version', + + // ── EnvSetupGuide ── + 'view.env.installing': 'Installing ocr CLI…', + 'view.env.checking': 'Checking, please wait…', + 'view.env.ready': 'Environment is ready. Continue to Provider Config.', + 'view.env.stepLead': 'Complete each step in order. Move to the next after each passes.', + 'view.env.nodeHint': 'Node.js not detected. Visit nodejs.org to install the LTS version, then restart VS Code.', + 'view.env.npmHint': 'npm not detected. npm is usually bundled with Node.js — verify your Node installation.', + 'view.env.ocrHint': 'Install open-code-review globally in your terminal, or click "One-Click Install" below.', + 'view.env.oneClickInstall': 'One-Click Install', + 'view.env.redetect': 'Re-detect', + 'view.env.checkingStatus': 'Checking', + 'view.env.readyStatus': 'Ready', + 'view.env.notReady': 'Not ready', + 'view.env.pass': 'Pass', + 'view.env.fail': 'Fail', + 'view.env.waitPrev': 'Waiting for previous', + 'view.env.copy': 'Copy', + 'view.env.copiedToast': 'Copied ✓', + + // ── CustomProviderManager ── + 'cmp.custom.title': 'Custom Providers', + 'cmp.custom.desc': 'Manage self-hosted LLM gateways and compatible endpoints. Switch the active review model.', + 'cmp.custom.add': 'Add', + 'cmp.custom.empty': 'No custom providers', + 'cmp.custom.addFirst': 'Add custom provider', + 'cmp.custom.currentUse': 'Currently using', + 'cmp.custom.model': 'Model', + 'cmp.custom.edit': 'Edit', + 'cmp.custom.setCurrent': 'Set as current', + 'cmp.custom.delete': 'Delete', + + // ── FileList ── + 'cmp.fileList.pending': 'Pending files', + 'cmp.fileList.noChanges': 'No changed files', + 'cmp.fileList.viewDiff': 'Click to view diff', + + // ── LogViewer ── + 'cmp.log.waiting': 'Waiting for output', + + // ── CommentCard ── + 'cmp.comment.view': 'View', + 'cmp.comment.discard': 'Discard', + + // ── PasswordInput ── + 'cmp.password.hideSecret': 'Hide secret', + 'cmp.password.showSecret': 'Show secret', + + // ── Select ── + 'cmp.select.placeholder': 'Select', + + // ── Extension ── + 'ext.commentController': 'Open Code Review', + 'ext.configPanelTitle': 'Model Configuration', + 'ext.config.legacyDisplayName': 'Legacy LLM Endpoint', + 'ext.comment.threadLabel': 'Code Review', + 'ext.comment.pending': '⏳ [Pending]', + 'ext.comment.noSuggestion': '_💡 No code suggestion, please handle manually_', + 'ext.comment.applyFailedStale': 'Apply failed: code location is stale, please refresh and retry.', + 'ext.comment.applyFailedLocked': 'Apply failed: cannot modify file, check if it is read-only or locked.', + 'ext.comment.statusApplied': '✅ [Applied]', + 'ext.comment.statusDiscarded': '✅ [Discarded]', + 'ext.comment.statusFalsePositive': '✅ [False Positive]', + 'ext.comment.jumpFailed': 'Cannot locate ', + 'ext.comment.jumpNotAFile': ': is not an openable file.', + 'ext.deleteProviderConfirm': 'Delete custom provider "{name}"?', + 'ext.deleteProviderConfirmBtn': 'Delete', + 'ext.git.justNow': 'just now', + 'ext.git.hoursAgo': '{h} hours ago', + 'ext.git.hourAgo': '1 hour ago', + 'ext.git.yesterday': 'yesterday', + 'ext.git.daysAgo': '{d} days ago', + 'ext.git.workspaceVsHead': 'Workspace ↔ HEAD', + 'ext.cli.installOk': '✓ Install complete', + 'ext.cli.installFail': '✗ Install failed (exit ', + }, + + 'zh-cn': { + 'view.idle.configFirst': '请先配置模型', + 'view.idle.reviewing': '审查中…', + 'view.idle.selectBranch': '请选择对比分支', + 'view.idle.selectCommit': '请选择提交', + 'view.idle.noFiles': '无可审查文件', + 'view.idle.reviewAll': '审查所有变更', + 'view.idle.workspace': '工作区', + 'view.idle.branch': '分支对比', + 'view.idle.commit': '单次提交', + 'view.idle.baseRef': '基础引用', + 'view.idle.targetRef': '目标引用', + 'view.idle.chooseBranch': '选择分支', + 'view.idle.commitHistory': '提交历史', + 'view.idle.customPrompt': '自定义审查提示词(可选)', + 'view.idle.manageCustom': '管理自定义 Provider', + 'view.idle.modelConfig': '模型配置', + + 'view.running.reviewLog': '审查日志', + 'view.running.cancel': '取消', + + 'view.done.comments': '条评论', + 'view.done.files': '个文件', + 'view.done.processLog': '过程日志', + + 'view.empty.noIssues': '未发现问题 · 已通过', + 'view.empty.processLog': '过程日志', + + 'view.cancelled.title': '审查已取消', + + 'view.failed.title': '审查失败。', + 'view.failed.checkConfig': '请检查模型配置后重试。', + 'view.failed.checkApiKey': '请检查 API Key 和网络连接。', + 'view.failed.retry': '重试', + + 'view.config.title': '模型配置', + 'view.config.desc': '连接 LLM Provider 以开始代码审查', + 'view.config.close': '关闭', + 'view.config.step1': '环境检测', + 'view.config.step2': 'Provider 配置', + 'view.config.checking': 'ocr 检测中…', + 'view.config.notInstalled': 'ocr 未安装', + 'view.config.official': '官方 Provider', + 'view.config.custom': '自定义 Provider', + 'view.config.currentUse': '当前使用', + 'view.config.notConfigured': '尚未配置 Provider', + 'view.config.officialLabel': '官方', + 'view.config.customLabel': '自定义', + 'view.config.legacyLabel': 'Legacy', + 'view.config.model': '模型', + 'view.config.customModel': '输入自定义模型…', + 'view.config.apiKey': 'API 密钥', + 'view.config.apiKeyEnvHint': '也可通过环境变量', + 'view.config.apiKeySaved': '已保存(留空保持不变)', + 'view.config.testing': '正在测试连接…', + 'view.config.testOk': '✓ 连接成功', + 'view.config.testFail': '✗ 连接失败', + 'view.config.testFailDetail': '✗ 连接失败:{message}', + 'view.config.previous': '上一步', + 'view.config.test': '测试连接', + 'view.config.save': '保存', + 'view.config.continueProvider': '继续配置 Provider', + 'view.config.providerName': 'Provider 名称', + 'view.config.protocol': '协议', + 'view.config.baseUrl': 'Base URL', + 'view.config.modelList': '模型列表', + 'view.config.modelListPlaceholder': '逗号分隔,如 model-a, model-b', + 'view.config.authHeader': 'Auth Header', + 'view.config.authHeaderHint': 'Anthropic 协议下可选 x-api-key 或 authorization', + 'view.config.authHeaderDefault': '默认 (Authorization)', + 'view.config.backToList': '← 返回列表', + 'view.config.optional': '(可选)', + 'view.config.ocrVersionTooltip': 'Open Code Review CLI 版本', + + 'view.env.installing': '正在安装 ocr CLI…', + 'view.env.checking': '正在检测,请稍候…', + 'view.env.ready': '环境已就绪,可继续配置 Provider。', + 'view.env.stepLead': '按顺序完成环境准备,通过一项后再进行下一项。', + 'view.env.nodeHint': '未检测到 Node.js。请前往 nodejs.org 安装 LTS 版本,完成后重启 VS Code。', + 'view.env.npmHint': '未检测到 npm。npm 通常随 Node 一起安装,请确认 Node 安装完整。', + 'view.env.ocrHint': '在终端全局安装 open-code-review,或点击下方「一键安装」。', + 'view.env.oneClickInstall': '一键安装', + 'view.env.redetect': '重新检测', + 'view.env.checkingStatus': '检测中', + 'view.env.readyStatus': '就绪', + 'view.env.notReady': '未就绪', + 'view.env.pass': '通过', + 'view.env.fail': '未通过', + 'view.env.waitPrev': '等待上一步', + 'view.env.copy': '复制', + 'view.env.copiedToast': '已复制到剪贴板 ✓', + + 'cmp.custom.title': '自定义 Provider', + 'cmp.custom.desc': '管理自建 LLM 网关与兼容端点,可切换为当前审查模型。', + 'cmp.custom.add': '添加', + 'cmp.custom.empty': '暂无自定义 Provider', + 'cmp.custom.addFirst': '添加自定义 Provider', + 'cmp.custom.currentUse': '当前使用', + 'cmp.custom.model': '模型', + 'cmp.custom.edit': '编辑', + 'cmp.custom.setCurrent': '设为当前', + 'cmp.custom.delete': '删除', + + 'cmp.fileList.pending': '待审查文件', + 'cmp.fileList.noChanges': '无变更文件', + 'cmp.fileList.viewDiff': '点击查看 diff', + + 'cmp.log.waiting': '等待输出', + + 'cmp.comment.view': '查看', + 'cmp.comment.discard': '忽略', + + 'cmp.password.hideSecret': '隐藏密钥', + 'cmp.password.showSecret': '显示密钥', + + 'cmp.select.placeholder': '请选择', + + 'ext.commentController': 'Open Code Review', + 'ext.configPanelTitle': '模型配置', + 'ext.config.legacyDisplayName': 'Legacy LLM 端点', + 'ext.comment.threadLabel': 'Code Review', + 'ext.comment.pending': '⏳ [未处理]', + 'ext.comment.noSuggestion': '_💡 无代码建议,请手动处理_', + 'ext.comment.applyFailedStale': '应用失败:代码位置已失效,请刷新后重试。', + 'ext.comment.applyFailedLocked': '应用失败:无法修改文件,请检查文件是否被占用或处于只读状态。', + 'ext.comment.statusApplied': '✅ [已应用]', + 'ext.comment.statusDiscarded': '✅ [已忽略]', + 'ext.comment.statusFalsePositive': '✅ [已误报]', + 'ext.comment.jumpFailed': '无法定位到 ', + 'ext.comment.jumpNotAFile': ':该路径不是可打开的文件。', + 'ext.deleteProviderConfirm': '确定删除自定义 Provider「{name}」?', + 'ext.deleteProviderConfirmBtn': '删除', + 'ext.git.justNow': '刚刚', + 'ext.git.hoursAgo': '{h} 小时前', + 'ext.git.hourAgo': '1 小时前', + 'ext.git.yesterday': '昨天', + 'ext.git.daysAgo': '{d} 天前', + 'ext.git.workspaceVsHead': '工作区 ↔ HEAD', + 'ext.cli.installOk': '✓ 安装完成', + 'ext.cli.installFail': '✗ 安装失败 (exit ', + }, +}; + +export function t(locale: SupportedLocale, key: string): string { + return messages[locale]?.[key] ?? messages.en[key] ?? key; +} + +/** + * Resolve a VS Code locale string to a {@link SupportedLocale}. + * Only `zh-cn` (case-insensitive) maps to Simplified Chinese; + * other Chinese variants like `zh-tw` / `zh-hk` fall back to English + * until their translations are added. + */ +export function resolveLocale(raw: string): SupportedLocale { + if (raw.toLowerCase() === 'zh-cn') return 'zh-cn'; + return 'en'; +} + +/** + * Convert a {@link SupportedLocale} to the BCP 47 HTML `lang` attribute value. + * `zh-cn` → `zh-CN`, others stay as-is. + */ +export function toHtmlLang(locale: SupportedLocale): string { + return locale === 'zh-cn' ? 'zh-CN' : locale; +} diff --git a/extensions/vscode/src/shared/messages.ts b/extensions/vscode/src/shared/messages.ts index a868c48..059d72d 100644 --- a/extensions/vscode/src/shared/messages.ts +++ b/extensions/vscode/src/shared/messages.ts @@ -3,6 +3,7 @@ import { OcrConfig, ReviewMode, ReviewState, } from './types'; import { ConfigPanelFocus } from './configUtils'; +import { SupportedLocale } from './i18n'; export type WebviewToHost = | { type: 'ready' } @@ -28,7 +29,7 @@ export type WebviewToHost = | { type: 'commentAction'; index: number; action: 'apply' | 'discard' | 'falsePositive' }; export type HostToWebview = - | { type: 'init'; config: OcrConfig | null; gitState: GitState } + | { type: 'init'; config: OcrConfig | null; gitState: GitState; locale: SupportedLocale } | { type: 'gitState'; gitState: GitState } | { type: 'modeFiles'; mode: ReviewMode; files: FileChange[] } | { type: 'logLine'; line: LogLine } @@ -38,7 +39,7 @@ export type HostToWebview = | { type: 'commentSync'; comments: CommentSyncState[] }; export type ConfigPanelHostToWebview = - | { type: 'configPanelInit'; config: OcrConfig | null; focus?: ConfigPanelFocus | null; env?: EnvCheckResult | null; skipEnvCheck?: boolean } + | { type: 'configPanelInit'; config: OcrConfig | null; focus?: ConfigPanelFocus | null; env?: EnvCheckResult | null; skipEnvCheck?: boolean; locale: SupportedLocale } | { type: 'configPanelFocus'; focus?: ConfigPanelFocus | null } | { type: 'config'; config: OcrConfig | null } | { type: 'connectionResult'; ok: boolean; message?: string } diff --git a/extensions/vscode/src/webview/App.tsx b/extensions/vscode/src/webview/App.tsx index 3236268..caebc28 100644 --- a/extensions/vscode/src/webview/App.tsx +++ b/extensions/vscode/src/webview/App.tsx @@ -1,3 +1,4 @@ +import { I18nContext, resolveLocale } from './I18nProvider'; import { useEffect, useReducer } from 'preact/hooks'; import { reducer, initialState } from './store'; import { bridge } from './bridge'; @@ -38,32 +39,34 @@ export function App() { }; return ( -
-
- bridge.post({ type: 'openConfigPanel' })} - onOpenCustomProviders={() => bridge.post({ - type: 'openConfigPanel', - focus: { step: 2, tab: 'custom', customView: 'list' }, - })} - running={state.view === 'running'} /> + +
+
+ bridge.post({ type: 'openConfigPanel' })} + onOpenCustomProviders={() => bridge.post({ + type: 'openConfigPanel', + focus: { step: 2, tab: 'custom', customView: 'list' }, + })} + running={state.view === 'running'} /> - {state.view !== 'idle' && ( -
- {state.view === 'running' && bridge.post({ type: 'cancelReview' })} />} - {state.view === 'done' && state.session.result && ( - bridge.post({ type: 'jumpToComment', index: i })} - onAction={(i, action) => bridge.post({ type: 'commentAction', index: i, action })} /> - )} - {state.view === 'empty' && } - {state.view === 'cancelled' && } - {state.view === 'failed' && start({ mode: 'workspace' })} />} -
- )} + {state.view !== 'idle' && ( +
+ {state.view === 'running' && bridge.post({ type: 'cancelReview' })} />} + {state.view === 'done' && state.session.result && ( + bridge.post({ type: 'jumpToComment', index: i })} + onAction={(i, action) => bridge.post({ type: 'commentAction', index: i, action })} /> + )} + {state.view === 'empty' && } + {state.view === 'cancelled' && } + {state.view === 'failed' && start({ mode: 'workspace' })} />} +
+ )} +
-
+ ); } diff --git a/extensions/vscode/src/webview/ConfigPanelApp.tsx b/extensions/vscode/src/webview/ConfigPanelApp.tsx index 9721c15..1439a12 100644 --- a/extensions/vscode/src/webview/ConfigPanelApp.tsx +++ b/extensions/vscode/src/webview/ConfigPanelApp.tsx @@ -1,3 +1,4 @@ +import { I18nContext, resolveLocale } from './I18nProvider'; import { useEffect, useReducer } from 'preact/hooks'; import { bridge } from './bridge'; import { ConfigView } from './views/ConfigView'; @@ -38,6 +39,7 @@ export function ConfigPanelApp() { }, [state.errorHint]); return ( +
{state.copyHint &&
{state.copyHint}
} {state.errorHint &&
{state.errorHint}
} @@ -63,5 +65,6 @@ export function ConfigPanelApp() { onClose={() => bridge.post({ type: 'closeConfigPanel' })} />
+
); } diff --git a/extensions/vscode/src/webview/I18nProvider.tsx b/extensions/vscode/src/webview/I18nProvider.tsx new file mode 100644 index 0000000..f19dc27 --- /dev/null +++ b/extensions/vscode/src/webview/I18nProvider.tsx @@ -0,0 +1,13 @@ +import { createContext } from 'preact'; +import { useContext } from 'preact/hooks'; +import { t, resolveLocale, SupportedLocale } from '../shared/i18n'; + +export const I18nContext = createContext('en'); + +export function useT(): (key: string) => string { + const locale = useContext(I18nContext); + return (key: string) => t(locale, key); +} + +export { resolveLocale, t }; +export type { SupportedLocale }; diff --git a/extensions/vscode/src/webview/components/CommentCard.tsx b/extensions/vscode/src/webview/components/CommentCard.tsx index 59a5150..2e6b9d2 100644 --- a/extensions/vscode/src/webview/components/CommentCard.tsx +++ b/extensions/vscode/src/webview/components/CommentCard.tsx @@ -1,3 +1,4 @@ +import { useT } from '../I18nProvider'; import { ReviewComment, CommentStatus } from '../../shared/types'; interface Props { @@ -10,6 +11,7 @@ interface Props { } export function CommentCard({ comment, index, status, canJump, onOpen, onAction }: Props) { + const t = useT(); return (
@@ -18,8 +20,8 @@ export function CommentCard({ comment, index, status, canJump, onOpen, onAction
{comment.content}
- {canJump && } - + {canJump && } +
); diff --git a/extensions/vscode/src/webview/components/CustomProviderManager.tsx b/extensions/vscode/src/webview/components/CustomProviderManager.tsx index ef1317a..f7e2a28 100644 --- a/extensions/vscode/src/webview/components/CustomProviderManager.tsx +++ b/extensions/vscode/src/webview/components/CustomProviderManager.tsx @@ -1,5 +1,6 @@ import { listCustomProviderNames } from '../../shared/configUtils'; import { OcrConfig, ProviderEntry } from '../../shared/types'; +import { useT } from '../I18nProvider'; interface Props { config: OcrConfig | null; @@ -16,6 +17,7 @@ function formatModels(entry: ProviderEntry): string { } export function CustomProviderManager({ config, onAdd, onEdit, onActivate, onDelete }: Props) { + const t = useT(); const names = listCustomProviderNames(config); const activeProvider = config?.provider ?? ''; @@ -23,16 +25,16 @@ export function CustomProviderManager({ config, onAdd, onEdit, onActivate, onDel
-

自定义 Provider

-

管理自建 LLM 网关与兼容端点,可切换为当前审查模型。

+

{t('cmp.custom.title')}

+

{t('cmp.custom.desc')}

- +
{names.length === 0 ? (
-

暂无自定义 Provider

- +

{t('cmp.custom.empty')}

+
) : (
@@ -45,21 +47,21 @@ export function CustomProviderManager({ config, onAdd, onEdit, onActivate, onDel
{name} - {isActive && 当前使用} + {isActive && {t('cmp.custom.currentUse')}}
{entry.protocol || '—'} · {entry.url || '—'}
-
模型:{formatModels(entry)}
+
{t('cmp.custom.model')}: {formatModels(entry)}
- + {!isActive && ( - + )} - +
); diff --git a/extensions/vscode/src/webview/components/EnvSetupGuide.tsx b/extensions/vscode/src/webview/components/EnvSetupGuide.tsx index 72af1f5..3d05893 100644 --- a/extensions/vscode/src/webview/components/EnvSetupGuide.tsx +++ b/extensions/vscode/src/webview/components/EnvSetupGuide.tsx @@ -1,6 +1,7 @@ import { EnvCheckResult, LogLine } from '../../shared/types'; import { CliStatus } from '../configStore'; import { LogViewer } from './LogViewer'; +import { useT } from '../I18nProvider'; export const OCR_INSTALL_CMD = 'npm install -g @alibaba-group/open-code-review'; @@ -40,12 +41,13 @@ export function EnvSetupGuide({ layout, cliStatus, envCheck, skipEnvCheck = false, installing, installLogs, onInstall, onCheckEnv, onCopy, onNext, }: Props) { + const t = useT(); const checking = cliStatus === 'checking' || cliStatus === 'unknown'; if (installing) { return (
- +
); @@ -54,7 +56,7 @@ export function EnvSetupGuide({ if (checking) { return (
- +
); @@ -66,7 +68,7 @@ export function EnvSetupGuide({
- +
@@ -76,10 +78,10 @@ export function EnvSetupGuide({ if (cliStatus === 'installed' && skipEnvCheck) { return (
-

环境已就绪,可继续配置 Provider。

+

{t('view.env.ready')}

- +
@@ -92,7 +94,7 @@ export function EnvSetupGuide({ return (
-

按顺序完成环境准备,通过一项后再进行下一项。

+

{t('view.env.stepLead')}

@@ -124,9 +126,9 @@ export function EnvSetupGuide({
- + {ocrActive && !envCheck?.ocr.ok && ( - + )}
@@ -144,6 +146,7 @@ function EnvCheckingBanner({ label }: { label: string }) { } function EnvChecklist({ checking, env }: { checking?: boolean; env?: EnvCheckResult }) { + const t = useT(); return (
    {CHECK_ITEMS.map(({ key, label }, i) => { @@ -155,9 +158,9 @@ function EnvChecklist({ checking, env }: { checking?: boolean; env?: EnvCheckRes