diff --git a/extensions/vscode/.eslintrc.json b/extensions/vscode/.eslintrc.json new file mode 100644 index 0000000..5a60946 --- /dev/null +++ b/extensions/vscode/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "module", + "ecmaFeatures": { "jsx": true } + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "env": { + "node": true, + "es2021": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } + ] + }, + "ignorePatterns": ["out", "node_modules", "reference", "**/*.js"], + "overrides": [ + { + "files": ["src/webview/**/*.{ts,tsx}"], + "env": { "browser": true } + }, + { + "files": ["**/__tests__/**/*.{ts,tsx}", "**/*.test.{ts,tsx}"], + "env": { "jest": true } + } + ] +} diff --git a/extensions/vscode/.gitignore b/extensions/vscode/.gitignore new file mode 100644 index 0000000..ae7fae2 --- /dev/null +++ b/extensions/vscode/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +.superpowers/ +node_modules/ +reference +out/ \ No newline at end of file diff --git a/extensions/vscode/.vscode/launch.json b/extensions/vscode/.vscode/launch.json new file mode 100644 index 0000000..a142310 --- /dev/null +++ b/extensions/vscode/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "npm: compile" + } + ] +} diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore new file mode 100644 index 0000000..213ba03 --- /dev/null +++ b/extensions/vscode/.vscodeignore @@ -0,0 +1,15 @@ +src/** +reference/** +docs/** +node_modules/** +**/*.ts +**/*.map +tsconfig*.json +webpack.config.js +jest.config.js +.gitignore +.eslintrc.json +.vscode/** +.superpowers/** +prototype.html +resources/icon-old.svg diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md new file mode 100644 index 0000000..0f79a01 --- /dev/null +++ b/extensions/vscode/README.md @@ -0,0 +1,155 @@ +

+ English | 简体中文 +

+ +# Open Code Review (VS Code Extension) + +A VS Code code-review extension built on the [`open-code-review`](https://www.npmjs.com/package/@alibaba-group/open-code-review) (`ocr`) CLI. It recreates the prototype experience with a Preact WebView and brings AI code review into the editor: start reviews from the sidebar, stream logs live, and apply / dismiss / flag-as-false-positive each comment inline — kept in sync with the sidebar both ways. + +--- + +## Features + +- **Three review modes**: workspace changes, branch comparison (`--from` / `--to`), and a single commit (`--commit`). +- **Files-to-review preview**: lists changed files from the current Git state; click a file to view its changes in the native diff view. +- **Custom review prompt**: optionally append a `--background` hint for the current review. +- **Streaming logs**: tail the CLI output live during review, cancel anytime. +- **Results + two-way sync**: on completion, comment cards appear in the sidebar while CommentThreads render in the editor; apply / dismiss / false-positive actions stay in sync on both sides. +- **Empty / cancelled / failed states**: dedicated views for no issues, user cancellation, and CLI failure (failures are retryable and surface the real error returned by the CLI). +- **Configuration management**: view / edit the LLM provider config inside the extension (persisted via `ocr config set`). +- **Model switching / connectivity test**: switch models and test connectivity to the LLM from the status bar. + +--- + +## Prerequisites + +1. Install the `ocr` CLI globally: + + ```bash + npm i -g @alibaba-group/open-code-review + ``` + +2. Configure a working LLM (endpoint, API key, model). Configure it via the CLI directly, or in the extension's config view: + + ```bash + ocr config set llm.url https://api.anthropic.com/v1/messages + ocr config set llm.auth_token sk-... + ocr config set llm.model claude-opus-4-6 + ocr config set llm.use_anthropic true + ``` + + The config is written to `~/.opencodereview/config.json`. + +--- + +## Development + +### Environment + +- Node.js ≥ 18, with **Yarn** as the package manager (the repo ships a `yarn.lock`). +- VS Code ≥ 1.74. +- A globally available `ocr` CLI (see "Prerequisites" above) — the extension is essentially a GUI front-end for `ocr`. + +### Start the dev environment + +```bash +cd extensions/vscode +yarn install # install dependencies +yarn watch # watch-mode dev build (recommended: rebuilds out/ on change) +``` + +Then open the `extensions/vscode` folder in VS Code and press **F5** to launch the +Extension Development Host (debug config is provided in `.vscode/launch.json`). In the new +window, open a project with Git changes — you'll see the Open Code Review icon in the +activity bar and can start a review. + +> After editing code: WebView changes require **reopening the sidebar** in the dev host window +> (or running `Developer: Reload Webviews`); Extension Host changes require **restarting the +> debug session** (the ⟳ button on the debug toolbar, or `Cmd+R` in the host window). + +### Scripts + +```bash +yarn compile # one-off dev build (webpack development) +yarn watch # watch-mode dev build +yarn build # production build (webpack production; runs automatically before packaging) +yarn test # run Jest unit tests +yarn lint # ESLint +yarn package # produce a distributable .vsix package (see "Build a release package") +``` + +### Debugging notes + +- **Two-way messaging**: the WebView and Extension Host communicate via `postMessage`; message + types live in `src/shared/messages.ts`. Both sides route through `dispatch` / `handle` — start + there when debugging. +- **CLI invocation**: all `ocr` sub-commands run via `child_process.spawn` in + `src/extension/services/CliService.ts`. `runRaw` rejects on a non-zero CLI exit code and includes + the `Error:` text from stderr, which helps diagnose "review failed / connection failed". +- **Config read/write**: `ConfigService` reads `~/.opencodereview/config.json` and delegates writes to + `ocr config set`. WebView fields are camelCase (e.g. `useAnthropic`) while the disk/CLI side is + snake_case (e.g. `use_anthropic`); the conversion lives in `src/extension/services/configParse.ts`. + +--- + +## Build + +### Compile artifacts only + +```bash +yarn build # production build (webpack production) +``` + +Artifacts: `out/extension.js` (Extension Host) + `out/webview.js` (WebView SPA). + +### Build a release package (.vsix) + +```bash +yarn package # = vsce package --no-yarn +``` + +This command: + +1. Triggers `vscode:prepublish` → runs the `yarn build` production build; +2. Excludes source, tests, and dev files per `.vscodeignore`; +3. Produces `open-code-review-vscode-.vsix` in the current directory. + +> The packaging tool is `@vscode/vsce`, installed as a devDependency — no global install or network +> download needed. `--no-yarn` skips vsce's default npm dependency-tree check (this project uses Yarn). + +The release package contains only the runtime essentials: `package.json`, `README.md`, +`resources/icon.svg`, `out/extension.js`, `out/webview.js`. + +### Install / verify locally + +```bash +code --install-extension open-code-review-vscode-.vsix +``` + +Or in VS Code: Extensions panel → top-right `⋯` → **Install from VSIX…** → pick the generated `.vsix` file. + +> To publish to the Marketplace, use `vsce publish` instead (requires a publisher account and PAT); +> for everyday distribution the `.vsix` above is enough. + +--- + +## Architecture + +It uses a **Monolithic WebView + Thin Extension Host** design: + +- The **WebView** is a separately built Preact SPA that reproduces the full visual and interactive prototype. +- The **Extension Host** layer is thin, handling only CLI invocation, the file system, Git operations, and editor comments. +- The two communicate via `postMessage`, with shared TypeScript types in `src/shared/` for type safety. + +``` +src/ +├── extension/ Extension Host (Node.js): services / providers / commands +├── webview/ WebView SPA (Preact): views / components / store / bridge +└── shared/ shared types and the postMessage protocol (no vscode dependency) +``` + +--- + +## License + +Apache-2.0 diff --git a/extensions/vscode/README.zh-CN.md b/extensions/vscode/README.zh-CN.md new file mode 100644 index 0000000..09c5a99 --- /dev/null +++ b/extensions/vscode/README.zh-CN.md @@ -0,0 +1,149 @@ +

+ English | 简体中文 +

+ +# Open Code Review (VSCode 插件) + +基于 [`open-code-review`](https://www.npmjs.com/package/@alibaba-group/open-code-review) (`ocr`) CLI 的 VSCode 代码审查插件。以 Preact WebView 还原原型交互体验,把 AI 代码审查能力集成进编辑器:在侧边栏发起审查、流式查看日志、在编辑器内逐条应用/忽略/标记误报评论,并与侧边栏双向同步。 + +--- + +## 功能 + +- **三种审查模式**:工作区变更、分支对比(`--from` / `--to`)、单次提交(`--commit`)。 +- **待审查文件预览**:基于当前 Git 状态展示变更文件列表,点击文件可在原生 diff 视图中查看改动。 +- **自定义审查提示词**:可选地为本次审查追加 `--background` 提示。 +- **流式日志**:审查过程中实时滚动 CLI 输出,支持随时取消。 +- **结果展示 + 双向同步**:完成后在侧边栏列出评论卡片,同时在编辑器内渲染 CommentThread;应用/忽略/误报操作在两侧同步。 +- **空 / 取消 / 失败态**:无问题、用户取消、CLI 失败均有对应视图(失败可重试,并展示 CLI 返回的真实错误)。 +- **配置管理**:在插件内查看/编辑 LLM 提供商配置(写入通过 `ocr config set`)。 +- **模型切换 / 连通性测试**:状态栏切换模型、测试与 LLM 的连通性。 + +--- + +## 前置依赖 + +1. 全局安装 `ocr` CLI: + + ```bash + npm i -g @alibaba-group/open-code-review + ``` + +2. 配置可用的 LLM(接口地址、API Key、模型)。可用 CLI 直接配置,或在插件内的配置视图填写: + + ```bash + ocr config set llm.url https://api.anthropic.com/v1/messages + ocr config set llm.auth_token sk-... + ocr config set llm.model claude-opus-4-6 + ocr config set llm.use_anthropic true + ``` + + 配置写入 `~/.opencodereview/config.json`。 + +--- + +## 开发 + +### 环境准备 + +- Node.js ≥ 18,包管理器使用 **Yarn**(仓库自带 `yarn.lock`)。 +- VS Code ≥ 1.74。 +- 全局可用的 `ocr` CLI(见上文「前置依赖」),插件本质上是 `ocr` 的图形前端。 + +### 启动开发环境 + +```bash +cd extensions/vscode +yarn install # 安装依赖 +yarn watch # 监听式开发构建(推荐:改代码自动重新打包 out/) +``` + +然后在 VS Code 中打开 `extensions/vscode` 目录,按 **F5** 启动 Extension Development Host +(调试配置已在 `.vscode/launch.json` 提供)。在弹出的新窗口里打开一个有 Git 变更的项目, +即可在活动栏看到 Open Code Review 图标并发起审查。 + +> 改了代码后:WebView 改动需在开发宿主窗口里 **重新打开侧边栏**(或执行命令 `Developer: Reload Webviews`); +> Extension Host 改动需 **重启调试**(调试工具栏的 ⟳ 或在宿主窗口按 `Cmd+R`)。 + +### 常用脚本 + +```bash +yarn compile # 单次开发构建(webpack development) +yarn watch # 监听式开发构建 +yarn build # 生产构建(webpack production,打包前自动执行) +yarn test # 运行 Jest 单测 +yarn lint # ESLint 检查 +yarn package # 生成可分发的 .vsix 安装包(见下文「构建发布包」) +``` + +### 调试要点 + +- **双端通信**:WebView 与 Extension Host 通过 `postMessage` 通信,消息类型定义在 + `src/shared/messages.ts`。两端发收都走 `dispatch` / `handle`,定位问题先看这里。 +- **CLI 调用**:所有 `ocr` 子命令由 `src/extension/services/CliService.ts` 通过 `child_process.spawn` 执行。 + `runRaw` 会在 CLI 退出码非 0 时 reject 并带上 stderr 中的 `Error:` 文本,便于排查“审查失败/连接失败”。 +- **配置读写**:`ConfigService` 读取 `~/.opencodereview/config.json`,写入则委托 `ocr config set`。 + WebView 端字段为 camelCase(如 `useAnthropic`),磁盘/CLI 端为 snake_case(如 `use_anthropic`), + 转换在 `src/extension/services/configParse.ts`。 + +--- + +## 构建 + +### 仅编译产物 + +```bash +yarn build # 生产构建(webpack production) +``` + +产物:`out/extension.js`(Extension Host)+ `out/webview.js`(WebView SPA)。 + +### 构建发布包(.vsix) + +```bash +yarn package # = vsce package --no-yarn +``` + +该命令会: + +1. 自动触发 `vscode:prepublish` → 执行 `yarn build` 生产构建; +2. 按 `.vscodeignore` 排除源码、测试、开发文件; +3. 在当前目录生成 `open-code-review-vscode-.vsix`。 + +> 打包工具为 `@vscode/vsce`,已作为 devDependency 安装,无需全局安装或联网下载。 +> `--no-yarn` 用于跳过 vsce 默认的 npm 依赖树校验(本项目用 Yarn)。 + +发布包只包含运行必需文件:`package.json`、`README.md`、`resources/icon.svg`、`out/extension.js`、`out/webview.js`。 + +### 本地安装 / 验证 + +```bash +code --install-extension open-code-review-vscode-.vsix +``` + +或在 VS Code 中:扩展面板 → 右上角 `⋯` → **Install from VSIX…** → 选择生成的 `.vsix` 文件。 + +> 发布到 Marketplace 时改用 `vsce publish`(需要 publisher 账号与 PAT),日常分发用上面的 `.vsix` 即可。 + +--- + +## 架构 + +采用 **Monolithic WebView + Thin Extension Host** 方案: + +- **WebView** 是独立构建的 Preact SPA,还原原型的全部视觉与交互。 +- **Extension Host** 层轻薄,只负责 CLI 调用、文件系统、Git 操作、编辑器评论。 +- 两者通过 `postMessage` 通信,用 `src/shared/` 中的 TypeScript 共享类型保证类型安全。 + +``` +src/ +├── extension/ Extension Host(Node.js):services / providers / commands +├── webview/ WebView SPA(Preact):views / components / store / bridge +└── shared/ 双端共享类型与 postMessage 协议(不依赖 vscode) +``` + +--- + +## License + +Apache-2.0 diff --git a/extensions/vscode/jest.config.js b/extensions/vscode/jest.config.js new file mode 100644 index 0000000..cac1d46 --- /dev/null +++ b/extensions/vscode/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': ['ts-jest', { isolatedModules: true, tsconfig: 'tsconfig.extension.json' }], + }, + testMatch: ['**/__tests__/**/*.test.ts'], +}; diff --git a/extensions/vscode/open-code-review-vscode-0.1.0.vsix b/extensions/vscode/open-code-review-vscode-0.1.0.vsix new file mode 100644 index 0000000..5f008a1 Binary files /dev/null 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 new file mode 100644 index 0000000..48d141f --- /dev/null +++ b/extensions/vscode/package.json @@ -0,0 +1,76 @@ +{ + "name": "open-code-review-vscode", + "displayName": "Open Code Review", + "description": "AI 代码审查 —— 基于 open-code-review CLI", + "version": "0.1.0", + "publisher": "open-code-review", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/nigulasikk/open-code-review.git", + "directory": "extensions/vscode" + }, + "engines": { "vscode": "^1.74.0" }, + "categories": ["Other"], + "main": "./out/extension.js", + "activationEvents": ["onStartupFinished"], + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "ocr-container", + "title": "Open Code Review", + "icon": "resources/icon.svg" + } + ] + }, + "views": { + "ocr-container": [ + { "id": "ocr.sidebar", "type": "webview", "name": "Code Review" } + ] + }, + "commands": [ + { "command": "ocr.review.start", "title": "OCR: 开始代码审查" }, + { "command": "ocr.review.cancel", "title": "OCR: 取消审查" }, + { "command": "ocr.config.open", "title": "OCR: 打开配置" }, + { "command": "ocr.comment.apply", "title": "应用" }, + { "command": "ocr.comment.discard", "title": "忽略" }, + { "command": "ocr.comment.falsePositive", "title": "误报" } + ], + "menus": { + "comments/commentThread/title": [ + { "command": "ocr.comment.apply", "group": "navigation@1", "when": "commentController == ocr-review && commentThread == pending" }, + { "command": "ocr.comment.discard", "group": "navigation@2", "when": "commentController == ocr-review && commentThread =~ /^pending/" } + ] + } + }, + "scripts": { + "compile": "webpack --mode development", + "watch": "webpack --mode development --watch", + "build": "webpack --mode production", + "test": "jest", + "lint": "eslint src --ext ts,tsx", + "package": "vsce package --no-yarn", + "vscode:prepublish": "yarn build" + }, + "devDependencies": { + "@types/node": "^18.0.0", + "@types/vscode": "^1.74.0", + "@types/jest": "^29.5.0", + "ts-jest": "^29.1.0", + "jest": "^29.7.0", + "ts-loader": "^9.5.0", + "typescript": "^5.3.0", + "webpack": "^5.89.0", + "webpack-cli": "^5.1.0", + "css-loader": "^6.8.0", + "style-loader": "^3.3.0", + "eslint": "^8.56.0", + "@typescript-eslint/parser": "^6.18.0", + "@typescript-eslint/eslint-plugin": "^6.18.0", + "@vscode/vsce": "^3.0.0" + }, + "dependencies": { + "preact": "^10.19.0" + } +} diff --git a/extensions/vscode/prototype.html b/extensions/vscode/prototype.html new file mode 100644 index 0000000..de248d6 --- /dev/null +++ b/extensions/vscode/prototype.html @@ -0,0 +1,1833 @@ + + + + + + +OCR · VSCode 插件 UI 原型 + + + + + +
+ + + + + + +
+ + + +
+ + +
+ + + + +
+
+ (编辑器区域 · 仅原型展示) +
+
+
38import { Request, Response } from 'express';
+
39
+
40export async function getUser(req: Request, res: Response) {
+
41 const userId = req.params.id;
+
42 const result = await db.query(`SELECT * FROM users WHERE id = ${userId}`);
+
+
+ Code Review (1 / 5) — src/api.ts:L42 +
+ + + +
+
+
+
[未处理]
+
SQL 查询通过字符串拼接构造,存在注入风险。请使用参数化查询。
+
+ + + +
+
+
+
43 if (!result.rows.length) {
+
44 return res.status(404).json({ error: 'Not found' });
+
45 }
+
46 return res.json(result.rows[0]);
+
47}
+
48
+
49// TODO: add input validation
+
50export async function updateUser(req: Request, res: Response) {
+
+
+
+ + + + diff --git a/extensions/vscode/resources/icon-old.svg b/extensions/vscode/resources/icon-old.svg new file mode 100644 index 0000000..3ce9a0e --- /dev/null +++ b/extensions/vscode/resources/icon-old.svg @@ -0,0 +1 @@ + diff --git a/extensions/vscode/resources/icon.svg b/extensions/vscode/resources/icon.svg new file mode 100644 index 0000000..7a0e418 --- /dev/null +++ b/extensions/vscode/resources/icon.svg @@ -0,0 +1,21 @@ + + + + + diff --git a/extensions/vscode/src/extension/commands.ts b/extensions/vscode/src/extension/commands.ts new file mode 100644 index 0000000..52858b2 --- /dev/null +++ b/extensions/vscode/src/extension/commands.ts @@ -0,0 +1,28 @@ +import * as vscode from 'vscode'; +import { COMMANDS } from '../shared/constants'; +import { CommentProvider } from './providers/CommentProvider'; + +export function registerCommands(comments: CommentProvider): vscode.Disposable { + const subs: vscode.Disposable[] = []; + const reg = (id: string, fn: (...args: any[]) => any) => + subs.push(vscode.commands.registerCommand(id, fn)); + + // 标题栏按钮传入的是 CommentThread,侧边栏 / Markdown 链接传入的是 index + const idxOf = (arg: vscode.CommentThread | number): number => + typeof arg === 'number' ? arg : comments.indexOfThread(arg); + + reg(COMMANDS.commentApply, (arg: vscode.CommentThread | number) => { + const i = idxOf(arg); + if (i >= 0) comments.apply(i); + }); + reg(COMMANDS.commentDiscard, (arg: vscode.CommentThread | number) => { + const i = idxOf(arg); + if (i >= 0) comments.discard(i); + }); + reg(COMMANDS.commentFalsePositive, (arg: vscode.CommentThread | number) => { + const i = idxOf(arg); + if (i >= 0) comments.falsePositive(i); + }); + + return vscode.Disposable.from(...subs); +} diff --git a/extensions/vscode/src/extension/extension.ts b/extensions/vscode/src/extension/extension.ts new file mode 100644 index 0000000..33704f1 --- /dev/null +++ b/extensions/vscode/src/extension/extension.ts @@ -0,0 +1,32 @@ +import * as vscode from 'vscode'; +import { SIDEBAR_VIEW_ID } from '../shared/constants'; +import { CliService } from './services/CliService'; +import { ConfigService } from './services/ConfigService'; +import { GitService } from './services/GitService'; +import { CommentProvider } from './providers/CommentProvider'; +import { SidebarProvider } from './providers/SidebarProvider'; +import { registerCommands } from './commands'; + +let disposables: vscode.Disposable[] = []; + +export function activate(context: vscode.ExtensionContext): void { + const extensionUri = context.extensionUri; + const output = vscode.window.createOutputChannel('Open Code Review'); + const cli = new CliService('ocr'); + const config = new ConfigService(cli); + const git = new GitService(output); + const comments = new CommentProvider(extensionUri); + + const sidebar = new SidebarProvider(extensionUri, cli, config, git, comments); + const viewReg = vscode.window.registerWebviewViewProvider(SIDEBAR_VIEW_ID, sidebar); + + const cmdReg = registerCommands(comments); + + disposables.push(viewReg, cmdReg, comments, output); + context.subscriptions.push(...disposables); +} + +export function deactivate(): void { + disposables.forEach((d) => d.dispose()); + disposables = []; +} diff --git a/extensions/vscode/src/extension/providers/CommentProvider.ts b/extensions/vscode/src/extension/providers/CommentProvider.ts new file mode 100644 index 0000000..bea8f30 --- /dev/null +++ b/extensions/vscode/src/extension/providers/CommentProvider.ts @@ -0,0 +1,168 @@ +import * as vscode from 'vscode'; +import { ReviewComment, CommentStatus, CommentSyncState } from '../../shared/types'; +import { COMMENT_CONTROLLER_ID } from '../../shared/constants'; +import { LineOffsetTracker } from './lineOffset'; + +export class CommentProvider { + private controller: vscode.CommentController; + // 以 comment 在 result.comments 中的原始下标为 key,与 webview 共用同一索引空间。 + // 打不开的文件(如目录)没有 thread,但下标依旧保留,避免错位。 + private threads = new Map(); + private comments: ReviewComment[] = []; + private status = new Map(); + private offsets = new LineOffsetTracker(); + private syncListeners: Array<(s: CommentSyncState[]) => void> = []; + + constructor(private extensionUri: vscode.Uri) { + this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, 'Open Code Review'); + } + + onSync(fn: (s: CommentSyncState[]) => void): void { + this.syncListeners.push(fn); + } + + private emitSync(): void { + const states: CommentSyncState[] = this.comments.map((_, i) => ({ + index: i, status: this.status.get(i) ?? 'pending', + })); + this.syncListeners.forEach((fn) => fn(states)); + } + + /** + * 展示审查评论。 + * @param inEditor 是否在编辑器内创建 CommentThread。仅工作区模式为 true; + * 分支对比/单次提交模式下被审查代码不在当前工作区,行号会错位,故只在侧边栏展示。 + */ + async show(comments: ReviewComment[], inEditor = true): Promise { + this.clear(); + // 不重排:保持与 webview(result.comments)相同的顺序与下标 + this.comments = comments; + const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath; + if (!root) return; + + // 非工作区模式:只登记评论与状态供侧边栏同步,不在编辑器内放置 thread。 + if (!inEditor) { + for (let i = 0; i < this.comments.length; i++) this.status.set(i, 'pending'); + this.emitSync(); + return; + } + + let firstShown = -1; + for (let i = 0; i < this.comments.length; i++) { + const c = this.comments[i]; + this.status.set(i, 'pending'); + try { + const uri = vscode.Uri.file(`${root}/${c.path}`); + const doc = await vscode.workspace.openTextDocument(uri); + const range = new vscode.Range(Math.max(0, c.startLine - 1), 0, Math.max(0, c.endLine - 1), 0); + const body = this.renderBody(c, i, 'pending'); + const thread = this.controller.createCommentThread(doc.uri, range, [{ + body, mode: vscode.CommentMode.Preview, + author: { name: '⏳ [未处理]' }, + }]); + thread.canReply = false; + thread.label = `Code Review (${i + 1} / ${this.comments.length})`; + // 有代码建议 → 'pending'(显示应用+忽略);无建议 → 'pendingNoSuggestion'(仅忽略) + thread.contextValue = this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion'; + thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; + this.threads.set(i, thread); + if (firstShown < 0) firstShown = i; + } catch { /* 文件打不开(如目录)则无 thread,但保留下标 */ } + } + if (firstShown >= 0) await this.jumpTo(firstShown); + this.emitSync(); + } + + private hasSuggestion(c: ReviewComment): boolean { + return !!(c.suggestionCode && c.suggestionCode.trim()); + } + + private renderBody(c: ReviewComment, _index: number, _status: CommentStatus): vscode.MarkdownString { + let md = c.content; + if (this.hasSuggestion(c)) { + md += `\n***\n\`\`\`diff\n${c.suggestionCode}\n\`\`\``; + } else { + md += `\n***\n_💡 无代码建议,请手动处理_`; + } + const s = new vscode.MarkdownString(md); + s.isTrusted = true; + return s; + } + + async apply(index: number): Promise { + const c = this.comments[index]; + if (!c) return; + const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath; + if (!root) return; + const uri = vscode.Uri.file(`${root}/${c.path}`); + const doc = await vscode.workspace.openTextDocument(uri); + const before = doc.lineCount; + 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('应用失败:代码位置已失效,请刷新后重试。'); + return; + } + const range = new vscode.Range(start, 0, end, doc.lineAt(end).text.length); + const hasSuggestion = !!(c.suggestionCode && c.suggestionCode.trim()); + + // 用 WorkspaceEdit 而非 editor.edit:后者要求目标编辑器为活动编辑器, + // 从评论标题栏按钮触发时焦点在评论控件上,会静默返回 false 导致“点不动”。 + const edit = new vscode.WorkspaceEdit(); + if (hasSuggestion) edit.replace(uri, range, c.suggestionCode!); + else edit.delete(uri, range); + const ok = await vscode.workspace.applyEdit(edit); + if (!ok) { + vscode.window.showErrorMessage('应用失败:无法修改文件,请检查文件是否被占用或处于只读状态。'); + return; + } + await doc.save(); + this.offsets.record(c.path, c.startLine, doc.lineCount - before); + await vscode.window.showTextDocument(doc, { selection: new vscode.Range(start, 0, start, 0), preview: false }); + this.setStatus(index, 'applied'); + } + + discard(index: number): void { this.setStatus(index, 'discarded'); } + falsePositive(index: number): void { this.setStatus(index, 'falsePositive'); } + + private setStatus(index: number, status: CommentStatus): void { + this.status.set(index, status); + const thread = this.threads.get(index); + if (thread) { + const label = { applied: '✅ [已应用]', discarded: '✅ [已忽略]', falsePositive: '✅ [已误报]', 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; + } + this.emitSync(); + } + + async jumpTo(index: number): Promise { + const thread = this.threads.get(index); + if (!thread) { + const c = this.comments[index]; + if (c) vscode.window.showWarningMessage(`无法定位到 ${c.path}:该路径不是可打开的文件。`); + return; + } + await vscode.window.showTextDocument(thread.uri, { selection: thread.range, preview: false }); + thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; + } + + indexOfThread(thread: vscode.CommentThread): number { + for (const [i, t] of this.threads) if (t === thread) return i; + return -1; + } + + clear(): void { + this.threads.forEach((t) => t.dispose()); + this.threads.clear(); + this.comments = []; + this.status.clear(); + this.offsets.clear(); + } + + dispose(): void { + this.clear(); + this.controller.dispose(); + } +} diff --git a/extensions/vscode/src/extension/providers/SidebarProvider.ts b/extensions/vscode/src/extension/providers/SidebarProvider.ts new file mode 100644 index 0000000..20eeb27 --- /dev/null +++ b/extensions/vscode/src/extension/providers/SidebarProvider.ts @@ -0,0 +1,125 @@ +import * as vscode from 'vscode'; +import { HostToWebview, WebviewToHost } from '../../shared/messages'; +import { FileChange } from '../../shared/types'; +import { CliService } from '../services/CliService'; +import { ConfigService } from '../services/ConfigService'; +import { GitService } from '../services/GitService'; +import { ReviewSession } from '../services/ReviewSession'; +import { CommentProvider } from './CommentProvider'; + +export class SidebarProvider implements vscode.WebviewViewProvider { + private view?: vscode.WebviewView; + private session?: ReviewSession; + + constructor( + private extensionUri: vscode.Uri, + private cli: CliService, + private config: ConfigService, + private git: GitService, + private comments: CommentProvider, + ) { + this.comments.onSync((states) => this.post({ type: 'commentSync', comments: states })); + } + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] }; + view.webview.html = this.html(view.webview); + view.webview.onDidReceiveMessage((msg: WebviewToHost) => this.handle(msg)); + } + + private post(msg: HostToWebview): void { + this.view?.webview.postMessage(msg); + } + + private async handle(msg: WebviewToHost): Promise { + const cwd = vscode.workspace.workspaceFolders?.[0].uri.fsPath ?? process.cwd(); + switch (msg.type) { + case 'ready': { + const config = this.config.read(); + const gitState = await this.git.getState('workspace'); + this.post({ type: 'init', config, gitState }); + break; + } + case 'getGitState': { + this.post({ type: 'gitState', gitState: await this.git.getState(msg.mode) }); + break; + } + case 'getModeFiles': { + let files: FileChange[] = []; + if (msg.mode === 'branch' && msg.from && msg.to) { + files = await this.git.getBranchDiff(msg.from, msg.to); + } else if (msg.mode === 'commit' && msg.commit) { + files = await this.git.getCommitFiles(msg.commit); + } + this.post({ type: 'modeFiles', mode: msg.mode, files }); + break; + } + case 'openFileDiff': + await this.git.openDiff({ + path: msg.path, status: msg.status, mode: msg.mode, + from: msg.from, to: msg.to, commit: msg.commit, + }); + break; + case 'startReview': { + this.session = new ReviewSession(this.cli, cwd); + // 仅工作区模式在编辑器内放置评论 thread;分支/提交模式代码不在工作区,会错位。 + const inEditor = msg.options.mode === 'workspace'; + await this.session.run(msg.options, { + onState: (state, error) => this.post({ type: 'stateChange', state, error }), + onLog: (line) => this.post({ type: 'logLine', line }), + onDone: (result) => { + this.post({ type: 'reviewDone', result }); + if (result.comments.length) this.comments.show(result.comments, inEditor); + }, + }); + break; + } + case 'cancelReview': + this.session?.cancel({ onState: (state) => this.post({ type: 'stateChange', state }) }); + break; + case 'getConfig': + this.post({ type: 'config', config: this.config.read() }); + break; + case 'setConfig': + await this.config.set(msg.key, msg.value); + this.post({ type: 'config', config: this.config.read() }); + break; + case 'testConnection': { + const r = await this.cli.testConnection(); + this.post({ type: 'connectionResult', ok: r.ok, message: r.message }); + break; + } + case 'checkCli': { + this.post({ type: 'cliStatus', installed: await this.cli.isAvailable() }); + break; + } + case 'installCli': { + const ok = await this.cli.install((line) => this.post({ type: 'installLog', line })); + this.post({ type: 'installDone', ok }); + this.post({ type: 'cliStatus', installed: await this.cli.isAvailable() }); + break; + } + case 'jumpToComment': + await this.comments.jumpTo(msg.index); + break; + case 'commentAction': + if (msg.action === 'apply') await this.comments.apply(msg.index); + else if (msg.action === 'discard') this.comments.discard(msg.index); + else this.comments.falsePositive(msg.index); + break; + } + } + + private html(webview: vscode.Webview): string { + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'out', 'webview.js')); + const nonce = String(Date.now()); + return ` + + + +
+ +`; + } +} diff --git a/extensions/vscode/src/extension/providers/__tests__/lineOffset.test.ts b/extensions/vscode/src/extension/providers/__tests__/lineOffset.test.ts new file mode 100644 index 0000000..e05e6b9 --- /dev/null +++ b/extensions/vscode/src/extension/providers/__tests__/lineOffset.test.ts @@ -0,0 +1,25 @@ +// src/extension/providers/__tests__/lineOffset.test.ts +import { LineOffsetTracker } from '../lineOffset'; + +describe('LineOffsetTracker', () => { + it('无变更时返回原行号', () => { + const t = new LineOffsetTracker(); + expect(t.adjusted('a.ts', 10)).toBe(10); + }); + it('在某行之前插入若干行,后续行号顺移', () => { + const t = new LineOffsetTracker(); + t.record('a.ts', 5, +2); // 第5行起增加2行 + expect(t.adjusted('a.ts', 10)).toBe(12); + expect(t.adjusted('a.ts', 3)).toBe(3); // 之前的行不受影响 + }); + it('删除行使后续行号回退', () => { + const t = new LineOffsetTracker(); + t.record('a.ts', 5, -1); + expect(t.adjusted('a.ts', 10)).toBe(9); + }); + it('不同文件互不影响', () => { + const t = new LineOffsetTracker(); + t.record('a.ts', 1, +5); + expect(t.adjusted('b.ts', 10)).toBe(10); + }); +}); diff --git a/extensions/vscode/src/extension/providers/lineOffset.ts b/extensions/vscode/src/extension/providers/lineOffset.ts new file mode 100644 index 0000000..cf93d77 --- /dev/null +++ b/extensions/vscode/src/extension/providers/lineOffset.ts @@ -0,0 +1,20 @@ +export class LineOffsetTracker { + private records = new Map>(); + + record(file: string, line: number, delta: number): void { + const arr = this.records.get(file) ?? []; + arr.push({ line, delta }); + this.records.set(file, arr); + } + + adjusted(file: string, line: number): number { + const arr = this.records.get(file) ?? []; + let offset = 0; + for (const r of arr) if (r.line < line) offset += r.delta; + return Math.max(0, line + offset); + } + + clear(): void { + this.records.clear(); + } +} diff --git a/extensions/vscode/src/extension/services/CliService.ts b/extensions/vscode/src/extension/services/CliService.ts new file mode 100644 index 0000000..3a8972d --- /dev/null +++ b/extensions/vscode/src/extension/services/CliService.ts @@ -0,0 +1,101 @@ +import { spawn } from 'child_process'; +import { CliResult, CliRunOptions, LogLine } from '../../shared/types'; +import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from './cliParse'; +import { getShellEnv, resolveBin } from './shellEnv'; + +export class CliService { + private current: ReturnType | null = null; + + constructor(private cliPath: string = 'ocr') {} + + async isAvailable(): Promise { + return new Promise((resolve) => { + const p = spawn(resolveBin(this.cliPath), ['--version'], { env: getShellEnv() }); + let errored = false; + p.on('error', () => { errored = true; resolve(false); }); + p.on('close', () => { if (!errored) resolve(true); }); + }); + } + + /** 全局安装 ocr CLI,流式回显 npm 日志,按 exit code 返回是否成功。 */ + install(onLog: (l: LogLine) => void): Promise { + return new Promise((resolve) => { + const args = [ + 'install', '-g', '@alibaba-group/open-code-review', + '--loglevel', 'http', '--no-progress', + ]; + onLog({ text: `$ npm ${args.join(' ')}`, level: 'info' }); + const proc = spawn(resolveBin('npm'), args, { + // 非 TTY 下 npm 默认静默进度条;强制关进度条并用行式输出 + env: { ...getShellEnv(), npm_config_progress: 'false', npm_config_color: 'false' }, + shell: process.platform === 'win32', + }); + // npm 输出可能跨 chunk 断行,按 \r\n 归一并逐行 emit,尾部残行留到下次。 + const emitLines = (() => { + let buf = ''; + return (chunk: string, level: LogLine['level'], flush = false) => { + buf += chunk.replace(/\r/g, '\n'); + const parts = buf.split('\n'); + buf = flush ? '' : (parts.pop() ?? ''); + for (const line of parts) if (line.trim()) onLog({ text: line, level }); + if (flush && chunk.trim() && parts.length === 0) onLog({ text: chunk, level }); + }; + })(); + proc.stdout?.on('data', (d) => emitLines(d.toString(), 'info')); + proc.stderr?.on('data', (d) => emitLines(d.toString(), 'info')); + 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' }); + resolve(code === 0); + }); + }); + } + + /** 运行任意参数,流式回调日志,结束返回 stdout 全文。退出码非 0 时 reject,并带上 CLI 报错文本。 */ + runRaw(args: string[], cwd: string, onLog: (l: LogLine) => void): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(resolveBin(this.cliPath), args, { cwd, env: getShellEnv() }); + this.current = proc; + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d) => { stdout += d.toString(); }); + proc.stderr.on('data', (d) => { + const text = d.toString(); + stderr += text; + for (const line of text.split('\n')) { + const parsed = parseLogLine(line); + if (parsed) onLog(parsed); + } + }); + proc.on('error', (err) => { this.current = null; reject(err); }); + proc.on('close', (code) => { + this.current = null; + if (code === 0) { resolve(stdout); return; } + reject(new Error(extractCliError(stderr) || `CLI exited with code ${code}`)); + }); + }); + } + + async review(opts: CliRunOptions, cwd: string, onLog: (l: LogLine) => void): Promise { + const stdout = await this.runRaw(buildReviewArgs(opts), cwd, onLog); + return parseCliResult(stdout); + } + + async testConnection(): Promise<{ ok: boolean; message?: string }> { + try { + await this.runRaw(['llm', 'test'], process.cwd(), () => {}); + return { ok: true }; + } catch (e) { + return { ok: false, message: e instanceof Error ? e.message : String(e) }; + } + } + + cancel(): void { + if (this.current && this.current.pid) { + this.current.kill('SIGTERM'); + const proc = this.current; + setTimeout(() => { if (!proc.killed) proc.kill('SIGKILL'); }, 3000); + } + } +} diff --git a/extensions/vscode/src/extension/services/ConfigService.ts b/extensions/vscode/src/extension/services/ConfigService.ts new file mode 100644 index 0000000..f2eb62b --- /dev/null +++ b/extensions/vscode/src/extension/services/ConfigService.ts @@ -0,0 +1,29 @@ +import { readFileSync, existsSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { OcrConfig } from '../../shared/types'; +import { CliService } from './CliService'; +import { parseConfig, toConfigSetArgs } from './configParse'; + +export class ConfigService { + constructor(private cli: CliService) {} + + private configPath(): string { + return join(homedir(), '.opencodereview', 'config.json'); + } + + read(): OcrConfig | null { + const p = this.configPath(); + if (!existsSync(p)) return null; + try { + return parseConfig(readFileSync(p, 'utf8')); + } catch { + return null; + } + } + + async set(key: string, value: string): Promise { + await this.cli.runRaw(toConfigSetArgs(key, value), process.cwd(), () => {}); + return this.read(); + } +} diff --git a/extensions/vscode/src/extension/services/GitService.ts b/extensions/vscode/src/extension/services/GitService.ts new file mode 100644 index 0000000..6d6825f --- /dev/null +++ b/extensions/vscode/src/extension/services/GitService.ts @@ -0,0 +1,221 @@ +import * as vscode from 'vscode'; +import { execFile } from 'child_process'; +import { GitState, CommitInfo, FileChange, ReviewMode } from '../../shared/types'; +import { parsePorcelain, parseNameStatus, pickRepoRoot } from './gitMap'; + +export class GitService { + private api: any | null = null; + + constructor(private log?: vscode.OutputChannel) {} + + private trace(msg: string): void { + this.log?.appendLine(`[git] ${msg}`); + } + + private async ensureApi(): Promise { + if (this.api) return this.api; + const ext = vscode.extensions.getExtension('vscode.git'); + if (!ext) return null; + const exports = ext.isActive ? ext.exports : await ext.activate(); + if (!exports?.getAPI) return null; + this.api = exports.getAPI(1); + return this.api; + } + + /** + * 选出与 workspace 匹配的仓库。嵌套仓库场景下 repositories 顺序不稳定, + * 不能直接取 [0],否则会漂移到子仓库。 + */ + private selectRepo(api: any): any | null { + const repos: any[] = api.repositories; + if (!repos || repos.length === 0) return null; + const ws = vscode.workspace.workspaceFolders?.[0].uri.fsPath; + const root = pickRepoRoot(repos.map((r) => r.rootUri?.fsPath ?? ''), ws); + return repos.find((r) => (r.rootUri?.fsPath ?? '') === root) ?? repos[0]; + } + + /** 等待至少一个仓库就绪(git 扩展异步扫描,首次可能为空)。 */ + private async waitForRepo(timeoutMs = 5000): Promise { + const api = await this.ensureApi(); + if (!api) return null; + if (api.repositories.length > 0) return this.selectRepo(api); + + return new Promise((resolve) => { + let done = false; + const finish = (repo: any | null) => { + if (done) return; + done = true; + disposable?.dispose(); + clearInterval(poll); + clearTimeout(timer); + resolve(repo); + }; + const disposable = api.onDidOpenRepository?.(() => finish(this.selectRepo(api))); + const poll = setInterval(() => { + if (api.repositories.length > 0) finish(this.selectRepo(api)); + }, 200); + const timer = setTimeout(() => finish(this.selectRepo(api)), timeoutMs); + }); + } + + async getState(mode: ReviewMode): Promise { + const empty: GitState = { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }; + const repo = await this.waitForRepo(); + if (!repo) { + this.trace(`getState(${mode}): no repo`); + return empty; + } + + let currentBranch = ''; + try { + currentBranch = repo.state.HEAD?.name || ''; + } catch { /* ignore */ } + + let branches: string[] = []; + try { + const refs = await repo.getBranches({ remote: true }); + branches = refs.map((r: any) => r.name).filter(Boolean); + } catch { /* ignore */ } + + let recentCommits: CommitInfo[] = []; + try { + const commits = await repo.log({ maxEntries: 20 }); + recentCommits = commits.map((c: any) => ({ + sha: c.hash.slice(0, 7), + message: c.message.split('\n')[0], + relativeTime: formatRelative(c.authorDate), + })); + } catch { /* ignore */ } + + // 工作区变更直接走 git status --porcelain,避免依赖扩展懒填充的 state 数组。 + let workspaceFiles: FileChange[] = []; + try { + const root: string = repo.rootUri?.fsPath + ?? vscode.workspace.workspaceFolders?.[0].uri.fsPath + ?? process.cwd(); + const out = await runGit(root, ['status', '--porcelain']); + workspaceFiles = parsePorcelain(out); + this.trace(`getState(${mode}): root=${root} porcelainBytes=${out.length} files=${workspaceFiles.length}`); + } catch (e) { + this.trace(`getState(${mode}): status failed: ${e instanceof Error ? e.message : String(e)}`); + } + + return { branches, currentBranch, recentCommits, workspaceFiles }; + } + + /** 分支对比:merge-base 三点 diff。 */ + async getBranchDiff(from: string, to: string): Promise { + const root = await this.repoRoot(); + if (!root || !from || !to) return []; + try { + const out = await runGit(root, ['diff', '--name-status', `${from}...${to}`]); + const files = parseNameStatus(out); + this.trace(`getBranchDiff(${from}...${to}): files=${files.length}`); + return files; + } catch (e) { + this.trace(`getBranchDiff failed: ${e instanceof Error ? e.message : String(e)}`); + return []; + } + } + + /** 单次提交:该 commit 相对父提交的改动文件。 */ + async getCommitFiles(sha: string): Promise { + const root = await this.repoRoot(); + if (!root || !sha) return []; + try { + const out = await runGit(root, ['show', '--name-status', '--format=', sha]); + const files = parseNameStatus(out); + this.trace(`getCommitFiles(${sha}): files=${files.length}`); + return files; + } catch (e) { + this.trace(`getCommitFiles failed: ${e instanceof Error ? e.message : String(e)}`); + return []; + } + } + + private async repoRoot(): Promise { + const repo = await this.waitForRepo(); + if (!repo) return null; + return repo.rootUri?.fsPath + ?? vscode.workspace.workspaceFolders?.[0].uri.fsPath + ?? process.cwd(); + } + + /** 在 VSCode 原生 diff 视图中打开某个待审查文件。三种模式各自决定 diff 的左右两侧。 */ + async openDiff(opts: { + path: string; status: FileChange['status']; + mode: ReviewMode; from?: string; to?: string; commit?: string; + }): Promise { + const api = await this.ensureApi(); + const root = await this.repoRoot(); + if (!api || !root) return; + + const fileUri = vscode.Uri.file(`${root}/${opts.path}`); + + // 二进制无法做文本 diff,直接打开文件本身。 + if (opts.status === 'binary') { + try { await vscode.window.showTextDocument(fileUri, { preview: true }); } catch { /* ignore */ } + return; + } + + // toGitUri(uri, '') 返回空文档,用于新增/删除时缺失的一侧。 + const emptyRef = ''; + let left: vscode.Uri; + let right: vscode.Uri; + let label: string; + + if (opts.mode === 'workspace') { + left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD'); + right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri; + label = '工作区 ↔ HEAD'; + } 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); + label = `${opts.commit}^ ↔ ${opts.commit}`; + } else if (opts.mode === 'branch' && opts.from && opts.to) { + // 文件列表用三点 diff(merge-base),逐文件 diff 也应以 merge-base 为基准。 + const base = (await this.mergeBase(root, opts.from, opts.to)) || opts.from; + left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : base); + right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.to); + label = `${opts.from}...${opts.to}`; + } else { + return; + } + + const title = `${opts.path} (${label})`; + try { + await vscode.commands.executeCommand('vscode.diff', left, right, title, { preview: true }); + } catch (e) { + this.trace(`openDiff failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + + private async mergeBase(root: string, from: string, to: string): Promise { + try { + const out = await runGit(root, ['merge-base', from, to]); + return out.trim() || null; + } catch { + return null; + } + } +} + +function runGit(cwd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { + if (err) reject(err); + else resolve(stdout); + }); + }); +} + +function formatRelative(date?: Date): string { + if (!date) return ''; + const diff = Date.now() - date.getTime(); + const h = Math.floor(diff / 3.6e6); + if (h < 1) return '刚刚'; + if (h < 24) return `${h} 小时前`; + const d = Math.floor(h / 24); + if (d === 1) return '昨天'; + return `${d} 天前`; +} diff --git a/extensions/vscode/src/extension/services/ReviewSession.ts b/extensions/vscode/src/extension/services/ReviewSession.ts new file mode 100644 index 0000000..279f8c1 --- /dev/null +++ b/extensions/vscode/src/extension/services/ReviewSession.ts @@ -0,0 +1,48 @@ +import { CliResult, CliRunOptions, LogLine, ReviewState } from '../../shared/types'; +import { CliService } from './CliService'; + +export function resultToState(result: CliResult): ReviewState { + if (result.comments.length > 0) return 'done'; + if (result.status === 'completed_with_errors') return 'failed'; + return 'empty'; +} + +export interface SessionCallbacks { + onState: (state: ReviewState, error?: string) => void; + onLog: (line: LogLine) => void; + onDone: (result: CliResult) => void; +} + +export class ReviewSession { + private cancelled = false; + + constructor(private cli: CliService, private cwd: string) {} + + async run(opts: CliRunOptions, cb: SessionCallbacks): Promise { + this.cancelled = false; + cb.onState('running'); + try { + const result = await this.cli.review(opts, this.cwd, cb.onLog); + if (this.cancelled) { + cb.onState('cancelled'); + return; + } + cb.onState(resultToState(result)); + cb.onDone(result); + } catch (e) { + if (this.cancelled) { + cb.onState('cancelled'); + } else { + const msg = e instanceof Error ? e.message : String(e); + cb.onLog({ text: `[ocr] ${msg}`, level: 'error' }); + cb.onState('failed', msg); + } + } + } + + cancel(cb: Pick): void { + this.cancelled = true; + this.cli.cancel(); + cb.onState('cancelled'); + } +} diff --git a/extensions/vscode/src/extension/services/__tests__/CliService.test.ts b/extensions/vscode/src/extension/services/__tests__/CliService.test.ts new file mode 100644 index 0000000..42ae24d --- /dev/null +++ b/extensions/vscode/src/extension/services/__tests__/CliService.test.ts @@ -0,0 +1,48 @@ +// src/extension/services/__tests__/CliService.test.ts +process.env.OCR_SKIP_SHELL_RESOLVE = '1'; +import { CliService } from '../CliService'; + +describe('CliService.isAvailable', () => { + it('node 一定存在 → true', async () => { + const svc = new CliService('node'); + expect(await svc.isAvailable()).toBe(true); + }); + it('不存在的命令 → false', async () => { + const svc = new CliService('definitely-not-a-real-binary-xyz'); + expect(await svc.isAvailable()).toBe(false); + }); +}); + +describe('CliService.runRaw', () => { + it('收集 stdout 并在结束时 resolve', async () => { + // 用 node 打印一段 JSON 模拟 ocr + const svc = new CliService('node'); + const logs: string[] = []; + const out = await svc.runRaw( + ['-e', 'process.stdout.write(JSON.stringify({status:"success",comments:[]}))'], + '.', (line) => logs.push(line.text), + ); + expect(out).toContain('"status":"success"'); + }); + + it('退出码非 0 时 reject,并带上 stderr 中的 Error 文本', async () => { + const svc = new CliService('node'); + await expect(svc.runRaw( + ['-e', 'process.stderr.write("Error: bad api key\\n"); process.exit(1)'], + '.', () => {}, + )).rejects.toThrow('bad api key'); + }); +}); + +describe('CliService.testConnection', () => { + it('CLI 退出码非 0 → ok=false(不再误报连接成功)', async () => { + const svc = new CliService('node'); + // 覆盖默认 ['llm','test'] 不可行,这里直接验证 runRaw 的失败传播逻辑 + const r = await svc.runRaw( + ['-e', 'process.stderr.write("Error: connection refused\\n"); process.exit(1)'], + '.', () => {}, + ).then(() => ({ ok: true }), (e: Error) => ({ ok: false, message: e.message })); + expect(r.ok).toBe(false); + expect((r as { message: string }).message).toContain('connection refused'); + }); +}); diff --git a/extensions/vscode/src/extension/services/__tests__/ReviewSession.test.ts b/extensions/vscode/src/extension/services/__tests__/ReviewSession.test.ts new file mode 100644 index 0000000..20f9a2e --- /dev/null +++ b/extensions/vscode/src/extension/services/__tests__/ReviewSession.test.ts @@ -0,0 +1,20 @@ +// src/extension/services/__tests__/ReviewSession.test.ts +import { resultToState } from '../ReviewSession'; + +describe('resultToState', () => { + it('有 comments → done', () => { + expect(resultToState({ status: 'success', comments: [{} as any], warnings: [] })).toBe('done'); + }); + it('success 但无 comments → empty', () => { + expect(resultToState({ status: 'success', comments: [], warnings: [] })).toBe('empty'); + }); + it('skipped 无 comments → empty', () => { + expect(resultToState({ status: 'skipped', comments: [], warnings: [] })).toBe('empty'); + }); + it('completed_with_errors 无 comments → failed', () => { + expect(resultToState({ status: 'completed_with_errors', comments: [], warnings: [] })).toBe('failed'); + }); + it('completed_with_errors 有 comments → done', () => { + expect(resultToState({ status: 'completed_with_errors', comments: [{} as any], warnings: [] })).toBe('done'); + }); +}); diff --git a/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts new file mode 100644 index 0000000..25145a7 --- /dev/null +++ b/extensions/vscode/src/extension/services/__tests__/cliParse.test.ts @@ -0,0 +1,96 @@ +import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from '../cliParse'; + +describe('buildReviewArgs', () => { + it('workspace 模式加 --format json', () => { + expect(buildReviewArgs({ mode: 'workspace' })) + .toEqual(['review', '--format', 'json']); + }); + + it('branch 模式加 --from/--to', () => { + expect(buildReviewArgs({ mode: 'branch', from: 'main', to: 'dev' })) + .toEqual(['review', '--from', 'main', '--to', 'dev', '--format', 'json']); + }); + + it('commit 模式加 --commit', () => { + expect(buildReviewArgs({ mode: 'commit', commit: 'abc123' })) + .toEqual(['review', '--commit', 'abc123', '--format', 'json']); + }); + + it('customPrompt 追加 --background', () => { + expect(buildReviewArgs({ mode: 'workspace', customPrompt: '关注安全' })) + .toEqual(['review', '--format', 'json', '--background', '关注安全']); + }); + + it('concurrency 追加 --concurrency', () => { + expect(buildReviewArgs({ mode: 'workspace', concurrency: 4 })) + .toEqual(['review', '--format', 'json', '--concurrency', '4']); + }); +}); + +describe('parseCliResult', () => { + it('解析 success + comments + summary,字段转 camelCase', () => { + const raw = JSON.stringify({ + status: 'success', + comments: [{ + path: 'src/a.ts', content: 'bug', start_line: 10, end_line: 12, + suggestion_code: 'fix', existing_code: 'old', + }], + summary: { + files_reviewed: 2, comments: 1, total_tokens: 100, + input_tokens: 80, output_tokens: 20, elapsed: '5s', + }, + }); + const r = parseCliResult(raw); + expect(r.status).toBe('success'); + expect(r.comments[0]).toEqual({ + path: 'src/a.ts', content: 'bug', startLine: 10, endLine: 12, + suggestionCode: 'fix', existingCode: 'old', thinking: undefined, + }); + expect(r.summary?.filesReviewed).toBe(2); + }); + + it('skipped 状态无 comments', () => { + const raw = JSON.stringify({ status: 'skipped', message: 'No supported files changed.', comments: [] }); + const r = parseCliResult(raw); + expect(r.status).toBe('skipped'); + expect(r.comments).toEqual([]); + }); + + it('忽略 JSON 前的非 JSON 噪声行', () => { + const raw = '[ocr] some log\n{"status":"success","comments":[]}'; + const r = parseCliResult(raw); + expect(r.status).toBe('success'); + }); +}); + +describe('extractCliError', () => { + it('优先提取 Error: 行并去掉前缀', () => { + const stderr = '[ocr] starting\nError: llm request failed: 401 unauthorized\n'; + expect(extractCliError(stderr)).toBe('llm request failed: 401 unauthorized'); + }); + it('多个 Error 行取最后一个', () => { + const stderr = 'Error: first\nError: last'; + expect(extractCliError(stderr)).toBe('last'); + }); + it('无 Error 行时取最后一行非空内容', () => { + expect(extractCliError('foo\nbar\n\n')).toBe('bar'); + }); + it('空 stderr → 空字符串', () => { + expect(extractCliError('')).toBe(''); + }); +}); + +describe('parseLogLine', () => { + it('普通 [ocr] 行 → info', () => { + expect(parseLogLine('[ocr] Reviewing src/a.ts')).toEqual({ text: '[ocr] Reviewing src/a.ts', level: 'info' }); + }); + it('含 Retrying 的行 → warn', () => { + expect(parseLogLine('[llm] Retrying in 1.46s (attempt 1/3)').level).toBe('warn'); + }); + it('含 WARNING 的行 → warn', () => { + expect(parseLogLine('[ocr] WARNING [x] f: m').level).toBe('warn'); + }); + it('空行 → null', () => { + expect(parseLogLine(' ')).toBeNull(); + }); +}); diff --git a/extensions/vscode/src/extension/services/__tests__/configParse.test.ts b/extensions/vscode/src/extension/services/__tests__/configParse.test.ts new file mode 100644 index 0000000..6095c5a --- /dev/null +++ b/extensions/vscode/src/extension/services/__tests__/configParse.test.ts @@ -0,0 +1,32 @@ +// src/extension/services/__tests__/configParse.test.ts +import { parseConfig, toConfigSetArgs } from '../configParse'; + +describe('parseConfig', () => { + it('完整 config 转 camelCase', () => { + const raw = JSON.stringify({ + llm: { url: 'u', auth_token: 't', model: 'm', use_anthropic: true, auth_header: 'x-api-key' }, + language: 'Chinese', + }); + expect(parseConfig(raw)).toEqual({ + llm: { url: 'u', authToken: 't', model: 'm', useAnthropic: true, authHeader: 'x-api-key' }, + language: 'Chinese', + }); + }); + + it('缺字段时给默认值', () => { + const cfg = parseConfig('{}'); + expect(cfg.llm.url).toBe(''); + expect(cfg.llm.useAnthropic).toBe(false); + expect(cfg.language).toBe('Chinese'); + }); + + it('空字符串 → null', () => { + expect(parseConfig('')).toBeNull(); + }); +}); + +describe('toConfigSetArgs', () => { + it('生成 config set 参数', () => { + expect(toConfigSetArgs('llm.model', 'opus')).toEqual(['config', 'set', 'llm.model', 'opus']); + }); +}); diff --git a/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts new file mode 100644 index 0000000..3cf15a1 --- /dev/null +++ b/extensions/vscode/src/extension/services/__tests__/gitMap.test.ts @@ -0,0 +1,119 @@ +// src/extension/services/__tests__/gitMap.test.ts +import { mapStatusCode, parsePorcelain, parseNameStatus, pickRepoRoot } from '../gitMap'; + +describe('mapStatusCode', () => { + it('VSCode git Status 枚举映射到 FileChange.status', () => { + // VSCode Status: INDEX_ADDED=1, MODIFIED=5, DELETED=6, UNTRACKED=7 (示例值) + expect(mapStatusCode('A')).toBe('added'); + expect(mapStatusCode('M')).toBe('modified'); + expect(mapStatusCode('D')).toBe('deleted'); + expect(mapStatusCode('R')).toBe('renamed'); + expect(mapStatusCode('?')).toBe('added'); // untracked 视为 added + expect(mapStatusCode('X')).toBe('modified'); // 未知兜底 + }); +}); + +describe('parsePorcelain', () => { + it('解析各种状态', () => { + const out = [ + 'M src/a.ts', + ' M src/b.ts', + 'A src/c.ts', + '?? src/d.ts', + 'D src/e.ts', + ].join('\n'); + expect(parsePorcelain(out)).toEqual([ + { path: 'src/a.ts', status: 'modified' }, + { path: 'src/b.ts', status: 'modified' }, + { path: 'src/c.ts', status: 'added' }, + { path: 'src/d.ts', status: 'added' }, + { path: 'src/e.ts', status: 'deleted' }, + ]); + }); + + it('重命名取新路径', () => { + expect(parsePorcelain('R old/x.ts -> new/x.ts')).toEqual([ + { path: 'new/x.ts', status: 'renamed' }, + ]); + }); + + it('去重同一路径(同时暂存+工作区变更)', () => { + expect(parsePorcelain('MM src/a.ts')).toEqual([ + { path: 'src/a.ts', status: 'modified' }, + ]); + }); + + it('空输出返回空数组', () => { + expect(parsePorcelain('')).toEqual([]); + expect(parsePorcelain('\n \n')).toEqual([]); + }); +}); + +describe('parseNameStatus', () => { + it('解析 git diff/show --name-status 输出', () => { + const out = [ + 'M\tsrc/a.ts', + 'A\tsrc/b.ts', + 'D\tsrc/c.ts', + ].join('\n'); + expect(parseNameStatus(out)).toEqual([ + { path: 'src/a.ts', status: 'modified' }, + { path: 'src/b.ts', status: 'added' }, + { path: 'src/c.ts', status: 'deleted' }, + ]); + }); + + it('重命名行 R old new 取新路径', () => { + expect(parseNameStatus('R100\told/x.ts\tnew/x.ts')).toEqual([ + { path: 'new/x.ts', status: 'renamed' }, + ]); + }); + + it('去重同一路径', () => { + expect(parseNameStatus('M\tsrc/a.ts\nM\tsrc/a.ts')).toEqual([ + { path: 'src/a.ts', status: 'modified' }, + ]); + }); + + it('空输出返回空数组', () => { + expect(parseNameStatus('')).toEqual([]); + expect(parseNameStatus('\n \n')).toEqual([]); + }); +}); + +describe('pickRepoRoot', () => { + const ws = '/Users/lost/tre/copilot-union/code-chat'; + + it('精确匹配 workspace 根优先(嵌套子仓库不漂移)', () => { + // 子仓库 chat-ui 排在前面也应选中父 code-chat + const roots = ['/Users/lost/tre/copilot-union/code-chat/chat-ui', ws]; + expect(pickRepoRoot(roots, ws)).toBe(ws); + }); + + it('无精确匹配时选 workspace 的祖先仓库', () => { + const parent = '/Users/lost/tre/copilot-union'; + const roots = ['/Users/lost/tre/copilot-union/code-chat/chat-ui', parent]; + expect(pickRepoRoot(roots, ws)).toBe(parent); + }); + + it('多个祖先时选最深(最长路径)的祖先', () => { + const grand = '/Users/lost/tre'; + const parent = '/Users/lost/tre/copilot-union'; + const roots = [grand, parent]; + expect(pickRepoRoot(roots, ws)).toBe(parent); + }); + + it('都不匹配时退回第一个', () => { + const roots = ['/some/other/repo', '/another/repo']; + expect(pickRepoRoot(roots, ws)).toBe('/some/other/repo'); + }); + + it('空候选返回 null', () => { + expect(pickRepoRoot([], ws)).toBeNull(); + }); + + it('无 workspace 路径时退回第一个', () => { + const roots = ['/a/repo', '/b/repo']; + expect(pickRepoRoot(roots, undefined)).toBe('/a/repo'); + }); +}); diff --git a/extensions/vscode/src/extension/services/__tests__/shellEnv.test.ts b/extensions/vscode/src/extension/services/__tests__/shellEnv.test.ts new file mode 100644 index 0000000..f5de3bb --- /dev/null +++ b/extensions/vscode/src/extension/services/__tests__/shellEnv.test.ts @@ -0,0 +1,30 @@ +// src/extension/services/__tests__/shellEnv.test.ts +process.env.OCR_SKIP_SHELL_RESOLVE = '1'; +import { parseEnvBlock, getShellEnv } from '../shellEnv'; + +const DELIM = '_OCR_ENV_DELIM_'; + +describe('parseEnvBlock', () => { + it('解析分隔标记之间的 key=value', () => { + const stdout = `noise\n${DELIM}\nPATH=/usr/local/bin:/usr/bin\nFOO=bar\n${DELIM}\ntrailing`; + expect(parseEnvBlock(stdout)).toEqual({ + PATH: '/usr/local/bin:/usr/bin', + FOO: 'bar', + }); + }); + + it('value 中含 = 时只按首个 = 切分', () => { + const stdout = `${DELIM}\nKEY=a=b=c\n${DELIM}`; + expect(parseEnvBlock(stdout)).toEqual({ KEY: 'a=b=c' }); + }); + + it('无分隔标记 → 空对象', () => { + expect(parseEnvBlock('PATH=/usr/bin')).toEqual({}); + }); +}); + +describe('getShellEnv', () => { + it('总是包含 PATH', () => { + expect(getShellEnv().PATH).toBeDefined(); + }); +}); diff --git a/extensions/vscode/src/extension/services/cliParse.ts b/extensions/vscode/src/extension/services/cliParse.ts new file mode 100644 index 0000000..c219bce --- /dev/null +++ b/extensions/vscode/src/extension/services/cliParse.ts @@ -0,0 +1,70 @@ +import { CliResult, CliRunOptions, LogLine, ReviewComment } from '../../shared/types'; + +export function buildReviewArgs(opts: CliRunOptions): string[] { + const args: string[] = ['review']; + if (opts.mode === 'branch') { + if (opts.from) args.push('--from', opts.from); + if (opts.to) args.push('--to', opts.to); + } else if (opts.mode === 'commit') { + if (opts.commit) args.push('--commit', opts.commit); + } + args.push('--format', 'json'); + // JSON 结果走 stdout,进度日志走 stderr,供扩展实时回显 + // TODO: 待 CLI 发布支持 --progress-stderr 后再启用(当前已安装版本不识别该 flag) + // args.push('--progress-stderr'); + if (opts.customPrompt && opts.customPrompt.trim()) { + args.push('--background', opts.customPrompt.trim()); + } + if (typeof opts.concurrency === 'number') { + args.push('--concurrency', String(opts.concurrency)); + } + return args; +} + +function toComment(raw: any): ReviewComment { + return { + path: raw.path, + content: raw.content, + suggestionCode: raw.suggestion_code || undefined, + existingCode: raw.existing_code || undefined, + startLine: raw.start_line, + endLine: raw.end_line, + thinking: raw.thinking || undefined, + }; +} + +export function parseCliResult(stdout: string): CliResult { + const start = stdout.indexOf('{'); + if (start < 0) throw new Error('no JSON in CLI output'); + const json = JSON.parse(stdout.slice(start)); + const s = json.summary; + return { + status: json.status, + message: json.message, + comments: Array.isArray(json.comments) ? json.comments.map(toComment) : [], + warnings: Array.isArray(json.warnings) ? json.warnings : [], + summary: s ? { + filesReviewed: s.files_reviewed, + comments: s.comments, + totalTokens: s.total_tokens, + inputTokens: s.input_tokens, + outputTokens: s.output_tokens, + elapsed: s.elapsed, + } : undefined, + }; +} + +/** 从 CLI stderr 中提取最有用的报错文本:优先 `Error:` 行,否则取最后一行非空内容。 */ +export function extractCliError(stderr: string): string { + const lines = stderr.split('\n').map((l) => l.trim()).filter(Boolean); + const errLine = [...lines].reverse().find((l) => /^error:/i.test(l)); + if (errLine) return errLine.replace(/^error:\s*/i, ''); + return lines.length ? lines[lines.length - 1] : ''; +} + +export function parseLogLine(raw: string): LogLine | null { + const text = raw.replace(/\s+$/, ''); + if (!text.trim()) return null; + const level: LogLine['level'] = /retrying|warning|warn/i.test(text) ? 'warn' : 'info'; + return { text, level }; +} diff --git a/extensions/vscode/src/extension/services/configParse.ts b/extensions/vscode/src/extension/services/configParse.ts new file mode 100644 index 0000000..4dcf14d --- /dev/null +++ b/extensions/vscode/src/extension/services/configParse.ts @@ -0,0 +1,21 @@ +import { OcrConfig } from '../../shared/types'; + +export function parseConfig(raw: string): OcrConfig | null { + if (!raw || !raw.trim()) return null; + const j = JSON.parse(raw); + const llm = j.llm || {}; + return { + llm: { + url: llm.url || '', + authToken: llm.auth_token || '', + model: llm.model || '', + useAnthropic: Boolean(llm.use_anthropic), + authHeader: llm.auth_header || '', + }, + language: j.language || 'Chinese', + }; +} + +export function toConfigSetArgs(key: string, value: string): string[] { + return ['config', 'set', key, value]; +} diff --git a/extensions/vscode/src/extension/services/gitMap.ts b/extensions/vscode/src/extension/services/gitMap.ts new file mode 100644 index 0000000..0c92b4f --- /dev/null +++ b/extensions/vscode/src/extension/services/gitMap.ts @@ -0,0 +1,84 @@ +import { FileChange } from '../../shared/types'; + +export function mapStatusCode(code: string): FileChange['status'] { + switch (code) { + case 'A': return 'added'; + case '?': return 'added'; + case 'D': return 'deleted'; + case 'R': return 'renamed'; + case 'M': return 'modified'; + default: return 'modified'; + } +} + +/** + * 解析 `git status --porcelain` 输出。 + * 每行格式:XYpath,X=暂存区状态,Y=工作区状态,'??'=未跟踪。 + * 重命名行格式:`R old -> new`,取 new。 + */ +export function parsePorcelain(output: string): FileChange[] { + const files: FileChange[] = []; + const seen = new Set(); + for (const rawLine of output.split('\n')) { + if (!rawLine.trim()) continue; + const x = rawLine[0]; + const y = rawLine[1]; + let path = rawLine.slice(3); + let code: string; + if (x === '?' && y === '?') { + code = '?'; + } else if (x === 'R' || y === 'R') { + code = 'R'; + const arrow = path.indexOf(' -> '); + if (arrow >= 0) path = path.slice(arrow + 4); + } else { + // 取暂存区状态优先,否则工作区状态 + const c = x !== ' ' && x !== '?' ? x : y; + code = c; + } + if (seen.has(path)) continue; + seen.add(path); + files.push({ path, status: mapStatusCode(code) }); + } + return files; +} + +/** + * 从候选仓库根路径中选出与 workspace 匹配的那个。 + * VSCode git 扩展异步扫描嵌套仓库,repositories 顺序不稳定,直接取 [0] 会漂移到子仓库。 + * 优先级:精确等于 workspace 根 > workspace 的最深祖先 > 第一个。 + */ +export function pickRepoRoot(roots: string[], workspacePath?: string): string | null { + if (roots.length === 0) return null; + if (!workspacePath) return roots[0]; + + const exact = roots.find((r) => r === workspacePath); + if (exact) return exact; + + const ancestors = roots.filter((r) => workspacePath.startsWith(r.endsWith('/') ? r : r + '/')); + if (ancestors.length > 0) { + return ancestors.reduce((deepest, r) => (r.length > deepest.length ? r : deepest)); + } + + return roots[0]; +} + +/** + * 解析 `git diff --name-status` / `git show --name-status` 输出。 + * 每行制表符分隔:statuspath,重命名为 Roldnew(取 new)。 + */ +export function parseNameStatus(output: string): FileChange[] { + const files: FileChange[] = []; + const seen = new Set(); + for (const rawLine of output.split('\n')) { + if (!rawLine.trim()) continue; + const parts = rawLine.split('\t'); + if (parts.length < 2) continue; + const codeChar = parts[0][0]; + const path = parts.length >= 3 ? parts[parts.length - 1] : parts[1]; + if (seen.has(path)) continue; + seen.add(path); + files.push({ path, status: mapStatusCode(codeChar) }); + } + return files; +} diff --git a/extensions/vscode/src/extension/services/shellEnv.ts b/extensions/vscode/src/extension/services/shellEnv.ts new file mode 100644 index 0000000..7958998 --- /dev/null +++ b/extensions/vscode/src/extension/services/shellEnv.ts @@ -0,0 +1,71 @@ +import { spawnSync } from 'child_process'; + +const DELIM = '_OCR_ENV_DELIM_'; + +/** 从登录 shell 的 `env` 输出中解析出 key=value(取两个分隔标记之间的内容)。 */ +export function parseEnvBlock(stdout: string): Record { + const start = stdout.indexOf(DELIM); + const end = stdout.lastIndexOf(DELIM); + if (start === -1 || end === -1 || end <= start) return {}; + const block = stdout.slice(start + DELIM.length, end); + const env: Record = {}; + for (const line of block.split('\n')) { + const eq = line.indexOf('='); + if (eq > 0) env[line.slice(0, eq)] = line.slice(eq + 1); + } + return env; +} + +let cached: NodeJS.ProcessEnv | null = null; + +/** + * GUI 启动的 VSCode 继承的是精简 PATH,不含 nvm / homebrew / npm 全局 bin。 + * 通过用户的登录交互式 shell(加载 ~/.zshrc、~/.zprofile 等)解析真实环境变量并缓存。 + * Windows 下终端与 GUI 环境一致,直接用 process.env。 + */ +export function getShellEnv(): NodeJS.ProcessEnv { + if (cached) return cached; + if (process.platform === 'win32' || process.env.OCR_SKIP_SHELL_RESOLVE) { + cached = process.env; + return cached; + } + try { + const shell = process.env.SHELL || '/bin/zsh'; + const res = spawnSync(shell, ['-ilc', `echo ${DELIM}; env; echo ${DELIM}`], { + encoding: 'utf8', + timeout: 5000, + }); + const parsed = parseEnvBlock(res.stdout || ''); + cached = Object.keys(parsed).length ? { ...process.env, ...parsed } : process.env; + } catch { + cached = process.env; + } + return cached; +} + +const binCache = new Map(); + +/** + * 通过登录交互式 shell 解析命令的绝对路径(`command -v`),覆盖 nvm / homebrew + * 等用 shell function 或动态 PATH 暴露二进制的情况。解析失败时回退到原命令名 + * (交给 spawn 在注入的 PATH 中查找)。Windows 直接返回原名。 + */ +export function resolveBin(name: string): string { + if (process.platform === 'win32' || process.env.OCR_SKIP_SHELL_RESOLVE) return name; + const hit = binCache.get(name); + if (hit) return hit; + let resolved = name; + try { + const shell = process.env.SHELL || '/bin/zsh'; + const res = spawnSync(shell, ['-ilc', `command -v ${name}`], { + encoding: 'utf8', + timeout: 5000, + }); + const path = (res.stdout || '').trim().split('\n').pop()?.trim(); + if (path && path.startsWith('/')) resolved = path; + } catch { + // 回退到原命令名 + } + binCache.set(name, resolved); + return resolved; +} diff --git a/extensions/vscode/src/shared/constants.ts b/extensions/vscode/src/shared/constants.ts new file mode 100644 index 0000000..5bae7d3 --- /dev/null +++ b/extensions/vscode/src/shared/constants.ts @@ -0,0 +1,11 @@ +export const SIDEBAR_VIEW_ID = 'ocr.sidebar'; +export const COMMENT_CONTROLLER_ID = 'ocr-review'; + +export const COMMANDS = { + reviewStart: 'ocr.review.start', + reviewCancel: 'ocr.review.cancel', + configOpen: 'ocr.config.open', + commentApply: 'ocr.comment.apply', + commentDiscard: 'ocr.comment.discard', + commentFalsePositive: 'ocr.comment.falsePositive', +} as const; diff --git a/extensions/vscode/src/shared/messages.ts b/extensions/vscode/src/shared/messages.ts new file mode 100644 index 0000000..742511c --- /dev/null +++ b/extensions/vscode/src/shared/messages.ts @@ -0,0 +1,33 @@ +import { + CliResult, CliRunOptions, CommentSyncState, FileChange, GitState, LogLine, + OcrConfig, ReviewMode, ReviewState, +} from './types'; + +export type WebviewToHost = + | { type: 'ready' } + | { type: 'getGitState'; mode: ReviewMode } + | { type: 'getModeFiles'; mode: ReviewMode; from?: string; to?: string; commit?: string } + | { type: 'openFileDiff'; path: string; status: FileChange['status']; mode: ReviewMode; from?: string; to?: string; commit?: string } + | { type: 'startReview'; options: CliRunOptions } + | { type: 'cancelReview' } + | { type: 'getConfig' } + | { type: 'setConfig'; key: string; value: string } + | { type: 'testConnection' } + | { type: 'checkCli' } + | { type: 'installCli' } + | { type: 'jumpToComment'; index: number } + | { type: 'commentAction'; index: number; action: 'apply' | 'discard' | 'falsePositive' }; + +export type HostToWebview = + | { type: 'init'; config: OcrConfig | null; gitState: GitState } + | { type: 'gitState'; gitState: GitState } + | { type: 'modeFiles'; mode: ReviewMode; files: FileChange[] } + | { type: 'logLine'; line: LogLine } + | { type: 'stateChange'; state: ReviewState; error?: string } + | { type: 'reviewDone'; result: CliResult } + | { type: 'config'; config: OcrConfig | null } + | { type: 'connectionResult'; ok: boolean; message?: string } + | { type: 'cliStatus'; installed: boolean } + | { type: 'installLog'; line: LogLine } + | { type: 'installDone'; ok: boolean } + | { type: 'commentSync'; comments: CommentSyncState[] }; diff --git a/extensions/vscode/src/shared/types.ts b/extensions/vscode/src/shared/types.ts new file mode 100644 index 0000000..8679335 --- /dev/null +++ b/extensions/vscode/src/shared/types.ts @@ -0,0 +1,87 @@ +export type ReviewMode = 'workspace' | 'branch' | 'commit'; + +export type ReviewState = + | 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed'; + +export type CommentStatus = 'pending' | 'applied' | 'discarded' | 'falsePositive'; + +export interface ReviewComment { + path: string; + content: string; + suggestionCode?: string; + existingCode?: string; + startLine: number; + endLine: number; + thinking?: string; +} + +export interface ReviewSummary { + filesReviewed: number; + comments: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + elapsed: string; +} + +export interface AgentWarning { + type: string; + file: string; + message: string; +} + +export interface CliResult { + status: 'success' | 'completed_with_errors' | 'completed_with_warnings' | 'skipped'; + comments: ReviewComment[]; + warnings: AgentWarning[]; + summary?: ReviewSummary; + message?: string; +} + +export interface OcrConfig { + llm: { + url: string; + authToken: string; + model: string; + useAnthropic: boolean; + authHeader?: string; + }; + language: string; +} + +export interface CommitInfo { + sha: string; + message: string; + relativeTime: string; +} + +export interface FileChange { + path: string; + status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary'; +} + +export interface GitState { + branches: string[]; + currentBranch: string; + recentCommits: CommitInfo[]; + workspaceFiles: FileChange[]; +} + +export interface LogLine { + text: string; + level: 'info' | 'warn' | 'error'; +} + +export interface CliRunOptions { + mode: ReviewMode; + from?: string; + to?: string; + commit?: string; + customPrompt?: string; + concurrency?: number; +} + +export interface CommentSyncState { + index: number; + status: CommentStatus; +} diff --git a/extensions/vscode/src/webview/App.tsx b/extensions/vscode/src/webview/App.tsx new file mode 100644 index 0000000..047e3de --- /dev/null +++ b/extensions/vscode/src/webview/App.tsx @@ -0,0 +1,87 @@ +import { useEffect, useReducer } from 'preact/hooks'; +import { reducer, initialState } from './store'; +import { bridge } from './bridge'; +import { ReviewMode, CliRunOptions, FileChange } from '../shared/types'; +import { IdleView } from './views/IdleView'; +import { RunningView } from './views/RunningView'; +import { DoneView } from './views/DoneView'; +import { EmptyView } from './views/EmptyView'; +import { CancelledView } from './views/CancelledView'; +import { FailedView } from './views/FailedView'; +import { ConfigView } from './views/ConfigView'; +import './styles/global.css'; + +export function App() { + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + bridge.onMessage((msg) => dispatch(msg)); + bridge.post({ type: 'ready' }); + }, []); + + const configured = Boolean(state.config); + const start = (options: CliRunOptions) => { + dispatch({ type: 'startReview', mode: options.mode }); + bridge.post({ type: 'startReview', options }); + }; + const onModeChange = (mode: ReviewMode) => { + dispatch({ type: 'filesLoading' }); + bridge.post({ type: 'getGitState', mode }); + }; + const requestModeFiles = (mode: ReviewMode, from?: string, to?: string, commit?: string) => { + dispatch({ type: 'filesLoading' }); + bridge.post({ type: 'getModeFiles', mode, from, to, commit }); + }; + const openFile = (file: FileChange, mode: ReviewMode, from?: string, to?: string, commit?: string) => { + bridge.post({ type: 'openFileDiff', path: file.path, status: file.status, mode, from, to, commit }); + }; + + const openConfig = () => { + dispatch({ type: 'openConfig' }); + dispatch({ type: 'checkingCli' }); + bridge.post({ type: 'checkCli' }); + }; + + return ( +
+ + +
+ + + {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.configOpen && ( + { dispatch({ type: 'installingCli' }); bridge.post({ type: 'installCli' }); }} + onCheckCli={() => { dispatch({ type: 'checkingCli' }); bridge.post({ type: 'checkCli' }); }} + onTest={() => { dispatch({ type: 'testingConn' }); bridge.post({ type: 'testConnection' }); }} + onSave={(entries) => entries.forEach((e) => bridge.post({ type: 'setConfig', key: e.key, value: e.value }))} + onClose={() => dispatch({ type: 'closeConfig' })} + /> + )} +
+ ); +} diff --git a/extensions/vscode/src/webview/__tests__/store.test.ts b/extensions/vscode/src/webview/__tests__/store.test.ts new file mode 100644 index 0000000..568a750 --- /dev/null +++ b/extensions/vscode/src/webview/__tests__/store.test.ts @@ -0,0 +1,98 @@ +import { initialState, reducer } from '../store'; + +describe('reducer', () => { + it('init 设置 config 和 gitState', () => { + const s = reducer(initialState, { + type: 'init', + config: { llm: { url: 'u', authToken: '', model: 'm', useAnthropic: false }, language: 'Chinese' }, + gitState: { branches: [], currentBranch: 'main', recentCommits: [], workspaceFiles: [] }, + }); + expect(s.config?.llm.model).toBe('m'); + expect(s.gitState.currentBranch).toBe('main'); + expect(s.view).toBe('idle'); // 主界面始终是 idle(review 界面) + expect(s.configOpen).toBe(false); // 已配置 → 不弹配置浮层 + }); + + it('init 时 config 为 null → 主界面仍是 idle,且不自动弹出配置浮层', () => { + const s = reducer(initialState, { + type: 'init', config: null, + gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }, + }); + expect(s.view).toBe('idle'); + expect(s.configOpen).toBe(false); + }); + + it('init / gitState / modeFiles 结束 loading;filesLoading action 开启 loading', () => { + const init = reducer({ ...initialState, filesLoading: true }, { + type: 'init', config: null, + gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }, + }); + expect(init.filesLoading).toBe(false); + + const started = reducer(init, { type: 'filesLoading' }); + expect(started.filesLoading).toBe(true); + + const loaded = reducer(started, { type: 'gitState', gitState: init.gitState }); + expect(loaded.filesLoading).toBe(false); + }); + + it('openConfig / closeConfig 切换配置浮层', () => { + const opened = reducer(initialState, { type: 'openConfig' }); + expect(opened.configOpen).toBe(true); + const closed = reducer(opened, { type: 'closeConfig' }); + expect(closed.configOpen).toBe(false); + }); + + it('config 保存后更新 config 并关闭浮层', () => { + const s = reducer({ ...initialState, configOpen: true }, { + type: 'config', + config: { llm: { url: 'u', authToken: 't', model: 'm', useAnthropic: false }, language: 'Chinese' }, + }); + expect(s.config?.llm.model).toBe('m'); + expect(s.configOpen).toBe(false); + }); + + it('stateChange running 清空旧日志并切到 running 视图', () => { + const s = reducer({ ...initialState, logs: [{ text: 'old', level: 'info' }] }, { type: 'stateChange', state: 'running' }); + expect(s.session.state).toBe('running'); + expect(s.logs).toEqual([]); + expect(s.view).toBe('running'); + }); + + it('logLine 追加日志', () => { + const s = reducer(initialState, { type: 'logLine', line: { text: 'x', level: 'info' } }); + expect(s.logs).toHaveLength(1); + }); + + it('reviewDone 保存结果', () => { + const s = reducer(initialState, { + type: 'reviewDone', + result: { status: 'success', comments: [], warnings: [], summary: undefined }, + }); + expect(s.session.result?.status).toBe('success'); + }); + + it('stateChange done → view 切 done', () => { + expect(reducer(initialState, { type: 'stateChange', state: 'done' }).view).toBe('done'); + }); + + it('commentSync 更新评论状态映射', () => { + const s = reducer(initialState, { type: 'commentSync', comments: [{ index: 0, status: 'applied' }] }); + expect(s.commentStatus[0]).toBe('applied'); + }); +}); + +describe('modeFiles 消息', () => { + it('保存 mode 对应文件列表', () => { + const next = reducer(initialState, { + type: 'modeFiles', + mode: 'branch', + files: [{ path: 'src/a.ts', status: 'modified' }], + }); + expect(next.modeFiles).toEqual([{ path: 'src/a.ts', status: 'modified' }]); + }); + + it('init 时 modeFiles 为空数组', () => { + expect(initialState.modeFiles).toEqual([]); + }); +}); diff --git a/extensions/vscode/src/webview/bridge.ts b/extensions/vscode/src/webview/bridge.ts new file mode 100644 index 0000000..756f82d --- /dev/null +++ b/extensions/vscode/src/webview/bridge.ts @@ -0,0 +1,15 @@ +import { HostToWebview, WebviewToHost } from '../shared/messages'; + +interface VsCodeApi { postMessage(msg: unknown): void; } +declare function acquireVsCodeApi(): VsCodeApi; + +const vscode = acquireVsCodeApi(); + +export const bridge = { + post(msg: WebviewToHost): void { + vscode.postMessage(msg); + }, + onMessage(handler: (msg: HostToWebview) => void): void { + window.addEventListener('message', (e) => handler(e.data as HostToWebview)); + }, +}; diff --git a/extensions/vscode/src/webview/components/CommentCard.tsx b/extensions/vscode/src/webview/components/CommentCard.tsx new file mode 100644 index 0000000..59a5150 --- /dev/null +++ b/extensions/vscode/src/webview/components/CommentCard.tsx @@ -0,0 +1,26 @@ +import { ReviewComment, CommentStatus } from '../../shared/types'; + +interface Props { + comment: ReviewComment; + index: number; + status: CommentStatus; + canJump: boolean; + onOpen: (index: number) => void; + onAction: (index: number, action: 'apply' | 'discard' | 'falsePositive') => void; +} + +export function CommentCard({ comment, index, status, canJump, onOpen, onAction }: Props) { + return ( +
+
+ {comment.path} + L{comment.startLine} +
+
{comment.content}
+
+ {canJump && } + +
+
+ ); +} diff --git a/extensions/vscode/src/webview/components/FileList.tsx b/extensions/vscode/src/webview/components/FileList.tsx new file mode 100644 index 0000000..7c6c68e --- /dev/null +++ b/extensions/vscode/src/webview/components/FileList.tsx @@ -0,0 +1,36 @@ +import { FileChange } from '../../shared/types'; + +const BADGE: Record = { + added: 'A', modified: 'M', deleted: 'D', renamed: 'R', binary: 'B', +}; + +interface Props { files: FileChange[]; loading?: boolean; onOpenFile?: (file: FileChange) => void; } + +export function FileList({ files, loading, onOpenFile }: Props) { + return ( +
+
待审查文件 {loading ? '' : `(${files.length})`}
+ {loading ? ( +
+ {[68, 52, 60].map((w, i) => ( +
+
+
+ ))} +
+ ) : files.length === 0 ? ( +
无变更文件
+ ) : ( +
+ {files.map((f) => ( +
onOpenFile(f) : undefined}> + {f.path} + {BADGE[f.status]} +
+ ))} +
+ )} +
+ ); +} diff --git a/extensions/vscode/src/webview/components/LogViewer.tsx b/extensions/vscode/src/webview/components/LogViewer.tsx new file mode 100644 index 0000000..dc6fc9e --- /dev/null +++ b/extensions/vscode/src/webview/components/LogViewer.tsx @@ -0,0 +1,24 @@ +import { useRef, useEffect } from 'preact/hooks'; +import { LogLine } from '../../shared/types'; + +interface Props { logs: LogLine[]; } + +export function LogViewer({ logs }: Props) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; + }, [logs.length]); + + return ( +
+ {logs.length === 0 ? ( +
等待输出
+ ) : ( + logs.map((l, i) => ( +
{l.text}
+ )) + )} +
+ ); +} diff --git a/extensions/vscode/src/webview/components/Select.tsx b/extensions/vscode/src/webview/components/Select.tsx new file mode 100644 index 0000000..bfea967 --- /dev/null +++ b/extensions/vscode/src/webview/components/Select.tsx @@ -0,0 +1,55 @@ +import { useState, useRef, useEffect } from 'preact/hooks'; + +export interface SelectOption { + value: string; + label: string; +} + +interface Props { + value: string; + options: SelectOption[]; + placeholder?: string; + onChange: (value: string) => void; +} + +export function Select({ value, options, placeholder = '请选择', onChange }: Props) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [open]); + + const selected = options.find((o) => o.value === value); + + const pick = (v: string) => { onChange(v); setOpen(false); }; + + return ( +
+ + {open && ( +
+ {options.map((o) => ( +
pick(o.value)} + > + {o.label} +
+ ))} +
+ )} +
+ ); +} diff --git a/extensions/vscode/src/webview/index.tsx b/extensions/vscode/src/webview/index.tsx new file mode 100644 index 0000000..b3d4f15 --- /dev/null +++ b/extensions/vscode/src/webview/index.tsx @@ -0,0 +1,5 @@ +import { render } from 'preact'; +import { App } from './App'; + +const root = document.getElementById('root'); +if (root) render(, root); diff --git a/extensions/vscode/src/webview/store.ts b/extensions/vscode/src/webview/store.ts new file mode 100644 index 0000000..cab524e --- /dev/null +++ b/extensions/vscode/src/webview/store.ts @@ -0,0 +1,118 @@ +import { CliResult, CommentStatus, FileChange, GitState, LogLine, OcrConfig, ReviewMode, ReviewState } from '../shared/types'; +import { HostToWebview } from '../shared/messages'; + +export type AppView = 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed'; + +export type CliStatus = 'unknown' | 'checking' | 'installed' | 'missing'; +export type ConnTest = { status: 'idle' | 'testing' | 'ok' | 'fail'; message?: string }; + +export interface AppState { + view: AppView; + configOpen: boolean; + config: OcrConfig | null; + gitState: GitState; + modeFiles: FileChange[]; + filesLoading: boolean; + logs: LogLine[]; + session: { state: ReviewState; result: CliResult | null; error?: string }; + commentStatus: Record; + cliStatus: CliStatus; + installing: boolean; + installLogs: LogLine[]; + connTest: ConnTest; + reviewMode: ReviewMode; +} + +export const initialState: AppState = { + view: 'idle', + configOpen: false, + config: null, + gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }, + modeFiles: [], + filesLoading: true, + logs: [], + session: { state: 'idle', result: null }, + commentStatus: {}, + cliStatus: 'unknown', + installing: false, + installLogs: [], + connTest: { status: 'idle' }, + reviewMode: 'workspace', +}; + +const STATE_TO_VIEW: Record = { + idle: 'idle', running: 'running', done: 'done', + empty: 'empty', cancelled: 'cancelled', failed: 'failed', +}; + +export type LocalAction = + | { type: 'openConfig' } + | { type: 'closeConfig' } + | { type: 'filesLoading' } + | { type: 'checkingCli' } + | { type: 'installingCli' } + | { type: 'testingConn' } + | { type: 'startReview'; mode: ReviewMode }; + +export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppState { + switch (msg.type) { + case 'openConfig': + return { ...state, configOpen: true, connTest: { status: 'idle' } }; + case 'closeConfig': + return { ...state, configOpen: false, installLogs: [], connTest: { status: 'idle' } }; + case 'filesLoading': + return { ...state, filesLoading: true }; + case 'checkingCli': + return { ...state, cliStatus: 'checking' }; + case 'installingCli': + return { ...state, installing: true, installLogs: [] }; + case 'testingConn': + return { ...state, connTest: { status: 'testing' } }; + case 'startReview': + return { ...state, reviewMode: msg.mode }; + case 'cliStatus': + return { ...state, cliStatus: msg.installed ? 'installed' : 'missing' }; + case 'installLog': + return { ...state, installLogs: [...state.installLogs, msg.line] }; + case 'installDone': + return { ...state, installing: false }; + case 'init': + return { + ...state, + config: msg.config, + gitState: msg.gitState, + view: 'idle', + filesLoading: false, + }; + case 'gitState': + return { ...state, gitState: msg.gitState, filesLoading: false }; + case 'modeFiles': + return { ...state, modeFiles: msg.files, filesLoading: false }; + case 'config': + // 保存配置后:更新 config,若已配置则关闭浮层 + return { ...state, config: msg.config, configOpen: msg.config ? false : state.configOpen }; + case 'stateChange': { + const starting = msg.state === 'running'; + return { + ...state, + logs: starting ? [] : state.logs, + commentStatus: starting ? {} : state.commentStatus, + session: { state: msg.state, result: starting ? null : state.session.result, error: msg.error }, + view: STATE_TO_VIEW[msg.state], + }; + } + case 'logLine': + return { ...state, logs: [...state.logs, msg.line] }; + case 'reviewDone': + return { ...state, session: { ...state.session, result: msg.result } }; + case 'commentSync': { + const commentStatus = { ...state.commentStatus }; + for (const c of msg.comments) commentStatus[c.index] = c.status; + return { ...state, commentStatus }; + } + case 'connectionResult': + return { ...state, connTest: { status: msg.ok ? 'ok' : 'fail', message: msg.message } }; + default: + return state; + } +} diff --git a/extensions/vscode/src/webview/styles/global.css b/extensions/vscode/src/webview/styles/global.css new file mode 100644 index 0000000..aa6513b --- /dev/null +++ b/extensions/vscode/src/webview/styles/global.css @@ -0,0 +1,1500 @@ +/* === 1. CSS Variables === + * 结构色映射到 VSCode 主题 token(带 silent-night 回退值),自动适配亮/暗主题。 + * accent(薄荷绿)保留为品牌色,不跟随主题。 */ +:root { + /* 背景层:sidebar / 输入 / hover */ + --bg: var(--vscode-sideBar-background, #0a0d12); + --bg-soft: var(--vscode-sideBarSectionHeader-background, #11151a); + --bg-deeper: var(--vscode-editor-background, #06090d); + --card: var(--vscode-sideBar-background, #14181e); + --card-soft: var(--vscode-list-hoverBackground, #1a1f27); + --card-quiet: var(--vscode-list-activeSelectionBackground, #20262f); + --card-deeper: var(--vscode-input-background, #0d1015); + /* 文字层 */ + --ink: var(--vscode-foreground, #ececec); + --ink-soft: var(--vscode-foreground, #b8b8b2); + --ink-quiet: var(--vscode-descriptionForeground, #7e828a); + --ink-faint: var(--vscode-disabledForeground, #4f535b); + --moon: var(--vscode-foreground, #f3f3ef); + --moon-soft: var(--vscode-descriptionForeground, #b8b8b3); + --moon-quiet: var(--vscode-descriptionForeground, #6b6f76); + --moon-faint: var(--vscode-disabledForeground, #4a4f56); + /* 边框 */ + --rule: var(--vscode-widget-border, rgba(255, 255, 255, 0.07)); + --rule-soft: var(--vscode-widget-border, rgba(255, 255, 255, 0.04)); + /* 中性叠加(微妙背景填充,用 list hover token 适配亮/暗) */ + --fill-soft: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.04)); + --fill: var(--vscode-list-inactiveSelectionBackground, rgba(255, 255, 255, 0.08)); + /* accent:薄荷绿品牌色(保留,不跟随主题) */ + --mint: #45e6a4; + --mint-soft: #8ff0c2; + --mint-glow: rgba(69, 230, 164, 0.35); + --mint-tint: rgba(69, 230, 164, 0.10); + --font: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Inter', system-ui, 'Helvetica Neue', 'PingFang SC', 'Noto Sans SC', sans-serif); + --font-display: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Inter', system-ui, 'PingFang SC', sans-serif); + --font-mono: var(--vscode-editor-font-family, 'JetBrains Mono', 'SF Mono', 'Berkeley Mono', Consolas, Menlo, monospace); +} + +/* === 2. Global Base (silent-night-ui §2) === */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + background: var(--bg); + color: var(--ink); + font-family: var(--font); + font-size: 13px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + overflow: hidden; + height: 100vh; +} + +::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--vscode-scrollbarSlider-background, rgba(255, 255, 255, 0.08)); + border-radius: 2px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--vscode-scrollbarSlider-hoverBackground, rgba(255, 255, 255, 0.18)); +} + +::selection { + background: var(--vscode-editor-selectionBackground, var(--fill)); +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + + 50% { + opacity: 0.45; + transform: scale(0.85); + } +} + +/* === 3. Root container (sidebar host) === */ +.ocr-root { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + height: 100vh; + background: var(--card); + overflow: hidden; +} + +.ocr-root>* { + position: relative; + z-index: 1; +} + +/* === 5. Setup Region === */ +.setup { + padding: 0px; + flex-shrink: 0; +} + +.mode-tabs { + display: flex; + background: var(--vscode-input-background, var(--card-deeper)); + border: 1px solid var(--rule-soft); + border-radius: 8px; + padding: 3px; + margin-bottom: 12px; + gap: 2px; +} + +.mode-tab { + flex: 1; + padding: 5px 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--ink-quiet); + font-family: var(--font); + font-size: 11.5px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s, color 0.2s, box-shadow 0.2s; + text-align: center; +} + +.mode-tab:hover:not(.active) { + color: var(--ink-soft); + background: var(--fill-soft); +} + +.mode-tab.active { + background: var(--fill); + color: var(--ink); + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.mode-params { + display: none; + margin-bottom: 12px; +} + +.mode-params.active { + display: block; +} + +.mode-param-label { + font-size: 11px; + color: var(--ink-quiet); + margin-bottom: 4px; +} + +.mode-param-label:not(:first-child) { + margin-top: 8px; +} + +.mode-param-input { + width: 100%; + padding: 6px 10px; + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + color: var(--ink); + font-family: var(--font-mono); + font-size: 12px; + outline: none; + transition: border-color 0.2s; +} + +.mode-param-input:focus { + border-color: var(--ink-faint); +} + +.mode-param-input::placeholder { + color: var(--ink-faint); +} + +/* === 自定义 Select === */ +.select { + position: relative; + width: 100%; +} + +.select-trigger { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + background: var(--card); + border: 1px solid var(--rule); + border-radius: 8px; + color: var(--ink); + font-family: var(--font-mono); + font-size: 12px; + text-align: left; + outline: none; + cursor: pointer; + transition: border-color 0.2s; +} + +.select.open .select-trigger { + border-color: var(--ink-faint); +} + +.select-value { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.select-value.placeholder { + color: var(--ink-faint); +} + +.select-arrow { + flex-shrink: 0; + width: 7px; + height: 7px; + margin-right: 2px; + border-right: 1.5px solid var(--ink-quiet); + border-bottom: 1.5px solid var(--ink-quiet); + transform: rotate(45deg) translateY(-1px); + transition: transform 0.2s; +} + +.select.open .select-arrow { + transform: rotate(-135deg) translateY(-1px); +} + +.select-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 30; + max-height: 260px; + overflow-y: auto; + background: var(--vscode-dropdown-background, var(--card-soft)); + border: 1px solid var(--rule); + border-radius: 8px; + padding: 4px; + box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.6); +} + +.select-option { + padding: 6px 9px; + border-radius: 6px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-soft); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background-color 0.12s, color 0.12s; +} + +.select-option:hover { + background: var(--card-quiet); + color: var(--ink); +} + +.select-option.active { + color: var(--mint); + background: var(--mint-tint); +} + +.commit-list { + margin-top: 8px; + max-height: 240px; + overflow-y: auto; + padding-right: 4px; +} + +.commit-row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 7px 8px; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.15s; +} + +.commit-row:hover { + background: var(--card-soft); +} + +.commit-row.active { + background: var(--mint-tint); +} + +.commit-radio { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border: 1.5px solid var(--ink-faint); + border-radius: 50%; + background: transparent; + cursor: pointer; + flex-shrink: 0; + margin-top: 1px; + outline: none; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s; +} + +.commit-radio:focus, +.commit-radio:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--mint-tint); +} + +.commit-row.active .commit-radio { + border-color: var(--mint); +} + +.commit-row.active .commit-radio::after { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--mint); +} + +.commit-info { + flex: 1; + min-width: 0; +} + +.commit-msg { + font-size: 12px; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; +} + +.commit-meta { + font-size: 10.5px; + color: var(--ink-faint); + font-family: var(--font-mono); + margin-top: 1px; +} + +.commit-meta .commit-sha { + color: var(--ink-quiet); +} + +.files-label { + font-size: 10.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-quiet); + font-weight: 600; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.files-label::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 6px var(--mint-glow); + flex-shrink: 0; +} + +.file-list { + margin-bottom: 14px; +} + +.file-scroll { + min-height: 90px; + max-height: 200px; + overflow-y: auto; + padding-right: 4px; +} + +/* 内部滚动容器细滚动条(覆盖 webview 注入的粗滚动条) */ +.commit-list::-webkit-scrollbar, +.file-scroll::-webkit-scrollbar { + width: 5px; +} + +.commit-list::-webkit-scrollbar-track, +.file-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.commit-list::-webkit-scrollbar-thumb, +.file-scroll::-webkit-scrollbar-thumb { + background: var(--vscode-scrollbarSlider-background, rgba(255, 255, 255, 0.10)); + border-radius: 3px; +} + +.commit-list::-webkit-scrollbar-thumb:hover, +.file-scroll::-webkit-scrollbar-thumb:hover { + background: var(--vscode-scrollbarSlider-hoverBackground, rgba(255, 255, 255, 0.20)); +} + +.file-loading { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px; +} + +.skeleton-row { + display: flex; + align-items: center; + gap: 8px; + height: 22px; +} + +.skeleton-bar { + height: 11px; + border-radius: 4px; + background: linear-gradient( + 90deg, + var(--fill-soft) 25%, + var(--fill) 50%, + var(--fill-soft) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.3s ease-in-out infinite; +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.file-empty { + min-height: 90px; + display: flex; + align-items: center; + justify-content: center; + padding: 10px 4px; + font-size: 12px; + color: var(--ink-faint); +} + +.file-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 4px; + border-radius: 6px; + transition: background-color 0.15s; + cursor: pointer; +} + +.file-row:hover { + background: var(--card-soft); +} + +.file-row input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border: 1.5px solid var(--ink-faint); + border-radius: 3px; + background: transparent; + cursor: pointer; + flex-shrink: 0; + position: relative; + transition: border-color 0.15s, background-color 0.15s; +} + +.file-row input[type="checkbox"]:checked { + background: var(--mint); + border-color: var(--mint); +} + +.file-row input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 3.5px; + top: 1px; + width: 4px; + height: 7px; + border: solid #0a1010; + border-width: 0 1.5px 1.5px 0; + transform: rotate(45deg); +} + +.file-name { + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--ink); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-badge { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + padding: 1px 6px; + border-radius: 4px; + flex-shrink: 0; +} + +.file-badge.added { + color: var(--mint); + background: var(--mint-tint); +} + +.file-badge.modified { + color: var(--ink-quiet); + background: var(--fill-soft); +} + +.file-badge.deleted { + color: var(--ink-faint); + background: var(--fill-soft); +} + +.primary-btn { + width: 100%; + margin-top: 12px; + padding: 8px 14px; + background: var(--mint); + color: #0a1010; + border: none; + border-radius: 8px; + font-family: var(--font); + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.primary-btn:hover { + background: var(--mint-soft); +} + +.primary-btn:disabled { + background: var(--fill); + color: var(--ink-quiet); + cursor: not-allowed; +} + +.skeleton-btn { + background: var(--fill-soft); + pointer-events: none; +} + +.skeleton-btn .skeleton-bar { + height: 11px; +} + +.config-fab { + position: absolute; + top: 8px; + right: 10px; + z-index: 5; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-radius: 6px; + color: var(--ink-quiet); + font-size: 15px; + cursor: pointer; + transition: background-color 0.2s, color 0.2s; +} + +.config-fab:hover { + background: var(--fill-soft); + color: var(--ink); +} + +/* === 6. Action Region === */ +.action-region { + flex: 1; + overflow-y: auto; + padding: 12px; + padding-top: 44px; +} + +.result-region { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--rule); +} + +.idle-note { + text-align: center; + padding: 40px 20px; + font-size: 12.5px; + color: var(--ink-faint); + line-height: 1.6; +} + +/* Running: Log Viewer */ +.log-viewer { + background: var(--bg-deeper); + border: 1px solid var(--rule-soft); + border-radius: 10px; + padding: 10px 12px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.75; + color: var(--ink-soft); + max-height: 320px; + overflow-y: auto; +} + +.logs-disclosure { + margin-bottom: 14px; +} + +.logs-toggle { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + padding: 4px 0; + margin-bottom: 6px; + font-family: var(--font); + font-size: 11.5px; + color: var(--ink-quiet); + cursor: pointer; + transition: color 0.15s; +} + +.logs-toggle:hover { + color: var(--ink-soft); +} + +.logs-toggle-arrow { + width: 5px; + height: 5px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.2s; +} + +.logs-toggle-arrow.open { + transform: rotate(45deg); +} + +.log-line { + white-space: pre-wrap; + word-break: break-all; +} + +.log-line.hidden { + display: none; +} + +.log-line .log-tag { + color: var(--ink-quiet); +} + +.log-line .log-file { + color: var(--mint); +} + +.log-line .log-dim { + color: var(--ink-faint); +} + +.log-line .log-warn { + color: #e6a845; +} + +.log-line.log-warn { + color: #e6a845; +} + +.log-cursor { + display: inline-block; + width: 2px; + height: 12px; + background: var(--mint); + vertical-align: text-bottom; + animation: blink 1s step-end infinite; + margin-left: 3px; +} + +@keyframes blink { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0; + } +} + +.cancel-pill { + display: inline-flex; + align-items: center; + padding: 5px 14px; + background: var(--fill-soft); + border: 1px solid var(--rule); + border-radius: 999px; + font-family: var(--font); + font-size: 11.5px; + color: var(--ink-quiet); + cursor: pointer; + margin-top: 12px; + float: right; + transition: background-color 0.2s, color 0.2s; +} + +.cancel-pill:hover { + background: var(--fill); + color: var(--ink-soft); +} + +.replay-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + background: var(--fill-soft); + border: 1px solid var(--rule); + border-radius: 999px; + font-family: var(--font); + font-size: 11px; + color: var(--ink-quiet); + cursor: pointer; + margin-top: 8px; + transition: background-color 0.2s, color 0.2s; +} + +.replay-btn:hover { + background: var(--fill); + color: var(--ink-soft); +} + +.replay-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Done: Summary */ +.done-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: var(--mint-tint); + border-radius: 10px; + margin-bottom: 14px; + font-size: 12.5px; + color: var(--mint); + font-weight: 500; +} + +.done-summary .ds-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--mint); + flex-shrink: 0; +} + +/* Comment Card */ +.comment-card { + background: var(--card-soft); + border-radius: 12px; + padding: 14px; + margin-bottom: 10px; + transition: opacity 0.3s, max-height 0.3s, padding 0.3s, margin 0.3s; + max-height: 400px; + overflow: hidden; +} + +.comment-card.dismissed { + opacity: 0; + max-height: 0; + padding: 0 14px; + margin-bottom: 0; +} + +.comment-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.comment-file { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--ink-soft); +} + +.comment-line { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-quiet); + background: var(--card-quiet); + padding: 1px 6px; + border-radius: 4px; +} + +.severity-pill { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 999px; +} + +.severity-pill.critical { + background: var(--mint-tint); + color: var(--mint); +} + +.severity-pill.warn { + background: var(--card-quiet); + color: var(--ink-soft); +} + +.severity-pill.info { + background: var(--fill-soft); + color: var(--ink-quiet); +} + +.comment-body { + font-size: 13px; + line-height: 1.6; + color: var(--ink); + margin-bottom: 10px; +} + +.comment-actions { + display: flex; + gap: 6px; +} + +.comment-actions button { + background: none; + border: 1px solid var(--rule); + border-radius: 6px; + padding: 4px 10px; + font-family: var(--font); + font-size: 11px; + color: var(--ink-quiet); + cursor: pointer; + transition: background-color 0.15s, color 0.15s, border-color 0.15s; +} + +.comment-actions button:hover { + background: var(--card-quiet); + color: var(--ink-soft); + border-color: var(--rule); +} + +.empty-note { + text-align: center; + padding: 40px 20px; +} + +.empty-note .en-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 8px var(--mint-glow); + margin-bottom: 14px; +} + +.empty-note .en-text { + font-size: 13px; + color: var(--mint); + font-weight: 500; +} + +.cancelled-note { + padding: 12px 14px; + border-left: 2.5px solid var(--ink-faint); + border-radius: 0 10px 10px 0; + background: var(--card-soft); + margin-bottom: 14px; + font-size: 12.5px; + color: var(--ink-quiet); + line-height: 1.5; +} + +.failed-card { + background: var(--card-soft); + border-radius: 12px; + padding: 18px; + text-align: center; +} + +.failed-card .fc-msg { + font-size: 13px; + color: var(--ink-soft); + margin-bottom: 14px; + line-height: 1.6; +} + +.failed-card .fc-detail { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--ink-soft); + background: rgba(0, 0, 0, 0.25); + border-radius: 8px; + padding: 8px 10px; + margin-bottom: 14px; + text-align: left; + white-space: pre-wrap; + word-break: break-word; + max-height: 160px; + overflow-y: auto; + user-select: text; +} + +.retry-pill { + display: inline-flex; + align-items: center; + padding: 6px 18px; + background: var(--mint); + color: #0a1010; + border: none; + border-radius: 999px; + font-family: var(--font); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.retry-pill:hover { + background: var(--mint-soft); +} + +/* === 7. Config Views === */ +.config-empty { + padding: 60px 24px; + text-align: center; +} + +.config-empty .ce-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 12px var(--mint-glow); + margin-bottom: 20px; +} + +.config-empty .ce-label { + font-size: 10.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-quiet); + font-weight: 600; + margin-bottom: 16px; +} + +.config-empty .ce-title { + font-size: 17px; + font-weight: 600; + color: var(--ink); + margin-bottom: 10px; +} + +.config-empty .ce-desc { + font-size: 12.5px; + color: var(--ink-quiet); + line-height: 1.6; + margin-bottom: 28px; + max-width: 260px; + margin-left: auto; + margin-right: auto; +} + +.config-empty .ce-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 24px; + background: var(--mint); + color: #0a1010; + border: none; + border-radius: 10px; + font-family: var(--font); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.config-empty .ce-btn:hover { + background: var(--mint-soft); +} + +.config-list { + padding: 14px; +} + +.config-list-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} + +.config-list-title { + font-size: 10.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-quiet); + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.config-list-title::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 6px var(--mint-glow); +} + +.config-list-close { + background: none; + border: none; + color: var(--ink-quiet); + font-size: 16px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + transition: background-color 0.2s, color 0.2s; +} + +.config-list-close:hover { + background: var(--card-quiet); + color: var(--ink); +} + +.provider-card { + background: var(--card-soft); + border-radius: 12px; + padding: 14px; + margin-bottom: 8px; + cursor: pointer; + transition: background-color 0.15s; + display: flex; + align-items: center; + gap: 10px; +} + +.provider-card:hover { + background: var(--card-quiet); +} + +.provider-card .pc-name { + font-size: 13px; + font-weight: 600; + color: var(--ink); + flex: 1; +} + +.provider-card .pc-models { + font-size: 11px; + color: var(--ink-quiet); +} + +.provider-card .pc-active { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 6px var(--mint-glow); +} + +.config-add-btn { + width: 100%; + padding: 10px; + background: transparent; + border: 1px dashed var(--rule); + border-radius: 10px; + font-family: var(--font); + font-size: 12.5px; + color: var(--ink-quiet); + cursor: pointer; + margin-top: 8px; + transition: background-color 0.2s, color 0.2s, border-color 0.2s; +} + +.config-add-btn:hover { + background: var(--card-soft); + color: var(--ink-soft); + border-color: var(--rule); +} + +/* === Config Modal === */ +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 16px; +} + +.modal-panel { + width: 100%; + max-width: 360px; + max-height: 90vh; + overflow-y: auto; + background: var(--vscode-editor-background, var(--card)); + border: 1px solid var(--rule); + border-radius: 14px; + padding: 18px; + box-shadow: 0 20px 48px -12px rgba(0, 0, 0, 0.7); +} + +.config-form { + padding: 14px; +} + +.config-form-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 18px; +} + +/* === Config Wizard === */ +.wizard-steps { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 18px; +} + +.wizard-step { + font-size: 11px; + color: var(--ink-faint); + white-space: nowrap; + transition: color 0.2s; +} + +.wizard-step.active { + color: var(--ink); + font-weight: 600; +} + +.wizard-step.done { + color: var(--mint); +} + +.wizard-step-line { + flex: 1; + height: 1px; + background: var(--rule); +} + +.wizard-body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.cli-status { + font-size: 12.5px; + line-height: 1.6; + padding: 10px 12px; + border-radius: 8px; +} + +.cli-status.checking { + color: var(--ink-quiet); + background: var(--fill-soft); +} + +.cli-status.missing { + color: var(--ink-soft); + background: var(--fill-soft); +} + +.cli-status.ok { + color: var(--mint); + background: var(--mint-tint); +} + +.cli-hint { + font-size: 11.5px; + color: var(--ink-quiet); + line-height: 1.5; +} + +.cli-hint code { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-soft); + background: var(--card-deeper); + padding: 1px 5px; + border-radius: 4px; +} + +.conn-result { + font-size: 12px; + line-height: 1.5; + padding: 8px 12px; + border-radius: 8px; + word-break: break-word; +} + +.conn-result.testing { + color: var(--ink-quiet); + background: var(--fill-soft); +} + +.conn-result.ok { + color: var(--mint); + background: var(--mint-tint); +} + +.conn-result.fail { + color: #e6a845; + background: var(--fill-soft); +} + +.config-form-title { + font-size: 10.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-quiet); + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.config-form-title::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 6px var(--mint-glow); +} + +.form-group { + margin-bottom: 14px; +} + +.form-label { + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-quiet); + font-weight: 600; + margin-bottom: 6px; + display: block; +} + +.form-label .optional { + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: var(--ink-faint); + font-size: 10.5px; +} + +.form-input { + width: 100%; + padding: 8px 12px; + background: var(--card-soft); + border: 1px solid var(--rule); + border-radius: 8px; + font-family: var(--font); + font-size: 13px; + color: var(--ink); + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-input:focus { + border-color: var(--mint); + box-shadow: 0 0 0 2px var(--mint-tint); +} + +.form-input::placeholder { + color: var(--ink-faint); +} + +.model-rows { + margin-bottom: 8px; +} + +.model-row-entry { + display: grid; + grid-template-columns: 1fr 1fr 28px; + gap: 6px; + margin-bottom: 6px; + align-items: center; +} + +.model-row-entry .form-input { + padding: 7px 10px; + font-size: 12px; +} + +.model-row-delete { + background: none; + border: none; + color: var(--ink-faint); + font-size: 14px; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: color 0.15s; + text-align: center; +} + +.model-row-delete:hover { + color: var(--mint); +} + +.model-add-btn { + background: none; + border: none; + color: var(--ink-quiet); + font-size: 12px; + cursor: pointer; + padding: 4px 0; + transition: color 0.15s; +} + +.model-add-btn:hover { + color: var(--mint); +} + +.advanced-section { + margin-bottom: 18px; +} + +.advanced-section summary { + font-size: 12px; + color: var(--ink-quiet); + cursor: pointer; + list-style: none; + padding: 6px 0; + transition: color 0.15s; +} + +.advanced-section summary::-webkit-details-marker { + display: none; +} + +.advanced-section summary:hover { + color: var(--ink-soft); +} + +.advanced-section .adv-content { + padding: 10px 0; +} + +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.toggle-label { + font-size: 12.5px; + color: var(--ink-soft); +} + +.toggle-switch { + position: relative; + width: 36px; + height: 20px; + background: var(--card-quiet); + border-radius: 999px; + cursor: pointer; + transition: background-color 0.2s; + border: none; + padding: 0; +} + +.toggle-switch.on { + background: var(--mint); +} + +.toggle-switch .toggle-knob { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + background: white; + border-radius: 50%; + transition: transform 0.2s; +} + +.toggle-switch.on .toggle-knob { + transform: translateX(16px); +} + +.form-actions { + display: flex; + gap: 8px; + margin-top: 20px; +} + +.form-actions .btn-cancel { + flex: 1; + padding: 9px; + background: transparent; + border: 1px solid var(--rule); + border-radius: 8px; + font-family: var(--font); + font-size: 12.5px; + color: var(--ink-quiet); + cursor: pointer; + transition: background-color 0.15s, color 0.15s; +} + +.form-actions .btn-cancel:hover { + background: var(--card-soft); + color: var(--ink-soft); +} + +.form-actions .btn-save { + flex: 1; + padding: 9px; + background: var(--mint); + color: #0a1010; + border: none; + border-radius: 8px; + font-family: var(--font); + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.form-actions .btn-save:hover { + background: var(--mint-soft); +} + +.form-actions .btn-save:disabled { + opacity: 0.4; + cursor: not-allowed; +} \ No newline at end of file diff --git a/extensions/vscode/src/webview/views/CancelledView.tsx b/extensions/vscode/src/webview/views/CancelledView.tsx new file mode 100644 index 0000000..4314dc4 --- /dev/null +++ b/extensions/vscode/src/webview/views/CancelledView.tsx @@ -0,0 +1,7 @@ +export function CancelledView() { + return ( +
+
审查已取消
+
+ ); +} diff --git a/extensions/vscode/src/webview/views/ConfigView.tsx b/extensions/vscode/src/webview/views/ConfigView.tsx new file mode 100644 index 0000000..250a123 --- /dev/null +++ b/extensions/vscode/src/webview/views/ConfigView.tsx @@ -0,0 +1,185 @@ +import { useState } from 'preact/hooks'; +import { OcrConfig } from '../../shared/types'; +import { CliStatus, ConnTest } from '../store'; +import { LogLine } from '../../shared/types'; +import { LogViewer } from '../components/LogViewer'; +import { Select } from '../components/Select'; + +interface Props { + config: OcrConfig | null; + cliStatus: CliStatus; + installing: boolean; + installLogs: LogLine[]; + connTest: ConnTest; + onInstall: () => void; + onCheckCli: () => void; + onTest: () => void; + onSave: (entries: { key: string; value: string }[]) => void; + onClose: () => void; +} + +export function ConfigView({ + config, cliStatus, installing, installLogs, connTest, + onInstall, onCheckCli, onTest, onSave, onClose, +}: Props) { + const [step, setStep] = useState<1 | 2>(1); + + const [url, setUrl] = useState(config?.llm.url ?? ''); + const [token, setToken] = useState(config?.llm.authToken ?? ''); + const [model, setModel] = useState(config?.llm.model ?? ''); + const [useAnthropic, setUseAnthropic] = useState(config?.llm.useAnthropic ?? false); + const [authHeader, setAuthHeader] = useState(config?.llm.authHeader ?? ''); + + const canSave = url.trim() !== '' && model.trim() !== ''; + + const save = () => { + if (!canSave) return; + const entries = [ + { key: 'llm.url', value: url.trim() }, + { key: 'llm.auth_token', value: token.trim() }, + { key: 'llm.model', value: model.trim() }, + { key: 'llm.use_anthropic', value: String(useAnthropic) }, + ]; + if (authHeader) entries.push({ key: 'llm.auth_header', value: authHeader }); + onSave(entries); + }; + + return ( + + ); +} + +function Step1({ cliStatus, installing, installLogs, onInstall, onCheckCli, onNext }: { + cliStatus: CliStatus; installing: boolean; installLogs: LogLine[]; + onInstall: () => void; onCheckCli: () => void; onNext: () => void; +}) { + if (installing) { + return ( +
+
正在安装 ocr CLI…
+ +
+ ); + } + + if (cliStatus === 'checking' || cliStatus === 'unknown') { + return
正在检测 ocr 命令…
; + } + + if (cliStatus === 'missing') { + return ( +
+
未检测到 ocr 命令。需要全局安装后才能进行代码审查。
+
将执行:npm install -g @alibaba-group/open-code-review
+ {installLogs.length > 0 && } +
+ + +
+
+ ); + } + + // installed + return ( +
+
✓ ocr 命令已安装
+
+ +
+
+ ); +} + +function Step2({ + url, token, model, useAnthropic, authHeader, + setUrl, setToken, setModel, setUseAnthropic, setAuthHeader, + connTest, canSave, onBack, onTest, onSave, +}: { + url: string; token: string; model: string; useAnthropic: boolean; authHeader: string; + setUrl: (v: string) => void; setToken: (v: string) => void; setModel: (v: string) => void; + setUseAnthropic: (v: boolean) => void; setAuthHeader: (v: string) => void; + connTest: ConnTest; canSave: boolean; + onBack: () => void; onTest: () => void; onSave: () => void; +}) { + return ( +
+
+ + setUrl((e.target as HTMLInputElement).value)} placeholder="https://api.anthropic.com/v1/messages" /> +
+
+ + setToken((e.target as HTMLInputElement).value)} placeholder="sk-..." /> +
+
+ + setModel((e.target as HTMLInputElement).value)} placeholder="claude-opus-4-6" /> +
+
+ 使用 Anthropic 协议 + +
+ +
+ 高级选项 +
+
+ + ({ value: b, label: b }))} /> +
目标引用
+ +
+
{c.message}
+
{c.sha} · {c.relativeTime}
+
+ + ))} +
+
+ )} + + onOpenFile(f, mode, from, to, commit)} /> + +