Feature/vscode i18n (#219)

* feat(i18n): add localization support for Chinese and update description in package.json

* feat: add internationalization support to various components and views

- Integrated translation functionality using `useT` from `I18nProvider` across multiple components including CustomProviderManager, EnvSetupGuide, FileList, LogViewer, PasswordInput, Select, and various views (CancelledView, ConfigView, DoneView, EmptyView, FailedView, IdleView, RunningView).
- Replaced hardcoded strings with localized strings to enhance user experience for different languages.
- Updated button labels, titles, and hints to reflect the new translation implementation.

* fix: correct regex for validating command names in resolveBin function(预存代码,非本次 PR 引入,阻塞了 lint,这里进行修复)

* chore: update open-code-review-vscode-0.1.0.vsix binary file

* fix: address OpenCodeReview bot findings for i18n PR

   - Fix singular '1 hour ago' / '1 小时前' in GitService.formatRelative
   - Replace hardcoded 'en' locale with dynamic resolveLocale in CliService.install
   - Move hardcoded full-width colon into i18n translation strings
   - Narrow locale type from string to SupportedLocale in messages, stores
   - Extract toHtmlLang() helper to deduplicate locale→HTML lang mapping
   - Replace nested ternary with mapping object in ConfigView
   - Add missing trailing newlines to 7 files
   - Add jest __mocks__/vscode.js for CliService test
This commit is contained in:
zephyrq-z 2026-06-25 22:30:52 +08:00 committed by GitHub
parent d939b327cf
commit b1db14f4ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 640 additions and 175 deletions

View file

@ -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 () {},
};

View file

@ -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": {

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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 });

View file

@ -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 `<!DOCTYPE html>
<html lang="zh-CN"><head>
<html lang="${lang}"><head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
</head><body><div id="root"></div>

View file

@ -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 `<!DOCTYPE html>
<html lang="zh-CN"><head>
<html lang="${lang}"><head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
</head><body><div id="root"></div>

View file

@ -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);
});

View file

@ -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<string> {
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));
}

View file

@ -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,

View file

@ -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<SupportedLocale, Record<string, string>> = {
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;
}

View file

@ -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 }

View file

@ -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 (
<div class="ocr-root">
<div class="action-region">
<IdleView gitState={state.gitState} modeFiles={state.modeFiles} filesLoading={state.filesLoading}
configured={configured} onModeChange={onModeChange} onRequestModeFiles={requestModeFiles}
onOpenFile={openFile} onStart={start} onOpenConfig={() => bridge.post({ type: 'openConfigPanel' })}
onOpenCustomProviders={() => bridge.post({
type: 'openConfigPanel',
focus: { step: 2, tab: 'custom', customView: 'list' },
})}
running={state.view === 'running'} />
<I18nContext.Provider value={resolveLocale(state.locale)}>
<div class="ocr-root">
<div class="action-region">
<IdleView gitState={state.gitState} modeFiles={state.modeFiles} filesLoading={state.filesLoading}
configured={configured} onModeChange={onModeChange} onRequestModeFiles={requestModeFiles}
onOpenFile={openFile} onStart={start} onOpenConfig={() => bridge.post({ type: 'openConfigPanel' })}
onOpenCustomProviders={() => bridge.post({
type: 'openConfigPanel',
focus: { step: 2, tab: 'custom', customView: 'list' },
})}
running={state.view === 'running'} />
{state.view !== 'idle' && (
<div class="result-region">
{state.view === 'running' && <RunningView logs={state.logs} onCancel={() => bridge.post({ type: 'cancelReview' })} />}
{state.view === 'done' && state.session.result && (
<DoneView result={state.session.result} commentStatus={state.commentStatus} logs={state.logs}
canJump={state.reviewMode === 'workspace'}
onOpen={(i) => bridge.post({ type: 'jumpToComment', index: i })}
onAction={(i, action) => bridge.post({ type: 'commentAction', index: i, action })} />
)}
{state.view === 'empty' && <EmptyView logs={state.logs} />}
{state.view === 'cancelled' && <CancelledView />}
{state.view === 'failed' && <FailedView error={state.session.error} onRetry={() => start({ mode: 'workspace' })} />}
</div>
)}
{state.view !== 'idle' && (
<div class="result-region">
{state.view === 'running' && <RunningView logs={state.logs} onCancel={() => bridge.post({ type: 'cancelReview' })} />}
{state.view === 'done' && state.session.result && (
<DoneView result={state.session.result} commentStatus={state.commentStatus} logs={state.logs}
canJump={state.reviewMode === 'workspace'}
onOpen={(i) => bridge.post({ type: 'jumpToComment', index: i })}
onAction={(i, action) => bridge.post({ type: 'commentAction', index: i, action })} />
)}
{state.view === 'empty' && <EmptyView logs={state.logs} />}
{state.view === 'cancelled' && <CancelledView />}
{state.view === 'failed' && <FailedView error={state.session.error} onRetry={() => start({ mode: 'workspace' })} />}
</div>
)}
</div>
</div>
</div>
</I18nContext.Provider>
);
}

View file

@ -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 (
<I18nContext.Provider value={resolveLocale(state.locale)}>
<div class="config-panel-root">
{state.copyHint && <div class="config-toast">{state.copyHint}</div>}
{state.errorHint && <div class="config-toast error">{state.errorHint}</div>}
@ -63,5 +65,6 @@ export function ConfigPanelApp() {
onClose={() => bridge.post({ type: 'closeConfigPanel' })}
/>
</div>
</I18nContext.Provider>
);
}

View file

@ -0,0 +1,13 @@
import { createContext } from 'preact';
import { useContext } from 'preact/hooks';
import { t, resolveLocale, SupportedLocale } from '../shared/i18n';
export const I18nContext = createContext<SupportedLocale>('en');
export function useT(): (key: string) => string {
const locale = useContext(I18nContext);
return (key: string) => t(locale, key);
}
export { resolveLocale, t };
export type { SupportedLocale };

View file

@ -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 (
<div class={`comment-card${status !== 'pending' ? ' dismissed' : ''}`}>
<div class="comment-header">
@ -18,8 +20,8 @@ export function CommentCard({ comment, index, status, canJump, onOpen, onAction
</div>
<div class="comment-body">{comment.content}</div>
<div class="comment-actions">
{canJump && <button onClick={() => onOpen(index)}></button>}
<button onClick={() => onAction(index, 'discard')}></button>
{canJump && <button onClick={() => onOpen(index)}>{t('cmp.comment.view')}</button>}
<button onClick={() => onAction(index, 'discard')}>{t('cmp.comment.discard')}</button>
</div>
</div>
);

View file

@ -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
<div class="custom-provider-manager">
<div class="custom-provider-manager-header">
<div>
<h2 class="custom-provider-manager-title"> Provider</h2>
<p class="custom-provider-manager-desc"> LLM </p>
<h2 class="custom-provider-manager-title">{t('cmp.custom.title')}</h2>
<p class="custom-provider-manager-desc">{t('cmp.custom.desc')}</p>
</div>
<button type="button" class="btn-primary" onClick={onAdd}></button>
<button type="button" class="btn-primary" onClick={onAdd}>{t('cmp.custom.add')}</button>
</div>
{names.length === 0 ? (
<div class="custom-provider-empty">
<p> Provider</p>
<button type="button" class="btn-default" onClick={onAdd}> Provider</button>
<p>{t('cmp.custom.empty')}</p>
<button type="button" class="btn-default" onClick={onAdd}>{t('cmp.custom.addFirst')}</button>
</div>
) : (
<div class="custom-provider-list">
@ -45,21 +47,21 @@ export function CustomProviderManager({ config, onAdd, onEdit, onActivate, onDel
<div class="custom-provider-card-main">
<div class="custom-provider-card-title">
<span class="custom-provider-name">{name}</span>
{isActive && <span class="custom-provider-badge">使</span>}
{isActive && <span class="custom-provider-badge">{t('cmp.custom.currentUse')}</span>}
</div>
<div class="custom-provider-card-meta">
<span>{entry.protocol || '—'}</span>
<span class="custom-provider-card-dot">·</span>
<span class="custom-provider-card-url" title={entry.url}>{entry.url || '—'}</span>
</div>
<div class="custom-provider-card-model">{formatModels(entry)}</div>
<div class="custom-provider-card-model">{t('cmp.custom.model')}: {formatModels(entry)}</div>
</div>
<div class="custom-provider-card-actions">
<button type="button" class="btn-text" onClick={() => onEdit(name)}></button>
<button type="button" class="btn-text" onClick={() => onEdit(name)}>{t('cmp.custom.edit')}</button>
{!isActive && (
<button type="button" class="btn-text" onClick={() => onActivate(name)}></button>
<button type="button" class="btn-text" onClick={() => onActivate(name)}>{t('cmp.custom.setCurrent')}</button>
)}
<button type="button" class="btn-text danger" onClick={() => onDelete(name)}></button>
<button type="button" class="btn-text danger" onClick={() => onDelete(name)}>{t('cmp.custom.delete')}</button>
</div>
</div>
);

View file

@ -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 (
<div class="wizard-body">
<EnvCheckingBanner label="正在安装 ocr CLI…" />
<EnvCheckingBanner label={t('view.env.installing')} />
<LogViewer logs={installLogs} />
</div>
);
@ -54,7 +56,7 @@ export function EnvSetupGuide({
if (checking) {
return (
<div class="wizard-body">
<EnvCheckingBanner label="正在检测,请稍候…" />
<EnvCheckingBanner label={t('view.env.checking')} />
<EnvChecklist checking />
</div>
);
@ -66,7 +68,7 @@ export function EnvSetupGuide({
<EnvChecklist env={envCheck} />
<div class={`form-footer${layout === 'panel' ? ' page-footer' : ''}`}>
<div class={`form-actions${layout === 'panel' ? ' panel-actions' : ''}`}>
<button type="button" class="btn-primary" onClick={onNext}> Provider</button>
<button type="button" class="btn-primary" onClick={onNext}>{t('view.config.continueProvider')}</button>
</div>
</div>
</div>
@ -76,10 +78,10 @@ export function EnvSetupGuide({
if (cliStatus === 'installed' && skipEnvCheck) {
return (
<div class="wizard-body">
<p class="env-guide-lead"> Provider</p>
<p class="env-guide-lead">{t('view.env.ready')}</p>
<div class={`form-footer${layout === 'panel' ? ' page-footer' : ''}`}>
<div class={`form-actions${layout === 'panel' ? ' panel-actions' : ''}`}>
<button type="button" class="btn-primary" onClick={onNext}> Provider</button>
<button type="button" class="btn-primary" onClick={onNext}>{t('view.config.continueProvider')}</button>
</div>
</div>
</div>
@ -92,7 +94,7 @@ export function EnvSetupGuide({
return (
<div class="wizard-body">
<p class="env-guide-lead"></p>
<p class="env-guide-lead">{t('view.env.stepLead')}</p>
<div class="env-timeline">
<EnvTimelineItem
@ -100,21 +102,21 @@ export function EnvSetupGuide({
state={resolveStepState(nodeActive, false, envCheck?.node.ok)}
version={envCheck?.node.version}
command="node --version"
hint="未检测到 Node.js。请前往 nodejs.org 安装 LTS 版本,完成后重启 VS Code。"
hint={t('view.env.nodeHint')}
/>
<EnvTimelineItem
title="npm"
state={resolveStepState(npmActive, false, envCheck?.npm.ok)}
version={envCheck?.npm.version}
command="npm --version"
hint="未检测到 npm。npm 通常随 Node 一起安装,请确认 Node 安装完整。"
hint={t('view.env.npmHint')}
/>
<EnvTimelineItem
title="ocr CLI"
state={resolveStepState(ocrActive, false, envCheck?.ocr.ok)}
version={envCheck?.ocr.version}
command={OCR_INSTALL_CMD}
hint="在终端全局安装 open-code-review或点击下方「一键安装」。"
hint={t('view.env.ocrHint')}
onCopy={onCopy}
last
/>
@ -124,9 +126,9 @@ export function EnvSetupGuide({
<div class={`form-footer${layout === 'panel' ? ' page-footer' : ''}`}>
<div class={`form-actions${layout === 'panel' ? ' panel-actions' : ''}`}>
<button type="button" class="btn-default" onClick={onCheckEnv}></button>
<button type="button" class="btn-default" onClick={onCheckEnv}>{t('view.env.redetect')}</button>
{ocrActive && !envCheck?.ocr.ok && (
<button type="button" class="btn-primary" onClick={onInstall}></button>
<button type="button" class="btn-primary" onClick={onInstall}>{t('view.env.oneClickInstall')}</button>
)}
</div>
</div>
@ -144,6 +146,7 @@ function EnvCheckingBanner({ label }: { label: string }) {
}
function EnvChecklist({ checking, env }: { checking?: boolean; env?: EnvCheckResult }) {
const t = useT();
return (
<ul class={`env-checklist${checking ? ' is-checking' : ''}${env ? ' is-done' : ''}`}>
{CHECK_ITEMS.map(({ key, label }, i) => {
@ -155,9 +158,9 @@ function EnvChecklist({ checking, env }: { checking?: boolean; env?: EnvCheckRes
<span class="env-checklist-marker" aria-hidden="true" />
<span class="env-checklist-label">{label}</span>
<span class="env-checklist-meta">
{checking && '检测中'}
{!checking && ok && (item?.version ?? '就绪')}
{!checking && env && !ok && '未就绪'}
{checking && t('view.env.checkingStatus')}
{!checking && ok && (item?.version ?? t('view.env.readyStatus'))}
{!checking && env && !ok && t('view.env.notReady')}
</span>
</li>
);
@ -177,6 +180,7 @@ function EnvTimelineItem({
onCopy?: (text: string) => void;
last?: boolean;
}) {
const t = useT();
const showDetail = state === 'fail' || state === 'ok';
return (
<div class={`env-timeline-item ${state}${last ? ' last' : ''}`}>
@ -188,10 +192,10 @@ function EnvTimelineItem({
<div class="env-timeline-head">
<span class="env-timeline-title">{title}</span>
<span class={`env-timeline-status ${state}`}>
{state === 'ok' && (version ?? '通过')}
{state === 'fail' && '未通过'}
{state === 'pending' && '等待上一步'}
{state === 'checking' && '检测中'}
{state === 'ok' && (version ?? t('view.env.pass'))}
{state === 'fail' && t('view.env.fail')}
{state === 'pending' && t('view.env.waitPrev')}
{state === 'checking' && t('view.env.checkingStatus')}
</span>
</div>
{showDetail && (
@ -200,7 +204,7 @@ function EnvTimelineItem({
<div class="env-cmd-block">
<code>{command}</code>
{onCopy && (
<button type="button" class="env-cmd-copy" onClick={() => onCopy(command)}></button>
<button type="button" class="env-cmd-copy" onClick={() => onCopy(command)}>{t('view.env.copy')}</button>
)}
</div>
</div>

View file

@ -1,3 +1,4 @@
import { useT } from '../I18nProvider';
import { FileChange } from '../../shared/types';
const BADGE: Record<FileChange['status'], string> = {
@ -7,9 +8,10 @@ const BADGE: Record<FileChange['status'], string> = {
interface Props { files: FileChange[]; loading?: boolean; onOpenFile?: (file: FileChange) => void; }
export function FileList({ files, loading, onOpenFile }: Props) {
const t = useT();
return (
<div class="file-list">
<div class="files-label"> {loading ? '' : `(${files.length})`}</div>
<div class="files-label">{t('cmp.fileList.pending')} {loading ? '' : `(${files.length})`}</div>
{loading ? (
<div class="file-loading">
{[68, 52, 60].map((w, i) => (
@ -19,11 +21,11 @@ export function FileList({ files, loading, onOpenFile }: Props) {
))}
</div>
) : files.length === 0 ? (
<div class="file-empty"></div>
<div class="file-empty">{t('cmp.fileList.noChanges')}</div>
) : (
<div class="file-scroll">
{files.map((f) => (
<div class="file-row" key={f.path} title={onOpenFile ? '点击查看 diff' : undefined}
<div class="file-row" key={f.path} title={onOpenFile ? t('cmp.fileList.viewDiff') : undefined}
onClick={onOpenFile ? () => onOpenFile(f) : undefined}>
<span class="file-name">{f.path}</span>
<span class={`file-badge ${f.status}`}>{BADGE[f.status]}</span>

View file

@ -1,3 +1,4 @@
import { useT } from '../I18nProvider';
import { useRef, useEffect } from 'preact/hooks';
import { LogLine } from '../../shared/types';
@ -5,6 +6,7 @@ interface Props { logs: LogLine[]; }
export function LogViewer({ logs }: Props) {
const ref = useRef<HTMLDivElement>(null);
const t = useT();
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
@ -13,7 +15,7 @@ export function LogViewer({ logs }: Props) {
return (
<div class="log-viewer" ref={ref}>
{logs.length === 0 ? (
<div class="log-line"><span class="log-dim"></span><span class="log-cursor"></span></div>
<div class="log-line"><span class="log-dim">{t('cmp.log.waiting')}</span><span class="log-cursor"></span></div>
) : (
logs.map((l, i) => (
<div class={`log-line ${l.level === 'warn' ? 'log-warn' : ''}`} key={i}>{l.text}</div>

View file

@ -1,3 +1,4 @@
import { useT } from '../I18nProvider';
import { useState } from 'preact/hooks';
interface Props {
@ -29,6 +30,7 @@ function EyeOffIcon() {
export function PasswordInput({ value, placeholder, className, onInput }: Props) {
const [visible, setVisible] = useState(false);
const t = useT();
return (
<div class="password-input-wrap">
@ -43,7 +45,7 @@ export function PasswordInput({ value, placeholder, className, onInput }: Props)
type="button"
class="password-toggle"
onClick={() => setVisible(!visible)}
aria-label={visible ? '隐藏密钥' : '显示密钥'}
aria-label={visible ? t('cmp.password.hideSecret') : t('cmp.password.showSecret')}
tabIndex={-1}
>
{visible ? <EyeOffIcon /> : <EyeIcon />}

View file

@ -1,3 +1,4 @@
import { useT } from '../I18nProvider';
import { useState, useRef, useEffect } from 'preact/hooks';
export interface SelectOption {
@ -12,7 +13,9 @@ interface Props {
onChange: (value: string) => void;
}
export function Select({ value, options, placeholder = '请选择', onChange }: Props) {
export function Select({ value, options, placeholder, onChange }: Props) {
const t = useT();
const resolvedPlaceholder = placeholder ?? t('cmp.select.placeholder');
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@ -33,7 +36,7 @@ export function Select({ value, options, placeholder = '请选择', onChange }:
<div class={`select${open ? ' open' : ''}`} ref={ref}>
<button type="button" class="select-trigger" onClick={() => setOpen(!open)}>
<span class={`select-value${selected ? '' : ' placeholder'}`}>
{selected ? selected.label : placeholder}
{selected ? selected.label : resolvedPlaceholder}
</span>
<span class="select-arrow"></span>
</button>

View file

@ -1,6 +1,7 @@
import { ConfigPanelFocus, isConfigReady } from '../shared/configUtils';
import { ConfigPanelHostToWebview, HostToWebview } from '../shared/messages';
import { EnvCheckResult, OcrConfig, LogLine } from '../shared/types';
import { resolveLocale, t, SupportedLocale } from '../shared/i18n';
export type CliStatus = 'unknown' | 'checking' | 'installed' | 'missing';
export type ConnTest = { status: 'idle' | 'testing' | 'ok' | 'fail'; message?: string };
@ -16,6 +17,7 @@ export interface ConfigPanelState {
connTest: ConnTest;
copyHint: string;
errorHint: string;
locale: SupportedLocale;
}
export const configPanelInitialState: ConfigPanelState = {
@ -29,6 +31,7 @@ export const configPanelInitialState: ConfigPanelState = {
connTest: { status: 'idle' },
copyHint: '',
errorHint: '',
locale: 'en',
};
export type ConfigPanelLocalAction =
@ -66,6 +69,7 @@ export function configPanelReducer(
skipEnvCheck,
envCheck: env,
cliStatus: env ? envToCliStatus(env) : (skipEnvCheck ? 'installed' : 'unknown'),
locale: msg.locale,
};
}
case 'configPanelFocus':
@ -90,7 +94,7 @@ export function configPanelReducer(
case 'installDone':
return { ...state, installing: false };
case 'copyDone':
return { ...state, copyHint: '已复制到剪贴板' };
return { ...state, copyHint: t(resolveLocale(state.locale), 'view.env.copiedToast') };
case 'clearCopyHint':
return { ...state, copyHint: '' };
case 'clearErrorHint':

View file

@ -1,4 +1,5 @@
import { CliResult, CommentStatus, FileChange, GitState, LogLine, OcrConfig, ReviewMode, ReviewState } from '../shared/types';
import { SupportedLocale } from '../shared/i18n';
import { HostToWebview } from '../shared/messages';
export type AppView = 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed';
@ -13,6 +14,7 @@ export interface AppState {
session: { state: ReviewState; result: CliResult | null; error?: string };
commentStatus: Record<number, CommentStatus>;
reviewMode: ReviewMode;
locale: SupportedLocale;
}
export const initialState: AppState = {
@ -25,6 +27,7 @@ export const initialState: AppState = {
session: { state: 'idle', result: null },
commentStatus: {},
reviewMode: 'workspace',
locale: 'en',
};
const STATE_TO_VIEW: Record<ReviewState, AppView> = {
@ -49,6 +52,7 @@ export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppS
gitState: msg.gitState,
view: 'idle',
filesLoading: false,
locale: msg.locale,
};
case 'gitState':
return { ...state, gitState: msg.gitState, filesLoading: false };

View file

@ -1,7 +1,9 @@
import { useT } from '../I18nProvider';
export function CancelledView() {
const t = useT();
return (
<div class="action-cancelled" style="display:block">
<div class="cancelled-note"></div>
<div class="cancelled-note">{t('view.cancelled.title')}</div>
</div>
);
}

View file

@ -6,9 +6,9 @@ import { EnvCheckResult, LogLine, OcrConfig } from '../../shared/types';
import { CliStatus, ConnTest } from '../configStore';
import { CustomProviderManager } from '../components/CustomProviderManager';
import { EnvSetupGuide } from '../components/EnvSetupGuide';
import { LogViewer } from '../components/LogViewer';
import { PasswordInput } from '../components/PasswordInput';
import { Select } from '../components/Select';
import { useT } from '../I18nProvider';
interface Props {
layout?: 'modal' | 'panel';
@ -71,15 +71,16 @@ export function ConfigView({
}, [panelFocus, config, onClearConnTest]);
const wide = layout === 'panel';
const t = useT();
const stepper = (
<div class="config-stepper">
<div class={`config-step-pill${step === 1 ? ' active' : ''}${cliStatus === 'installed' ? ' done' : ''}`}>
<span class="config-step-num">1</span>
<span></span>
<span>{t('view.config.step1')}</span>
</div>
<div class={`config-step-pill${step === 2 ? ' active' : ''}`}>
<span class="config-step-num">2</span>
<span>Provider </span>
<span>{t('view.config.step2')}</span>
</div>
</div>
);
@ -129,8 +130,8 @@ export function ConfigView({
<header class="config-page-header">
<div class="config-page-header-row">
<div>
<h1 class="config-page-title"></h1>
<p class="config-page-desc"> LLM Provider </p>
<h1 class="config-page-title">{t('view.config.title')}</h1>
<p class="config-page-desc">{t('view.config.desc')}</p>
</div>
<OcrVersionMeta envCheck={envCheck} cliStatus={cliStatus} />
</div>
@ -147,10 +148,10 @@ export function ConfigView({
<>
<div class="config-form-header">
<div class="config-form-heading">
<span class="config-form-title"></span>
<span class="config-form-subtitle"> LLM Provider </span>
<span class="config-form-title">{t('view.config.title')}</span>
<span class="config-form-subtitle">{t('view.config.desc')}</span>
</div>
<button type="button" class="config-list-close" onClick={onClose} aria-label="关闭">×</button>
<button type="button" class="config-list-close" onClick={onClose} aria-label={t('view.config.close')}>×</button>
</div>
{stepper}
{stepContent}
@ -167,16 +168,17 @@ export function ConfigView({
}
function OcrVersionMeta({ envCheck, cliStatus }: { envCheck: EnvCheckResult | null; cliStatus: CliStatus }) {
let label = 'ocr 检测中…';
const t = useT();
let label = t('view.config.checking');
if (envCheck?.ocr.ok && envCheck.ocr.version) {
label = envCheck.ocr.version.startsWith('v') ? envCheck.ocr.version : `v${envCheck.ocr.version}`;
} else if (envCheck && !envCheck.ocr.ok) {
label = 'ocr 未安装';
label = t('view.config.notInstalled');
} else if (cliStatus !== 'checking' && cliStatus === 'missing') {
label = 'ocr 未安装';
label = t('view.config.notInstalled');
}
return (
<div class="config-page-meta" title="Open Code Review CLI 版本">
<div class="config-page-meta" title={t('view.config.ocrVersionTooltip')}>
<span class="config-page-meta-label">OCR</span>
<span class="config-page-meta-value">{label}</span>
</div>
@ -184,21 +186,28 @@ function OcrVersionMeta({ envCheck, cliStatus }: { envCheck: EnvCheckResult | nu
}
function ActiveProviderBanner({ config }: { config: OcrConfig | null }) {
const t = useT();
const active = describeActiveProvider(config);
if (!active) {
return (
<div class="active-provider-banner empty">
<span class="active-provider-label">使</span>
<span class="active-provider-empty-text"> Provider</span>
<span class="active-provider-label">{t('view.config.currentUse')}</span>
<span class="active-provider-empty-text">{t('view.config.notConfigured')}</span>
</div>
);
}
const kindLabel = active.kind === 'official' ? '官方' : active.kind === 'custom' ? '自定义' : 'Legacy';
const kindLabelMap: Record<string, string> = {
official: t('view.config.officialLabel'),
custom: t('view.config.customLabel'),
legacy: t('view.config.legacyLabel'),
};
const kindLabel = kindLabelMap[active.kind] ?? t('view.config.legacyLabel');
const displayName = active.kind === 'legacy' ? t('ext.config.legacyDisplayName') : active.displayName;
return (
<div class="active-provider-banner">
<span class="active-provider-label">使</span>
<span class="active-provider-label">{t('view.config.currentUse')}</span>
<span class="active-provider-badge">{kindLabel}</span>
<span class="active-provider-name">{active.displayName}</span>
<span class="active-provider-name">{displayName}</span>
<span class="active-provider-dot">·</span>
<span class="active-provider-model">{active.model}</span>
{active.detail && (
@ -231,12 +240,13 @@ function ProviderStep({
onActivateCustomProvider?: (name: string) => void;
onClearConnTest?: () => void;
}) {
const t = useT();
return (
<div class="wizard-body provider-step">
<div class="segmented-control">
{([
['official', '官方 Provider'],
['custom', '自定义 Provider'],
['official', t('view.config.official')],
['custom', t('view.config.custom')],
] as const).map(([id, label]) => (
<button
key={id}
@ -297,11 +307,12 @@ function FormItem({
hint?: string;
children: ComponentChildren;
}) {
const t = useT();
return (
<div class={`form-item${span === 2 ? ' span-2' : ''}`}>
<label class="form-label">
{label}
{optional && <span class="optional"></span>}
{optional && <span class="optional">{t('view.config.optional')}</span>}
</label>
{children}
{hint && <div class="form-hint">{hint}</div>}
@ -310,6 +321,7 @@ function FormItem({
}
function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormProps) {
const t = useT();
const initialProvider = config?.provider && PROVIDER_PRESETS.some((p) => p.name === config.provider)
? config.provider
: PROVIDER_PRESETS[0].name;
@ -373,13 +385,13 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr
/>
</FormItem>
<FormItem label="模型">
<FormItem label={t('view.config.model')}>
<Select
value={modelChoice}
onChange={setModelChoice}
options={[
...modelOptions.map((m) => ({ value: m, label: m })),
{ value: MODEL_CUSTOM, label: '输入自定义模型…' },
{ value: MODEL_CUSTOM, label: t('view.config.customModel') },
]}
/>
{modelChoice === MODEL_CUSTOM && (
@ -393,13 +405,13 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr
</FormItem>
<FormItem
label="API 密钥"
hint={`也可通过环境变量 ${preset.envVar} 提供密钥`}
label={t('view.config.apiKey')}
hint={`${t('view.config.apiKeyEnvHint')} ${preset.envVar}`}
>
<PasswordInput
value={apiKey}
onInput={(v) => { setApiKey(v); setApiKeyTouched(true); }}
placeholder={hasStoredKey && !apiKeyTouched ? '已保存(留空保持不变)' : 'sk-...'}
placeholder={hasStoredKey && !apiKeyTouched ? t('view.config.apiKeySaved') : 'sk-...'}
/>
</FormItem>
@ -414,6 +426,7 @@ function CustomForm({
selection: string;
onBackToList?: () => void;
}) {
const t = useT();
const isCreate = selection === CUSTOM_NEW;
const entry = !isCreate ? config?.customProviders[selection] : undefined;
@ -470,11 +483,11 @@ function CustomForm({
<FormSection wide={wide}>
{onBackToList && (
<div class="form-item span-2">
<button type="button" class="btn-text back-link" onClick={onBackToList}> </button>
<button type="button" class="btn-text back-link" onClick={onBackToList}>{t('view.config.backToList')}</button>
</div>
)}
<FormItem label="Provider 名称">
<FormItem label={t('view.config.providerName')}>
<input
class="form-input"
value={name}
@ -483,7 +496,7 @@ function CustomForm({
placeholder="my-llm"
/>
</FormItem>
<FormItem label="协议">
<FormItem label={t('view.config.protocol')}>
<Select
value={protocol}
onChange={(v) => setProtocol(v as 'anthropic' | 'openai')}
@ -493,26 +506,26 @@ function CustomForm({
]}
/>
</FormItem>
<FormItem label="Base URL" span={2}>
<FormItem label={t('view.config.baseUrl')} span={2}>
<input class="form-input" value={url} onInput={(e) => setUrl((e.target as HTMLInputElement).value)} placeholder="https://api.example.com/v1" />
</FormItem>
<FormItem label="模型">
<FormItem label={t('view.config.model')}>
<input class="form-input" value={model} onInput={(e) => setModel((e.target as HTMLInputElement).value)} placeholder="model name" />
</FormItem>
<FormItem label="模型列表" optional>
<input class="form-input" value={models} onInput={(e) => setModels((e.target as HTMLInputElement).value)} placeholder="逗号分隔,如 model-a, model-b" />
<FormItem label={t('view.config.modelList')} optional>
<input class="form-input" value={models} onInput={(e) => setModels((e.target as HTMLInputElement).value)} placeholder={t('view.config.modelListPlaceholder')} />
</FormItem>
<FormItem label="API 密钥" span={2}>
<FormItem label={t('view.config.apiKey')} span={2}>
<PasswordInput
value={apiKey}
onInput={(v) => { setApiKey(v); setApiKeyTouched(true); }}
placeholder={!isCreate && entry?.apiKey && !apiKeyTouched ? '已保存(留空保持不变)' : 'sk-...'}
placeholder={!isCreate && entry?.apiKey && !apiKeyTouched ? t('view.config.apiKeySaved') : 'sk-...'}
/>
</FormItem>
<FormItem label="Auth Header" optional hint="Anthropic 协议下可选 x-api-key 或 authorization">
<Select value={authHeader} placeholder="默认 (Authorization)" onChange={setAuthHeader}
<FormItem label={t('view.config.authHeader')} optional hint={t('view.config.authHeaderHint')}>
<Select value={authHeader} placeholder={t('view.config.authHeaderDefault')} onChange={setAuthHeader}
options={[
{ value: '', label: '默认 (Authorization)' },
{ value: '', label: t('view.config.authHeaderDefault') },
{ value: 'x-api-key', label: 'x-api-key' },
{ value: 'authorization', label: 'authorization' },
]} />
@ -536,20 +549,21 @@ function ConnActions({ wide, connTest, canSave, onBack, onTest, onSave }: {
connTest: ConnTest; canSave: boolean;
onBack: () => void; onTest: () => void; onSave: () => void;
}) {
const t = useT();
return (
<div class={`form-footer${wide ? ' page-footer' : ''}`}>
{connTest.status !== 'idle' && (
<div class={`conn-result ${connTest.status}`}>
{connTest.status === 'testing' && '正在测试连接…'}
{connTest.status === 'ok' && '✓ 连接成功'}
{connTest.status === 'fail' && `✗ 连接失败${connTest.message ? '' + connTest.message : ''}`}
{connTest.status === 'testing' && t('view.config.testing')}
{connTest.status === 'ok' && t('view.config.testOk')}
{connTest.status === 'fail' && t(connTest.message ? 'view.config.testFailDetail' : 'view.config.testFail').replace('{message}', connTest.message ?? '')}
</div>
)}
<div class="form-actions">
<button type="button" class="btn-default" onClick={onBack}></button>
<button type="button" class="btn-default" disabled={connTest.status === 'testing' || !canSave} onClick={onTest}></button>
<button type="button" class="btn-primary" disabled={!canSave} onClick={onSave}></button>
<button type="button" class="btn-default" onClick={onBack}>{t('view.config.previous')}</button>
<button type="button" class="btn-default" disabled={connTest.status === 'testing' || !canSave} onClick={onTest}>{t('view.config.test')}</button>
<button type="button" class="btn-primary" disabled={!canSave} onClick={onSave}>{t('view.config.save')}</button>
</div>
</div>
);
}
}

View file

@ -2,6 +2,7 @@ import { useState } from 'preact/hooks';
import { CliResult, CommentStatus, LogLine } from '../../shared/types';
import { CommentCard } from '../components/CommentCard';
import { LogViewer } from '../components/LogViewer';
import { useT } from '../I18nProvider';
interface Props {
result: CliResult;
@ -14,19 +15,20 @@ interface Props {
export function DoneView({ result, commentStatus, logs, canJump, onOpen, onAction }: Props) {
const [showLogs, setShowLogs] = useState(false);
const t = useT();
const s = result.summary;
return (
<div class="action-done" style="display:block">
<div class="done-summary">
<span class="ds-dot"></span>
<span>{result.comments.length} · {s?.filesReviewed ?? 0} · {s?.elapsed ?? ''}</span>
<span>{result.comments.length} {t('view.done.comments')} · {s?.filesReviewed ?? 0} {t('view.done.files')} · {s?.elapsed ?? ''}</span>
</div>
{logs.length > 0 && (
<div class="logs-disclosure">
<button class="logs-toggle" onClick={() => setShowLogs(!showLogs)}>
<span class={`logs-toggle-arrow${showLogs ? ' open' : ''}`}></span>
{t('view.done.processLog')}
</button>
{showLogs && <LogViewer logs={logs} />}
</div>

View file

@ -1,23 +1,25 @@
import { useState } from 'preact/hooks';
import { LogLine } from '../../shared/types';
import { LogViewer } from '../components/LogViewer';
import { useT } from '../I18nProvider';
interface Props { logs?: LogLine[]; }
export function EmptyView({ logs = [] }: Props) {
const [showLogs, setShowLogs] = useState(false);
const t = useT();
return (
<div class="action-empty" style="display:block">
<div class="empty-note">
<div class="en-dot"></div>
<div class="en-text"> · </div>
<div class="en-text">{t('view.empty.noIssues')}</div>
</div>
{logs.length > 0 && (
<div class="logs-disclosure">
<button class="logs-toggle" onClick={() => setShowLogs(!showLogs)}>
<span class={`logs-toggle-arrow${showLogs ? ' open' : ''}`}></span>
{t('view.empty.processLog')}
</button>
{showLogs && <LogViewer logs={logs} />}
</div>

View file

@ -1,11 +1,14 @@
import { useT } from '../I18nProvider';
interface Props { onRetry: () => void; error?: string; }
export function FailedView({ onRetry, error }: Props) {
const t = useT();
return (
<div class="action-failed" style="display:block">
<div class="failed-card">
<div class="fc-msg"><br/>{error ? '请检查模型配置后重试。' : '请检查 API Key 和网络连接。'}</div>
<div class="fc-msg">{t('view.failed.title')}<br/>{error ? t('view.failed.checkConfig') : t('view.failed.checkApiKey')}</div>
{error && <div class="fc-detail">{error}</div>}
<button class="retry-pill" onClick={onRetry}></button>
<button class="retry-pill" onClick={onRetry}>{t('view.failed.retry')}</button>
</div>
</div>
);

View file

@ -1,23 +1,9 @@
import { useState, useEffect } from 'preact/hooks';
import { useT } from '../I18nProvider';
import { GitState, ReviewMode, CliRunOptions, FileChange } from '../../shared/types';
import { FileList } from '../components/FileList';
import { Select } from '../components/Select';
function getPrimaryLabel(params: {
configured: boolean;
running?: boolean;
selectionReady: boolean;
mode: ReviewMode;
filesCount: number;
}): string {
if (!params.configured) return '请先配置模型';
if (params.running) return '审查中…';
if (!params.selectionReady) {
return params.mode === 'branch' ? '请选择对比分支' : '请选择提交';
}
if (params.filesCount === 0) return '无可审查文件';
return '审查所有变更';
}
interface Props {
gitState: GitState;
@ -39,6 +25,17 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
const [to, setTo] = useState('');
const [commit, setCommit] = useState('');
const [prompt, setPrompt] = useState('');
const t = useT();
const getPrimaryLabel = () => {
if (!configured) return t('view.idle.configFirst');
if (running) return t('view.idle.reviewing');
if (!selectionReady) {
return mode === 'branch' ? t('view.idle.selectBranch') : t('view.idle.selectCommit');
}
if (files.length === 0) return t('view.idle.noFiles');
return t('view.idle.reviewAll');
};
const switchMode = (m: ReviewMode) => { setMode(m); onModeChange(m); };
@ -75,25 +72,25 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
<div class="mode-tabs">
{(['workspace', 'branch', 'commit'] as ReviewMode[]).map((m) => (
<button key={m} class={`mode-tab${mode === m ? ' active' : ''}`} onClick={() => switchMode(m)}>
{m === 'workspace' ? '工作区' : m === 'branch' ? '分支对比' : '单次提交'}
{m === 'workspace' ? t('view.idle.workspace') : m === 'branch' ? t('view.idle.branch') : t('view.idle.commit')}
</button>
))}
</div>
{mode === 'branch' && (
<div class="mode-params active">
<div class="mode-param-label"></div>
<Select value={from} placeholder="选择分支" onChange={setFrom}
<div class="mode-param-label">{t('view.idle.baseRef')}</div>
<Select value={from} placeholder={t('view.idle.chooseBranch')} onChange={setFrom}
options={gitState.branches.map((b) => ({ value: b, label: b }))} />
<div class="mode-param-label"></div>
<Select value={to} placeholder="选择分支" onChange={setTo}
<div class="mode-param-label">{t('view.idle.targetRef')}</div>
<Select value={to} placeholder={t('view.idle.chooseBranch')} onChange={setTo}
options={gitState.branches.map((b) => ({ value: b, label: b }))} />
</div>
)}
{mode === 'commit' && (
<div class="mode-params active">
<div class="files-label"></div>
<div class="files-label">{t('view.idle.commitHistory')}</div>
<div class="commit-list">
{gitState.recentCommits.map((c) => (
<label key={c.sha} class={`commit-row${commit === c.sha ? ' active' : ''}`} onClick={() => setCommit(c.sha)}>
@ -111,14 +108,14 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
<FileList files={files} loading={loading}
onOpenFile={(f) => onOpenFile(f, mode, from, to, commit)} />
<textarea class="mode-param-input" rows={3} placeholder="自定义审查提示词(可选)"
<textarea class="mode-param-input" rows={3} placeholder={t('view.idle.customPrompt')}
value={prompt} onInput={(e) => setPrompt((e.target as HTMLTextAreaElement).value)} />
{configured && (
<div class="setup-secondary">
<button type="button" class="link-btn" onClick={onOpenCustomProviders}> Provider</button>
<button type="button" class="link-btn" onClick={onOpenCustomProviders}>{t('view.idle.manageCustom')}</button>
<span class="setup-secondary-sep">·</span>
<button type="button" class="link-btn" onClick={onOpenConfig}></button>
<button type="button" class="link-btn" onClick={onOpenConfig}>{t('view.idle.modelConfig')}</button>
</div>
)}
@ -127,7 +124,7 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
) : (
<button class={`primary-btn${!configured ? ' configure' : ''}`} disabled={primaryDisabled}
onClick={handlePrimary}>
{getPrimaryLabel({ configured, running, selectionReady, mode, filesCount: files.length })}
{getPrimaryLabel()}
</button>
)}
</div>

View file

@ -1,14 +1,16 @@
import { useT } from '../I18nProvider';
import { LogLine } from '../../shared/types';
import { LogViewer } from '../components/LogViewer';
interface Props { logs: LogLine[]; onCancel: () => void; }
export function RunningView({ logs, onCancel }: Props) {
const t = useT();
return (
<div class="action-running" style="display:block">
<div class="files-label"></div>
<div class="files-label">{t('view.running.reviewLog')}</div>
<LogViewer logs={logs} />
<button class="cancel-pill" onClick={onCancel}></button>
<button class="cancel-pill" onClick={onCancel}>{t('view.running.cancel')}</button>
<div style="clear:both"></div>
</div>
);