mirror of
https://github.com/Heretek-AI/open-code-review.git
synced 2026-11-07 02:46:04 -10:00
Feat/idea plugin (#1031)
* feat(idea): add IntelliJ IDEA plugin extension Add a new JetBrains IDE extension under extensions/idea/ that brings the Open Code Review workflow to IntelliJ-based IDEs. The implementation mirrors the VSCode extension architecture: - JCEF-based webview for sidebar and config panel (shared HTML/JS bundle) - Same CLI integration (ocr review, config, install) - Git integration via command-line git (branch/commit/workspace modes) - Comment mounting with RangeHighlighter and inline panels - i18n support (en/zh-cn), matching the VSCode extension - Theme-aware CSS variables synced from IDEA's LookAndFeel 195 tests pass across 15 suites. ./gradlew runIde launches successfully. Closes #499 * fix(idea): harden process/threading/disposal + subdir jump path-basis Address ocr code-review findings across the host side: - CliService: probe/runRaw/install process lifecycle (bounded joins, closeStreamsQuietly, SIGTERM+grace, independent install slot, atomic current claim), cancel getAndSet, install stale killStaleInstall. - GitService: stdout UTF-8, reader join on timeout, getState synchronized, isRelevantTo case-insensitive + os.name hoist, safeRef injection guard, getCommitFiles dedup. - ConfigService: atomic temp+rename write, dir 700, readRaw log, setMany rollback + failing-key log, @Synchronized writers, trySetPosix Windows skip. - JCEF disposal: EDT-safe browser/dialog teardown, idempotent dispose, PCE rethrow, init try-catch self-cleanup, @Volatile channel. - ReviewSession: cancel CAS, run-before-cancel guard, catch Exception+PCE. - SidebarRouter: CancelReview target capture + identity guard, startReview cancels stale session. - CommentService: jump path-basis fix (resolveProjectFile uses repoRoot to match repo-relative comment paths when project opened at a subdir). - Misc: HostStrings single-pass param replace, IdeaTheme alpha clamp + font-name sanitization, CliParse isNotBlank, Providers Locale.ROOT, ConfigUtils isNotBlank + key normalization, ConfigDraft preset-key normalization, WebviewHtml BOM strip + IOException fallback. build.gradle mavenCentral fallback, jest SPDX header, webpack mode, gradle/settings trailing newline. * Update extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/jcef/JcefConfigPanelHost.kt Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/messages/HostMessages.kt Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fix(idea): update HostMessagesTest for explicitNulls=true + trailing newline - HostMessagesTest: 3 assertions updated (assertFalse→assertTrue + is JsonNull) to match explicitNulls=true (null fields now present as "field": null) - HostMessages: comment updated to reflect explicitNulls=true - CommentService: trailing newline * fix(idea): parsePorcelain arrow parsing for filenames containing ' -> ' When a filename contains ' -> ' (spaces + arrow), git quotes it. The old code used indexOf(" -> ") which matched the arrow INSIDE the filename instead of the separator, producing a corrupted path. Fix: prioritize '" -> ' (closing quote + separator, 5 chars) which unambiguously finds the separator for quoted paths. Fall back to ' -> ' for unquoted paths (no spaces → no false match). Tests: 3 new cases — quoted rename, arrow-in-name rename, arrow-no-space. * test(idea): add parsePorcelain case for unquoted-old/quoted-new rename * chore(idea): remove unused JComponent import in OcrToolWindowFactory * fix(idea): log JSON parse errors inside parseRawConfig parseRawConfig's internal runCatching swallowed JSON errors silently. readRaw's outer runCatching only catches IO errors (readText), not JSON parse errors. Added Logger.warn inside parseRawConfig so JSON errors are also recorded. Note: thisLogger() unresolved from top-level function in ConfigDraft.kt (likely inline-function context issue); used Logger.getInstance instead. * fix(idea): path traversal guard in readWorkspaceFile + isBinaryFile File(root, relPath) without validation allows ../../etc/passwd to escape the repo root. Added canonicalPath.startsWith(root + separator) check, wrapped in runCatching for graceful degradation on IO errors. pathExistsAtRef not affected — uses git ref:path syntax which git itself validates (path stays within repo tree). * Update extensions/idea/src/main/kotlin/com/alibaba/opencodereview/idea/services/ConfigService.kt Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fix(idea): @Synchronized on set() to prevent race with deleteCustomProvider set() goes through cli.runRaw (external CLI process) to write config.json, bypassing the @Synchronized lock that deleteCustomProvider/writeRaw use. Adding @Synchronized serializes set with deleteCustomProvider, preventing interleaving that could silently undo config changes. * style(idea): extract magic strings + trim CLI args + remove unnecessary toList() - OpenOcrToolWindowAction: extract tool window ID to companion const - ReviewSession: extract 'completed_with_errors' to named constant - CliParse: trim --from/--to/--commit args (consistent with --background) - CliParse: remove unnecessary .toList() in extractCliError (sequence is enough) * style(idea): FONT_NAME_INVALID add { symmetry + normalizeLine val expression - IdeaTheme: add { to FONT_NAME_INVALID char class (symmetric with }) - CommentAnchor: var s → val s with expression body in normalizeLine * style(idea): locale when-exhaustiveness + Linux shell fallback - HostStrings: if(locale==ZH_CN) → when(locale) for compiler exhaustiveness - ShellEnv: /bin/zsh fallback → macOS zsh / Linux bash (portability) * fix(idea): @Volatile on GitService callback properties diffDecorator and diffViewerReady are written from ReviewProjectService (on init/dispose) and read from pooled threads (git operations). Without @Volatile, the pooled thread may not see the latest value. Added @Volatile for cross-thread visibility. * fix(idea): close process streams in ShellEnv.capture via finally Added closeStreamsQuietly() extension on Process (matching CliService pattern) and a finally block in capture() to ensure inputStream/ outputStream/errorStream are closed on all three exit paths (timeout/ normal/interrupt). Previously only outputStream was closed (stdin at start), inputStream fd leaked until GC. * fix(idea): clear stale maps in show() + guard malformed FAILED + close stale streams + fix leftRefFor fallback - CommentService: clear jumpBlockReasons/locateNotes in show() first synchronized block (was only cleared later in clearHighlighters, leaving a window for EDT jumpTo to read stale entries from previous review) - SidebarRouter: only post FAILED for Malformed when session.get() != null (was unconditionally overriding IDLE/DONE state when no review was running) - CliService: close stale process streams after eviction in runRaw (killStaleInstall had closeStreamsQuietly, runRaw did not) - GitService: leftRefFor mergeBase failure returns null instead of falling back to 'from' (was inconsistent with getBranchDiff's three-dot merge-base diff) * fix(idea): resolveProjectFile use toRealPath to resolve symlinks normalize() only resolves .. and . lexically, not symlinks. A symlink inside the repo pointing outside (e.g. evil_link.txt -> /etc/passwd) passes the startsWith check but refreshAndFindFileByNioFile resolves to the symlink target — reading files outside the repo. Switched normalize() to toRealPath() which resolves all symlinks, so the startsWith check catches symlink escapes. * fix(idea): add disposed guard to scheduleFullRepaint Consistent with forceFullRepaint which already checks disposed. Prevents restarting stopped timers after dispose(). * fix(idea): register OcrWebview with proper Disposer parent to fix memory leak OcrWebview's constructor calls messageBus.connect(this), which adds OcrWebview to the Disposer tree under ROOT_DISPOSABLE (no parent). Neither JcefReviewPanel nor JcefConfigPanelHost registered OcrWebview as their child, so on IDE shutdown Disposer found 11+ leaked instances. Fix: Disposer.register(this, webview) in both JcefReviewPanel and JcefConfigPanelHost. webview.dispose() is idempotent, so the existing manual dispose calls in dispose() methods are safe alongside Disposer. * refactor: extract shared frontend to extensions/frontend/ - Move webview/ and shared/ from vscode/src/ to extensions/frontend/src/ - Split bridge.ts into bridge.vsc.ts and bridge.idea.ts - Shared webpack.config.js with OCR_TARGET env var (NormalModuleReplacementPlugin) - VSC webpack.config.js: only extension host config; webview build delegates to extensions/frontend - IDEA build.gradle.kts: frontendDir points to ../frontend, OCR_TARGET=idea - VSC extension host imports: ../../shared → @shared (tsconfig paths + webpack alias) - ts-loader: transpileOnly=true (type checking via tsc separately) - Delete extensions/idea/frontend/ (no longer needed) - Delete extensions/vscode/src/webview/ and src/shared/ (moved) Both VSC and IDEA builds verified: extension.js, webview.js, configPanel.js all produced successfully. * chore: update READMEs and tests for shared frontend extraction - idea: update architecture docs, remove stale UPSTREAM.md references - idea: bump version to 0.3.0 - idea: add MessageContractTest for host↔frontend message type drift - vscode: update architecture docs for shared frontend at extensions/frontend/ - vscode: fix test imports (../../../shared → @shared) broken by extraction * chore(idea): use brand plugin icons and expand description for marketplace listing Replace the template default plugin icon with the project brand icon (shield+eye, transparent background, aligned with the VS Code extension); add pluginIcon_dark.svg for dark IDE themes. Expand plugin.xml description with features/prerequisites HTML for the JetBrains Marketplace listing. * fix(idea): terminate CLI process tree to prevent orphaned grandchildren Process.destroy()/destroyForcibly() only signal the direct child. The globally-installed ocr CLI is a Node launcher that spawns the Go binary as a grandchild; killing only the launcher orphans the Go process, which gets reparented to the system root and keeps running after cancellation. Snapshot descendants before SIGTERM (handles stay valid after the parent dies, while live enumeration goes blind due to reparenting), then tree-kill from the snapshot after the grace period, parent first so supervisor-like parents cannot respawn children. Applied to all six kill sites: cancel, runRaw stale/finally, install finally, killStaleInstall, and probe timeout. Fallback sites drop the isAlive guard to shrink the check-then-enumerate TOCTOU window, and kill helpers never throw so stream cleanup in finally blocks is always reached. Adds CliServiceTreeKillTest: spawns a fake CLI with a grandchild, cancels, and asserts the grandchild dies (POSIX only). * build(idea): enable local Plugin Verifier and fix deprecated JCEF API Configure pluginVerification so ./gradlew verifyPlugin has an IDE to check against (2.x requires an explicit list); this only wires the verification task — the packaged zip is byte-identical before/after. Switch OcrWebview to the JBCefBrowserBase overload of JBCefJSQuery.create: the JBCefBrowser overload is scheduled for removal and was flagged by the verifier. Verifier verdict is now clean ("Compatible"). * fix: add .gitignore for extensions/frontend, remove node_modules from tracking * fix(extensions): satisfy license and english-only CI checks Add missing SPDX headers to shared config files, translate the remaining Chinese comments in extensions/frontend, exempt the webview i18n copy table in english-check, and untrack extensions/.idea. * fix(idea): validate review results and isolate cancellation * chore(idea): drop internal handoff notes * ci(extensions): cover frontend and idea, restore webview type checking - bridge.ts facade lets tsc resolve ./bridge, which only webpack's NormalModuleReplacementPlugin could map before (typecheck was broken) - build:webview installs frontend deps first so clean clones build - add frontend-ext and idea-ext workflows; extend vscode-ext paths filter * ci(idea): drop gradle cache upload on self-hosted host The idea job runs on the self-hosted host without a container, so ~/.gradle (including the ~1GB IDE distribution) already persists between runs. setup-java's post-job cache save tried to upload a 2.4GB archive and stalled the runner, leaving the job red despite green tests. * chore(extensions): add SPDX headers to kt, kts and css sources Upstream #1407 extends the license gate to kt/kts/css; the IDEA plugin and the shared frontend predated that requirement. Headers generated with scripts/add-license.sh from upstream/main. * chore(scripts): temporarily exempt extensions/idea from english-only gate The plugin's comments and zh-cn UI strings are still Chinese. Mirror the extensions/vscode/ precedent with a TEMPORARY allowedPrefixes entry, dropped once the sources are translated. Takes effect together with upstream #1407, which adds .kt/.kts to scannedExts. * chore(vscode): drop accidentally committed package-lock.json The frontend-extraction refactor ran npm install inside the vscode extension package and committed the resulting npm lockfile. That package is yarn-managed (yarn.lock, yarn install --frozen-lockfile in CI), so the npm lockfile is redundant and risks drifting out of sync with yarn.lock. Nothing references it. * chore(extensions): gitignore the foreign lockfile in each subpackage extensions/vscode is yarn-managed and extensions/frontend is npm-managed; ignore the other manager's lockfile in each so an accidental npm/yarn install cannot sneak a second lockfile into the repo again. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
5619140431
commit
db555b8db7
136 files changed
+15371
-151
No files matched your search
@@ -0,0 +1,61 @@
|
||||
name: Frontend Extension Shared Webview
|
||||
|
||||
# Covers extensions/frontend, the shared webview built into both the VS Code
|
||||
# and IDEA extensions: type checking, unit tests, and a production build of
|
||||
# each target (OCR_TARGET=vscode / idea). Kept separate from vscode-ext.yml so
|
||||
# a frontend-only change gets feedback without paying for the VS Code job.
|
||||
# Scoped with paths: so it only runs when the webview source or this workflow
|
||||
# itself changes.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'extensions/frontend/**'
|
||||
- '.github/workflows/frontend-ext.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 10
|
||||
container:
|
||||
image: node:24.4.1
|
||||
defaults:
|
||||
run:
|
||||
working-directory: extensions/frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Trust workspace
|
||||
run: git config --global --replace-all safe.directory '*'
|
||||
working-directory: .
|
||||
|
||||
- name: Cache npm packages
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('extensions/frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Build VS Code webview
|
||||
run: OCR_TARGET=vscode npm run build
|
||||
|
||||
- name: Build IDEA webview
|
||||
run: OCR_TARGET=idea npm run build
|
||||
@@ -0,0 +1,52 @@
|
||||
name: IDEA Extension
|
||||
|
||||
# Covers extensions/idea: unit tests and plugin.xml structure validation.
|
||||
# -PskipFrontend=true skips the webview rebuild so the job needs no Node
|
||||
# toolchain; the webview bundle itself is covered by frontend-ext.yml.
|
||||
# The full IntelliJ Plugin Verifier (verifyPlugin) downloads IDE builds and
|
||||
# runs for minutes, so it stays a release-grade check and out of the PR path.
|
||||
# Scoped with paths: so it only runs when the plugin or this workflow itself
|
||||
# changes.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'extensions/idea/**'
|
||||
- '.github/workflows/idea-ext.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
working-directory: extensions/idea
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
# No actions/cache for Gradle: this job runs on the self-hosted host
|
||||
# (no container), so ~/.gradle already persists between runs — the IDE
|
||||
# distribution is downloaded once and reused. Uploading a multi-GB
|
||||
# cache archive after every run only risks stalling the job.
|
||||
|
||||
- name: Test
|
||||
run: ./gradlew test -PskipFrontend=true
|
||||
|
||||
- name: Verify plugin structure
|
||||
run: ./gradlew verifyPluginStructure -PskipFrontend=true
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'extensions/vscode/**'
|
||||
# The webview source lives in extensions/frontend and is built into the
|
||||
# VS Code extension via build:webview, so frontend changes must also
|
||||
# trigger this workflow.
|
||||
- 'extensions/frontend/**'
|
||||
- '.github/workflows/vscode-ext.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# IDEA
|
||||
.idea/*
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
out/
|
||||
.DS_Store
|
||||
# This package is npm-managed: keep yarn's lockfile out (use npm ci).
|
||||
yarn.lock
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.tsx?$': ['ts-jest', { isolatedModules: true, tsconfig: 'tsconfig.json' }],
|
||||
},
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
};
|
||||
Generated
+5999
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "open-code-review-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"dev": "webpack --mode development",
|
||||
"watch": "webpack --mode development --watch",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"jest": "^30.4.2",
|
||||
"style-loader": "^4.0.0",
|
||||
"ts-jest": "^29.4.12",
|
||||
"ts-loader": "^9.5.0",
|
||||
"typescript": "^5.3.0",
|
||||
"webpack": "^5.109.2",
|
||||
"webpack-cli": "^7.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"preact": "^10.29.8"
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -36,7 +36,7 @@ export interface ActiveProviderSummary {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** 描述 config.json 中当前生效的 Provider(非表单草稿)。 */
|
||||
/** Describes the provider currently active in config.json (not the form draft). */
|
||||
export function describeActiveProvider(config: OcrConfig | null): ActiveProviderSummary | null {
|
||||
if (!config) return null;
|
||||
|
||||
@@ -68,7 +68,7 @@ export function describeActiveProvider(config: OcrConfig | null): ActiveProvider
|
||||
return {
|
||||
kind: 'legacy',
|
||||
name: 'legacy',
|
||||
displayName: 'Legacy LLM 端点',
|
||||
displayName: 'Legacy LLM endpoint',
|
||||
model: config.llm.model,
|
||||
detail: config.llm.url,
|
||||
};
|
||||
@@ -77,7 +77,7 @@ export function describeActiveProvider(config: OcrConfig | null): ActiveProvider
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 判断配置是否足以发起审查(与 resolver 要求对齐,官方 provider 允许仅依赖环境变量中的 API Key) */
|
||||
/** Checks whether the config is sufficient to start a review (aligned with resolver requirements; an official provider may rely solely on the API key from the environment). */
|
||||
export function isConfigReady(config: OcrConfig | null): boolean {
|
||||
if (!config) return false;
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -11,7 +11,7 @@ export interface OcrProviderPreset {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
/** 与 internal/llm/providers.go 内置 registry 对齐 */
|
||||
/** Aligned with the built-in registry in internal/llm/providers.go */
|
||||
export const PROVIDER_PRESETS: OcrProviderPreset[] = [
|
||||
{
|
||||
name: 'anthropic',
|
||||
@@ -112,7 +112,7 @@ export interface CliRunOptions {
|
||||
concurrency?: number;
|
||||
}
|
||||
|
||||
/** 审查完成后的评论挂载上下文(与 CliRunOptions 字段一致)。 */
|
||||
/** Comment anchor context after a review completes (matches CliRunOptions fields). */
|
||||
export type ReviewContext = Pick<CliRunOptions, 'mode' | 'from' | 'to' | 'commit'>;
|
||||
|
||||
export interface CommentSyncState {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+18
-18
@@ -15,7 +15,7 @@ const baseConfig = {
|
||||
};
|
||||
|
||||
describe('reducer', () => {
|
||||
it('init 设置 config 和 gitState', () => {
|
||||
it('init sets config and gitState', () => {
|
||||
const s = reducer(initialState, {
|
||||
type: 'init',
|
||||
config: baseConfig,
|
||||
@@ -26,7 +26,7 @@ describe('reducer', () => {
|
||||
expect(s.view).toBe('idle');
|
||||
});
|
||||
|
||||
it('init 时 config 为 null → 主界面仍是 idle', () => {
|
||||
it('init with null config keeps the main view idle', () => {
|
||||
const s = reducer(initialState, {
|
||||
type: 'init', config: null,
|
||||
gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] },
|
||||
@@ -34,7 +34,7 @@ describe('reducer', () => {
|
||||
expect(s.view).toBe('idle');
|
||||
});
|
||||
|
||||
it('init / gitState / modeFiles 结束 loading;filesLoading action 开启 loading', () => {
|
||||
it('init / gitState / modeFiles stop loading; the filesLoading action starts it', () => {
|
||||
const init = reducer({ ...initialState, filesLoading: true }, {
|
||||
type: 'init', config: null,
|
||||
gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] },
|
||||
@@ -48,24 +48,24 @@ describe('reducer', () => {
|
||||
expect(loaded.filesLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('config 消息更新 config', () => {
|
||||
it('config message updates config', () => {
|
||||
const s = reducer(initialState, { type: 'config', config: baseConfig });
|
||||
expect(s.config?.llm.model).toBe('m');
|
||||
});
|
||||
|
||||
it('stateChange running 清空旧日志并切到 running 视图', () => {
|
||||
it('stateChange running clears old logs and switches to the running view', () => {
|
||||
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 追加日志', () => {
|
||||
it('logLine appends a log entry', () => {
|
||||
const s = reducer(initialState, { type: 'logLine', line: { text: 'x', level: 'info' } });
|
||||
expect(s.logs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reviewDone 保存结果', () => {
|
||||
it('reviewDone stores the result', () => {
|
||||
const s = reducer(initialState, {
|
||||
type: 'reviewDone',
|
||||
result: { status: 'success', comments: [], warnings: [], summary: undefined },
|
||||
@@ -73,18 +73,18 @@ describe('reducer', () => {
|
||||
expect(s.session.result?.status).toBe('success');
|
||||
});
|
||||
|
||||
it('stateChange done → view 切 done', () => {
|
||||
it('stateChange done switches view to done', () => {
|
||||
expect(reducer(initialState, { type: 'stateChange', state: 'done' }).view).toBe('done');
|
||||
});
|
||||
|
||||
it('commentSync 更新评论状态映射', () => {
|
||||
it('commentSync updates the comment status map', () => {
|
||||
const s = reducer(initialState, { type: 'commentSync', comments: [{ index: 0, status: 'applied' }] });
|
||||
expect(s.commentStatus[0]).toBe('applied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigReady', () => {
|
||||
it('legacy llm 配置需要 url/model/token', () => {
|
||||
it('legacy llm config requires url/model/token', () => {
|
||||
expect(isConfigReady({
|
||||
...baseConfig,
|
||||
llm: { url: 'u', authToken: 't', model: 'm', useAnthropic: true },
|
||||
@@ -95,7 +95,7 @@ describe('isConfigReady', () => {
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('official provider 需要 model', () => {
|
||||
it('official provider requires model', () => {
|
||||
expect(isConfigReady({
|
||||
...baseConfig,
|
||||
provider: 'anthropic',
|
||||
@@ -103,7 +103,7 @@ describe('isConfigReady', () => {
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('custom provider 需要 url/protocol/apiKey/model', () => {
|
||||
it('custom provider requires url/protocol/apiKey/model', () => {
|
||||
expect(isConfigReady({
|
||||
...baseConfig,
|
||||
provider: 'my-llm',
|
||||
@@ -115,7 +115,7 @@ describe('isConfigReady', () => {
|
||||
});
|
||||
|
||||
describe('describeActiveProvider', () => {
|
||||
it('官方 provider', () => {
|
||||
it('official provider', () => {
|
||||
expect(describeActiveProvider({
|
||||
...baseConfig,
|
||||
provider: 'anthropic',
|
||||
@@ -127,7 +127,7 @@ describe('describeActiveProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('自定义 provider', () => {
|
||||
it('custom provider', () => {
|
||||
expect(describeActiveProvider({
|
||||
...baseConfig,
|
||||
provider: 'my-llm',
|
||||
@@ -142,7 +142,7 @@ describe('describeActiveProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('未配置时返回 null', () => {
|
||||
it('returns null when unconfigured', () => {
|
||||
expect(describeActiveProvider({
|
||||
...baseConfig,
|
||||
llm: { url: '', authToken: '', model: '', useAnthropic: true },
|
||||
@@ -150,8 +150,8 @@ describe('describeActiveProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('modeFiles 消息', () => {
|
||||
it('保存 mode 对应文件列表', () => {
|
||||
describe('modeFiles message', () => {
|
||||
it('stores the file list for the mode', () => {
|
||||
const next = reducer(initialState, {
|
||||
type: 'modeFiles',
|
||||
mode: ReviewMode.Branch,
|
||||
@@ -160,7 +160,7 @@ describe('modeFiles 消息', () => {
|
||||
expect(next.modeFiles).toEqual([{ path: 'src/a.ts', status: 'modified' }]);
|
||||
});
|
||||
|
||||
it('init 时 modeFiles 为空数组', () => {
|
||||
it('modeFiles starts as an empty array on init', () => {
|
||||
expect(initialState.modeFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
import { HostToWebview, WebviewToHost } from '../shared/messages';
|
||||
import type { Bridge } from './bridge';
|
||||
|
||||
/**
|
||||
* The only file under frontend/ that diverges from upstream; everything else is
|
||||
* byte-identical to upstream (send changes there instead).
|
||||
*
|
||||
* Upstream uses `acquireVsCodeApi()` + `addEventListener('message')`; IDEA has
|
||||
* no such API, so the host injects two globals:
|
||||
* `window.__ocrPost(json)` (injected via JBCefJSQuery, delivers to Kotlin) and
|
||||
* `window.__ocrReceive(msg)` (called by Kotlin, pushes into the page).
|
||||
*
|
||||
* `onMessage` keeps the "multi-subscribe + unsubscribe handle" semantics:
|
||||
* upstream useEffect relies on them, and replacing this with a single-handler
|
||||
* assignment would silently drop the second subscriber.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ocrPost?: (json: string) => void;
|
||||
__ocrReceive?: (msg: unknown) => void;
|
||||
}
|
||||
}
|
||||
|
||||
type Handler = (msg: HostToWebview) => void;
|
||||
|
||||
const handlers = new Set<Handler>();
|
||||
|
||||
// The host calls this exact function via executeJavaScript. Registered once,
|
||||
// it only dispatches into handlers from then on.
|
||||
window.__ocrReceive = (msg: unknown): void => {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(msg as HostToWebview);
|
||||
} catch (err) {
|
||||
// One subscriber throwing must not stop the others from receiving the message.
|
||||
console.error('[ocr] message handler failed', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const bridge: Bridge = {
|
||||
post(msg: WebviewToHost): void {
|
||||
const post = window.__ocrPost;
|
||||
if (!post) {
|
||||
console.error('[ocr] host bridge is not injected yet', msg);
|
||||
return;
|
||||
}
|
||||
post(JSON.stringify(msg));
|
||||
},
|
||||
onMessage(handler: Handler): () => void {
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
import { HostToWebview, WebviewToHost } from '../shared/messages';
|
||||
|
||||
/**
|
||||
* Host bridge contract shared by both targets. Entry apps import './bridge';
|
||||
* webpack's NormalModuleReplacementPlugin swaps that specifier for the target
|
||||
* implementation (bridge.vsc.ts or bridge.idea.ts) at bundle time, so this
|
||||
* facade is never bundled or executed. It exists so tsc and jest can resolve
|
||||
* the import without a bundler in the loop.
|
||||
*/
|
||||
export interface Bridge {
|
||||
post(msg: WebviewToHost): void;
|
||||
onMessage(handler: (msg: HostToWebview) => void): () => void;
|
||||
}
|
||||
|
||||
export declare const bridge: Bridge;
|
||||
+2
-1
@@ -2,13 +2,14 @@
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
import { HostToWebview, WebviewToHost } from '../shared/messages';
|
||||
import type { Bridge } from './bridge';
|
||||
|
||||
interface VsCodeApi { postMessage(msg: unknown): void; }
|
||||
declare function acquireVsCodeApi(): VsCodeApi;
|
||||
|
||||
const vscode = acquireVsCodeApi();
|
||||
|
||||
export const bridge = {
|
||||
export const bridge: Bridge = {
|
||||
post(msg: WebviewToHost): void {
|
||||
vscode.postMessage(msg);
|
||||
},
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+10
-9
@@ -4,10 +4,11 @@ Copyright 2026 alibaba/open-code-review Contributors
|
||||
*/
|
||||
|
||||
/* === 1. CSS Variables ===
|
||||
* 结构色映射到 VSCode 主题 token(带 silent-night 回退值),自动适配亮/暗主题。
|
||||
* accent(薄荷绿)保留为品牌色,不跟随主题。 */
|
||||
* Structural colors map to VSCode theme tokens (with silent-night fallback
|
||||
* values), adapting to light/dark themes automatically.
|
||||
* The accent (mint green) stays a fixed brand color and does not follow the theme. */
|
||||
:root {
|
||||
/* 背景层:sidebar / 输入 / hover */
|
||||
/* Background layer: sidebar / input / hover */
|
||||
--bg: var(--vscode-sideBar-background, #0a0d12);
|
||||
--bg-soft: var(--vscode-sideBarSectionHeader-background, #11151a);
|
||||
--bg-deeper: var(--vscode-editor-background, #06090d);
|
||||
@@ -15,7 +16,7 @@ Copyright 2026 alibaba/open-code-review Contributors
|
||||
--card-soft: var(--vscode-list-hoverBackground, #1a1f27);
|
||||
--card-quiet: var(--vscode-list-activeSelectionBackground, #20262f);
|
||||
--card-deeper: var(--vscode-input-background, #0d1015);
|
||||
/* 文字层 */
|
||||
/* Text layer */
|
||||
--ink: var(--vscode-foreground, #ececec);
|
||||
--ink-soft: var(--vscode-foreground, #b8b8b2);
|
||||
--ink-quiet: var(--vscode-descriptionForeground, #7e828a);
|
||||
@@ -24,13 +25,13 @@ Copyright 2026 alibaba/open-code-review Contributors
|
||||
--moon-soft: var(--vscode-descriptionForeground, #b8b8b3);
|
||||
--moon-quiet: var(--vscode-descriptionForeground, #6b6f76);
|
||||
--moon-faint: var(--vscode-disabledForeground, #4a4f56);
|
||||
/* 边框 */
|
||||
/* Borders */
|
||||
--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 适配亮/暗) */
|
||||
/* Neutral fills (subtle background fills, using list hover tokens to adapt to light/dark) */
|
||||
--fill-soft: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.04));
|
||||
--fill: var(--vscode-list-inactiveSelectionBackground, rgba(255, 255, 255, 0.08));
|
||||
/* accent:薄荷绿品牌色(保留,不跟随主题) */
|
||||
/* Accent: mint brand color (fixed, does not follow the theme) */
|
||||
--mint: #45e6a4;
|
||||
--mint-soft: #8ff0c2;
|
||||
--mint-glow: rgba(69, 230, 164, 0.35);
|
||||
@@ -227,7 +228,7 @@ body {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* === 自定义 Select === */
|
||||
/* === Custom Select === */
|
||||
.select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -438,7 +439,7 @@ body {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* 内部滚动容器细滚动条(覆盖 webview 注入的粗滚动条) */
|
||||
/* Slim scrollbars for inner scroll containers (override the thick scrollbars injected by the webview) */
|
||||
.commit-list::-webkit-scrollbar,
|
||||
.file-scroll::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+4
-4
@@ -42,21 +42,21 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
|
||||
|
||||
const switchMode = (m: ReviewMode) => { setMode(m); onModeChange(m); };
|
||||
|
||||
// 分支两端都选好后,拉取 diff 文件列表
|
||||
// Fetch the diff file list once both branch endpoints are selected
|
||||
useEffect(() => {
|
||||
if (mode === ReviewMode.Branch && from && to) onRequestModeFiles(ReviewMode.Branch, from, to);
|
||||
}, [mode, from, to]);
|
||||
|
||||
// 选中某 commit 后,拉取该 commit 文件列表
|
||||
// Fetch the file list of a commit once it is selected
|
||||
useEffect(() => {
|
||||
if (mode === ReviewMode.Commit && commit) onRequestModeFiles(ReviewMode.Commit, undefined, undefined, commit);
|
||||
}, [mode, commit]);
|
||||
|
||||
const files = mode === ReviewMode.Workspace ? gitState.workspaceFiles : modeFiles;
|
||||
// 仅在「确实发起了请求」时显示 loading:分支需选满两端,提交需选中 commit。
|
||||
// Show loading only when a request was actually fired: branch needs both endpoints, commit needs a selected commit.
|
||||
const willRequest = mode === ReviewMode.Workspace || (mode === ReviewMode.Branch && !!from && !!to) || (mode === ReviewMode.Commit && !!commit);
|
||||
const loading = filesLoading && willRequest;
|
||||
// 可发起审查的前置条件:按 tab 校验选择已就绪,且有待审查文件、不在加载/审查中。
|
||||
// Preconditions for starting a review: the per-tab selection is complete, files are pending review, and nothing is loading/reviewing.
|
||||
const selectionReady =
|
||||
mode === ReviewMode.Workspace || (mode === ReviewMode.Branch && !!from && !!to) || (mode === ReviewMode.Commit && !!commit);
|
||||
const canReview = configured && !running && !loading && selectionReady && files.length > 0;
|
||||
File renamed without changes.
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const isIdea = process.env.OCR_TARGET === 'idea';
|
||||
const bridge = isIdea ? './bridge.idea' : './bridge.vsc';
|
||||
const outDir = isIdea
|
||||
? path.resolve(__dirname, '../idea/src/main/resources/webview')
|
||||
: path.resolve(__dirname, '../vscode/out');
|
||||
|
||||
function webConfig(name, entry, mode) {
|
||||
return {
|
||||
name,
|
||||
mode,
|
||||
target: 'web',
|
||||
entry: { [name]: `./src/webview/${entry}` },
|
||||
output: {
|
||||
path: outDir,
|
||||
filename: '[name].js',
|
||||
clean: false,
|
||||
},
|
||||
resolve: { extensions: ['.ts', '.tsx', '.js'] },
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: { loader: 'ts-loader', options: { configFile: 'tsconfig.json', transpileOnly: true } },
|
||||
},
|
||||
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new webpack.NormalModuleReplacementPlugin(/bridge$/, bridge),
|
||||
],
|
||||
devtool: mode === 'production' ? false : 'inline-source-map',
|
||||
performance: { hints: false },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = (_env, argv) => {
|
||||
const mode = argv.mode === 'production' ? 'production' : 'development';
|
||||
return [
|
||||
webConfig('webview', 'index.tsx', mode),
|
||||
webConfig('configPanel', 'configPanel.tsx', mode),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
.gradle/
|
||||
.idea/
|
||||
.intellijPlatform/
|
||||
.kotlin/
|
||||
.run/
|
||||
local.properties
|
||||
build/
|
||||
out/
|
||||
frontend/node_modules/
|
||||
src/main/resources/webview/*.js
|
||||
src/main/resources/webview/*.js.map
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,159 @@
|
||||
<p align="center">
|
||||
English | <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
# Open Code Review (IntelliJ IDEA Plugin)
|
||||
|
||||
An IntelliJ IDEA plugin built on the [`open-code-review`](https://www.npmjs.com/package/@alibaba-group/open-code-review) (`ocr`) CLI. It hosts the VS Code extension's Preact webview inside a JCEF browser and brings AI code review into the editor: start reviews from the tool window, stream logs live, and apply / dismiss / flag-as-false-positive each comment inline — kept in sync with the tool window 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 tool window while inline comment panels 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 plugin (persisted via `ocr config set`).
|
||||
- **Model switching / connectivity test**: switch models and test connectivity to the LLM from the config panel.
|
||||
|
||||
---
|
||||
|
||||
## 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 plugin's config panel:
|
||||
|
||||
```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
|
||||
|
||||
- JDK 21 (used by Gradle to run the build). The Gradle wrapper is included, so no separate Gradle install is required.
|
||||
- Node.js + npm — build-time only, to bundle the webview. Not required at runtime.
|
||||
- A globally available `ocr` CLI and `git` (see "Prerequisites") — the plugin is essentially a GUI front-end for `ocr`.
|
||||
|
||||
### Start the dev environment
|
||||
|
||||
```bash
|
||||
cd extensions/idea
|
||||
./gradlew runIde # launch a sandbox IDE with the plugin installed
|
||||
```
|
||||
|
||||
Open a project with Git changes in the sandbox IDE — you'll see the Open Code Review icon in the
|
||||
tool window strip and can start a review.
|
||||
|
||||
> After editing code: webview changes require **reopening the tool window** in the sandbox IDE;
|
||||
> host-side (Kotlin) changes require **restarting `runIde`**.
|
||||
|
||||
### Scripts
|
||||
|
||||
```bash
|
||||
./gradlew build # compile Kotlin + npm install + npm run build (bundles the webview)
|
||||
./gradlew test # run unit tests
|
||||
./gradlew runIde # launch a sandbox IDE with the plugin
|
||||
./gradlew buildPlugin # produce a distributable .zip (see "Build a release package")
|
||||
```
|
||||
|
||||
> No Node.js / npm on PATH? Skip the frontend build with `./gradlew <task> -PskipFrontend=true`
|
||||
> (uses the previously bundled `src/main/resources/webview/` output).
|
||||
|
||||
### Debugging notes
|
||||
|
||||
- **Two-way messaging**: the webview and host communicate via `postMessage` through the JCEF bridge;
|
||||
message types live in `extensions/frontend/src/shared/messages.ts`. Both sides route through `dispatch`
|
||||
/ `handle` — start there when debugging. The bridge transport is the only file that differs from the
|
||||
VS Code extension: `frontend/src/webview/bridge.idea.ts` (vs. `bridge.vsc.ts`). webpack selects the
|
||||
correct bridge via `NormalModuleReplacementPlugin` based on the `OCR_TARGET` environment variable.
|
||||
- **CLI invocation**: all `ocr` sub-commands run via `ProcessBuilder` in
|
||||
`src/main/kotlin/com/alibaba/opencodereview/idea/services/CliService.kt`. A non-zero CLI exit code
|
||||
rejects with the `Error:` text from stderr, which helps diagnose "review failed / connection failed".
|
||||
- **Config read/write**: `ConfigService.kt` 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 `ConfigParse.kt`.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
### Compile artifacts only
|
||||
|
||||
```bash
|
||||
./gradlew build # compiles Kotlin and bundles the webview into src/main/resources/webview/
|
||||
```
|
||||
|
||||
Artifacts: the plugin jar (Kotlin host) + `src/main/resources/webview/{webview.js,configPanel.js}` (webview SPA).
|
||||
|
||||
### Build a release package (.zip)
|
||||
|
||||
```bash
|
||||
./gradlew buildPlugin
|
||||
```
|
||||
|
||||
This compiles the host, bundles the webview, and produces
|
||||
`build/distributions/open-code-review-idea-<version>.zip` in the `extensions/idea` directory.
|
||||
|
||||
### Install / verify locally
|
||||
|
||||
In IntelliJ IDEA: `Settings → Plugins → ⚙️ → Install Plugin from Disk…` → pick the generated `.zip`.
|
||||
|
||||
> To use a locally installed IntelliJ IDEA instead of the downloaded platform, set `localIdePath`
|
||||
> in `~/.gradle/gradle.properties` (not the repo's `gradle.properties`, which is machine-independent).
|
||||
> The build falls back to the downloaded platform if the path is missing.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
It uses the same **Monolithic WebView + Thin Host** design as the VS Code extension:
|
||||
|
||||
- The **WebView** is a separately built Preact SPA, shared with the VS Code extension at
|
||||
`extensions/frontend/`. The only IDEA-specific file is `bridge.idea.ts`; webpack selects the
|
||||
correct bridge (`bridge.idea.ts` vs. `bridge.vsc.ts`) via `NormalModuleReplacementPlugin` based on
|
||||
the `OCR_TARGET` environment variable.
|
||||
- The **Host** layer (Kotlin / IntelliJ) is thin, handling only CLI invocation, the file system, Git
|
||||
operations, and editor comments. It hosts the webview inside JCEF.
|
||||
- The two communicate via `postMessage` through a JCEF bridge, with shared TypeScript types in
|
||||
`extensions/frontend/src/shared/` for type safety.
|
||||
|
||||
```
|
||||
extensions/
|
||||
├── frontend/ shared frontend (webview SPA + shared types)
|
||||
│ ├── src/shared/ shared types & postMessage protocol
|
||||
│ └── src/webview/ WebView SPA (Preact): views / components / store / bridge
|
||||
└── idea/
|
||||
├── src/main/kotlin/ Host (Kotlin / IntelliJ): services / providers / jcef / messages
|
||||
└── src/main/resources/ plugin.xml / icons / bundled webview output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
|
||||
- `com.intellij.modules.jcef` is declared as an **optional** dependency, so the plugin loads on both
|
||||
2025.1 (JCEF in the core) and 2026.2 (JCEF as a separate bundled plugin).
|
||||
- The webview's `--vscode-*` CSS variables are injected from the current IntelliJ theme, so the UI
|
||||
follows Darcula / Light automatically.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,154 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | 简体中文
|
||||
</p>
|
||||
|
||||
# Open Code Review(IntelliJ IDEA 插件)
|
||||
|
||||
基于 [`open-code-review`](https://www.npmjs.com/package/@alibaba-group/open-code-review)(`ocr`)CLI 的 IntelliJ IDEA 插件。把 VS Code 扩展的 Preact WebView 承载于 JCEF 浏览器中,把 AI 代码审查能力集成进编辑器:在工具窗口发起审查、流式查看日志、在编辑器内逐条应用/忽略/标记误报评论,并与工具窗口双向同步。
|
||||
|
||||
---
|
||||
|
||||
## 功能
|
||||
|
||||
- **三种审查模式**:工作区变更、分支对比(`--from` / `--to`)、单次提交(`--commit`)。
|
||||
- **待审查文件预览**:基于当前 Git 状态展示变更文件列表,点击文件可在原生 diff 视图中查看改动。
|
||||
- **自定义审查提示词**:可选地为本次审查追加 `--background` 提示。
|
||||
- **流式日志**:审查过程中实时滚动 CLI 输出,支持随时取消。
|
||||
- **结果展示 + 双向同步**:完成后在工具窗口列出评论卡片,同时在编辑器内渲染行内评论面板;应用/忽略/误报操作在两侧同步。
|
||||
- **空 / 取消 / 失败态**:无问题、用户取消、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`。
|
||||
|
||||
---
|
||||
|
||||
## 开发
|
||||
|
||||
### 环境准备
|
||||
|
||||
- JDK 21(Gradle 用它跑构建)。仓库自带 Gradle wrapper,无需单独安装 Gradle。
|
||||
- Node.js + npm——仅构建期需要,用于打包 WebView,运行期不需要。
|
||||
- 全局可用的 `ocr` CLI 与 `git`(见上文「前置依赖」),插件本质上是 `ocr` 的图形前端。
|
||||
|
||||
### 启动开发环境
|
||||
|
||||
```bash
|
||||
cd extensions/idea
|
||||
./gradlew runIde # 起一个装好插件的沙箱 IDE
|
||||
```
|
||||
|
||||
在沙箱 IDE 里打开一个有 Git 变更的项目,即可在工具窗口栏看到 Open Code Review 图标并发起审查。
|
||||
|
||||
> 改了代码后:WebView 改动需在沙箱 IDE 里 **重新打开工具窗口**;宿主侧(Kotlin)改动需 **重启 `runIde`**。
|
||||
|
||||
### 常用脚本
|
||||
|
||||
```bash
|
||||
./gradlew build # 编译 Kotlin + npm install + npm run build(打包 WebView)
|
||||
./gradlew test # 运行单元测试
|
||||
./gradlew runIde # 起一个装好插件的沙箱 IDE
|
||||
./gradlew buildPlugin # 生成可分发的 .zip 安装包(见下文「构建发布包」)
|
||||
```
|
||||
|
||||
> PATH 里没有 Node.js / npm?用 `./gradlew <task> -PskipFrontend=true` 跳过前端构建
|
||||
> (使用 `src/main/resources/webview/` 里已有的打包产物)。
|
||||
|
||||
### 调试要点
|
||||
|
||||
- **双端通信**:WebView 与宿主通过 JCEF 桥以 `postMessage` 通信,消息类型定义在
|
||||
`extensions/frontend/src/shared/messages.ts`。两端发收都走 `dispatch` / `handle`,定位问题先看这里。
|
||||
桥接传输层是唯一与 VS Code 扩展不同的文件:`frontend/src/webview/bridge.idea.ts`(对应
|
||||
`bridge.vsc.ts`)。webpack 通过 `NormalModuleReplacementPlugin` 根据 `OCR_TARGET` 选择正确的桥接模块。
|
||||
- **CLI 调用**:所有 `ocr` 子命令由
|
||||
`src/main/kotlin/com/alibaba/opencodereview/idea/services/CliService.kt` 通过 `ProcessBuilder` 执行。
|
||||
CLI 退出码非 0 时会 reject 并带上 stderr 中的 `Error:` 文本,便于排查「审查失败/连接失败」。
|
||||
- **配置读写**:`ConfigService.kt` 读取 `~/.opencodereview/config.json`,写入则委托 `ocr config set`。
|
||||
WebView 端字段为 camelCase(如 `useAnthropic`),磁盘/CLI 端为 snake_case(如 `use_anthropic`),
|
||||
转换在 `ConfigParse.kt`。
|
||||
|
||||
---
|
||||
|
||||
## 构建
|
||||
|
||||
### 仅编译产物
|
||||
|
||||
```bash
|
||||
./gradlew build # 编译 Kotlin 并把 WebView 打包进 src/main/resources/webview/
|
||||
```
|
||||
|
||||
产物:插件 jar(Kotlin 宿主)+ `src/main/resources/webview/{webview.js,configPanel.js}`(WebView SPA)。
|
||||
|
||||
### 构建发布包(.zip)
|
||||
|
||||
```bash
|
||||
./gradlew buildPlugin
|
||||
```
|
||||
|
||||
该命令编译宿主、打包 WebView,并在 `extensions/idea` 目录下生成
|
||||
`build/distributions/open-code-review-idea-<version>.zip`。
|
||||
|
||||
### 本地安装 / 验证
|
||||
|
||||
在 IntelliJ IDEA 中:`Settings → Plugins → ⚙️ → Install Plugin from Disk…` → 选择生成的 `.zip` 文件。
|
||||
|
||||
> 若想用本机已安装的 IntelliJ IDEA 代替下载的平台包,把 `localIdePath` 写进
|
||||
> `~/.gradle/gradle.properties`(不要写进仓库的 `gradle.properties`,它是机器无关的)。
|
||||
> 路径不存在时构建会回退到下载平台包。
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
采用与 VS Code 扩展相同的 **Monolithic WebView + Thin Host** 方案:
|
||||
|
||||
- **WebView** 是独立构建的 Preact SPA,与 VS Code 扩展共用 `extensions/frontend/` 下的前端源码。
|
||||
唯一 IDEA 特有文件为 `bridge.idea.ts`;webpack 通过 `NormalModuleReplacementPlugin` 根据 `OCR_TARGET`
|
||||
环境变量选择正确的桥接模块(`bridge.idea.ts` / `bridge.vsc.ts`)。
|
||||
- **宿主层**(Kotlin / IntelliJ)轻薄,只负责 CLI 调用、文件系统、Git 操作、编辑器评论,
|
||||
并通过 JCEF 承载 WebView。
|
||||
- 两者通过 JCEF 桥以 `postMessage` 通信,用 `extensions/frontend/src/shared/` 中的 TypeScript
|
||||
共享类型保证类型安全。
|
||||
|
||||
```
|
||||
extensions/
|
||||
├── frontend/ 共享前端(WebView SPA + 共享类型)
|
||||
│ ├── src/shared/ 双端共享类型与 postMessage 协议
|
||||
│ └── src/webview/ WebView SPA(Preact):views / components / store / bridge
|
||||
└── idea/
|
||||
├── src/main/kotlin/ 宿主(Kotlin / IntelliJ):services / providers / jcef / messages
|
||||
└── src/main/resources/ plugin.xml / icons / 已打包的 webview 产物
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 兼容性
|
||||
|
||||
- `com.intellij.modules.jcef` 声明为**可选**依赖,因此插件在 2025.1(JCEF 在核心)和
|
||||
2026.2(JCEF 作为独立捆绑插件)上都能加载。
|
||||
- WebView 的 `--vscode-*` CSS 变量取自当前 IntelliJ 主题注入,因此界面跟随 Darcula / Light 自动变化。
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,164 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
plugins {
|
||||
id("java")
|
||||
id("org.jetbrains.kotlin.jvm") version "2.1.0"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0"
|
||||
id("org.jetbrains.intellij.platform") version "2.11.0"
|
||||
}
|
||||
|
||||
group = "com.alibaba"
|
||||
version = providers.gradleProperty("pluginVersion").get()
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url = uri("https://maven.aliyun.com/repository/public")
|
||||
}
|
||||
// 阿里云镜像 unreachable 时(境外开发者 / CI)回落到 Maven Central,保证全球可构建。
|
||||
mavenCentral()
|
||||
intellijPlatform {
|
||||
defaultRepositories()
|
||||
}
|
||||
}
|
||||
|
||||
// Configure IntelliJ Platform Gradle Plugin
|
||||
// Read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin.html
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
testImplementation(kotlin("test-junit"))
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
|
||||
intellijPlatform {
|
||||
// localIdePath 是"这台机器"的配置,不要提交到仓库:写在 ~/.gradle/gradle.properties 里。
|
||||
// 路径不存在时(例如换了操作系统)回退到下载 platformVersion 指定的 IDE,而不是让构建直接失败。
|
||||
val localIdePath = providers.gradleProperty("localIdePath").orNull?.takeIf(String::isNotBlank)
|
||||
val localIde = localIdePath?.let(::file)?.takeIf(File::isDirectory)
|
||||
if (localIdePath != null && localIde == null) {
|
||||
logger.warn("[ocr] localIdePath '$localIdePath' 不存在,回退到下载 IC ${providers.gradleProperty("platformVersion").get()}")
|
||||
}
|
||||
if (localIde != null) local(localIde.absolutePath) else create("IC", providers.gradleProperty("platformVersion"))
|
||||
testFramework(org.jetbrains.intellij.platform.gradle.TestFrameworkType.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
intellijPlatform {
|
||||
pluginConfiguration {
|
||||
ideaVersion {
|
||||
sinceBuild = "251"
|
||||
}
|
||||
|
||||
changeNotes = "Initial IntelliJ IDEA implementation."
|
||||
}
|
||||
|
||||
// verifyPlugin 要求显式声明验证目标 IDE(2.x 不再隐式推导);与构建用同一版本,本地缓存可复用。
|
||||
// 只影响 verifyPlugin 这个检查任务,不参与打包,产物 zip 内容不变。
|
||||
pluginVerification {
|
||||
ides {
|
||||
ide("IC", providers.gradleProperty("platformVersion").get())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 前端构建
|
||||
//
|
||||
// frontend/ 是 VS Code 扩展前端的副本,产物直接落到
|
||||
// src/main/resources/webview/。processResources 依赖它,所以 ./gradlew build
|
||||
// 会自动带上 npm install + npm run build。
|
||||
//
|
||||
// 没装 node、或者只想编 Kotlin 时:./gradlew build -PskipFrontend=true
|
||||
// ---------------------------------------------------------------------------
|
||||
val frontendDir = layout.projectDirectory.dir("../frontend")
|
||||
val webviewOutDir = layout.projectDirectory.dir("src/main/resources/webview")
|
||||
val skipFrontend = providers.gradleProperty("skipFrontend").map(String::toBoolean).getOrElse(false)
|
||||
val npmCommand = if (System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) "npm.cmd" else "npm"
|
||||
|
||||
// npm 在 PATH 里的绝对路径,配置期解析一次。
|
||||
// 之所以要自己找而不是直接 commandLine("npm"):nvm 装的 node 只在登录 shell 的 PATH 里,
|
||||
// 而 GUI 启动的 IDEA 里跑 Gradle 时 PATH 常常是精简版,报错会是难懂的 "Cannot run program npm"。
|
||||
val npmExecutable: String? = providers.environmentVariable("PATH").orElse("").get()
|
||||
.split(File.pathSeparator)
|
||||
.asSequence()
|
||||
.filter(String::isNotBlank)
|
||||
.map { File(it, npmCommand) }
|
||||
.firstOrNull { it.canExecute() }
|
||||
?.absolutePath
|
||||
|
||||
val npmMissingHint = """
|
||||
[ocr] 找不到 $npmCommand(PATH 里没有)。二选一:
|
||||
· 在能跑 npm 的终端里执行 ./gradlew ...(nvm 装的 node 只在登录 shell 的 PATH 里)
|
||||
· 或者跳过前端构建:./gradlew <task> -PskipFrontend=true
|
||||
(跳过时用的是 src/main/resources/webview/ 里已有的产物,可能是旧的)
|
||||
""".trimIndent()
|
||||
|
||||
// doFirst / onlyIf 里一律只用**局部变量**,不要直接引用上面那些脚本级的 val:
|
||||
// Kotlin DSL 里脚本级 val 是脚本对象的字段,lambda 捕获它等于捕获整个脚本对象,
|
||||
// 而脚本对象没法进配置缓存("cannot serialize Gradle script object references")。
|
||||
// 同理 logger 用 task 自己的(doFirst 的 it),不用脚本的 project.logger。
|
||||
val frontendInstall by tasks.registering(Exec::class) {
|
||||
val npm = npmExecutable
|
||||
val hint = npmMissingHint
|
||||
val skip = skipFrontend
|
||||
group = "frontend"
|
||||
description = "安装 frontend/ 的 npm 依赖"
|
||||
workingDir = frontendDir.asFile
|
||||
commandLine(npm ?: npmCommand, "install", "--no-audit", "--no-fund")
|
||||
doFirst { if (npm == null) throw GradleException(hint) }
|
||||
// package.json 变了才重装;node_modules 作为产物让 Gradle 判 UP-TO-DATE。
|
||||
inputs.file(frontendDir.file("package.json"))
|
||||
outputs.dir(frontendDir.dir("node_modules"))
|
||||
onlyIf { !skip }
|
||||
}
|
||||
|
||||
val frontendBuild by tasks.registering(Exec::class) {
|
||||
val npm = npmExecutable
|
||||
val hint = npmMissingHint
|
||||
val skip = skipFrontend
|
||||
val dir = frontendDir.asFile
|
||||
group = "frontend"
|
||||
description = "把 frontend/ 打包到 src/main/resources/webview/"
|
||||
dependsOn(frontendInstall)
|
||||
workingDir = dir
|
||||
environment("OCR_TARGET", "idea")
|
||||
commandLine(npm ?: npmCommand, "run", "build")
|
||||
inputs.dir(frontendDir.dir("src"))
|
||||
inputs.files(
|
||||
frontendDir.file("package.json"),
|
||||
frontendDir.file("tsconfig.json"),
|
||||
frontendDir.file("webpack.config.js"),
|
||||
)
|
||||
outputs.dir(webviewOutDir)
|
||||
onlyIf { !skip }
|
||||
doFirst {
|
||||
if (npm == null) throw GradleException(hint)
|
||||
println("[ocr] 构建前端:$dir")
|
||||
}
|
||||
}
|
||||
|
||||
tasks {
|
||||
withType<JavaCompile> {
|
||||
sourceCompatibility = "21"
|
||||
targetCompatibility = "21"
|
||||
}
|
||||
|
||||
processResources {
|
||||
dependsOn(frontendBuild)
|
||||
}
|
||||
|
||||
// 把 `-Pocr.xxx=...` 转发成沙箱 IDE 的系统属性。
|
||||
// `./gradlew runIde -Docr.devtools=true` 是**没用**的:那个 -D 只加在 Gradle 自己的 JVM 上,
|
||||
// 沙箱 IDE 是另一个进程,读不到——这个转发补上之前,devtools 开关其实一直是失效的。
|
||||
// 插件里靠 System.getProperty 取的开关都得走这里。
|
||||
runIde {
|
||||
listOf("ocr.devtools").forEach { key ->
|
||||
providers.gradleProperty(key).orNull?.let { systemProperty(key, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib
|
||||
kotlin.stdlib.default.dependency=false
|
||||
# Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html
|
||||
org.gradle.configuration-cache=true
|
||||
# Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html
|
||||
org.gradle.caching=true
|
||||
pluginVersion=0.3.0
|
||||
platformVersion=2025.1.4.1
|
||||
# 想用本机已安装的 IDEA 代替下载的平台包时,把 localIdePath 写进 ~/.gradle/gradle.properties,
|
||||
# 不要写在这里——它是机器相关的,提交后换操作系统就会构建失败。例如:
|
||||
# macOS: localIdePath=/Users/<you>/Applications/IntelliJ IDEA.app
|
||||
# Windows: localIdePath=D:/Program Files/IntelliJ IDEA 2025.2.1
|
||||
# 注意:本机 IDEA 版本要和 platformVersion 同代,跨代(例如 2026.2)会因 Kotlin metadata 版本不兼容编译失败。
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
rootProject.name = "open-code-review-idea"
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.actions
|
||||
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.wm.ToolWindowManager
|
||||
|
||||
class OpenOcrToolWindowAction : AnAction() {
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.BGT
|
||||
override fun update(event: AnActionEvent) {
|
||||
event.presentation.isEnabledAndVisible = event.project != null
|
||||
}
|
||||
companion object {
|
||||
private const val TOOL_WINDOW_ID = "Open Code Review"
|
||||
}
|
||||
|
||||
override fun actionPerformed(event: AnActionEvent) {
|
||||
val project = event.project ?: return
|
||||
ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID)?.activate(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.jcef
|
||||
|
||||
import com.intellij.openapi.editor.colors.EditorColorsManager
|
||||
import java.awt.Color
|
||||
import java.util.Locale
|
||||
import javax.swing.UIManager
|
||||
|
||||
/**
|
||||
* 将当前 IDEA 主题映射为页面所用的 `--vscode-*` CSS 变量集合。
|
||||
* 从 UIManager 与编辑器配色实时取值,Darcula/Light/第三方主题均随之同步。
|
||||
*
|
||||
* 两条硬性规则:
|
||||
* 一、alpha 必须保留。IntelliJ 主题存在大量半透明叠加层,压缩为 #rrggbb 会得到实心纯白。
|
||||
* 二、颜色仅取自 LaF。前景与背景一律取 UIManager,避免「暗色界面搭配亮色编辑器配色」时拼出白底浅灰。
|
||||
*/
|
||||
object IdeaTheme {
|
||||
|
||||
/** 变量名 -> 取值。使用惰性 lambda,使 VARIABLE_NAMES 读取全部键时不触碰 UI API,可在纯 JUnit 环境运行。 */
|
||||
private val SPEC: List<Pair<String, () -> String>> = listOf(
|
||||
// ---------------------------------------------------------- 文字
|
||||
"--vscode-foreground" to { css(ui("Label.foreground", fallback = LIGHT_TEXT)) },
|
||||
// 次要说明文字,使用频率最高(卡片副标题、行号、token 统计等)。
|
||||
"--vscode-descriptionForeground" to {
|
||||
css(ui("Label.infoForeground", "Component.infoForeground", fallback = MUTED_TEXT))
|
||||
},
|
||||
"--vscode-disabledForeground" to {
|
||||
css(ui("Label.disabledForeground", "Component.disabledForeground", fallback = MUTED_TEXT))
|
||||
},
|
||||
"--vscode-errorForeground" to { css(ui("Label.errorForeground", fallback = ERROR_TEXT)) },
|
||||
"--vscode-textLink-foreground" to {
|
||||
css(ui("Link.activeForeground", "Component.linkForeground", fallback = LINK))
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------- 背景
|
||||
// 侧栏整体底色,取 IDEA 工具窗背景(Panel.background)。
|
||||
"--vscode-sideBar-background" to { css(ui("Panel.background", fallback = PANEL_BG)) },
|
||||
"--vscode-sideBarSectionHeader-background" to {
|
||||
css(ui("ToolWindow.Header.background", "Panel.background", fallback = PANEL_BG))
|
||||
},
|
||||
// 日志面板底色,应比侧栏底色更深一层。
|
||||
// 不取编辑器配色 defaultBackground:若从 EditorColorsManager 取值,在暗色界面搭配亮色编辑器配色时,会得到白底,而字色取自 LaF,导致不可读。
|
||||
"--vscode-editor-background" to {
|
||||
css(ui("EditorPane.background", "TextArea.background", "TextField.background", fallback = INPUT_BG))
|
||||
},
|
||||
"--vscode-editor-selectionBackground" to {
|
||||
css(ui("TextField.selectionBackground", "List.selectionBackground", fallback = SELECTION))
|
||||
},
|
||||
"--vscode-input-background" to { css(ui("TextField.background", fallback = INPUT_BG)) },
|
||||
"--vscode-dropdown-background" to {
|
||||
css(ui("ComboBox.background", "TextField.background", fallback = INPUT_BG))
|
||||
},
|
||||
"--vscode-button-secondaryBackground" to { css(ui("Button.background", fallback = PANEL_BG)) },
|
||||
|
||||
// ---------------------------------------------------------- 列表与悬停
|
||||
"--vscode-list-activeSelectionBackground" to {
|
||||
css(ui("List.selectionBackground", fallback = SELECTION))
|
||||
},
|
||||
"--vscode-list-inactiveSelectionBackground" to {
|
||||
css(ui("List.selectionInactiveBackground", "List.selectionBackground", fallback = SELECTION))
|
||||
},
|
||||
"--vscode-list-hoverBackground" to {
|
||||
css(ui("List.hoverBackground", "ActionButton.hoverBackground", fallback = HOVER_BG))
|
||||
},
|
||||
"--vscode-toolbar-hoverBackground" to {
|
||||
css(ui("ActionButton.hoverBackground", "List.hoverBackground", fallback = HOVER_BG))
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------- 边框
|
||||
"--vscode-widget-border" to { css(ui("Component.borderColor", fallback = BORDER)) },
|
||||
"--vscode-input-border" to { css(ui("Component.borderColor", fallback = BORDER)) },
|
||||
"--vscode-button-border" to {
|
||||
css(ui("Button.startBorderColor", "Component.borderColor", fallback = BORDER))
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------- 角标
|
||||
"--vscode-badge-background" to {
|
||||
css(ui("Counter.background", "List.selectionBackground", fallback = SELECTION))
|
||||
},
|
||||
"--vscode-badge-foreground" to {
|
||||
css(ui("Counter.foreground", "List.selectionForeground", fallback = LIGHT_TEXT))
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------- 滚动条
|
||||
// 滚动条底色:IDEA 无对应 UIManager 键,沿用 VS Code 做法,以前景色叠加透明度生成。
|
||||
"--vscode-scrollbarSlider-background" to {
|
||||
rgba(ui("Label.foreground", fallback = LIGHT_TEXT), 0.25)
|
||||
},
|
||||
"--vscode-scrollbarSlider-hoverBackground" to {
|
||||
rgba(ui("Label.foreground", fallback = LIGHT_TEXT), 0.40)
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------- 字体
|
||||
"--vscode-font-family" to { cssFontStack(UIManager.getFont("Label.font")?.family, "sans-serif") },
|
||||
"--vscode-editor-font-family" to {
|
||||
cssFontStack(runCatching { scheme().editorFontName }.getOrNull(), "monospace")
|
||||
},
|
||||
)
|
||||
|
||||
/** 页面可用的全部变量名集合。不触碰任何 UI API,可在纯 JUnit 环境读取。 */
|
||||
val VARIABLE_NAMES: Set<String> = SPEC.map { it.first }.toSet()
|
||||
|
||||
/** 生成注入用的 `:root { ... }`。取不到某个值时使用兜底色,绝不抛出异常。 */
|
||||
fun cssVariables(): String = buildString {
|
||||
append(":root {\n")
|
||||
SPEC.forEach { (name, provider) ->
|
||||
val value = runCatching(provider).getOrElse { "inherit" }
|
||||
append(" ").append(name).append(": ").append(value).append(";\n")
|
||||
}
|
||||
append("}")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 取色
|
||||
|
||||
private fun ui(vararg keys: String, fallback: Color): Color =
|
||||
keys.firstNotNullOfOrNull { runCatching { UIManager.getColor(it) }.getOrNull() } ?: fallback
|
||||
|
||||
/** 仅用于取编辑器字体名——颜色一律走 [ui],参见类注释规则二。 */
|
||||
private fun scheme() = EditorColorsManager.getInstance().globalScheme
|
||||
|
||||
/** 转换为 CSS 颜色,半透明时输出 rgba()。 */
|
||||
internal fun css(color: Color): String =
|
||||
if (color.alpha == 255) {
|
||||
String.format(Locale.ROOT, "#%02x%02x%02x", color.red, color.green, color.blue)
|
||||
} else {
|
||||
rgba(color, 1.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出 `rgba()`,[extraAlpha] 与颜色自带 alpha 相乘(非覆盖:滚动条变量为「前景色叠加一层透明度」,
|
||||
* 前景本身可能已半透明,直接覆盖会比主题原意更实)。
|
||||
*/
|
||||
private fun rgba(color: Color, extraAlpha: Double): String {
|
||||
// 夹到 [0,1]:extraAlpha 或前景色 alpha 异常时不让 rgba 通道值越界导致 CSS 声明失效。
|
||||
val alpha = ((color.alpha / 255.0) * extraAlpha).coerceIn(0.0, 1.0)
|
||||
// 必须使用 Locale.ROOT:德语等 locale 小数点为逗号,`0,086` 会导致 CSS 声明失效。
|
||||
val formatted = String.format(Locale.ROOT, "%.3f", alpha)
|
||||
return "rgba(${color.red}, ${color.green}, ${color.blue}, $formatted)"
|
||||
}
|
||||
|
||||
/** 字体名里要剥掉的字符:会破坏 `:root{}` 结构(`} ; \ 换行)或提前闭合外层 `<style>` 块(`<`,防 `</style` 注入)。 */
|
||||
private val FONT_NAME_INVALID = Regex("[\"\\\\{};\\n\\r<]")
|
||||
|
||||
/** 字体名可能含空格(如 "JetBrains Mono"),必须加引号,否则整条声明会被浏览器丢弃。 */
|
||||
private fun cssFontStack(family: String?, generic: String): String {
|
||||
val name = family?.takeIf { it.isNotBlank() } ?: return generic
|
||||
// 字体名来自 UIManager,取值不可控,统一过这一层再拼进 CSS。
|
||||
val sanitized = name.replace(FONT_NAME_INVALID, "")
|
||||
return "\"$sanitized\", $generic"
|
||||
}
|
||||
|
||||
// 兜底色(Darcula 近似)。仅在 LaF 完全未装配时使用,保证页面不会因单个 null 值而整块透明。
|
||||
private val LIGHT_TEXT = Color(0xBB, 0xBB, 0xBB)
|
||||
private val MUTED_TEXT = Color(0x80, 0x80, 0x80)
|
||||
private val ERROR_TEXT = Color(0xFF, 0x52, 0x61)
|
||||
private val LINK = Color(0x35, 0x92, 0xC4)
|
||||
private val PANEL_BG = Color(0x3C, 0x3F, 0x41)
|
||||
private val INPUT_BG = Color(0x45, 0x49, 0x4A)
|
||||
private val SELECTION = Color(0x2F, 0x65, 0xCA)
|
||||
private val HOVER_BG = Color(0x4C, 0x50, 0x52)
|
||||
private val BORDER = Color(0x64, 0x64, 0x64)
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.jcef
|
||||
|
||||
import com.alibaba.opencodereview.idea.messages.WebviewChannel
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import com.alibaba.opencodereview.idea.review.ConfigPanelHost
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import java.awt.Dimension
|
||||
import javax.swing.Action
|
||||
import javax.swing.JComponent
|
||||
|
||||
/**
|
||||
* 配置面板的窗口实现,使用非模态对话框:可与编辑器并排查看、可拖动缩放、关闭即释放。
|
||||
*/
|
||||
class JcefConfigPanelHost(
|
||||
private val project: Project,
|
||||
private val locale: () -> SupportedLocale,
|
||||
private val onMessage: (String) -> Unit,
|
||||
) : ConfigPanelHost, Disposable {
|
||||
|
||||
private companion object {
|
||||
const val WIDTH = 900
|
||||
const val HEIGHT = 720
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var dialog: PanelDialog? = null
|
||||
|
||||
@Volatile
|
||||
private var webview: OcrWebview? = null
|
||||
|
||||
override val isOpen: Boolean
|
||||
get() = dialog?.isShowing == true
|
||||
|
||||
override val channel: WebviewChannel = WebviewChannel { json ->
|
||||
// 面板已关闭时静默丢弃:安装等长耗时任务的日志可能在关闭后才到达。
|
||||
webview?.post(json)
|
||||
}
|
||||
|
||||
override fun open() {
|
||||
onEdt {
|
||||
dialog?.let { existing ->
|
||||
if (existing.isShowing) {
|
||||
// 已打开时仅置顶,不重建窗口(重建会丢失用户已填写的表单)。
|
||||
existing.window?.toFront()
|
||||
return@onEdt
|
||||
}
|
||||
}
|
||||
createDialog().show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
onEdt { dialog?.close(DialogWrapper.OK_EXIT_CODE) }
|
||||
}
|
||||
|
||||
private fun createDialog(): PanelDialog {
|
||||
val view = OcrWebview(
|
||||
html = { bridge -> WebviewHtml.configPanel(locale(), bridge) },
|
||||
onMessage = onMessage,
|
||||
)
|
||||
webview = view
|
||||
val created = PanelDialog(view.component)
|
||||
dialog = created
|
||||
// OcrWebview 内部 messageBus.connect(this) 把自己挂到 Disposer 树(ROOT_DISPOSABLE 下);
|
||||
// 注册为本 host 的子节点,否则 IDE 关闭时 Disposer 找不到 parent → memory leak。
|
||||
Disposer.register(this, view)
|
||||
// 对话框关闭(点击关闭按钮、按 Esc 或由页面发送 closeConfigPanel)时销毁 webview,否则 CEF browser 会持续驻留,下次又重新创建。
|
||||
Disposer.register(created.disposable) {
|
||||
view.dispose() // 幂等,Disposer 也会调一次
|
||||
if (webview === view) webview = null
|
||||
if (dialog === created) dialog = null
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
// 取局部后清空,避免与对话框关闭回调的二次 dispose 竞态。
|
||||
val w = webview
|
||||
val d = dialog
|
||||
webview = null
|
||||
dialog = null
|
||||
// 关闭仍可见的对话框(项目关闭时若面板开着,避免留孤儿原生窗口)+ 释放 JCEF browser 都须在 EDT
|
||||
// (Swing/CEF 契约)。不用 onEdt——其 isDisposed 守卫会在项目关闭路径跳过、browser 永不释放;
|
||||
// 此处 invokeLater 不带守卫,关闭时 EDT 仍会派发;派发不到也无妨(JVM 退出由 OS 回收)。OcrWebview/dialog.close 幂等。
|
||||
val app = ApplicationManager.getApplication()
|
||||
val cleanup: () -> Unit = {
|
||||
d?.let { runCatching { it.close(DialogWrapper.OK_EXIT_CODE) } }
|
||||
w?.let { runCatching { it.dispose() } }
|
||||
}
|
||||
if (app.isDispatchThread) cleanup() else app.invokeLater(cleanup, com.intellij.openapi.application.ModalityState.any())
|
||||
}
|
||||
|
||||
private fun onEdt(block: () -> Unit) {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (!project.isDisposed) block()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class PanelDialog(private val content: JComponent) :
|
||||
DialogWrapper(project, /* canBeParent = */ false) {
|
||||
|
||||
init {
|
||||
// 每次开窗时重新取词:用户可能中途更换 IDE 语言,下次打开即采用新语言。
|
||||
title = HostStrings.t(locale(), "ext.configPanelTitle")
|
||||
isModal = false // 配置时要能回去看代码
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent = content.apply {
|
||||
preferredSize = Dimension(WIDTH, HEIGHT)
|
||||
}
|
||||
|
||||
/** 页面自身已有保存/关闭按钮,底部再放置一排 OK/Cancel 仅会造成困惑。 */
|
||||
override fun createActions(): Array<Action> = emptyArray()
|
||||
|
||||
/** 记住用户调过的窗口大小。 */
|
||||
override fun getDimensionServiceKey(): String = "ocr.configPanel"
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.jcef
|
||||
|
||||
import com.alibaba.opencodereview.idea.messages.WebviewChannel
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import com.alibaba.opencodereview.idea.model.currentIdeLocale
|
||||
import com.alibaba.opencodereview.idea.review.ReviewProjectService
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.progress.ProcessCanceledException
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.jcef.JBCefApp
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import java.awt.BorderLayout
|
||||
|
||||
/**
|
||||
* 侧栏的 JCEF 宿主:创建 webview、装配消息桥、投递宿主消息。消息分发交由路由层处理。
|
||||
* attach/detach 走侧栏专用入口:JCEF 回调仅提供字符串、不含来源标识,
|
||||
* 通道身份须由本层显式提供,否则配置面板消息会串入侧栏。
|
||||
*/
|
||||
class JcefReviewPanel(project: Project) : Disposable {
|
||||
|
||||
private val service = project.getService(ReviewProjectService::class.java)
|
||||
private val webview: OcrWebview?
|
||||
@Volatile
|
||||
private var channel: WebviewChannel? = null
|
||||
|
||||
val component: JComponent
|
||||
|
||||
init {
|
||||
if (!JBCefApp.isSupported()) {
|
||||
webview = null
|
||||
component = jcefUnsupportedPlaceholder()
|
||||
} else {
|
||||
// OcrWebview 构造或 attachSidebar 即便 isSupported 为真仍可能抛:
|
||||
// 工厂层会 catch 并显示占位,但半构造的 OcrWebview(已起 timer、连了消息总线)没人 dispose 会泄漏,故此处自清理后回退占位。
|
||||
val (vw, comp, ch) = try {
|
||||
val view = OcrWebview(
|
||||
html = { bridge -> WebviewHtml.sidebar(service.currentLocale(), bridge) },
|
||||
onMessage = service::handleFromSidebar,
|
||||
)
|
||||
val outbound = WebviewChannel { json -> view.post(json) }
|
||||
try {
|
||||
val viewComponent = view.component // 取在 attach 之前,缩小 attach 之后仍可能抛错的窗口
|
||||
service.attachSidebar(outbound)
|
||||
Triple(view, viewComponent, outbound)
|
||||
} catch (e: Exception) {
|
||||
// attach 前后都先尝试 detach(幂等):channel 可能已被部分注册,不清理会泄漏且继续向已 dispose 的 view 投递。
|
||||
runCatching { service.detachSidebar(outbound) }
|
||||
runCatching { view.dispose() }
|
||||
throw e
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 只接 Exception:OOM/LinkageError 等 Error 不在此吞,交给工厂层 catch(Throwable) 统一兜底,避免掩盖致命问题。
|
||||
// ProcessCanceledException 是 IntelliJ 取消信号,绝不能吞(否则破坏取消机制)。
|
||||
if (e is ProcessCanceledException) throw e
|
||||
thisLogger().warn("[ocr] 侧栏 JCEF webview 初始化失败,回退占位", e)
|
||||
Triple(null, jcefUnsupportedPlaceholder(), null)
|
||||
}
|
||||
webview = vw
|
||||
component = comp
|
||||
channel = ch
|
||||
// OcrWebview 内部 messageBus.connect(this) 把自己挂到 Disposer 树(ROOT_DISPOSable 下);
|
||||
// 必须注册为本面板的子节点,否则 IDE 关闭时 Disposer 找不到 parent → memory leak。
|
||||
vw?.let { Disposer.register(this, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
// 先取局部再清空:channel 是 @Volatile 但读后置空非原子,捕获局部避免与可能的再 attach 竞态。
|
||||
val ch = channel
|
||||
channel = null
|
||||
// detach 抛也不应阻断 webview 释放(否则 JCEF browser 泄漏:timer、消息总线监听),故 runCatching。
|
||||
if (ch != null) runCatching { service.detachSidebar(ch) }
|
||||
webview?.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JCEF 不可用时的兜底提示(此时插件所有 UI 均不可用)。[locale] 默认取 IDE 界面语言:
|
||||
* 进入此路径意味着 webview 无法启动,页面语言信息不可用。
|
||||
*/
|
||||
internal fun jcefUnsupportedPlaceholder(locale: SupportedLocale = currentIdeLocale()): JComponent =
|
||||
JPanel(BorderLayout()).apply {
|
||||
add(
|
||||
JBLabel("<html>" + HostStrings.t(locale, "ext.jcef.unsupportedPlaceholder") + "</html>"),
|
||||
BorderLayout.NORTH,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.jcef
|
||||
|
||||
import com.intellij.ide.ui.LafManagerListener
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.editor.colors.EditorColorsListener
|
||||
import com.intellij.openapi.editor.colors.EditorColorsManager
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.JreHiDpiUtil
|
||||
import com.intellij.ui.jcef.JBCefBrowser
|
||||
import com.intellij.ui.jcef.JBCefBrowserBase
|
||||
import com.intellij.ui.jcef.JBCefJSQuery
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.cef.handler.CefLoadHandlerAdapter
|
||||
import java.awt.event.HierarchyEvent
|
||||
import javax.swing.JComponent
|
||||
|
||||
/**
|
||||
* 装配双向消息桥的 JCEF 页面。侧栏与配置面板共用同一实现。
|
||||
*
|
||||
* 消息桥两个方向:页面→宿主 `window.__ocrPost(json)`;宿主→页面 `window.__ocrReceive(msg)`。
|
||||
*/
|
||||
internal class OcrWebview(
|
||||
html: (bridgeScript: String) -> String,
|
||||
private val onMessage: (String) -> Unit,
|
||||
) : Disposable {
|
||||
|
||||
private companion object {
|
||||
const val DEVTOOLS_PROPERTY = "ocr.devtools"
|
||||
val REPAINT_NUDGE_DELAYS_MS = listOf(150, 500, 1200)
|
||||
}
|
||||
|
||||
private val browser = JBCefBrowser()
|
||||
// create(JBCefBrowser) 重载是 scheduled-for-removal API(Plugin Verifier/市场审核会点名),用 JBCefBrowserBase 重载。
|
||||
private val query = JBCefJSQuery.create(browser as JBCefBrowserBase)
|
||||
|
||||
@Volatile
|
||||
private var disposed = false
|
||||
|
||||
private val repaintTimers = REPAINT_NUDGE_DELAYS_MS.map { delay ->
|
||||
javax.swing.Timer(delay) { forceFullRepaint() }.apply { isRepeats = false }
|
||||
}
|
||||
|
||||
val component: JComponent get() = browser.component
|
||||
|
||||
init {
|
||||
runCatching {
|
||||
thisLogger().info(
|
||||
"[ocr] browser=${browser.cefBrowser.javaClass.name} " +
|
||||
"windowless=${browser.cefBrowser.isWindowless}",
|
||||
)
|
||||
}
|
||||
query.addHandler { raw ->
|
||||
runCatching { onMessage(raw) }
|
||||
.onFailure { thisLogger().warn("[ocr] Failed to handle page message", it) }
|
||||
null
|
||||
}
|
||||
|
||||
val bus = ApplicationManager.getApplication().messageBus.connect(this)
|
||||
bus.subscribe(LafManagerListener.TOPIC, LafManagerListener { applyTheme() })
|
||||
bus.subscribe(EditorColorsManager.TOPIC, EditorColorsListener { applyTheme() })
|
||||
|
||||
browser.jbCefClient.addLoadHandler(
|
||||
object : CefLoadHandlerAdapter() {
|
||||
override fun onLoadEnd(b: org.cef.browser.CefBrowser?, f: org.cef.browser.CefFrame?, code: Int) {
|
||||
if (f?.isMain != false) {
|
||||
applyTheme()
|
||||
scheduleFullRepaint()
|
||||
}
|
||||
}
|
||||
},
|
||||
browser.cefBrowser,
|
||||
)
|
||||
|
||||
component.addHierarchyListener { e ->
|
||||
if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener
|
||||
if (!component.isShowing) return@addHierarchyListener
|
||||
resyncPixelDensity()
|
||||
scheduleFullRepaint()
|
||||
}
|
||||
component.addPropertyChangeListener("graphicsConfiguration") { resyncPixelDensity() }
|
||||
component.addComponentListener(object : java.awt.event.ComponentAdapter() {
|
||||
override fun componentResized(e: java.awt.event.ComponentEvent?) = scheduleFullRepaint()
|
||||
})
|
||||
|
||||
browser.loadHTML(html(query.inject("json")))
|
||||
if (System.getProperty(DEVTOOLS_PROPERTY)?.toBoolean() == true) {
|
||||
runCatching { browser.openDevtools() }
|
||||
.onFailure { thisLogger().warn("[ocr] Failed to open devtools", it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun findOsrComponent(root: java.awt.Component): java.awt.Component? {
|
||||
if (root.javaClass.simpleName == "JBCefOsrComponent") return root
|
||||
if (root !is java.awt.Container) return null
|
||||
return root.components.asSequence().mapNotNull { findOsrComponent(it) }.firstOrNull()
|
||||
}
|
||||
|
||||
private fun readField(target: Any, name: String): Any? = runCatching {
|
||||
generateSequence<Class<*>>(target.javaClass) { it.superclass }
|
||||
.mapNotNull { k -> k.declaredFields.firstOrNull { it.name == name } }
|
||||
.firstOrNull()
|
||||
?.also { it.isAccessible = true }
|
||||
?.get(target)
|
||||
}.getOrNull()
|
||||
|
||||
/** resize 后强制整屏重绘,修复离屏渲染模式下 resize 后面板大面积变白。 */
|
||||
private fun scheduleFullRepaint() {
|
||||
if (disposed) return
|
||||
repaintTimers.forEach { it.restart() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从页面端触发整屏重绘。给根元素 opacity 施加近乎无感的扰动再撤销,
|
||||
* 使合成器整块重绘但不触发重排。两层 rAF 确保修改与撤销落入不同帧。
|
||||
*/
|
||||
private fun forceFullRepaint() {
|
||||
if (disposed) return
|
||||
val js = "(function(){var d=document.documentElement;if(!d)return;" +
|
||||
"d.style.opacity='0.999';" +
|
||||
"requestAnimationFrame(function(){requestAnimationFrame(function(){d.style.opacity='';});});" +
|
||||
"})();"
|
||||
runCatching { browser.cefBrowser.executeJavaScript(js, browser.cefBrowser.url, 0) }
|
||||
.onFailure { thisLogger().warn("[ocr] Failed to trigger full page repaint", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步 OSR 渲染器的像素密度到组件当前屏幕的 DPI。
|
||||
* 切屏后若不一致,画面会按错误比例缩放。先 setScreenInfo 更新缓存再 notifyScreenInfoChanged。
|
||||
*/
|
||||
private fun resyncPixelDensity() {
|
||||
if (!JreHiDpiUtil.isJreHiDPIEnabled()) return
|
||||
runCatching {
|
||||
val gcScale = component.graphicsConfiguration?.defaultTransform?.scaleX ?: return
|
||||
val osr = findOsrComponent(component) ?: return
|
||||
val handler = readField(osr, "myRenderHandler") ?: return
|
||||
val cached = readField(handler, "myPixelDensity") as? Double ?: return
|
||||
if (cached == gcScale) return
|
||||
val scaleFactor = readField(handler, "myScaleFactor") as? Double ?: 1.0
|
||||
// 必须设置 isAccessible = true:JBCefOsrHandler 类为包私有,反射调用需绕过访问控制。
|
||||
handler.javaClass
|
||||
.getMethod("setScreenInfo", Double::class.javaPrimitiveType, Double::class.javaPrimitiveType)
|
||||
.also { it.isAccessible = true }
|
||||
.invoke(handler, gcScale, scaleFactor)
|
||||
browser.cefBrowser.notifyScreenInfoChanged()
|
||||
thisLogger().info("[ocr] Corrected JCEF pixelDensity: $cached -> $gcScale")
|
||||
}.onFailure { thisLogger().warn("[ocr] Failed to correct JCEF pixelDensity, skipping", it) }
|
||||
}
|
||||
|
||||
/** 重算 `--vscode-*` 并替换主题 style 内容。必须 EDT 取色,统一 invokeLater 调度。 */
|
||||
private fun applyTheme() {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (disposed) return@invokeLater
|
||||
val literal = Json.encodeToString(String.serializer(), IdeaTheme.cssVariables())
|
||||
val js = "(function(){var s=document.getElementById('${WebviewHtml.THEME_STYLE_ID}');" +
|
||||
"if(s){s.textContent=$literal;}})();"
|
||||
runCatching { browser.cefBrowser.executeJavaScript(js, browser.cefBrowser.url, 0) }
|
||||
.onFailure { thisLogger().warn("[ocr] Failed to refresh theme variables", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 把一条已序列化好的宿主消息推进页面。 */
|
||||
fun post(json: String) {
|
||||
// post 由消息处理线程调用、dispose 在 EDT 触发,二者并发:browser 可能已释放。
|
||||
// 与 forceFullRepaint/applyTheme 一致,先看 disposed 守卫;executeJavaScript 失败(browser 已销毁)也不让通道崩溃。
|
||||
if (disposed) return
|
||||
val literal = Json.encodeToString(String.serializer(), json)
|
||||
runCatching {
|
||||
browser.cefBrowser.executeJavaScript(
|
||||
"window.__ocrReceive && window.__ocrReceive(JSON.parse($literal));",
|
||||
browser.cefBrowser.url,
|
||||
0,
|
||||
)
|
||||
}.onFailure { thisLogger().warn("[ocr] Failed to post message to page", it) }
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
// 幂等:JcefConfigPanelHost 在项目关闭路径与对话框关闭回调都可能各调一次。
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
repaintTimers.forEach { it.stop() }
|
||||
runCatching { query.dispose() }
|
||||
runCatching { Disposer.dispose(browser) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.jcef
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import com.alibaba.opencodereview.idea.model.toHtmlLang
|
||||
|
||||
/**
|
||||
* 装配提交至 JCEF 的 HTML。JS 采用内联而非 `<script src>`:loadHTML() 无 base URL。
|
||||
* lang 属性保留,页面 :lang() 选择器与字体回退依赖该属性。
|
||||
*/
|
||||
object WebviewHtml {
|
||||
|
||||
/** 注入 `--vscode-*` 变量的 `<style>` 的 id,切换主题时据此定位。 */
|
||||
internal const val THEME_STYLE_ID = "ocr-theme"
|
||||
|
||||
/** 侧栏页面。[bridgeScript] 由 `JBCefJSQuery.inject("json")` 生成。 */
|
||||
fun sidebar(locale: SupportedLocale, bridgeScript: String): String =
|
||||
page("/webview/webview.js", locale, bridgeScript)
|
||||
|
||||
/** 配置面板页面。 */
|
||||
fun configPanel(locale: SupportedLocale, bridgeScript: String): String =
|
||||
page("/webview/configPanel.js", locale, bridgeScript)
|
||||
|
||||
private fun page(bundleResource: String, locale: SupportedLocale, bridgeScript: String): String {
|
||||
val bundle = readResource(bundleResource)
|
||||
?: return missingBundle(bundleResource, locale)
|
||||
return buildPage(
|
||||
lang = locale.toHtmlLang(),
|
||||
themeCss = IdeaTheme.cssVariables(),
|
||||
bridgeScript = bridgeScript,
|
||||
bundleJs = bundle,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯字符串拼装,不触碰 UI,便于直接单测。
|
||||
*/
|
||||
internal fun buildPage(
|
||||
lang: String,
|
||||
themeCss: String,
|
||||
bridgeScript: String,
|
||||
bundleJs: String,
|
||||
): String = buildString {
|
||||
append("<!DOCTYPE html>\n")
|
||||
append("<html lang=\"").append(lang).append("\">\n")
|
||||
append("<head>\n<meta charset=\"UTF-8\">\n")
|
||||
// 主题变量单独放一个 style、id 固定:切换主题时仅替换它的 textContent,下方布局规则不被覆盖;
|
||||
// id 由 THEME_STYLE_ID 统一提供,Kotlin 侧拼 JS 选择器用同一常量,避免改 id 漏改选择器。
|
||||
append("<style id=\"").append(THEME_STYLE_ID).append("\">\n")
|
||||
append(themeCss).append('\n')
|
||||
append("</style>\n<style>\n")
|
||||
append("html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }\n")
|
||||
append("#root { height: 100%; }\n")
|
||||
append("</style>\n</head>\n<body>\n")
|
||||
append("<div id=\"root\"></div>\n")
|
||||
// 桥必须在 bundle 之前就绪:页面加载后即注册 __ocrReceive,
|
||||
// 当页面发起 ready 消息时 __ocrPost 必须已存在。
|
||||
append("<script>\nwindow.__ocrPost = function (json) { ")
|
||||
append(escapeForInlineScript(bridgeScript))
|
||||
append(" };\n</script>\n")
|
||||
append("<script>\n").append(escapeForInlineScript(bundleJs)).append("\n</script>\n")
|
||||
append("</body>\n</html>\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联脚本中出现 `</script` 会被 HTML 解析器当作脚本结束标记,bundle 的后半段会被当作正文渲染。
|
||||
* minifier 输出不稳定,无条件转义;`<\/script` 在 JS 字符串与正则中与原文等价。
|
||||
*
|
||||
* **注意**:仅处理 `</script>` 序列,不做 HTML 实体转义;调用方须确保传入的是可信 JS(来自插件打包产物)。
|
||||
*/
|
||||
internal fun escapeForInlineScript(js: String): String =
|
||||
js.replace("</script", "<\\/script", ignoreCase = true)
|
||||
|
||||
private fun readResource(path: String): String? {
|
||||
// 必须是绝对 classpath 路径(以 / 开头):相对路径会按本类包名解析,静默落空后只剩 fallback 页面,难以排查。
|
||||
require(path.startsWith("/")) { "Resource path must be absolute: $path" }
|
||||
// runCatching:readText 抛 IOException(IO 故障)时返回 null,触发 page() 的 missingBundle 兜底,而非让异常上抛崩 JCEF 装配。
|
||||
// 剥掉开头的 UTF-8 BOM(U+FEFF),否则内联 JS 以 BOM 起头,部分引擎解析异常。
|
||||
return runCatching {
|
||||
javaClass.getResourceAsStream(path)?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||
}.getOrNull()?.removePrefix("\uFEFF")
|
||||
}
|
||||
|
||||
/**
|
||||
* bundle 不在 jar 内(几乎仅因前端未构建一种原因)。文案走 [HostStrings],不能写死中文:
|
||||
* 英文 IDE 下出现中文说明,用户既读不懂也无法修复。
|
||||
*/
|
||||
internal fun missingBundle(resource: String, locale: SupportedLocale): String = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="${locale.toHtmlLang()}"><head><meta charset="UTF-8"></head>
|
||||
<body style="font-family: sans-serif; padding: 16px; line-height: 1.6">
|
||||
<h3>${HostStrings.t(locale, "ext.webview.missingBundleTitle")}</h3>
|
||||
<p>${HostStrings.t(locale, "ext.webview.missingBundleBody", "resource" to resource)}</p>
|
||||
<p>${HostStrings.t(locale, "ext.webview.missingBundleHint")}</p>
|
||||
<pre>cd frontend && npm install && npm run build</pre>
|
||||
<p>${HostStrings.t(locale, "ext.webview.missingBundleAlt")}</p>
|
||||
</body></html>
|
||||
""".trimIndent()
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.messages
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.CliResult
|
||||
import com.alibaba.opencodereview.idea.model.CommentSyncState
|
||||
import com.alibaba.opencodereview.idea.model.EnvCheckResult
|
||||
import com.alibaba.opencodereview.idea.model.FileChange
|
||||
import com.alibaba.opencodereview.idea.model.GitState
|
||||
import com.alibaba.opencodereview.idea.model.LogLine
|
||||
import com.alibaba.opencodereview.idea.model.OcrConfig
|
||||
import com.alibaba.opencodereview.idea.model.ReviewMode
|
||||
import com.alibaba.opencodereview.idea.model.ReviewState
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* 出站消息集合,分为侧栏通道使用的 `HostToWebview`(8 条)与配置面板通道使用的 `ConfigPanelHostToWebview`(10 条)。
|
||||
*
|
||||
* 采用密封类加 `classDiscriminator = "type"`,而非手工拼接 `buildJsonObject`:手工拼接漏字段或误用 snake_case
|
||||
* 时编译器无法发现,前端将静默渲染空白卡片;密封类把"缺一个字段"提升为编译期错误。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 出站 JSON 编码器。`explicitNulls = true` 使值为 null 的字段显式输出为 `"field": null`,
|
||||
* 与前端 TypeScript 类型声明(`field: Type | null` 非可选)的契约一致——前端期望字段始终存在。
|
||||
*/
|
||||
val HostJson: Json = Json {
|
||||
classDiscriminator = "type"
|
||||
encodeDefaults = true
|
||||
explicitNulls = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class HostToWebview {
|
||||
|
||||
/** 前端 `ready` 之后的第一条消息。缺少 `config` 会使前端 `isConfigReady(null)` 判定配置未就绪,导致 UI 永久停留在配置视图。 */
|
||||
@Serializable
|
||||
@SerialName("init")
|
||||
data class Init(
|
||||
val config: OcrConfig?,
|
||||
val gitState: GitState,
|
||||
val locale: SupportedLocale,
|
||||
) : HostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("gitState")
|
||||
data class GitStateChanged(val gitState: GitState) : HostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("modeFiles")
|
||||
data class ModeFiles(val mode: ReviewMode, val files: List<FileChange>) : HostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("logLine")
|
||||
data class Log(val line: LogLine) : HostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("stateChange")
|
||||
data class StateChange(val state: ReviewState, val error: String? = null) : HostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("reviewDone")
|
||||
data class ReviewDone(val result: CliResult) : HostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("config")
|
||||
data class Config(val config: OcrConfig?) : HostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("commentSync")
|
||||
data class CommentSync(val comments: List<CommentSyncState>) : HostToWebview()
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置面板专用出站消息。与 [HostToWebview] 分开声明,原因是侧栏与配置面板为两个独立 webview,
|
||||
* 各自只识别本通道的 `type` 取值;共用通道会使其中一侧收到无法识别的消息。
|
||||
*/
|
||||
@Serializable
|
||||
sealed class ConfigPanelHostToWebview {
|
||||
|
||||
/**
|
||||
* [focus] 表示前端自定义的焦点描述,宿主不解释其内容、原样回传,
|
||||
* 故类型为 [JsonElement] 而非 Kotlin data class——宿主侧仅作透传。
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("configPanelInit")
|
||||
data class Init(
|
||||
val config: OcrConfig?,
|
||||
val focus: JsonElement? = null,
|
||||
val env: EnvCheckResult? = null,
|
||||
val skipEnvCheck: Boolean = false,
|
||||
val locale: SupportedLocale,
|
||||
) : ConfigPanelHostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("configPanelFocus")
|
||||
data class Focus(val focus: JsonElement? = null) : ConfigPanelHostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("config")
|
||||
data class Config(val config: OcrConfig?) : ConfigPanelHostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("connectionResult")
|
||||
data class ConnectionResult(val ok: Boolean, val message: String? = null) : ConfigPanelHostToWebview()
|
||||
|
||||
/** 该类型已不再发送(CLI 检查结果改由 `environmentResult` 承载);为保持消息契约完整仍予声明。 */
|
||||
@Serializable
|
||||
@SerialName("cliStatus")
|
||||
data class CliStatus(val installed: Boolean) : ConfigPanelHostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("environmentResult")
|
||||
data class EnvironmentResult(val env: EnvCheckResult) : ConfigPanelHostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("copyDone")
|
||||
data object CopyDone : ConfigPanelHostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("panelError")
|
||||
data class PanelError(val message: String) : ConfigPanelHostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("installLog")
|
||||
data class InstallLog(val line: LogLine) : ConfigPanelHostToWebview()
|
||||
|
||||
@Serializable
|
||||
@SerialName("installDone")
|
||||
data class InstallDone(val ok: Boolean) : ConfigPanelHostToWebview()
|
||||
}
|
||||
|
||||
fun HostToWebview.toJson(): String = HostJson.encodeToString(HostToWebview.serializer(), this)
|
||||
|
||||
fun ConfigPanelHostToWebview.toJson(): String =
|
||||
HostJson.encodeToString(ConfigPanelHostToWebview.serializer(), this)
|
||||
|
||||
/** 单条 webview 通道。由 JCEF 侧实现此接口,路由层仅负责将 JSON 字符串交付其发送。 */
|
||||
fun interface WebviewChannel {
|
||||
fun post(json: String)
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.messages
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.CliRunOptions
|
||||
import com.alibaba.opencodereview.idea.model.ConfigEntry
|
||||
import com.alibaba.opencodereview.idea.model.FileStatus
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.OcrJson
|
||||
import com.alibaba.opencodereview.idea.model.ReviewMode
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/**
|
||||
* 入站消息集合,分为侧栏通道使用的 `WebviewToHost` 侧栏 10 条与配置面板 11 条,共 21 条,
|
||||
* 按来源分派处理(参见 SidebarRouter / ConfigPanelRouter)。
|
||||
*
|
||||
* 刻意不采用 kotlinx 多态反序列化:该方式遇到未识别的 `type` 会抛异常,而前端版本较新时发送新增类型属正常情形,
|
||||
* 不应导致通道中断。手写解析还使本层不依赖 IDE API,21 个类型均可直接进行单元测试。
|
||||
*/
|
||||
sealed class WebviewToHost {
|
||||
|
||||
// ------------------------------------------------------------ 侧栏
|
||||
|
||||
data object Ready : WebviewToHost()
|
||||
data class GetGitState(val mode: ReviewMode) : WebviewToHost()
|
||||
data class GetModeFiles(
|
||||
val mode: ReviewMode,
|
||||
val from: String? = null,
|
||||
val to: String? = null,
|
||||
val commit: String? = null,
|
||||
) : WebviewToHost()
|
||||
|
||||
data class OpenFileDiff(
|
||||
val path: String,
|
||||
val status: FileStatus,
|
||||
val mode: ReviewMode,
|
||||
val from: String? = null,
|
||||
val to: String? = null,
|
||||
val commit: String? = null,
|
||||
) : WebviewToHost()
|
||||
|
||||
data class StartReview(val options: CliRunOptions) : WebviewToHost()
|
||||
data object CancelReview : WebviewToHost()
|
||||
data object GetConfig : WebviewToHost()
|
||||
data class JumpToComment(val index: Int) : WebviewToHost()
|
||||
data class CommentAction(val index: Int, val action: CommentActionKind) : WebviewToHost()
|
||||
|
||||
/** [focus] 表示前端自定义的焦点描述,宿主仅透传,不解释其内容。 */
|
||||
data class OpenConfigPanel(val focus: JsonElement? = null) : WebviewToHost()
|
||||
|
||||
// ------------------------------------------------------------ 配置面板
|
||||
|
||||
data object ReadyConfigPanel : WebviewToHost()
|
||||
data object CloseConfigPanel : WebviewToHost()
|
||||
data class SetConfig(val key: String, val value: String) : WebviewToHost()
|
||||
data class SetConfigBatch(val entries: List<ConfigEntry>) : WebviewToHost()
|
||||
data class TestConnection(val entries: List<ConfigEntry>) : WebviewToHost()
|
||||
data class DeleteCustomProvider(val name: String) : WebviewToHost()
|
||||
data class ActivateCustomProvider(val name: String) : WebviewToHost()
|
||||
data object CheckCli : WebviewToHost()
|
||||
data object CheckEnvironment : WebviewToHost()
|
||||
data object InstallCli : WebviewToHost()
|
||||
data class CopyToClipboard(val text: String) : WebviewToHost()
|
||||
|
||||
// ------------------------------------------------------------ 兜底
|
||||
|
||||
/** 无法识别的 `type`。保留原值仅供日志记录——路由层将其忽略,不视为错误。 */
|
||||
data class Unknown(val type: String) : WebviewToHost()
|
||||
|
||||
/** JSON 本身解析失败,或必填字段缺失、类型不符。[reason] 将展示给用户。 */
|
||||
data class Malformed(val reason: String) : WebviewToHost()
|
||||
}
|
||||
|
||||
/** `commentAction` 的三种动作,取值为 `'apply' | 'discard' | 'falsePositive'`,与前端约定一致。 */
|
||||
enum class CommentActionKind { APPLY, DISCARD, FALSE_POSITIVE }
|
||||
|
||||
private fun JsonObject.str(name: String): String? {
|
||||
val prim = this[name] as? JsonPrimitive ?: return null
|
||||
return if (prim.isString) prim.content else null
|
||||
}
|
||||
|
||||
/** 空串视为"未填写":前端表单中未选择分支时发送的是 `""` 而非省略字段。 */
|
||||
private fun JsonObject.optStr(name: String): String? = str(name)?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun JsonObject.int(name: String): Int? = (this[name] as? JsonPrimitive)?.intOrNull
|
||||
|
||||
private fun parseMode(raw: String?): ReviewMode = when (raw) {
|
||||
"branch" -> ReviewMode.BRANCH
|
||||
"commit" -> ReviewMode.COMMIT
|
||||
else -> ReviewMode.WORKSPACE
|
||||
}
|
||||
|
||||
private fun parseStatus(raw: String?): FileStatus = when (raw) {
|
||||
"added" -> FileStatus.ADDED
|
||||
"deleted" -> FileStatus.DELETED
|
||||
"renamed" -> FileStatus.RENAMED
|
||||
"binary" -> FileStatus.BINARY
|
||||
else -> FileStatus.MODIFIED
|
||||
}
|
||||
|
||||
private fun JsonObject.entries(name: String): List<ConfigEntry> {
|
||||
val array = this[name] as? JsonArray ?: return emptyList()
|
||||
return array.mapNotNull { item ->
|
||||
val obj = item as? JsonObject ?: return@mapNotNull null
|
||||
val key = obj.str("key") ?: return@mapNotNull null
|
||||
// value 允许为空串(清空某字段即以空串表达),因此此处不能使用 optStr。
|
||||
ConfigEntry(key, obj.str("value") ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
/** 「{type} 缺少 {field}」这类提示仅差两个参数,统一由本函数生成,避免各处重复构造文案。 */
|
||||
private fun missingField(locale: SupportedLocale, type: String, field: String): WebviewToHost.Malformed =
|
||||
WebviewToHost.Malformed(HostStrings.t(locale, "ext.message.missingField", "type" to type, "field" to field))
|
||||
|
||||
/**
|
||||
* 解析一条前端消息,过程中不抛出异常:解析失败返回 [WebviewToHost.Malformed],未识别类型返回 [WebviewToHost.Unknown]。
|
||||
* [locale] 仅影响 `Malformed` 的提示文案(这些字符串原样回传前端展示,故随 IDE 界面语言而定)。
|
||||
*/
|
||||
fun parseWebviewMessage(raw: String, locale: SupportedLocale): WebviewToHost {
|
||||
val msg = runCatching { OcrJson.parseToJsonElement(raw).jsonObject }.getOrElse {
|
||||
return WebviewToHost.Malformed(
|
||||
HostStrings.t(locale, "ext.message.parseFailed", "message" to it.message.orEmpty()),
|
||||
)
|
||||
}
|
||||
val type = msg.str("type")
|
||||
?: return WebviewToHost.Malformed(HostStrings.t(locale, "ext.message.missingTypeField"))
|
||||
|
||||
return when (type) {
|
||||
"ready" -> WebviewToHost.Ready
|
||||
"readyConfigPanel" -> WebviewToHost.ReadyConfigPanel
|
||||
"closeConfigPanel" -> WebviewToHost.CloseConfigPanel
|
||||
"cancelReview" -> WebviewToHost.CancelReview
|
||||
"getConfig" -> WebviewToHost.GetConfig
|
||||
"checkCli" -> WebviewToHost.CheckCli
|
||||
"checkEnvironment" -> WebviewToHost.CheckEnvironment
|
||||
"installCli" -> WebviewToHost.InstallCli
|
||||
|
||||
"openConfigPanel" -> {
|
||||
// "focus": null(JSON null 字面量)时 msg["focus"] 返回 JsonNull 而非 Kotlin null;
|
||||
// 过滤掉,使"字段缺省"与"显式 null"在下游语义一致。
|
||||
val focus = msg["focus"]?.takeIf { it !is JsonNull }
|
||||
WebviewToHost.OpenConfigPanel(focus)
|
||||
}
|
||||
|
||||
"getGitState" -> WebviewToHost.GetGitState(parseMode(msg.str("mode")))
|
||||
|
||||
"getModeFiles" -> WebviewToHost.GetModeFiles(
|
||||
mode = parseMode(msg.str("mode")),
|
||||
from = msg.optStr("from"),
|
||||
to = msg.optStr("to"),
|
||||
commit = msg.optStr("commit"),
|
||||
)
|
||||
|
||||
"openFileDiff" -> {
|
||||
val path = msg.optStr("path")
|
||||
?: return missingField(locale, "openFileDiff", "path")
|
||||
WebviewToHost.OpenFileDiff(
|
||||
path = path,
|
||||
status = parseStatus(msg.str("status")),
|
||||
mode = parseMode(msg.str("mode")),
|
||||
from = msg.optStr("from"),
|
||||
to = msg.optStr("to"),
|
||||
commit = msg.optStr("commit"),
|
||||
)
|
||||
}
|
||||
|
||||
"startReview" -> {
|
||||
val options = msg["options"] as? JsonObject
|
||||
?: return missingField(locale, "startReview", "options")
|
||||
val parsed = runCatching {
|
||||
OcrJson.decodeFromJsonElement(CliRunOptions.serializer(), options)
|
||||
}.getOrElse {
|
||||
return WebviewToHost.Malformed(
|
||||
HostStrings.t(locale, "ext.message.invalidReviewOptions", "message" to it.message.orEmpty()),
|
||||
)
|
||||
}
|
||||
WebviewToHost.StartReview(parsed)
|
||||
}
|
||||
|
||||
"setConfig" -> {
|
||||
val key = msg.optStr("key")
|
||||
?: return missingField(locale, "setConfig", "key")
|
||||
WebviewToHost.SetConfig(key, msg.str("value") ?: "")
|
||||
}
|
||||
|
||||
"setConfigBatch" -> WebviewToHost.SetConfigBatch(msg.entries("entries"))
|
||||
"testConnection" -> WebviewToHost.TestConnection(msg.entries("entries"))
|
||||
|
||||
"deleteCustomProvider" -> {
|
||||
val name = msg.optStr("name")
|
||||
?: return missingField(locale, "deleteCustomProvider", "name")
|
||||
WebviewToHost.DeleteCustomProvider(name)
|
||||
}
|
||||
|
||||
"activateCustomProvider" -> {
|
||||
val name = msg.optStr("name")
|
||||
?: return missingField(locale, "activateCustomProvider", "name")
|
||||
WebviewToHost.ActivateCustomProvider(name)
|
||||
}
|
||||
|
||||
// text 可为空串(复制空字段属合法操作);字段缺省时亦视为空串。
|
||||
"copyToClipboard" -> WebviewToHost.CopyToClipboard(msg.str("text") ?: "")
|
||||
|
||||
"jumpToComment" -> {
|
||||
val index = msg.int("index")
|
||||
?: return missingField(locale, "jumpToComment", "index")
|
||||
WebviewToHost.JumpToComment(index)
|
||||
}
|
||||
|
||||
"commentAction" -> {
|
||||
val index = msg.int("index")
|
||||
?: return missingField(locale, "commentAction", "index")
|
||||
val action = when (msg.str("action")) {
|
||||
"apply" -> CommentActionKind.APPLY
|
||||
"discard" -> CommentActionKind.DISCARD
|
||||
"falsePositive" -> CommentActionKind.FALSE_POSITIVE
|
||||
else -> return WebviewToHost.Malformed(HostStrings.t(locale, "ext.message.invalidCommentAction"))
|
||||
}
|
||||
WebviewToHost.CommentAction(index, action)
|
||||
}
|
||||
|
||||
else -> WebviewToHost.Unknown(type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.model
|
||||
|
||||
/**
|
||||
* 宿主侧原生 UI 文案表(Swing 弹窗、NotificationGroup 通知、gutter popup 等)。
|
||||
* 与前端共享的 `ext.*` 词条,key 与值均应与前端保持一致。
|
||||
* 前端未覆盖的条目按相同风格新增(前缀仍为 `ext.`)。
|
||||
*/
|
||||
object HostStrings {
|
||||
|
||||
/** `{param}` 占位符,单遍替换用(避免顺序依赖注入)。 */
|
||||
private val PARAM_REGEX = Regex("""\{(\w+)\}""")
|
||||
|
||||
private val EN: Map<String, String> = mapOf(
|
||||
// ---------------------------------------------------------------- 评论
|
||||
"ext.comment.threadLabel" to "Code Review",
|
||||
"ext.comment.pending" to "⏳ [Pending]",
|
||||
"ext.comment.statusApplied" to "✅ [Applied]",
|
||||
"ext.comment.statusDiscarded" to "✅ [Discarded]",
|
||||
"ext.comment.statusFalsePositive" to "✅ [False Positive]",
|
||||
// popup 为纯文本 JTextArea,markdown 斜体的 `_` 已去除,加回会导致原样显示为下划线。
|
||||
"ext.comment.noSuggestion" to "💡 No code suggestion, please handle manually",
|
||||
"ext.comment.applyWorkspaceOnly" to "Apply is only available in Workspace review mode.",
|
||||
"ext.comment.applyFailedStale" to "Apply failed: code location is stale, please refresh and retry.",
|
||||
"ext.comment.applyFailedLocked" to "Apply failed: cannot modify file, check if it is read-only or locked.",
|
||||
"ext.comment.jumpFileMissing" to "Cannot jump to {path}: file not found in the review snapshot.",
|
||||
"ext.comment.jumpLineUnresolved" to "Cannot jump to {path}: line number could not be resolved.",
|
||||
"ext.comment.apply" to "Apply",
|
||||
"ext.comment.discard" to "Discard",
|
||||
"ext.comment.locateNoteRelocatedFrom" to "⚠ Line {original} could not be matched, showing line {resolved} instead.",
|
||||
"ext.comment.locateNoteRelocated" to "⚠ Line number was relocated based on code content.",
|
||||
|
||||
// ---------------------------------------------------------------- 配置面板
|
||||
"ext.configPanelTitle" to "Model Configuration",
|
||||
"ext.deleteProviderConfirm" to "Delete custom provider \"{name}\"?",
|
||||
"ext.deleteProviderConfirmBtn" to "Delete",
|
||||
"ext.deleteProviderTitle" to "Delete Custom Provider",
|
||||
"ext.common.cancel" to "Cancel",
|
||||
|
||||
// ---------------------------------------------------------------- CLI / 环境
|
||||
"ext.cli.installOk" to "✓ Install complete",
|
||||
"ext.cli.installFail" to "✗ Install failed (exit {code})",
|
||||
"ext.config.tempDirFailed" to "Failed to create temporary directory: {message}",
|
||||
|
||||
// ---------------------------------------------------------------- 宿主生成、发给前端展示
|
||||
"ext.review.noProjectDir" to "No usable project directory in the current project.",
|
||||
"ext.message.parseFailed" to "Failed to parse frontend message: {message}",
|
||||
"ext.message.missingTypeField" to "Frontend message is missing the type field",
|
||||
"ext.message.missingField" to "{type} is missing required field: {field}",
|
||||
"ext.message.invalidReviewOptions" to "Invalid review options: {message}",
|
||||
"ext.message.invalidCommentAction" to "commentAction has an invalid action value",
|
||||
|
||||
// ---------------------------------------------------------------- JCEF 兜底(IDEA 特有)
|
||||
"ext.jcef.unsupportedTitle" to "Cannot open configuration panel",
|
||||
"ext.jcef.unsupportedBody" to "The current runtime does not support JCEF. Please switch to the JetBrains Runtime bundled with IDEA, or configure via the `ocr config` command line first.",
|
||||
// 嵌入 JBLabel 的 <html>,故含 <br> 断行,不可删除。
|
||||
"ext.jcef.unsupportedPlaceholder" to "The current runtime does not support JCEF, so the Open Code Review UI cannot be shown.<br>" +
|
||||
"Please switch the IDE's boot runtime to the bundled JetBrains Runtime (Choose Boot Java Runtime for the IDE) and restart.",
|
||||
|
||||
// ---------------------------------------------------------------- 前端 bundle 缺失(IDEA 特有)
|
||||
"ext.webview.missingBundleTitle" to "Frontend assets missing",
|
||||
"ext.webview.missingBundleBody" to "<code>{resource}</code> is not in the plugin resources.",
|
||||
"ext.webview.missingBundleHint" to "Build the frontend first:",
|
||||
"ext.webview.missingBundleAlt" to "Or just run <code>./gradlew build</code>, which includes this step.",
|
||||
|
||||
// ---------------------------------------------------------------- git
|
||||
"ext.git.workspace" to "Workspace",
|
||||
"ext.git.emptyRef" to "(empty)",
|
||||
"ext.git.justNow" to "just now",
|
||||
"ext.git.hourAgo" to "1 hour ago",
|
||||
"ext.git.hoursAgo" to "{h} hours ago",
|
||||
"ext.git.yesterday" to "yesterday",
|
||||
"ext.git.daysAgo" to "{d} days ago",
|
||||
"ext.git.minutesAgo" to "{m} minutes ago",
|
||||
"ext.git.monthsAgo" to "{mo} months ago",
|
||||
"ext.git.yearsAgo" to "{y} years ago",
|
||||
)
|
||||
|
||||
private val ZH_CN: Map<String, String> = mapOf(
|
||||
// ---------------------------------------------------------------- 评论
|
||||
"ext.comment.threadLabel" to "Code Review", // "Code Review" 是产品名称,不翻译
|
||||
"ext.comment.pending" to "⏳ [未处理]",
|
||||
"ext.comment.statusApplied" to "✅ [已应用]",
|
||||
"ext.comment.statusDiscarded" to "✅ [已忽略]",
|
||||
"ext.comment.statusFalsePositive" to "✅ [已误报]",
|
||||
"ext.comment.noSuggestion" to "💡 无代码建议,请手动处理", // markdown 的 `_` 已去除,原因见 EN 侧同 key 注释
|
||||
"ext.comment.applyWorkspaceOnly" to "仅工作区审查模式支持应用建议。",
|
||||
"ext.comment.applyFailedStale" to "应用失败:代码位置已失效,请刷新后重试。",
|
||||
"ext.comment.applyFailedLocked" to "应用失败:无法修改文件,请检查文件是否被占用或处于只读状态。",
|
||||
"ext.comment.jumpFileMissing" to "无法跳转到 {path}:在审查快照中找不到该文件。",
|
||||
"ext.comment.jumpLineUnresolved" to "无法跳转到 {path}:未能解析行号。",
|
||||
"ext.comment.apply" to "应用",
|
||||
"ext.comment.discard" to "忽略",
|
||||
"ext.comment.locateNoteRelocatedFrom" to "⚠ 原本第 {original} 行没能匹配上,改为显示第 {resolved} 行。",
|
||||
"ext.comment.locateNoteRelocated" to "⚠ 行号是根据代码内容重新定位的。",
|
||||
|
||||
// ---------------------------------------------------------------- 配置面板
|
||||
"ext.configPanelTitle" to "模型配置",
|
||||
"ext.deleteProviderConfirm" to "确定删除自定义 Provider「{name}」?",
|
||||
"ext.deleteProviderConfirmBtn" to "删除",
|
||||
"ext.deleteProviderTitle" to "删除自定义 Provider",
|
||||
"ext.common.cancel" to "取消",
|
||||
|
||||
// ---------------------------------------------------------------- CLI / 环境
|
||||
"ext.cli.installOk" to "✓ 安装完成",
|
||||
"ext.cli.installFail" to "✗ 安装失败 (exit {code})",
|
||||
"ext.config.tempDirFailed" to "无法创建临时目录:{message}",
|
||||
|
||||
// ---------------------------------------------------------------- 宿主生成、发给前端展示
|
||||
"ext.review.noProjectDir" to "当前项目没有可用的项目目录",
|
||||
"ext.message.parseFailed" to "无法解析前端消息:{message}",
|
||||
"ext.message.missingTypeField" to "前端消息缺少 type 字段",
|
||||
"ext.message.missingField" to "{type} 缺少 {field}",
|
||||
"ext.message.invalidReviewOptions" to "审查参数无效:{message}",
|
||||
"ext.message.invalidCommentAction" to "commentAction 的 action 无效",
|
||||
|
||||
// ---------------------------------------------------------------- JCEF 兜底(IDEA 特有)
|
||||
"ext.jcef.unsupportedTitle" to "无法打开配置面板",
|
||||
"ext.jcef.unsupportedBody" to "当前运行时不支持 JCEF。请改用 IDEA 自带的 JetBrains Runtime,或先用 `ocr config` 命令行配置。",
|
||||
"ext.jcef.unsupportedPlaceholder" to "当前运行时不支持 JCEF,Open Code Review 的界面无法显示。<br>" +
|
||||
"请用 IDEA 自带的 JetBrains Runtime(Choose Boot Java Runtime for the IDE)后重启。",
|
||||
|
||||
// ---------------------------------------------------------------- 前端 bundle 缺失(IDEA 特有)
|
||||
"ext.webview.missingBundleTitle" to "前端资源缺失",
|
||||
"ext.webview.missingBundleBody" to "<code>{resource}</code> 不在插件资源里。",
|
||||
"ext.webview.missingBundleHint" to "请先构建前端:",
|
||||
"ext.webview.missingBundleAlt" to "或直接 <code>./gradlew build</code>(会自动带上这一步)。",
|
||||
|
||||
// ---------------------------------------------------------------- git
|
||||
"ext.git.workspace" to "工作区",
|
||||
"ext.git.emptyRef" to "(空)",
|
||||
"ext.git.justNow" to "刚刚",
|
||||
"ext.git.hourAgo" to "1 小时前",
|
||||
"ext.git.hoursAgo" to "{h} 小时前",
|
||||
"ext.git.yesterday" to "昨天",
|
||||
"ext.git.daysAgo" to "{d} 天前",
|
||||
"ext.git.minutesAgo" to "{m} 分钟前",
|
||||
"ext.git.monthsAgo" to "{mo} 个月前",
|
||||
"ext.git.yearsAgo" to "{y} 年前",
|
||||
)
|
||||
|
||||
/** 取文案并执行 `{param}` 替换。未找到词条时回退至英文,两者皆无则原样返回 key——与前端的兜底策略一致。 */
|
||||
fun t(locale: SupportedLocale, key: String, vararg params: Pair<String, String>): String {
|
||||
val template = when (locale) {
|
||||
SupportedLocale.ZH_CN -> ZH_CN[key]
|
||||
SupportedLocale.EN -> null
|
||||
} ?: EN[key] ?: key
|
||||
// 单遍替换:避免某参数值含 `{otherParam}` 时被后续参数再次替换(顺序依赖注入)。
|
||||
val map = params.toMap()
|
||||
return PARAM_REGEX.replace(template) { m -> map[m.groupValues[1]] ?: m.value }
|
||||
}
|
||||
|
||||
/** 返回两张表的原始内容,仅供单元测试——确保「两种语言 key 齐全且占位符一致」。 */
|
||||
internal fun tables(): Map<SupportedLocale, Map<String, String>> =
|
||||
mapOf(SupportedLocale.EN to EN, SupportedLocale.ZH_CN to ZH_CN)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.model
|
||||
|
||||
import com.intellij.DynamicBundle
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 本插件支持的语言枚举。
|
||||
* 序列化值须为 `en` / `zh-cn` 字面量:前端以此从词条表中取值,写作 `ZH_CN` 或 `zh-CN` 将使文案退化为 key 本身。
|
||||
*/
|
||||
@Serializable
|
||||
enum class SupportedLocale {
|
||||
@SerialName("en") EN,
|
||||
@SerialName("zh-cn") ZH_CN,
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言判定规则:仅 `zh-cn`(忽略大小写)视为简体中文,
|
||||
* `zh-tw` / `zh-hk` 等变体在对应翻译补齐之前,一律回退为英文。
|
||||
*/
|
||||
fun resolveLocale(raw: String): SupportedLocale =
|
||||
if (raw.lowercase() == "zh-cn") SupportedLocale.ZH_CN else SupportedLocale.EN
|
||||
|
||||
/** 转换为 HTML `lang` 属性取值:`zh-cn` 转为 `zh-CN`,其余原样返回。 */
|
||||
fun SupportedLocale.toHtmlLang(): String = when (this) {
|
||||
SupportedLocale.ZH_CN -> "zh-CN"
|
||||
SupportedLocale.EN -> "en"
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前 IDE 界面语言。`DynamicBundle.getLocale()` 反映 IDE 界面语言
|
||||
* (安装中文语言包后为 zh-CN),取不到时回退至 JVM 默认区域。不持有 webview locale 的组件可直接调用,无需额外注入。
|
||||
*/
|
||||
fun currentIdeLocale(): SupportedLocale {
|
||||
val tag = runCatching { DynamicBundle.getLocale().toLanguageTag() }
|
||||
.getOrElse { java.util.Locale.getDefault().toLanguageTag() }
|
||||
return resolveLocale(tag)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* 与前端约定的领域模型,字段逐一对应。
|
||||
*
|
||||
* 字段名须为 camelCase:前端按属性名取值,snake_case 会导致前端静默渲染为空白卡片或 0 行号。
|
||||
* CLI 的 snake_case JSON 在 [com.alibaba.opencodereview.idea.services.parseCliResult] 转换为本层类型,不应将 CLI 的命名带入本层。
|
||||
*/
|
||||
|
||||
/** 出站与入站 JSON 的统一配置:默认值须发送(前端依属性存在性判断),null 不发送(对应前端 `undefined` 语义)。 */
|
||||
val OcrJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ReviewMode {
|
||||
@SerialName("workspace") WORKSPACE,
|
||||
@SerialName("branch") BRANCH,
|
||||
@SerialName("commit") COMMIT,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ReviewState {
|
||||
@SerialName("idle") IDLE,
|
||||
@SerialName("running") RUNNING,
|
||||
@SerialName("done") DONE,
|
||||
@SerialName("empty") EMPTY,
|
||||
@SerialName("cancelled") CANCELLED,
|
||||
@SerialName("failed") FAILED,
|
||||
}
|
||||
|
||||
/** 注意 `falsePositive` 为 camelCase,而非 `false_positive`——前端按此字面量进行比较。 */
|
||||
@Serializable
|
||||
enum class CommentStatus {
|
||||
@SerialName("pending") PENDING,
|
||||
@SerialName("applied") APPLIED,
|
||||
@SerialName("discarded") DISCARDED,
|
||||
@SerialName("falsePositive") FALSE_POSITIVE,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class LogLevel {
|
||||
@SerialName("info") INFO,
|
||||
@SerialName("warn") WARN,
|
||||
@SerialName("error") ERROR,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class FileStatus {
|
||||
@SerialName("added") ADDED,
|
||||
@SerialName("modified") MODIFIED,
|
||||
@SerialName("deleted") DELETED,
|
||||
@SerialName("renamed") RENAMED,
|
||||
@SerialName("binary") BINARY,
|
||||
}
|
||||
|
||||
/**
|
||||
* `startLine` / `endLine` 取 0 作为哨兵值,表示 CLI 未提供可用行号。
|
||||
* 评论定位逻辑(CommentAnchor)依据此约定进入 existingCode 重定位分支,不可改为 1。
|
||||
*/
|
||||
@Serializable
|
||||
data class ReviewComment(
|
||||
val path: String = "",
|
||||
val content: String = "",
|
||||
val suggestionCode: String? = null,
|
||||
val existingCode: String? = null,
|
||||
val startLine: Int = 0,
|
||||
val endLine: Int = 0,
|
||||
val thinking: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReviewSummary(
|
||||
val filesReviewed: Int = 0,
|
||||
val comments: Int = 0,
|
||||
val totalTokens: Int = 0,
|
||||
val inputTokens: Int = 0,
|
||||
val outputTokens: Int = 0,
|
||||
val elapsed: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AgentWarning(
|
||||
val type: String = "",
|
||||
val file: String = "",
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
/** [status] 保持 String 类型:CLI 的取值域(success / completed_with_errors / completed_with_warnings / skipped)由 CLI 决定,出现新增取值不应导致解析失败。 */
|
||||
@Serializable
|
||||
data class CliResult(
|
||||
val status: String = "",
|
||||
val comments: List<ReviewComment> = emptyList(),
|
||||
val warnings: List<AgentWarning> = emptyList(),
|
||||
val summary: ReviewSummary? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProviderEntry(
|
||||
val apiKey: String = "",
|
||||
val url: String = "",
|
||||
val protocol: String = "",
|
||||
val model: String = "",
|
||||
val models: List<String>? = null,
|
||||
val authHeader: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LlmConfig(
|
||||
val url: String = "",
|
||||
val authToken: String = "",
|
||||
val model: String = "",
|
||||
val useAnthropic: Boolean = true,
|
||||
val authHeader: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OcrConfig(
|
||||
val provider: String = "",
|
||||
val model: String = "",
|
||||
val providers: Map<String, ProviderEntry> = emptyMap(),
|
||||
val customProviders: Map<String, ProviderEntry> = emptyMap(),
|
||||
val llm: LlmConfig = LlmConfig(),
|
||||
val language: String = "Chinese",
|
||||
)
|
||||
|
||||
/** [sha] 采用 7 位短哈希,与前端截取规则一致。 */
|
||||
@Serializable
|
||||
data class CommitInfo(
|
||||
val sha: String = "",
|
||||
val message: String = "",
|
||||
val relativeTime: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FileChange(
|
||||
val path: String = "",
|
||||
val status: FileStatus = FileStatus.MODIFIED,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitState(
|
||||
val branches: List<String> = emptyList(),
|
||||
val currentBranch: String = "",
|
||||
val recentCommits: List<CommitInfo> = emptyList(),
|
||||
val workspaceFiles: List<FileChange> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LogLine(
|
||||
val text: String,
|
||||
val level: LogLevel = LogLevel.INFO,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EnvToolStatus(
|
||||
val ok: Boolean = false,
|
||||
val version: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EnvCheckResult(
|
||||
val node: EnvToolStatus = EnvToolStatus(),
|
||||
val npm: EnvToolStatus = EnvToolStatus(),
|
||||
val ocr: EnvToolStatus = EnvToolStatus(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CliRunOptions(
|
||||
val mode: ReviewMode = ReviewMode.WORKSPACE,
|
||||
val from: String? = null,
|
||||
val to: String? = null,
|
||||
val commit: String? = null,
|
||||
val customPrompt: String? = null,
|
||||
val concurrency: Int? = null,
|
||||
)
|
||||
|
||||
/** 审查完成后评论的挂载上下文,字段取自 [CliRunOptions](对应前端的字段子集)。 */
|
||||
@Serializable
|
||||
data class ReviewContext(
|
||||
val mode: ReviewMode = ReviewMode.WORKSPACE,
|
||||
val from: String? = null,
|
||||
val to: String? = null,
|
||||
val commit: String? = null,
|
||||
)
|
||||
|
||||
fun CliRunOptions.toReviewContext(): ReviewContext = ReviewContext(mode, from, to, commit)
|
||||
|
||||
@Serializable
|
||||
data class CommentSyncState(
|
||||
val index: Int,
|
||||
val status: CommentStatus = CommentStatus.PENDING,
|
||||
val jumpable: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConfigEntry(
|
||||
val key: String = "",
|
||||
val value: String = "",
|
||||
)
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.providers
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.FileStatus
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.ReviewComment
|
||||
import com.alibaba.opencodereview.idea.model.ReviewContext
|
||||
import com.alibaba.opencodereview.idea.model.ReviewMode
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import com.alibaba.opencodereview.idea.services.GitService
|
||||
|
||||
/**
|
||||
* 评论定位:将 CLI 行号落到文件内容,行号失效时用 existingCode 滑窗匹配兜底。
|
||||
* 挂载后行号漂移由 CommentService 的 RangeHighlighter(RangeMarker)处理。
|
||||
*/
|
||||
|
||||
/** 评论挂载的落点:workspace 模式挂当前文件;branch/commit 模式挂 diff 的左侧或右侧。 */
|
||||
enum class AnchorSide { WORKSPACE, LEFT, RIGHT }
|
||||
|
||||
/** 定位失败时,只能在侧栏展示、不能跳转的原因。 */
|
||||
enum class SidebarOnlyReason {
|
||||
/** 二进制文件——通过 GitService.isBinaryFile 内容嗅探判定。 */
|
||||
BINARY,
|
||||
|
||||
/** 文件存在,但行号(含 existingCode 兜底)均无法对齐。 */
|
||||
UNRESOLVED,
|
||||
|
||||
/** 文件在本次审查范围之外,或指定的引用根本读不到内容。 */
|
||||
MISSING_FILE,
|
||||
|
||||
/** 定位算出来了,但挂载到编辑器这一步本身失败(如 Document 获取失败)。仅 [CommentService] 会用到这个原因。 */
|
||||
MOUNT_FAILED,
|
||||
}
|
||||
|
||||
sealed class CommentAnchorResult {
|
||||
/** [startLine] / [endLine] 是 1-based、已解析好的行号;[relocated] 表示是否靠 existingCode 兜底重定位。 */
|
||||
data class Mountable(
|
||||
val startLine: Int,
|
||||
val endLine: Int,
|
||||
val side: AnchorSide,
|
||||
val relocated: Boolean,
|
||||
val locateNote: String?,
|
||||
) : CommentAnchorResult()
|
||||
|
||||
data class SidebarOnly(val reason: SidebarOnlyReason) : CommentAnchorResult()
|
||||
}
|
||||
|
||||
/** 去掉行首的 diff 标记(`+`/`-`)与首尾空白,用于 existingCode 与文件内容的宽松比较。 */
|
||||
internal fun normalizeLine(line: String): String {
|
||||
val s = line.trim()
|
||||
return if (s.startsWith("+") || s.startsWith("-")) s.substring(1).trim() else s
|
||||
}
|
||||
|
||||
/** 按行拆分并逐行 [normalizeLine],丢弃空行——空行在不同版本间易增减,纳入匹配只会拖累滑窗。 */
|
||||
internal fun splitAndNormalize(code: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
for (raw in code.split("\n")) {
|
||||
val n = normalizeLine(raw)
|
||||
if (n.isNotEmpty()) result += n
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
internal data class LineSpan(val start: Int, val end: Int)
|
||||
|
||||
/** 在文件内容里滑窗匹配 [existingCode],返回 1-based 行号;找不到时返回 null。 */
|
||||
internal fun findLinesByExistingCode(content: String, existingCode: String): LineSpan? {
|
||||
val target = splitAndNormalize(existingCode)
|
||||
if (target.isEmpty()) return null
|
||||
|
||||
val normalized = mutableListOf<String>()
|
||||
val lineNums = mutableListOf<Int>()
|
||||
content.split("\n").forEachIndexed { index, raw ->
|
||||
val n = normalizeLine(raw.removeSuffix("\r"))
|
||||
if (n.isNotEmpty()) {
|
||||
normalized += n
|
||||
lineNums += index + 1
|
||||
}
|
||||
}
|
||||
if (normalized.size < target.size) return null
|
||||
|
||||
for (i in 0..(normalized.size - target.size)) {
|
||||
var matched = true
|
||||
for (j in target.indices) {
|
||||
if (normalized[i + j] != target[j]) {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (matched) return LineSpan(lineNums[i], lineNums[i + target.size - 1])
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal data class ResolvedLines(val start: Int, val end: Int, val relocated: Boolean)
|
||||
|
||||
/**
|
||||
* 把 CLI 给的行号落到 [content] 里;行号在范围内直接使用,否则退到 [existingCode] 滑窗重定位。
|
||||
* 两者均失败时返回 null——调用方应转为侧栏兜底展示。
|
||||
*/
|
||||
internal fun resolveLinesInContent(
|
||||
content: String,
|
||||
startLine: Int,
|
||||
endLine: Int,
|
||||
existingCode: String?,
|
||||
): ResolvedLines? {
|
||||
val lineCount = content.split("\n").size // 与本文件 splitAndNormalize 的 split("\n") 分行方式保持一致
|
||||
val start = if (startLine > 0) startLine else 0
|
||||
val end = if (endLine > 0) endLine else start
|
||||
|
||||
if (start > 0 && end > 0 && start <= lineCount && end <= lineCount && start <= end) {
|
||||
return ResolvedLines(start, end, relocated = false)
|
||||
}
|
||||
|
||||
if (!existingCode.isNullOrBlank()) {
|
||||
val found = findLinesByExistingCode(content, existingCode)
|
||||
if (found != null) return ResolvedLines(found.start, found.end, relocated = true)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 重定位说明,附在评论正文前面,提示用户行号是推算得到的。 */
|
||||
internal fun formatLocateNote(originalLine: Int, resolvedLine: Int, locale: SupportedLocale): String =
|
||||
if (originalLine > 0 && originalLine != resolvedLine) {
|
||||
HostStrings.t(
|
||||
locale,
|
||||
"ext.comment.locateNoteRelocatedFrom",
|
||||
"original" to originalLine.toString(),
|
||||
"resolved" to resolvedLine.toString(),
|
||||
)
|
||||
} else {
|
||||
HostStrings.t(locale, "ext.comment.locateNoteRelocated")
|
||||
}
|
||||
|
||||
/** 按状态选出要尝试挂载的 (引用, 落点) 候选,顺序即尝试顺序。 */
|
||||
private fun candidateRefs(git: GitService, ctx: ReviewContext, status: FileStatus): List<Pair<String, AnchorSide>> {
|
||||
val leftRef = if (status == FileStatus.ADDED) null else git.leftRefFor(ctx)
|
||||
val rightRef = if (status == FileStatus.DELETED) null else git.rightRefFor(ctx)
|
||||
val mountLeft = status == FileStatus.DELETED
|
||||
|
||||
val primary = if (mountLeft) leftRef?.let { it to AnchorSide.LEFT } else rightRef?.let { it to AnchorSide.RIGHT }
|
||||
// 新增文件没有「改动前」一侧,不去尝试另一侧——尝试了也读不到内容,徒增一次 git show。
|
||||
val alt = if (status == FileStatus.ADDED) {
|
||||
null
|
||||
} else if (mountLeft) {
|
||||
rightRef?.let { it to AnchorSide.RIGHT }
|
||||
} else {
|
||||
leftRef?.let { it to AnchorSide.LEFT }
|
||||
}
|
||||
return listOfNotNull(primary, alt)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析评论定位。二进制判定置最前,workspace 读工作区文件,非 workspace 按状态选引用尝试。
|
||||
*/
|
||||
fun resolveCommentAnchor(comment: ReviewComment, ctx: ReviewContext, git: GitService, locale: SupportedLocale): CommentAnchorResult {
|
||||
if (git.isBinaryFile(comment.path, ctx)) {
|
||||
return CommentAnchorResult.SidebarOnly(SidebarOnlyReason.BINARY)
|
||||
}
|
||||
|
||||
if (ctx.mode == ReviewMode.WORKSPACE) {
|
||||
val content = git.readWorkspaceFile(comment.path)
|
||||
?: return CommentAnchorResult.SidebarOnly(SidebarOnlyReason.MISSING_FILE)
|
||||
return mountableOrUnresolved(comment, content, AnchorSide.WORKSPACE, locale)
|
||||
}
|
||||
|
||||
// 非 workspace 模式:路径不在本次审查范围内([GitService.prepareReviewFileStatus] 未记录到)视为文件缺失,
|
||||
// 不再往下尝试任何引用——与「status 为 null 即归为 missing-file」的短路逻辑一致。
|
||||
val status = git.getReviewFileStatus(comment.path)
|
||||
?: return CommentAnchorResult.SidebarOnly(SidebarOnlyReason.MISSING_FILE)
|
||||
|
||||
val candidates = candidateRefs(git, ctx, status)
|
||||
if (candidates.isEmpty()) return CommentAnchorResult.SidebarOnly(SidebarOnlyReason.MISSING_FILE)
|
||||
|
||||
for ((ref, side) in candidates) {
|
||||
val content = git.readFileAtRef(ref, comment.path) ?: continue
|
||||
val result = mountableOrUnresolved(comment, content, side, locale)
|
||||
if (result is CommentAnchorResult.Mountable) return result
|
||||
}
|
||||
return CommentAnchorResult.SidebarOnly(SidebarOnlyReason.UNRESOLVED)
|
||||
}
|
||||
|
||||
private fun mountableOrUnresolved(comment: ReviewComment, content: String, side: AnchorSide, locale: SupportedLocale): CommentAnchorResult {
|
||||
val lines = resolveLinesInContent(content, comment.startLine, comment.endLine, comment.existingCode)
|
||||
?: return CommentAnchorResult.SidebarOnly(SidebarOnlyReason.UNRESOLVED)
|
||||
return CommentAnchorResult.Mountable(
|
||||
startLine = lines.start,
|
||||
endLine = lines.end,
|
||||
side = side,
|
||||
relocated = lines.relocated,
|
||||
locateNote = if (lines.relocated) formatLocateNote(comment.startLine, lines.start, locale) else null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条评论在 diff 里的落点,CommentService.decorateDiff 的输入。
|
||||
* startLine/endLine 均为 1-based 闭区间。
|
||||
*/
|
||||
internal data class DiffMark(val index: Int, val path: String, val side: AnchorSide, val startLine: Int, val endLine: Int)
|
||||
|
||||
/**
|
||||
* 挑出该挂到这一侧文档上的评论:路径与侧别均须匹配。侧别必须判——一次 diff 把左右两个文档都交出来,
|
||||
* 不判的话每条评论会在两侧各挂一个图标,而本插件一条评论只挂到对应那一侧的 `git:` 文档。
|
||||
*/
|
||||
internal fun selectDiffMarks(relPath: String, side: AnchorSide, all: List<DiffMark>): List<DiffMark> =
|
||||
all.filter { it.path == relPath && it.side == side }
|
||||
|
||||
/**
|
||||
* 把 1-based 闭区间夹进文档实际范围,返回 0-based 闭区间。
|
||||
* 行号在审查后可能因文件被修改、diff 两侧内容不同等原因超出范围,不夹会导致 IndexOutOfBounds。
|
||||
* startLine > endLine 也在此收敛为 start<=end。空文档返回 0..0(调用方在进入前已对 lineCount==0 做守卫,不会据此访问不存在的行)。
|
||||
*/
|
||||
internal fun clampLineRange(startLine: Int, endLine: Int, lineCount: Int): IntRange {
|
||||
if (lineCount <= 0) return 0..0
|
||||
val last = lineCount - 1
|
||||
val start = (startLine - 1).coerceIn(0, last)
|
||||
val end = (endLine - 1).coerceIn(start, last)
|
||||
return start..end
|
||||
}
|
||||
+745
@@ -0,0 +1,745 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.providers
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.CommentStatus
|
||||
import com.alibaba.opencodereview.idea.model.CommentSyncState
|
||||
import com.alibaba.opencodereview.idea.model.FileStatus
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.ReviewComment
|
||||
import com.alibaba.opencodereview.idea.model.ReviewContext
|
||||
import com.alibaba.opencodereview.idea.model.ReviewMode
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import com.alibaba.opencodereview.idea.services.GitService
|
||||
import com.intellij.diff.util.Side
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
import com.intellij.openapi.editor.Inlay
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.editor.ex.RangeHighlighterEx
|
||||
import com.intellij.openapi.editor.impl.DocumentMarkupModel
|
||||
import com.intellij.openapi.editor.impl.EditorEmbeddedComponentManager
|
||||
import com.intellij.openapi.editor.markup.EffectType
|
||||
import com.intellij.openapi.editor.markup.GutterIconRenderer
|
||||
import com.intellij.openapi.editor.markup.HighlighterLayer
|
||||
import com.intellij.openapi.editor.markup.HighlighterTargetArea
|
||||
import com.intellij.openapi.editor.markup.RangeHighlighter
|
||||
import com.intellij.openapi.editor.markup.TextAttributes
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.fileEditor.FileEditorManagerListener
|
||||
import com.intellij.openapi.fileEditor.OpenFileDescriptor
|
||||
import com.intellij.openapi.fileEditor.TextEditor
|
||||
import com.intellij.openapi.project.DumbAwareAction
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.ReadonlyStatusHandler
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.ui.ColorUtil
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Font
|
||||
|
||||
import java.nio.file.Path
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JTextArea
|
||||
|
||||
/**
|
||||
* 评论挂载与操作。三级定位回退(行号可用 → existingCode 滑窗重定位 → 仅侧栏)
|
||||
* 见 [resolveCommentAnchor]。
|
||||
*
|
||||
* workspace 模式:RangeHighlighter 本身是 RangeMarker,文档编辑后 offset 自行调整,
|
||||
* apply 与 jumpTo 直接问它当前行号。
|
||||
*
|
||||
* branch/commit 模式:内容为 git 引用只读快照,mounts 仅记录跳转所需的侧与行号,
|
||||
* 编辑器内标记由 GitService.diffDecorator 在文档创建时回调挂载。
|
||||
*/
|
||||
class CommentService(
|
||||
private val project: Project,
|
||||
private val git: GitService,
|
||||
private val notify: (String, NotificationType) -> Unit,
|
||||
private val locale: () -> SupportedLocale,
|
||||
) : Disposable {
|
||||
|
||||
/**
|
||||
* 评论落点,两模式共用完整行范围(startLine/endLine,1-based 闭区间)。
|
||||
* 取范围统一走 lineRangeIn,不各自读取字段。
|
||||
*
|
||||
* workspace 以 RangeHighlighter(RangeMarker)为当前位置真相。
|
||||
* branch/commit 以存下的行号为真相,不需要活锚点。
|
||||
*/
|
||||
private sealed class MountTarget {
|
||||
abstract val startLine: Int
|
||||
abstract val endLine: Int
|
||||
|
||||
data class Workspace(
|
||||
val highlighter: RangeHighlighter,
|
||||
override val startLine: Int,
|
||||
override val endLine: Int,
|
||||
) : MountTarget()
|
||||
|
||||
data class Diff(
|
||||
val side: AnchorSide,
|
||||
override val startLine: Int,
|
||||
override val endLine: Int,
|
||||
) : MountTarget()
|
||||
}
|
||||
|
||||
/** 评论状态变化时的回调,载荷即发给前端的 commentSync 内容。 */
|
||||
var onSync: ((List<CommentSyncState>) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* workspace 模式对齐 VS Code:文件打开时,该文件所有已挂载评论立即在各自代码行下方展开。
|
||||
* 文件未打开时不主动打开,靠 FileEditorManagerListener 补挂。
|
||||
*/
|
||||
init {
|
||||
project.messageBus.connect(this).subscribe(
|
||||
FileEditorManagerListener.FILE_EDITOR_MANAGER,
|
||||
object : FileEditorManagerListener {
|
||||
override fun fileOpened(source: FileEditorManager, file: VirtualFile) {
|
||||
mountInlineCommentsForFile(file)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val lock = Any()
|
||||
private var comments: List<ReviewComment> = emptyList()
|
||||
private var context: ReviewContext? = null
|
||||
private val statuses = mutableMapOf<Int, CommentStatus>()
|
||||
private val mounts = mutableMapOf<Int, MountTarget>()
|
||||
private val jumpBlockReasons = mutableMapOf<Int, SidebarOnlyReason>()
|
||||
|
||||
/** diff 视图的标记,随每次 diff 打开临时文档重建。 */
|
||||
private val diffHighlighters = mutableMapOf<Int, RangeHighlighter>()
|
||||
|
||||
/**
|
||||
* workspace 模式评论下方展开的内嵌面板。key 带 EditorEx 因 Inlay 挂在编辑器实例上
|
||||
* 而非 Document——同一文件分两窗格时各需独立面板。
|
||||
*/
|
||||
private val panels = mutableMapOf<Pair<Int, EditorEx>, Inlay<*>>()
|
||||
|
||||
/** 内嵌面板展开/折叠状态。setStatus 处理后折叠,jumpTo 重新展开。 */
|
||||
private val expanded = mutableMapOf<Int, Boolean>()
|
||||
|
||||
/** 三级回退时「行号被重定位过」的提示,展示在评论正文前面。 */
|
||||
private val locateNotes = mutableMapOf<Int, String>()
|
||||
|
||||
fun show(comments: List<ReviewComment>, context: ReviewContext) {
|
||||
synchronized(lock) {
|
||||
this.comments = comments
|
||||
this.context = context
|
||||
statuses.clear()
|
||||
expanded.clear()
|
||||
jumpBlockReasons.clear()
|
||||
locateNotes.clear()
|
||||
}
|
||||
clearHighlighters()
|
||||
|
||||
val currentLocale = locale()
|
||||
val resolved = comments.mapIndexed { index, comment ->
|
||||
index to resolveCommentAnchor(comment, context, git, currentLocale)
|
||||
}
|
||||
synchronized(lock) {
|
||||
resolved.forEach { (index, result) ->
|
||||
when (result) {
|
||||
is CommentAnchorResult.Mountable -> {
|
||||
result.locateNote?.let { locateNotes[index] = it }
|
||||
if (result.side != AnchorSide.WORKSPACE) {
|
||||
mounts[index] = MountTarget.Diff(result.side, result.startLine, result.endLine)
|
||||
}
|
||||
}
|
||||
|
||||
is CommentAnchorResult.SidebarOnly -> jumpBlockReasons[index] = result.reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val workspaceItems = resolved.filter { (_, r) ->
|
||||
r is CommentAnchorResult.Mountable && r.side == AnchorSide.WORKSPACE
|
||||
}
|
||||
if (workspaceItems.isEmpty()) {
|
||||
publishSync()
|
||||
jumpToFirstMounted()
|
||||
return
|
||||
}
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (!project.isDisposed) {
|
||||
workspaceItems.forEach { (index, result) ->
|
||||
mountWorkspaceHighlighter(index, comments[index], result as CommentAnchorResult.Mountable)
|
||||
}
|
||||
}
|
||||
publishSync()
|
||||
jumpToFirstMounted()
|
||||
}
|
||||
}
|
||||
|
||||
/** 挂载完成后自动跳到第一条挂上的评论。 */
|
||||
private fun jumpToFirstMounted() {
|
||||
val first = synchronized(lock) { mounts.keys.minOrNull() } ?: return
|
||||
if (ApplicationManager.getApplication().isDispatchThread) {
|
||||
ApplicationManager.getApplication().executeOnPooledThread {
|
||||
if (!project.isDisposed) jumpTo(first)
|
||||
}
|
||||
} else {
|
||||
jumpTo(first)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空所有评论与高亮。 */
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
comments = emptyList()
|
||||
context = null
|
||||
statuses.clear()
|
||||
expanded.clear()
|
||||
}
|
||||
clearHighlighters()
|
||||
publishSync()
|
||||
}
|
||||
|
||||
fun jumpTo(index: Int) {
|
||||
val (comment, ctx) = snapshot(index) ?: return
|
||||
when (val target = synchronized(lock) { mounts[index] }) {
|
||||
is MountTarget.Workspace -> ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
val highlighter = target.highlighter
|
||||
val file = resolveProjectFile(comment.path)
|
||||
if (!highlighter.isValid || file == null) {
|
||||
notifyJumpFailed(index, comment)
|
||||
return@invokeLater
|
||||
}
|
||||
val line = highlighter.document.getLineNumber(highlighter.startOffset)
|
||||
FileEditorManager.getInstance(project).openTextEditor(
|
||||
OpenFileDescriptor(project, file, line, 0), true
|
||||
)
|
||||
synchronized(lock) { expanded[index] = true }
|
||||
rebuildInlinePanels(index, comment)
|
||||
mountInlineCommentsForFile(file)
|
||||
}
|
||||
|
||||
is MountTarget.Diff -> {
|
||||
val status = git.getReviewFileStatus(comment.path) ?: FileStatus.MODIFIED
|
||||
val side = if (target.side == AnchorSide.LEFT) Side.LEFT else Side.RIGHT
|
||||
git.openDiff(comment.path, status, ctx, side, (target.startLine - 1).coerceAtLeast(0), index)
|
||||
}
|
||||
|
||||
null -> notifyJumpFailed(index, comment)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 采纳建议。只有 workspace 模式允许 apply。无 suggestionCode 时删除被标记范围。
|
||||
*/
|
||||
fun apply(index: Int) {
|
||||
val (comment, ctx) = snapshot(index) ?: return
|
||||
if (ctx.mode != ReviewMode.WORKSPACE) {
|
||||
notify(HostStrings.t(locale(), "ext.comment.applyWorkspaceOnly"), NotificationType.WARNING)
|
||||
return
|
||||
}
|
||||
val file = resolveProjectFile(comment.path) ?: return
|
||||
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
val document = FileDocumentManager.getInstance().getDocument(file) ?: return@invokeLater
|
||||
if (document.lineCount == 0) return@invokeLater
|
||||
|
||||
val range = lineRangeIn(index, document) ?: run {
|
||||
notify(HostStrings.t(locale(), "ext.comment.applyFailedStale"), NotificationType.ERROR)
|
||||
return@invokeLater
|
||||
}
|
||||
val startLine = range.first
|
||||
val endLine = range.last
|
||||
|
||||
// ensureFilesWritable 放在 isWritable 前:它会弹 IDEA「从版本控制签出」对话框。
|
||||
val writable = ReadonlyStatusHandler.getInstance(project).ensureFilesWritable(listOf(file))
|
||||
if (writable.hasReadonlyFiles() || !document.isWritable) {
|
||||
notify(HostStrings.t(locale(), "ext.comment.applyFailedLocked"), NotificationType.ERROR)
|
||||
return@invokeLater
|
||||
}
|
||||
|
||||
val suggestion = comment.suggestionCode?.takeIf { it.isNotBlank() }
|
||||
val edited = runCatching {
|
||||
WriteCommandAction.runWriteCommandAction(project) {
|
||||
val startOffset = document.getLineStartOffset(startLine)
|
||||
if (suggestion != null) {
|
||||
val endOffset = document.getLineEndOffset(endLine)
|
||||
document.replaceString(startOffset, endOffset, suggestion)
|
||||
} else {
|
||||
val endOffset = if (endLine + 1 < document.lineCount)
|
||||
document.getLineStartOffset(endLine + 1)
|
||||
else
|
||||
document.getLineEndOffset(endLine)
|
||||
document.deleteString(startOffset, endOffset)
|
||||
}
|
||||
FileDocumentManager.getInstance().saveDocument(document)
|
||||
}
|
||||
}
|
||||
if (edited.isFailure) {
|
||||
thisLogger().warn("apply comment #$index failed", edited.exceptionOrNull())
|
||||
notify(HostStrings.t(locale(), "ext.comment.applyFailedLocked"), NotificationType.ERROR)
|
||||
return@invokeLater
|
||||
}
|
||||
FileEditorManager.getInstance(project).openTextEditor(
|
||||
OpenFileDescriptor(project, file, startLine, 0), true
|
||||
)
|
||||
setStatus(index, CommentStatus.APPLIED)
|
||||
}
|
||||
}
|
||||
|
||||
fun discard(index: Int) = setStatus(index, CommentStatus.DISCARDED)
|
||||
|
||||
fun falsePositive(index: Int) = setStatus(index, CommentStatus.FALSE_POSITIVE)
|
||||
|
||||
// ---------------------------------------------------------------- 内部
|
||||
|
||||
private fun snapshot(index: Int): Pair<ReviewComment, ReviewContext>? = synchronized(lock) {
|
||||
val comment = comments.getOrNull(index) ?: return null
|
||||
val ctx = context ?: return null
|
||||
comment to ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 第 index 条评论此刻在 document 里占哪几行(0-based 闭区间)。
|
||||
* 画行底色、挂内嵌面板、跳转、采纳均从此统一出口。
|
||||
*
|
||||
* workspace 优先问活锚点(RangeHighlighter 跟随用户编辑移动);
|
||||
* 锚点失效退回解析阶段行号。返回 null 表示无落点(仅侧栏展示)。
|
||||
*/
|
||||
private fun lineRangeIn(index: Int, document: Document): IntRange? {
|
||||
if (document.lineCount == 0) return null
|
||||
val target = synchronized(lock) { mounts[index] } ?: return null
|
||||
if (target is MountTarget.Workspace) {
|
||||
val highlighter = target.highlighter
|
||||
if (highlighter.isValid && highlighter.document == document) {
|
||||
return document.getLineNumber(highlighter.startOffset)..
|
||||
document.getLineNumber(highlighter.endOffset)
|
||||
}
|
||||
}
|
||||
return clampLineRange(target.startLine, target.endLine, document.lineCount)
|
||||
}
|
||||
|
||||
private fun setStatus(index: Int, status: CommentStatus) {
|
||||
val comment = synchronized(lock) {
|
||||
if (index !in comments.indices) return
|
||||
statuses[index] = status
|
||||
expanded[index] = false
|
||||
comments[index]
|
||||
}
|
||||
refreshGutter(index, comment, status)
|
||||
publishSync()
|
||||
}
|
||||
|
||||
/** 状态变化时刷新 gutter 图标、tooltip、error stripe 和内嵌面板。 */
|
||||
private fun refreshGutter(index: Int, comment: ReviewComment, status: CommentStatus) {
|
||||
val loc = locale()
|
||||
val targets = synchronized(lock) {
|
||||
listOfNotNull(
|
||||
(mounts[index] as? MountTarget.Workspace)?.highlighter,
|
||||
diffHighlighters[index],
|
||||
)
|
||||
}
|
||||
if (targets.isNotEmpty()) {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
val diffHl = synchronized(lock) { diffHighlighters[index] }
|
||||
targets.forEach { highlighter ->
|
||||
if (!highlighter.isValid) return@forEach
|
||||
highlighter.gutterIconRenderer = CommentGutterIconRenderer(index, comment, status)
|
||||
highlighter.setErrorStripeTooltip(tooltipFor(index, comment, status, loc))
|
||||
highlighter.setErrorStripeMarkColor(
|
||||
if (status == CommentStatus.PENDING) PENDING_STRIPE else null
|
||||
)
|
||||
val attrs = if (highlighter === diffHl) diffLineAttributes(status) else lineAttributes(status)
|
||||
(highlighter as? RangeHighlighterEx)?.setTextAttributes(attrs)
|
||||
}
|
||||
}
|
||||
}
|
||||
rebuildInlinePanels(index, comment)
|
||||
}
|
||||
|
||||
/** 按最新状态/展开状态重建内嵌面板。找不到旧面板则跳过。 */
|
||||
private fun rebuildInlinePanels(index: Int, comment: ReviewComment) {
|
||||
val stale = synchronized(lock) { panels.keys.filter { it.first == index } }
|
||||
if (stale.isEmpty()) return
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
stale.forEach { key ->
|
||||
val editor = key.second
|
||||
val old = synchronized(lock) { panels.remove(key) }
|
||||
val offset = old?.offset ?: return@forEach
|
||||
old.let { runCatching { it.dispose() } }
|
||||
if (editor.isDisposed) return@forEach
|
||||
mountInlineComment(index, comment, editor, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleExpanded(index: Int, comment: ReviewComment) {
|
||||
synchronized(lock) { expanded[index] = !(expanded[index] ?: true) }
|
||||
rebuildInlinePanels(index, comment)
|
||||
}
|
||||
|
||||
private fun tooltipFor(index: Int, comment: ReviewComment, status: CommentStatus, loc: SupportedLocale): String =
|
||||
buildString {
|
||||
append(statusLabel(status, loc)).append('\n')
|
||||
synchronized(lock) { locateNotes[index] }?.let { append(it).append('\n') }
|
||||
append(comment.content)
|
||||
}
|
||||
|
||||
private fun statusLabel(status: CommentStatus, loc: SupportedLocale): String = HostStrings.t(
|
||||
loc,
|
||||
when (status) {
|
||||
CommentStatus.PENDING -> "ext.comment.pending"
|
||||
CommentStatus.APPLIED -> "ext.comment.statusApplied"
|
||||
CommentStatus.DISCARDED -> "ext.comment.statusDiscarded"
|
||||
CommentStatus.FALSE_POSITIVE -> "ext.comment.statusFalsePositive"
|
||||
},
|
||||
)
|
||||
|
||||
private fun publishSync() {
|
||||
val states = synchronized(lock) {
|
||||
comments.mapIndexed { index, _ ->
|
||||
CommentSyncState(
|
||||
index = index,
|
||||
status = statuses[index] ?: CommentStatus.PENDING,
|
||||
jumpable = mounts.containsKey(index),
|
||||
)
|
||||
}
|
||||
}
|
||||
onSync?.invoke(states)
|
||||
}
|
||||
|
||||
private fun notifyJumpFailed(index: Int, comment: ReviewComment) {
|
||||
val reason = synchronized(lock) { jumpBlockReasons[index] } ?: inferJumpBlockReason(comment)
|
||||
val message = when (reason) {
|
||||
SidebarOnlyReason.MISSING_FILE, SidebarOnlyReason.MOUNT_FAILED ->
|
||||
HostStrings.t(locale(), "ext.comment.jumpFileMissing", "path" to comment.path)
|
||||
|
||||
SidebarOnlyReason.BINARY, SidebarOnlyReason.UNRESOLVED ->
|
||||
HostStrings.t(locale(), "ext.comment.jumpLineUnresolved", "path" to comment.path)
|
||||
}
|
||||
notify(message, NotificationType.WARNING)
|
||||
}
|
||||
|
||||
private fun inferJumpBlockReason(comment: ReviewComment): SidebarOnlyReason =
|
||||
if (comment.startLine <= 0 && comment.endLine <= 0) SidebarOnlyReason.UNRESOLVED
|
||||
else SidebarOnlyReason.MISSING_FILE
|
||||
|
||||
private fun mountWorkspaceHighlighter(
|
||||
index: Int, comment: ReviewComment, mountable: CommentAnchorResult.Mountable,
|
||||
) {
|
||||
val file = resolveProjectFile(comment.path)
|
||||
val document = file?.let { FileDocumentManager.getInstance().getDocument(it) }
|
||||
if (document == null || document.lineCount == 0) {
|
||||
synchronized(lock) { jumpBlockReasons[index] = SidebarOnlyReason.MOUNT_FAILED }
|
||||
return
|
||||
}
|
||||
val range = clampLineRange(mountable.startLine, mountable.endLine, document.lineCount)
|
||||
val status = synchronized(lock) { statuses[index] ?: CommentStatus.PENDING }
|
||||
val highlighter = DocumentMarkupModel.forDocument(document, project, true)
|
||||
.addRangeHighlighter(
|
||||
document.getLineStartOffset(range.first),
|
||||
document.getLineEndOffset(range.last),
|
||||
HighlighterLayer.WARNING,
|
||||
lineAttributes(status),
|
||||
HighlighterTargetArea.EXACT_RANGE,
|
||||
)
|
||||
highlighter.setErrorStripeMarkColor(PENDING_STRIPE)
|
||||
synchronized(lock) {
|
||||
mounts[index] = MountTarget.Workspace(highlighter, mountable.startLine, mountable.endLine)
|
||||
}
|
||||
highlighter.setErrorStripeTooltip(tooltipFor(index, comment, status, locale()))
|
||||
highlighter.gutterIconRenderer = CommentGutterIconRenderer(index, comment, status)
|
||||
|
||||
editorsShowing(file).forEach { editor ->
|
||||
mountInlineComment(index, comment, editor, document.getLineEndOffset(range.last))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 diff 文档挂属于这一侧的评论标记(RangeHighlighter + 装订线图标)。
|
||||
* 由 GitService.openDiff 在 EDT 回调、左右两侧各一次。
|
||||
*/
|
||||
fun decorateDiff(relPath: String, side: Side, document: Document) {
|
||||
val anchorSide = if (side == Side.LEFT) AnchorSide.LEFT else AnchorSide.RIGHT
|
||||
val all = diffMarks()
|
||||
val targets = selectDiffMarks(relPath, anchorSide, all)
|
||||
if (targets.isEmpty() || document.lineCount == 0) return
|
||||
|
||||
val loc = locale()
|
||||
val markup = DocumentMarkupModel.forDocument(document, project, true)
|
||||
targets.forEach { mark ->
|
||||
val index = mark.index
|
||||
val comment = synchronized(lock) { comments.getOrNull(index) } ?: return@forEach
|
||||
val range = lineRangeIn(index, document) ?: return@forEach
|
||||
val status = synchronized(lock) { statuses[index] ?: CommentStatus.PENDING }
|
||||
val highlighter = markup.addRangeHighlighter(
|
||||
document.getLineStartOffset(range.first),
|
||||
document.getLineEndOffset(range.last),
|
||||
HighlighterLayer.WARNING,
|
||||
diffLineAttributes(status),
|
||||
HighlighterTargetArea.EXACT_RANGE,
|
||||
)
|
||||
highlighter.setErrorStripeMarkColor(
|
||||
if (status == CommentStatus.PENDING) PENDING_STRIPE else null
|
||||
)
|
||||
highlighter.setErrorStripeTooltip(tooltipFor(index, comment, status, loc))
|
||||
highlighter.gutterIconRenderer = CommentGutterIconRenderer(index, comment, status)
|
||||
val stale = synchronized(lock) { diffHighlighters.put(index, highlighter) }
|
||||
stale?.let { runCatching { it.dispose() } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 装订线图标,点击展开/收起内嵌面板。
|
||||
* 不跳转到该行:图标就在该行旁边,已可见无需再跳。
|
||||
* 跨位置导航走侧栏卡片「查看」按钮 → jumpTo。
|
||||
*/
|
||||
private inner class CommentGutterIconRenderer(
|
||||
private val index: Int,
|
||||
private val comment: ReviewComment,
|
||||
private val status: CommentStatus,
|
||||
) : GutterIconRenderer() {
|
||||
override fun getIcon() =
|
||||
if (status == CommentStatus.PENDING) AllIcons.General.TodoDefault
|
||||
else AllIcons.General.InspectionsOK
|
||||
|
||||
override fun getTooltipText(): String = tooltipFor(index, comment, status, locale())
|
||||
override fun isNavigateAction(): Boolean = true
|
||||
override fun getClickAction(): AnAction = DumbAwareAction.create { toggleExpanded(index, comment) }
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is CommentGutterIconRenderer && other.index == index && other.status == status
|
||||
|
||||
override fun hashCode(): Int = 31 * index + status.ordinal
|
||||
}
|
||||
|
||||
private fun lineAttributes(status: CommentStatus): TextAttributes? =
|
||||
if (status != CommentStatus.PENDING) null
|
||||
else TextAttributes(null, ColorUtil.withAlpha(PENDING_STRIPE, 0.15), null, null, Font.PLAIN)
|
||||
|
||||
private fun diffLineAttributes(status: CommentStatus): TextAttributes? =
|
||||
if (status != CommentStatus.PENDING) null
|
||||
else TextAttributes(null, null, PENDING_STRIPE, EffectType.ROUNDED_BOX, Font.PLAIN)
|
||||
|
||||
private fun editorsShowing(file: VirtualFile?): List<EditorEx> {
|
||||
file ?: return emptyList()
|
||||
return FileEditorManager.getInstance(project).getEditors(file)
|
||||
.filterIsInstance<TextEditor>()
|
||||
.mapNotNull { it.editor as? EditorEx }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把评论的内嵌面板挂进 editor 的 offset 下方。
|
||||
* 已挂过则跳过(panels 按 (index, editor) 去重)。
|
||||
*/
|
||||
private fun mountInlineComment(index: Int, comment: ReviewComment, editor: EditorEx, offset: Int) {
|
||||
val key = index to editor
|
||||
synchronized(lock) { if (panels.containsKey(key)) return }
|
||||
val (status, isExpanded) = synchronized(lock) {
|
||||
(statuses[index] ?: CommentStatus.PENDING) to (expanded[index] ?: true)
|
||||
}
|
||||
val panel = buildCommentPanel(index, comment, status, isExpanded)
|
||||
runCatching {
|
||||
EditorEmbeddedComponentManager.getInstance().addComponent(
|
||||
editor,
|
||||
panel,
|
||||
EditorEmbeddedComponentManager.Properties(
|
||||
EditorEmbeddedComponentManager.ResizePolicy.none(),
|
||||
null,
|
||||
true, // relatesToPrecedingText
|
||||
false, // showAbove
|
||||
false, // showWhenFolded
|
||||
true, // fullWidth
|
||||
0,
|
||||
offset,
|
||||
),
|
||||
)
|
||||
}.onSuccess { inlay ->
|
||||
if (inlay == null) return@onSuccess
|
||||
synchronized(lock) { panels.put(key, inlay) }?.let { runCatching { it.dispose() } }
|
||||
}.onFailure { thisLogger().warn("[ocr] Failed to mount inline comment panel #$index, falling back to gutter icon", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 diff 视图挂内嵌评论面板。由 GitService.diffViewerReady 在 showDiff 之后回调。
|
||||
* 通过 EditorFactory 查询 document 对应的编辑器实例。
|
||||
*/
|
||||
fun mountDiffPanels(relPath: String, side: Side, document: Document, clickedIndex: Int? = null) {
|
||||
val anchorSide = if (side == Side.LEFT) AnchorSide.LEFT else AnchorSide.RIGHT
|
||||
val targets = selectDiffMarks(relPath, anchorSide, diffMarks())
|
||||
if (targets.isEmpty() || document.lineCount == 0) return
|
||||
|
||||
dropDisposedPanels()
|
||||
|
||||
val editors = EditorFactory.getInstance().getEditors(document, project)
|
||||
.filterIsInstance<EditorEx>()
|
||||
if (editors.isEmpty()) return
|
||||
|
||||
// 被点击的评论放到最后挂,使滚入目标落在点击行。
|
||||
val ordered = if (clickedIndex == null) targets
|
||||
else targets.sortedBy { it.index == clickedIndex }
|
||||
|
||||
ordered.forEach { mark ->
|
||||
val comment = synchronized(lock) { comments.getOrNull(mark.index) } ?: return@forEach
|
||||
val range = lineRangeIn(mark.index, document) ?: return@forEach
|
||||
editors.forEach { editor ->
|
||||
mountInlineComment(mark.index, comment, editor, document.getLineEndOffset(range.last))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun diffMarks(): List<DiffMark> = synchronized(lock) {
|
||||
comments.indices.mapNotNull { index ->
|
||||
val mount = mounts[index] as? MountTarget.Diff ?: return@mapNotNull null
|
||||
DiffMark(index, comments[index].path, mount.side, mount.startLine, mount.endLine)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropDisposedPanels() {
|
||||
val dead = synchronized(lock) {
|
||||
val keys = panels.keys.filter { it.second.isDisposed }
|
||||
keys.mapNotNull { panels.remove(it) }
|
||||
}
|
||||
dead.forEach { runCatching { it.dispose() } }
|
||||
}
|
||||
|
||||
/** 补挂 file 里所有已定位但未展开的评论面板。 */
|
||||
private fun mountInlineCommentsForFile(file: VirtualFile) {
|
||||
val toMount = synchronized(lock) {
|
||||
mounts.entries.mapNotNull { (index, target) ->
|
||||
if (target !is MountTarget.Workspace) return@mapNotNull null
|
||||
val comment = comments.getOrNull(index) ?: return@mapNotNull null
|
||||
if (resolveProjectFile(comment.path) != file) return@mapNotNull null
|
||||
index to comment
|
||||
}
|
||||
}
|
||||
if (toMount.isEmpty()) return
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
val editors = editorsShowing(file)
|
||||
if (editors.isEmpty()) return@invokeLater
|
||||
val document = FileDocumentManager.getInstance().getDocument(file) ?: return@invokeLater
|
||||
toMount.forEach { (index, comment) ->
|
||||
val range = lineRangeIn(index, document) ?: return@forEach
|
||||
val offset = document.getLineEndOffset(range.last)
|
||||
editors.forEach { editor -> mountInlineComment(index, comment, editor, offset) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内嵌面板内容:定位提示 + 正文 + 分隔线 + suggestion + 按钮。
|
||||
* expanded 为 false 时只画标题行(折叠态)。
|
||||
*/
|
||||
private fun buildCommentPanel(
|
||||
index: Int, comment: ReviewComment, status: CommentStatus, expanded: Boolean,
|
||||
): JPanel {
|
||||
val loc = locale()
|
||||
val total = synchronized(lock) { comments.size }
|
||||
val panel = JPanel(BorderLayout(0, 6)).apply { border = JBUI.Borders.empty(8, 12) }
|
||||
|
||||
val arrow = if (expanded) "▾" else "▸"
|
||||
val header = JBLabel(
|
||||
"$arrow ${HostStrings.t(loc, "ext.comment.threadLabel")} (${index + 1} / $total) · ${statusLabel(status, loc)}"
|
||||
).apply {
|
||||
font = font.deriveFont(Font.BOLD, font.size2D - 1f)
|
||||
cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
|
||||
addMouseListener(object : java.awt.event.MouseAdapter() {
|
||||
override fun mouseClicked(e: java.awt.event.MouseEvent) = toggleExpanded(index, comment)
|
||||
})
|
||||
}
|
||||
panel.add(header, BorderLayout.NORTH)
|
||||
if (!expanded) return panel
|
||||
|
||||
val centerPanel = JPanel().apply {
|
||||
layout = javax.swing.BoxLayout(this, javax.swing.BoxLayout.Y_AXIS)
|
||||
isOpaque = false
|
||||
}
|
||||
val contentText = buildString {
|
||||
synchronized(lock) { locateNotes[index] }?.let { append(it).append("\n\n") }
|
||||
append(comment.content)
|
||||
}
|
||||
centerPanel.add(JTextArea(contentText).apply {
|
||||
isEditable = false; isOpaque = false; lineWrap = true; wrapStyleWord = true
|
||||
})
|
||||
centerPanel.add(javax.swing.Box.createVerticalStrut(8))
|
||||
centerPanel.add(JPanel().apply {
|
||||
background = javax.swing.UIManager.getColor("Label.foreground") ?: JBColor.foreground()
|
||||
minimumSize = java.awt.Dimension(0, 1)
|
||||
preferredSize = java.awt.Dimension(0, 1)
|
||||
maximumSize = java.awt.Dimension(Int.MAX_VALUE, 1)
|
||||
})
|
||||
centerPanel.add(javax.swing.Box.createVerticalStrut(8))
|
||||
val suggestionText = comment.suggestionCode?.takeIf { it.isNotBlank() }
|
||||
?: HostStrings.t(loc, "ext.comment.noSuggestion")
|
||||
centerPanel.add(JTextArea(suggestionText).apply {
|
||||
isEditable = false; isOpaque = false; lineWrap = true; wrapStyleWord = true
|
||||
})
|
||||
panel.add(centerPanel, BorderLayout.CENTER)
|
||||
|
||||
// 已处理的评论不再给操作按钮。
|
||||
if (status == CommentStatus.PENDING) {
|
||||
val buttons = JPanel()
|
||||
val workspace = snapshot(index)?.second?.mode == ReviewMode.WORKSPACE
|
||||
if (workspace && !comment.suggestionCode.isNullOrBlank()) {
|
||||
buttons.add(JButton(HostStrings.t(loc, "ext.comment.apply")).apply {
|
||||
addActionListener { apply(index) }
|
||||
})
|
||||
}
|
||||
buttons.add(JButton(HostStrings.t(loc, "ext.comment.discard")).apply {
|
||||
addActionListener { discard(index) }
|
||||
})
|
||||
panel.add(buttons, BorderLayout.SOUTH)
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun clearHighlighters() {
|
||||
val stale = synchronized(lock) {
|
||||
val copy = mounts.values.filterIsInstance<MountTarget.Workspace>().map { it.highlighter } +
|
||||
diffHighlighters.values
|
||||
val stalePanels = panels.values.toList()
|
||||
mounts.clear()
|
||||
diffHighlighters.clear()
|
||||
panels.clear()
|
||||
jumpBlockReasons.clear()
|
||||
locateNotes.clear()
|
||||
copy to stalePanels
|
||||
}
|
||||
if (stale.first.isEmpty() && stale.second.isEmpty()) return
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
stale.first.forEach { runCatching { it.dispose() } }
|
||||
stale.second.forEach { runCatching { it.dispose() } }
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 CLI 给的相对路径解析成项目内文件;越出仓库根的路径一律拒绝。基准用 repoRoot:评论路径是仓库根相对(git diff 默认),子目录打开项目时 basePath≠repoRoot 会导致双重嵌套找不到文件。 */
|
||||
private fun resolveProjectFile(relative: String): VirtualFile? {
|
||||
val base = git.repoRoot()?.toPath()?.toRealPath() ?: return null
|
||||
val target = runCatching { base.resolve(relative).toRealPath() }.getOrNull() ?: return null
|
||||
if (!target.startsWith(base)) return null
|
||||
return LocalFileSystem.getInstance().refreshAndFindFileByNioFile(target)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
onSync = null
|
||||
clearHighlighters()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val PENDING_STRIPE = JBColor(0xD97706, 0xF59E0B)
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.review
|
||||
|
||||
import com.alibaba.opencodereview.idea.messages.WebviewChannel
|
||||
|
||||
/**
|
||||
* 配置面板的宿主窗口,只负责窗口生命周期:创建、显露与销毁。
|
||||
* 消息处理在 [ConfigPanelRouter]。拆成接口是因为具体面板依赖前端就绪信号才能实现;在该信号到达之前,
|
||||
* 打开配置的请求退化成一条 IDE 通知而非静默无响应。
|
||||
*/
|
||||
interface ConfigPanelHost {
|
||||
|
||||
/** 面板当前是否已经打开。 */
|
||||
val isOpen: Boolean
|
||||
|
||||
/** 打开面板;已经打开时把它带到前台。 */
|
||||
fun open()
|
||||
|
||||
/** 关闭面板。 */
|
||||
fun close()
|
||||
|
||||
/** 面板的出站通道;面板没打开时实现方应当直接丢弃。 */
|
||||
val channel: WebviewChannel
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.review
|
||||
|
||||
import com.alibaba.opencodereview.idea.messages.ConfigPanelHostToWebview
|
||||
import com.alibaba.opencodereview.idea.messages.WebviewToHost
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.OcrConfig
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import com.alibaba.opencodereview.idea.services.CliService
|
||||
import com.alibaba.opencodereview.idea.services.ConfigService
|
||||
import com.alibaba.opencodereview.idea.services.isConfigReady
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.ide.CopyPasteManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.MessageDialogBuilder
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 配置面板的消息路由,按消息类型分派处理。
|
||||
*
|
||||
* 两个关键行为,变更前需确认:
|
||||
* 1. 处理逻辑都包在 try/catch 里,异常统一转 `panelError` 发回前端(前端有专门错误条渲染;只写日志用户看到的是"点了保存无响应")。
|
||||
* 2. 每次写配置后 `notifyConfig`——既回 `config` 给面板,又推给侧栏,否则侧栏仍以旧配置判断能否开始审查。
|
||||
*/
|
||||
class ConfigPanelRouter(
|
||||
private val project: Project,
|
||||
private val cli: CliService,
|
||||
private val config: ConfigService,
|
||||
private val locale: () -> SupportedLocale,
|
||||
private val post: (ConfigPanelHostToWebview) -> Unit,
|
||||
/** 关闭面板窗口。 */
|
||||
private val closePanel: () -> Unit,
|
||||
/** 配置变化后通知侧栏。 */
|
||||
private val onConfigChanged: (OcrConfig?) -> Unit,
|
||||
) {
|
||||
|
||||
/** `open(focus)` 与 `readyConfigPanel` 之间暂存的 focus。 */
|
||||
private val pendingFocus = AtomicReference<JsonElement?>(null)
|
||||
|
||||
fun setPendingFocus(focus: JsonElement?) {
|
||||
pendingFocus.set(focus)
|
||||
}
|
||||
|
||||
fun takePendingFocus(): JsonElement? = pendingFocus.getAndSet(null)
|
||||
|
||||
fun handle(msg: WebviewToHost) {
|
||||
// closeConfigPanel 只是关窗口,切到后台线程反而会与 dispose 竞争,直接同步处理。
|
||||
if (msg is WebviewToHost.CloseConfigPanel) {
|
||||
invokeOnEdt { closePanel() }
|
||||
return
|
||||
}
|
||||
background {
|
||||
try {
|
||||
handleMessage(msg)
|
||||
} catch (e: Exception) {
|
||||
thisLogger().warn("[ocr] Config panel message handling failed", e)
|
||||
post(ConfigPanelHostToWebview.PanelError(e.message ?: e.javaClass.simpleName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(msg: WebviewToHost) {
|
||||
when (msg) {
|
||||
WebviewToHost.ReadyConfigPanel -> sendInit()
|
||||
|
||||
is WebviewToHost.SetConfig -> {
|
||||
config.set(msg.key, msg.value)
|
||||
notifyConfig(config.read())
|
||||
}
|
||||
|
||||
is WebviewToHost.SetConfigBatch -> {
|
||||
config.setMany(msg.entries)
|
||||
notifyConfig(config.read())
|
||||
}
|
||||
|
||||
is WebviewToHost.TestConnection -> {
|
||||
val (ok, message) = config.testWithEntries(msg.entries)
|
||||
post(ConfigPanelHostToWebview.ConnectionResult(ok, message))
|
||||
}
|
||||
|
||||
is WebviewToHost.DeleteCustomProvider -> {
|
||||
// 删除配置不可撤销,需以模态确认框拦截一次。
|
||||
if (!confirmDelete(msg.name)) return
|
||||
notifyConfig(config.deleteCustomProvider(msg.name))
|
||||
}
|
||||
|
||||
is WebviewToHost.ActivateCustomProvider -> {
|
||||
config.set("provider", msg.name)
|
||||
notifyConfig(config.read())
|
||||
}
|
||||
|
||||
// checkCli 和 checkEnvironment 合到同一分支,均为强制重新探测。
|
||||
WebviewToHost.CheckCli, WebviewToHost.CheckEnvironment ->
|
||||
post(ConfigPanelHostToWebview.EnvironmentResult(cli.checkEnvironment(force = true)))
|
||||
|
||||
is WebviewToHost.CopyToClipboard -> {
|
||||
// 写剪贴板与回执都在 EDT 内完成:否则 post(CopyDone) 在写盘前就到前端,
|
||||
// 用户看到"已复制"立即切走应用去粘贴时,剪贴板里可能还没有内容。
|
||||
invokeOnEdt {
|
||||
CopyPasteManager.getInstance().setContents(StringSelection(msg.text))
|
||||
post(ConfigPanelHostToWebview.CopyDone)
|
||||
}
|
||||
}
|
||||
|
||||
WebviewToHost.InstallCli -> {
|
||||
val ok = cli.install { line -> post(ConfigPanelHostToWebview.InstallLog(line)) }
|
||||
post(ConfigPanelHostToWebview.InstallDone(ok))
|
||||
// 安装成功时已清环境缓存,此处不带 force 也会重新探测一次。
|
||||
post(ConfigPanelHostToWebview.EnvironmentResult(cli.checkEnvironment()))
|
||||
}
|
||||
|
||||
is WebviewToHost.Malformed -> post(ConfigPanelHostToWebview.PanelError(msg.reason))
|
||||
|
||||
// 侧栏的消息不应经此通道投递;无法识别的类型可能来自更新的前端,忽略即可。
|
||||
else -> thisLogger().debug("[ocr] Config panel ignoring message: $msg")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `readyConfigPanel` 的响应。`env` 刻意只取缓存而不主动探测:
|
||||
* 面板需要时会自行发 `checkEnvironment`,此处同步探测会把首屏阻塞数秒。
|
||||
*/
|
||||
private fun sendInit() {
|
||||
val focus = takePendingFocus()
|
||||
val current = config.read()
|
||||
post(
|
||||
ConfigPanelHostToWebview.Init(
|
||||
config = current,
|
||||
focus = focus,
|
||||
env = cli.getCachedEnvironment(),
|
||||
// 直接跳到第 2 步、或者配置本就齐备,则无需再走环境检查引导。
|
||||
skipEnvCheck = focus.step() == 2 || isConfigReady(current),
|
||||
locale = locale(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun notifyConfig(updated: OcrConfig?) {
|
||||
post(ConfigPanelHostToWebview.Config(updated))
|
||||
onConfigChanged(updated)
|
||||
}
|
||||
|
||||
private fun confirmDelete(name: String): Boolean {
|
||||
// 在 pooled 线程上走到这里时项目可能已被关闭:对已释放 project 调 invokeAndWait 弹模态框可能死锁。
|
||||
if (project.isDisposed) return false
|
||||
var confirmed = false
|
||||
val loc = locale()
|
||||
ApplicationManager.getApplication().invokeAndWait {
|
||||
if (project.isDisposed) return@invokeAndWait
|
||||
confirmed = MessageDialogBuilder
|
||||
.yesNo(
|
||||
HostStrings.t(loc, "ext.deleteProviderTitle"),
|
||||
HostStrings.t(loc, "ext.deleteProviderConfirm", "name" to name),
|
||||
)
|
||||
.yesText(HostStrings.t(loc, "ext.deleteProviderConfirmBtn"))
|
||||
.noText(HostStrings.t(loc, "ext.common.cancel"))
|
||||
.ask(project)
|
||||
}
|
||||
return confirmed
|
||||
}
|
||||
|
||||
private fun invokeOnEdt(block: () -> Unit) {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (!project.isDisposed) runCatching(block).onFailure {
|
||||
thisLogger().warn("[ocr] Config panel UI operation failed", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun background(block: () -> Unit) {
|
||||
ApplicationManager.getApplication().executeOnPooledThread {
|
||||
if (!project.isDisposed) block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取 `ConfigPanelFocus.step`。宿主不解释 focus 的其余字段,只需要这一个数字。 */
|
||||
private fun JsonElement?.step(): Int? =
|
||||
((this as? JsonObject)?.get("step") as? JsonPrimitive)?.intOrNull
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.review
|
||||
|
||||
import com.alibaba.opencodereview.idea.messages.ConfigPanelHostToWebview
|
||||
import com.alibaba.opencodereview.idea.messages.HostToWebview
|
||||
import com.alibaba.opencodereview.idea.messages.WebviewChannel
|
||||
import com.alibaba.opencodereview.idea.messages.parseWebviewMessage
|
||||
import com.alibaba.opencodereview.idea.messages.toJson
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.OcrConfig
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import com.alibaba.opencodereview.idea.model.currentIdeLocale
|
||||
import com.alibaba.opencodereview.idea.jcef.JcefConfigPanelHost
|
||||
import com.alibaba.opencodereview.idea.providers.CommentService
|
||||
import com.alibaba.opencodereview.idea.services.CliService
|
||||
import com.alibaba.opencodereview.idea.services.ConfigService
|
||||
import com.alibaba.opencodereview.idea.services.GitService
|
||||
import com.intellij.notification.NotificationGroupManager
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.jcef.JBCefApp
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import java.io.File
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
/**
|
||||
* 项目级协调器:服务组装、webview 注册、跨组件连线均在此层。
|
||||
* 消息处理不在本层:侧栏在 [SidebarRouter],配置面板在 [ConfigPanelRouter]。
|
||||
*
|
||||
* 为什么要区分消息来源:侧栏与配置面板是两个独立 webview,各自收到的消息天然带来源;
|
||||
* JCEF 这边只有一个 `handle(raw)` 回调、字符串无来源信息,故入口按通道分 [handleFromSidebar]/[handleFromConfigPanel]。
|
||||
* 若混成一个入口,侧栏和面板会收到对方消息,`config` 这种两侧同名出站类型会被投递到错误的 webview。
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
class ReviewProjectService(private val project: Project) : Disposable {
|
||||
|
||||
private companion object {
|
||||
const val NOTIFICATION_GROUP = "Open Code Review"
|
||||
}
|
||||
|
||||
private val cli = CliService()
|
||||
private val git = GitService(project)
|
||||
private val config = ConfigService(cli, projectDir())
|
||||
private val comments = CommentService(project, git, notify = ::notifyComment, locale = ::currentLocale)
|
||||
|
||||
private val sidebarChannels = CopyOnWriteArraySet<WebviewChannel>()
|
||||
|
||||
/**
|
||||
* 配置面板窗口。惰性创建:绝大多数会话不开它,提前创建 JCEF browser 会占用不必要的内存。
|
||||
* JCEF 不可用时为 null,此时 [openConfigPanel] 退化成一条通知。
|
||||
*/
|
||||
private val panelHost: ConfigPanelHost? by lazy {
|
||||
if (!JBCefApp.isSupported()) return@lazy null
|
||||
JcefConfigPanelHost(project, ::currentLocale, ::handleFromConfigPanel)
|
||||
.also { Disposer.register(this, it) }
|
||||
}
|
||||
|
||||
private val sidebar = SidebarRouter(
|
||||
project = project,
|
||||
cli = cli,
|
||||
config = config,
|
||||
git = git,
|
||||
comments = comments,
|
||||
locale = ::currentLocale,
|
||||
post = ::postSidebar,
|
||||
openConfigPanel = ::openConfigPanel,
|
||||
)
|
||||
|
||||
private val panelRouter = ConfigPanelRouter(
|
||||
project = project,
|
||||
cli = cli,
|
||||
config = config,
|
||||
locale = ::currentLocale,
|
||||
post = ::postConfigPanel,
|
||||
closePanel = { panelHost?.close() },
|
||||
onConfigChanged = ::onConfigChanged,
|
||||
)
|
||||
|
||||
init {
|
||||
Disposer.register(this, comments)
|
||||
// 评论状态变化(应用 / 忽略 / 误报)要回推给侧栏,前端依赖它更新卡片上的状态角标。
|
||||
comments.onSync = { states -> postSidebar(HostToWebview.CommentSync(states)) }
|
||||
// branch/commit 模式评论要挂 diff 两侧的临时文档上,只有 GitService 造文档那一刻能拿到句柄。
|
||||
// GitService 不认识评论,这里接起来。见 CommentService.decorateDiff。
|
||||
git.diffDecorator = { path, side, document -> comments.decorateDiff(path, side, document) }
|
||||
// DiffDecorator 在 showDiff 之前挂行高亮和装订线图标,diffViewerReady 在之后挂内嵌面板——两个时机相反,故两个钩子。见 GitService.diffViewerReady。
|
||||
git.diffViewerReady = { path, side, document, clickedIndex -> comments.mountDiffPanels(path, side, document, clickedIndex) }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 侧栏通道
|
||||
|
||||
/** 工作区变更订阅,只在侧栏存活时存在。 */
|
||||
private var gitWatch: Disposable? = null
|
||||
private val watchLock = Any()
|
||||
|
||||
fun attachSidebar(channel: WebviewChannel) {
|
||||
sidebarChannels.add(channel)
|
||||
synchronized(watchLock) {
|
||||
// 用户在 IDE 里改了文件或切了分支,侧栏的工作区文件列表要随之更新。
|
||||
if (gitWatch == null) {
|
||||
gitWatch = git.watchWorkspaceChanges(this) { state -> postSidebar(HostToWebview.GitStateChanged(state)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun detachSidebar(channel: WebviewChannel) {
|
||||
sidebarChannels.remove(channel)
|
||||
if (sidebarChannels.isNotEmpty()) return
|
||||
synchronized(watchLock) {
|
||||
if (sidebarChannels.isNotEmpty()) return // attach 与 detach 交错时不要误断开
|
||||
gitWatch?.let(Disposer::dispose)
|
||||
gitWatch = null
|
||||
}
|
||||
}
|
||||
|
||||
fun handleFromSidebar(raw: String) {
|
||||
sidebar.handle(parseWebviewMessage(raw, currentLocale()))
|
||||
}
|
||||
|
||||
private fun postSidebar(msg: HostToWebview) {
|
||||
if (sidebarChannels.isEmpty()) return
|
||||
val json = msg.toJson()
|
||||
sidebarChannels.forEach { channel ->
|
||||
runCatching { channel.post(json) }
|
||||
.onFailure { thisLogger().warn("[ocr] Sidebar post failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 配置面板通道
|
||||
|
||||
fun handleFromConfigPanel(raw: String) {
|
||||
panelRouter.handle(parseWebviewMessage(raw, currentLocale()))
|
||||
}
|
||||
|
||||
private fun postConfigPanel(msg: ConfigPanelHostToWebview) {
|
||||
val host = panelHost ?: return
|
||||
runCatching { host.channel.post(msg.toJson()) }
|
||||
.onFailure { thisLogger().warn("[ocr] Config panel post failed", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开配置面板。已开时不重建,只补发 `configPanelFocus` 跳到目标步骤——
|
||||
* 重建会丢用户填了一半的表单。
|
||||
*/
|
||||
fun openConfigPanel(focus: JsonElement?) {
|
||||
val host = panelHost
|
||||
if (host == null) {
|
||||
notifyJcefUnsupported()
|
||||
return
|
||||
}
|
||||
if (host.isOpen) {
|
||||
postConfigPanel(ConfigPanelHostToWebview.Focus(focus))
|
||||
host.open() // 已打开时等价于显露,把面板带到前台
|
||||
panelRouter.setPendingFocus(null)
|
||||
} else {
|
||||
// 面板要等 webview 就绪才会发 readyConfigPanel,focus 先暂存。
|
||||
panelRouter.setPendingFocus(focus)
|
||||
host.open()
|
||||
}
|
||||
}
|
||||
|
||||
/** 配置面板改了配置之后,把新配置推给侧栏——侧栏依赖它判断能否开始审查。 */
|
||||
private fun onConfigChanged(updated: OcrConfig?) {
|
||||
postSidebar(HostToWebview.Config(updated))
|
||||
}
|
||||
|
||||
private fun notifyJcefUnsupported() {
|
||||
val loc = currentLocale()
|
||||
val group = NotificationGroupManager.getInstance().getNotificationGroup(NOTIFICATION_GROUP)
|
||||
group.createNotification(
|
||||
HostStrings.t(loc, "ext.jcef.unsupportedTitle"),
|
||||
HostStrings.t(loc, "ext.jcef.unsupportedBody"),
|
||||
NotificationType.WARNING,
|
||||
).notify(project)
|
||||
}
|
||||
|
||||
/** [CommentService] 的跳转失败 / 采纳失败通知都走这里——原生 IDE 通知。 */
|
||||
private fun notifyComment(message: String, type: NotificationType) {
|
||||
val group = NotificationGroupManager.getInstance().getNotificationGroup(NOTIFICATION_GROUP)
|
||||
group.createNotification(message, type).notify(project)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 杂项
|
||||
|
||||
/** 当前 IDE 界面语言。JCEF 面板装配 HTML 时也要用,所以是 internal 而不是 private。 */
|
||||
internal fun currentLocale(): SupportedLocale = currentIdeLocale()
|
||||
|
||||
private fun projectDir(): File =
|
||||
project.basePath?.let(::File)?.takeIf { it.isDirectory }
|
||||
?: File(System.getProperty("user.dir"))
|
||||
|
||||
override fun dispose() {
|
||||
sidebar.cancelActiveSession()
|
||||
sidebarChannels.clear()
|
||||
comments.onSync = null
|
||||
// 清空 git 回调:openDiff 里可能有未执行的 invokeLater,服务已释放后再触发会调到已 dispose 的 comments。
|
||||
git.diffDecorator = null
|
||||
git.diffViewerReady = null
|
||||
// panelHost 是 by lazy,这里不应访问——未打开过配置面板的会话不应因 dispose 去初始化 JCEF browser。
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.review
|
||||
|
||||
import com.alibaba.opencodereview.idea.messages.CommentActionKind
|
||||
import com.alibaba.opencodereview.idea.messages.HostToWebview
|
||||
import com.alibaba.opencodereview.idea.messages.WebviewToHost
|
||||
import com.alibaba.opencodereview.idea.model.CliResult
|
||||
import com.alibaba.opencodereview.idea.model.FileChange
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.LogLine
|
||||
import com.alibaba.opencodereview.idea.model.ReviewContext
|
||||
import com.alibaba.opencodereview.idea.model.ReviewMode
|
||||
import com.alibaba.opencodereview.idea.model.ReviewState
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import com.alibaba.opencodereview.idea.model.toReviewContext
|
||||
import com.alibaba.opencodereview.idea.providers.CommentService
|
||||
import com.alibaba.opencodereview.idea.services.CliService
|
||||
import com.alibaba.opencodereview.idea.services.ConfigService
|
||||
import com.alibaba.opencodereview.idea.services.GitService
|
||||
import com.alibaba.opencodereview.idea.services.ReviewSession
|
||||
import com.alibaba.opencodereview.idea.services.SessionCallbacks
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.io.FileUtil
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 侧栏消息路由:接收前端 webview 在工作区审查流程中发来的全部消息并分发处理。
|
||||
*
|
||||
* 本类处理的消息类型见 [WebviewToHost];配置面板的消息由 [ConfigPanelRouter] 处理,
|
||||
* 两者各自只处理归属自身通道的消息类型。
|
||||
*
|
||||
* 线程约定:凡涉及 git 或 CLI 的处理均提交至公共线程池执行,不在 EDT 上阻塞。
|
||||
*/
|
||||
class SidebarRouter(
|
||||
private val project: Project,
|
||||
private val cli: CliService,
|
||||
private val config: ConfigService,
|
||||
private val git: GitService,
|
||||
private val comments: CommentService,
|
||||
private val locale: () -> SupportedLocale,
|
||||
private val post: (HostToWebview) -> Unit,
|
||||
/** 打开配置面板。宿主侧的面板实现由 [ReviewProjectService] 注入。 */
|
||||
private val openConfigPanel: (JsonElement?) -> Unit,
|
||||
) {
|
||||
|
||||
private val session = AtomicReference<ReviewSession?>(null)
|
||||
|
||||
fun handle(msg: WebviewToHost) {
|
||||
when (msg) {
|
||||
WebviewToHost.Ready -> background { sendInit() }
|
||||
|
||||
is WebviewToHost.GetGitState -> background {
|
||||
post(HostToWebview.GitStateChanged(git.getState(msg.mode)))
|
||||
}
|
||||
|
||||
is WebviewToHost.GetModeFiles -> background { sendModeFiles(msg) }
|
||||
|
||||
is WebviewToHost.OpenFileDiff -> background {
|
||||
// openDiff 内部会自行切换到 EDT 打开差异视图,此处只负责读取内容。
|
||||
git.openDiff(msg.path, msg.status, msg.toReviewContext())
|
||||
}
|
||||
|
||||
is WebviewToHost.StartReview -> background { startReview(msg) }
|
||||
|
||||
WebviewToHost.CancelReview -> {
|
||||
// 在调用线程捕获目标 session:background(executeOnPooledThread) 与 StartReview 无序,
|
||||
// 若先进 background 再 StartReview 抢跑,session.get() 会取到新 session 误杀;捕获 target 即锁定旧目标。
|
||||
val target = session.get() ?: return
|
||||
background {
|
||||
target.cancel { state ->
|
||||
// 已被新一轮接管则不投递旧轮状态,避免 RUNNING(new)→CANCELLED(old) 乱序。
|
||||
if (session.get() === target) post(HostToWebview.StateChange(state))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WebviewToHost.GetConfig -> background { post(HostToWebview.Config(config.read())) }
|
||||
|
||||
// jumpTo 在 diff 模式下需读取 git 引用中的文件内容,属于阻塞式进程调用;
|
||||
// 不能在 JCEF 消息回调线程执行,否则该线程阻塞会导致前端后续消息排队、界面无响应。
|
||||
is WebviewToHost.JumpToComment -> background { comments.jumpTo(msg.index) }
|
||||
|
||||
// apply 同理:实际写入在 EDT 上完成,但需先解析路径、判断只读状态。
|
||||
is WebviewToHost.CommentAction -> background {
|
||||
when (msg.action) {
|
||||
CommentActionKind.APPLY -> comments.apply(msg.index)
|
||||
CommentActionKind.DISCARD -> comments.discard(msg.index)
|
||||
CommentActionKind.FALSE_POSITIVE -> comments.falsePositive(msg.index)
|
||||
}
|
||||
}
|
||||
|
||||
is WebviewToHost.OpenConfigPanel -> openConfigPanel(msg.focus)
|
||||
|
||||
is WebviewToHost.Malformed -> {
|
||||
thisLogger().warn("[ocr] Sidebar received invalid message: ${msg.reason}")
|
||||
// 仅在审查进行中时才发 FAILED,避免覆盖 IDLE/DONE 等正常状态
|
||||
if (session.get() != null) {
|
||||
session.getAndSet(null)
|
||||
post(HostToWebview.StateChange(ReviewState.FAILED, msg.reason))
|
||||
}
|
||||
}
|
||||
|
||||
// 配置面板的消息不应出现在侧栏通道;无法识别的类型可能来自更高版本的前端,直接忽略。
|
||||
else -> thisLogger().debug("[ocr] Sidebar ignoring message: $msg")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应前端的 `ready` 消息,返回初始化所需的三项数据。
|
||||
*
|
||||
* 三字段必须齐全:缺 `config` 时前端会判定为未配置、停留在配置视图;
|
||||
* 缺 `gitState` 时模式选择器没有可选分支与文件。
|
||||
*/
|
||||
private fun sendInit() {
|
||||
post(HostToWebview.Init(config.read(), git.getState(ReviewMode.WORKSPACE), locale()))
|
||||
}
|
||||
|
||||
private fun sendModeFiles(msg: WebviewToHost.GetModeFiles) {
|
||||
// workspace 模式不在此处理:工作区文件清单由初始化时下发的 gitState 提供。
|
||||
val files: List<FileChange> = when {
|
||||
msg.mode == ReviewMode.BRANCH && msg.from != null && msg.to != null ->
|
||||
git.getBranchDiff(msg.from, msg.to)
|
||||
|
||||
msg.mode == ReviewMode.COMMIT && msg.commit != null ->
|
||||
git.getCommitFiles(msg.commit)
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
post(HostToWebview.ModeFiles(msg.mode, files))
|
||||
}
|
||||
|
||||
private fun startReview(msg: WebviewToHost.StartReview) {
|
||||
val cwd = reviewCwd()
|
||||
if (cwd == null) {
|
||||
post(HostToWebview.StateChange(ReviewState.FAILED, HostStrings.t(locale(), "ext.review.noProjectDir")))
|
||||
return
|
||||
}
|
||||
|
||||
val options = msg.options
|
||||
val context = options.toReviewContext()
|
||||
val current = ReviewSession(cli, cwd)
|
||||
// Replace atomically so concurrent starts cannot lose an uncancelled session.
|
||||
session.getAndSet(current)?.cancel { }
|
||||
// 再清除上一轮的行标记,避免新旧两轮的高亮在同一文件上叠加。
|
||||
comments.clear()
|
||||
|
||||
current.run(options, object : SessionCallbacks {
|
||||
// 身份校验:cancel() 只杀进程不摘回调,旧 run 仍可能在别的线程回调 onState 等;
|
||||
// 若本 session 已被新一轮替换(session.get() !== current),丢弃过时回调,避免 RUNNING(new)→CANCELLED(old) 乱序。
|
||||
override fun onState(state: ReviewState, error: String?) {
|
||||
if (session.get() !== current) return
|
||||
post(HostToWebview.StateChange(state, error))
|
||||
}
|
||||
|
||||
override fun onLog(line: LogLine) {
|
||||
if (session.get() !== current) return
|
||||
post(HostToWebview.Log(line))
|
||||
}
|
||||
|
||||
override fun onDone(result: CliResult) {
|
||||
if (session.get() !== current) return
|
||||
if (result.comments.isNotEmpty()) {
|
||||
// 预先计算本次审查涉及的文件状态,供评论定位时区分"文件已删除"与"行号无法匹配"两种情况。
|
||||
git.prepareReviewFileStatus(context)
|
||||
comments.show(result.comments, context)
|
||||
}
|
||||
post(HostToWebview.ReviewDone(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定审查使用的工作目录。
|
||||
*
|
||||
* 优先取项目根目录;当项目根不在 git 仓库内时回退到仓库根,
|
||||
* 否则 CLI 在 workspace 模式下无法获取 diff。
|
||||
*/
|
||||
private fun reviewCwd(): File? {
|
||||
val base = project.basePath?.let(::File)?.takeIf { it.isDirectory }
|
||||
val root = git.repoRoot()
|
||||
return when {
|
||||
base == null -> root
|
||||
root == null -> base
|
||||
FileUtil.isAncestor(root, base, false) -> base
|
||||
else -> root
|
||||
}
|
||||
}
|
||||
|
||||
private fun background(block: () -> Unit) {
|
||||
ApplicationManager.getApplication().executeOnPooledThread {
|
||||
if (project.isDisposed) return@executeOnPooledThread
|
||||
runCatching(block).onFailure { thisLogger().warn("[ocr] Sidebar message handling failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelActiveSession() {
|
||||
session.getAndSet(null)?.cancel { }
|
||||
}
|
||||
}
|
||||
|
||||
/** `openFileDiff` 消息里的模式与引用字段,形状与 [ReviewContext] 相同。 */
|
||||
private fun WebviewToHost.OpenFileDiff.toReviewContext() = ReviewContext(mode, from, to, commit)
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
/** A single invocation owns this handle, including cancellation before process registration. */
|
||||
class CliCancellation {
|
||||
private val lock = Any()
|
||||
private var cancelled = false
|
||||
private var attached = false
|
||||
private var terminate: (() -> Unit)? = null
|
||||
|
||||
fun cancel() {
|
||||
val action = synchronized(lock) {
|
||||
if (cancelled) return
|
||||
cancelled = true
|
||||
terminate.also { terminate = null }
|
||||
}
|
||||
action?.invoke()
|
||||
}
|
||||
|
||||
internal fun checkCancelled() {
|
||||
synchronized(lock) {
|
||||
if (cancelled) throw CancellationException("Review cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun attach(action: () -> Unit) {
|
||||
val cancelNow = synchronized(lock) {
|
||||
check(!attached) { "A cancellation handle cannot be reused" }
|
||||
attached = true
|
||||
if (cancelled) true else {
|
||||
terminate = action
|
||||
false
|
||||
}
|
||||
}
|
||||
if (cancelNow) action()
|
||||
}
|
||||
|
||||
internal fun detach() {
|
||||
synchronized(lock) { terminate = null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.AgentWarning
|
||||
import com.alibaba.opencodereview.idea.model.CliResult
|
||||
import com.alibaba.opencodereview.idea.model.CliRunOptions
|
||||
import com.alibaba.opencodereview.idea.model.LogLevel
|
||||
import com.alibaba.opencodereview.idea.model.LogLine
|
||||
import com.alibaba.opencodereview.idea.model.OcrJson
|
||||
import com.alibaba.opencodereview.idea.model.ReviewComment
|
||||
import com.alibaba.opencodereview.idea.model.ReviewMode
|
||||
import com.alibaba.opencodereview.idea.model.ReviewSummary
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* CLI snake_case JSON 与插件 camelCase 契约之间的唯一转换点。
|
||||
* 下列 Dto 仅用于反序列化,不暴露到 model 包之外。
|
||||
*/
|
||||
|
||||
@Serializable
|
||||
private data class CliCommentDto(
|
||||
val path: String,
|
||||
val content: String,
|
||||
@SerialName("suggestion_code") val suggestionCode: String? = null,
|
||||
@SerialName("existing_code") val existingCode: String? = null,
|
||||
@SerialName("start_line") val startLine: Int = 0,
|
||||
@SerialName("end_line") val endLine: Int = 0,
|
||||
val thinking: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class CliSummaryDto(
|
||||
@SerialName("files_reviewed") val filesReviewed: Int = 0,
|
||||
val comments: Int = 0,
|
||||
@SerialName("total_tokens") val totalTokens: Int = 0,
|
||||
@SerialName("input_tokens") val inputTokens: Int = 0,
|
||||
@SerialName("output_tokens") val outputTokens: Int = 0,
|
||||
val elapsed: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class CliResultDto(
|
||||
val status: String,
|
||||
val message: String? = null,
|
||||
val comments: List<CliCommentDto>?,
|
||||
val warnings: List<AgentWarning> = emptyList(),
|
||||
val summary: CliSummaryDto? = null,
|
||||
)
|
||||
|
||||
fun buildReviewArgs(opts: CliRunOptions): List<String> = buildList {
|
||||
add("review")
|
||||
when (opts.mode) {
|
||||
ReviewMode.WORKSPACE -> Unit
|
||||
ReviewMode.BRANCH -> {
|
||||
opts.from?.takeIf(String::isNotBlank)?.let { addAll(listOf("--from", it.trim())) }
|
||||
opts.to?.takeIf(String::isNotBlank)?.let { addAll(listOf("--to", it.trim())) }
|
||||
}
|
||||
ReviewMode.COMMIT -> opts.commit?.takeIf(String::isNotBlank)?.let { addAll(listOf("--commit", it.trim())) }
|
||||
}
|
||||
addAll(listOf("--format", "json"))
|
||||
// JSON 结果走 stdout,进度日志走 stderr,供插件实时回显。
|
||||
opts.customPrompt?.takeIf(String::isNotBlank)?.let { addAll(listOf("--background", it.trim())) }
|
||||
opts.concurrency?.let { addAll(listOf("--concurrency", it.toString())) }
|
||||
}
|
||||
|
||||
private fun CliCommentDto.toComment(): ReviewComment = ReviewComment(
|
||||
path = path,
|
||||
content = content,
|
||||
// 将空白串归一为缺失:若直接透传空/空白串,调用方会把其当作"存在建议/原码"。
|
||||
suggestionCode = suggestionCode?.takeIf(String::isNotBlank),
|
||||
existingCode = existingCode?.takeIf(String::isNotBlank),
|
||||
startLine = startLine,
|
||||
endLine = endLine,
|
||||
thinking = thinking?.takeIf(String::isNotEmpty),
|
||||
)
|
||||
|
||||
/** Find complete top-level values without interpreting braces inside JSON strings. */
|
||||
private fun jsonCandidates(stdout: String): Sequence<String> = sequence {
|
||||
var start = -1
|
||||
var depth = 0
|
||||
var quoted = false
|
||||
var escaped = false
|
||||
for ((index, char) in stdout.withIndex()) {
|
||||
if (start < 0) {
|
||||
if (char != '{' && char != '[') continue
|
||||
start = index
|
||||
depth = 1
|
||||
continue
|
||||
}
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false
|
||||
else when (char) {
|
||||
'\\' -> escaped = true
|
||||
'"' -> quoted = false
|
||||
}
|
||||
} else {
|
||||
when (char) {
|
||||
'"' -> quoted = true
|
||||
'{', '[' -> depth++
|
||||
'}', ']' -> depth--
|
||||
}
|
||||
if (depth == 0) {
|
||||
yield(stdout.substring(start, index + 1))
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
// An incomplete result must not be hidden by an earlier successful-looking value.
|
||||
if (start >= 0) yield(stdout.substring(start))
|
||||
}
|
||||
|
||||
private val RESULT_FIELD = Regex("\"(?:status|comments)\"\\s*:")
|
||||
|
||||
/** Accept one validated review result; unrelated log objects cannot become empty results. */
|
||||
private fun findCliResultDto(stdout: String): CliResultDto {
|
||||
var result: CliResultDto? = null
|
||||
for (candidate in jsonCandidates(stdout)) {
|
||||
val element = try {
|
||||
OcrJson.parseToJsonElement(candidate)
|
||||
} catch (error: SerializationException) {
|
||||
require(!RESULT_FIELD.containsMatchIn(candidate)) { "Invalid review JSON in CLI output" }
|
||||
continue
|
||||
}
|
||||
val obj = element as? JsonObject ?: continue
|
||||
if ("status" !in obj && "comments" !in obj) continue
|
||||
val status = obj["status"] as? JsonPrimitive
|
||||
require(status?.isString == true && status.content in supportedReviewStatuses) {
|
||||
"Missing or unsupported review status in CLI output"
|
||||
}
|
||||
require("comments" in obj && (obj["comments"] is JsonArray || obj["comments"] == JsonNull)) {
|
||||
"Missing or invalid review comments in CLI output"
|
||||
}
|
||||
val dto = try {
|
||||
OcrJson.decodeFromJsonElement(CliResultDto.serializer(), obj)
|
||||
} catch (error: SerializationException) {
|
||||
throw IllegalArgumentException("Invalid review result in CLI output", error)
|
||||
}
|
||||
require(dto.comments.orEmpty().all { it.path.isNotBlank() && it.content.isNotBlank() }) {
|
||||
"Invalid review comment in CLI output"
|
||||
}
|
||||
require(result == null) { "Multiple review results in CLI output" }
|
||||
result = dto
|
||||
}
|
||||
return result ?: throw IllegalArgumentException("No valid review result in CLI output")
|
||||
}
|
||||
|
||||
fun parseCliResult(stdout: String): CliResult {
|
||||
val dto = findCliResultDto(stdout)
|
||||
return CliResult(
|
||||
status = dto.status,
|
||||
message = dto.message,
|
||||
comments = dto.comments.orEmpty().map { it.toComment() },
|
||||
warnings = dto.warnings,
|
||||
summary = dto.summary?.let {
|
||||
ReviewSummary(
|
||||
filesReviewed = it.filesReviewed,
|
||||
comments = it.comments,
|
||||
totalTokens = it.totalTokens,
|
||||
inputTokens = it.inputTokens,
|
||||
outputTokens = it.outputTokens,
|
||||
elapsed = it.elapsed,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** `error:` 前缀正则,提取报错文本时剥离用。 */
|
||||
private val ERROR_PREFIX_REGEX = Regex("^error:\\s*", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** 从 CLI stderr 中提取最有用的报错文本:优先最后一条 `error:` 行,否则取最后一行非空内容。 */
|
||||
fun extractCliError(stderr: String): String {
|
||||
val lines = stderr.lineSequence().map(String::trim).filter(String::isNotEmpty)
|
||||
val errLine = lines.lastOrNull { it.startsWith("error:", ignoreCase = true) }
|
||||
if (errLine != null) return errLine.replaceFirst(ERROR_PREFIX_REGEX, "")
|
||||
return lines.lastOrNull().orEmpty()
|
||||
}
|
||||
|
||||
private val WARN_PATTERN = Regex("retrying|warning|warn", RegexOption.IGNORE_CASE)
|
||||
|
||||
fun parseLogLine(raw: String): LogLine? {
|
||||
val text = raw.trimEnd()
|
||||
if (text.isBlank()) return null
|
||||
return LogLine(text, if (WARN_PATTERN.containsMatchIn(text)) LogLevel.WARN else LogLevel.INFO)
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.CliResult
|
||||
import com.alibaba.opencodereview.idea.model.CliRunOptions
|
||||
import com.alibaba.opencodereview.idea.model.EnvCheckResult
|
||||
import com.alibaba.opencodereview.idea.model.EnvToolStatus
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.LogLevel
|
||||
import com.alibaba.opencodereview.idea.model.LogLine
|
||||
import com.alibaba.opencodereview.idea.model.currentIdeLocale
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.util.concurrency.AppExecutorUtil
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/** CLI 以非 0 退出时抛出,message 已经过 [extractCliError] 提炼,可直接展示给用户。 */
|
||||
class CliException(message: String) : RuntimeException(message)
|
||||
|
||||
/**
|
||||
* 所有子进程必经 [ShellEnv]:环境取登录 shell 的、命令名由 `resolveBin` 解析为绝对路径。
|
||||
* 裸命令名加继承环境在 GUI 启动的 IDEA 中无法运行。本类方法均为阻塞调用,调用方须在后台线程执行。
|
||||
*/
|
||||
class CliService(private val cliPath: String = "ocr") {
|
||||
|
||||
private companion object {
|
||||
const val ENV_CACHE_TTL_MS = 5 * 60 * 1000L
|
||||
const val PROBE_TIMEOUT_MS = 10_000L
|
||||
const val FORCE_KILL_DELAY_MS = 3_000L
|
||||
const val NPM_PACKAGE = "@alibaba-group/open-code-review"
|
||||
}
|
||||
|
||||
/** Installation has a separate lifecycle from review and configuration commands. */
|
||||
private val installProcess = AtomicReference<Process?>(null)
|
||||
|
||||
@Volatile
|
||||
private var envCache: Pair<EnvCheckResult, Long>? = null
|
||||
|
||||
fun invalidateEnvironmentCache() {
|
||||
envCache = null
|
||||
}
|
||||
|
||||
fun getCachedEnvironment(): EnvCheckResult? {
|
||||
val (env, at) = envCache ?: return null
|
||||
if (System.currentTimeMillis() - at > ENV_CACHE_TTL_MS) {
|
||||
envCache = null
|
||||
return null
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
fun isAvailable(): Boolean = checkEnvironment().ocr.ok
|
||||
|
||||
/** node → npm → ocr 顺序探测并短路:前一个不可用时后续直接判失败,避免无意义的等待。 */
|
||||
fun checkEnvironment(force: Boolean = false): EnvCheckResult {
|
||||
if (!force) getCachedEnvironment()?.let { return it }
|
||||
val node = probeCommand("node")
|
||||
val npm = if (node.ok) probeCommand("npm") else EnvToolStatus()
|
||||
val ocr = if (node.ok && npm.ok) probeCommand(cliPath) else EnvToolStatus()
|
||||
val env = EnvCheckResult(node, npm, ocr)
|
||||
envCache = env to System.currentTimeMillis()
|
||||
return env
|
||||
}
|
||||
|
||||
private fun probeCommand(bin: String): EnvToolStatus = runCatching {
|
||||
// 参数固定为 --version,可安全套 shell(Windows 上 npm/ocr 是 .cmd,不套 shell 无法执行)。
|
||||
val process = ProcessBuilder(ShellEnv.forShell(listOf(ShellEnv.resolveBin(bin), "--version")))
|
||||
.withShellEnv()
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
process.outputStream.close()
|
||||
// stdout 必须分线程读取。本线程 readText() 会阻塞至进程退出(或管道写满),导致后续
|
||||
// waitFor(PROBE_TIMEOUT_MS) 无法执行——探测卡住的 node 会永久挂住整个环境检查。写法与 ShellEnv.capture 一致。
|
||||
val out = StringBuilder()
|
||||
val reader = Thread({
|
||||
runCatching { process.inputStream.bufferedReader().forEachLine { synchronized(out) { out.appendLine(it) } } }
|
||||
}, "ocr-probe-$bin").apply { isDaemon = true; start() }
|
||||
if (!process.waitFor(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
// Windows 上探测经 cmd.exe 套壳,只杀壳会留下卡死的 node/npm,须树级强杀。
|
||||
destroyTreeForcibly(process)
|
||||
// 进程被强杀后 stdout 管道关闭、reader 将很快 EOF 退出;短 join 避免 daemon 线程在反复环境检查中堆积。
|
||||
reader.join(500)
|
||||
// 关流与 runRaw/install 的清理一致,避免高频环境检查下 fd 累积到 GC。
|
||||
process.closeStreamsQuietly()
|
||||
return EnvToolStatus()
|
||||
}
|
||||
// 有界等待 reader 收完:--version 探测毫秒级结束;极端情况(子进程继承 stdout 管道不 EOF)2s 上限也避免主线程永久挂。
|
||||
// 读写 out 均 synchronized,即便超时 reader 仍在写,读 out 也不踩 StringBuilder 跨线程脏读。
|
||||
reader.join(2_000)
|
||||
// 关流(超时分支已自行关流并返回;此处覆盖正常退出/非零退出路径),避免高频环境检查下 fd 累积到 GC。
|
||||
process.closeStreamsQuietly()
|
||||
if (process.exitValue() != 0) return EnvToolStatus()
|
||||
val version = synchronized(out) { out.lineSequence().firstOrNull()?.trim()?.takeIf(String::isNotEmpty) }
|
||||
EnvToolStatus(ok = true, version = version)
|
||||
}.getOrElse { EnvToolStatus() }
|
||||
|
||||
/** 全局安装 ocr CLI,逐行回显 npm 日志,按 exit code 返回是否成功。 */
|
||||
fun install(onLog: (LogLine) -> Unit): Boolean {
|
||||
val args = listOf("install", "-g", NPM_PACKAGE, "--loglevel", "http", "--no-progress")
|
||||
onLog(LogLine("$ npm ${args.joinToString(" ")}"))
|
||||
return runCatching {
|
||||
// 参数均为固定值,同 probeCommand,可套 shell 以便 Windows 上执行 npm.cmd。
|
||||
// 先收尾上一个 install(若有)再 start 新的,避免两个 npm install 同时跑争全局 npm 缓存/lockfile。
|
||||
installProcess.getAndSet(null)?.let(::killStaleInstall)
|
||||
val process = ProcessBuilder(ShellEnv.forShell(listOf(ShellEnv.resolveBin("npm")) + args))
|
||||
// 非 TTY 下 npm 仍可能画进度条,强制关闭并去色,否则日志中充斥转义序列。
|
||||
.withShellEnv("npm_config_progress" to "false", "npm_config_color" to "false")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
// Register only in the installation slot; review handles are independent.
|
||||
// 正常刚清空过应为 null;并发 install 罕见,若有同样收尾。
|
||||
installProcess.getAndSet(process)?.let(::killStaleInstall)
|
||||
try {
|
||||
process.outputStream.close()
|
||||
// npm 用 \r 覆盖行,此处归一为 \n 再逐行输出。
|
||||
process.inputStream.bufferedReader().forEachLine { raw ->
|
||||
raw.replace('\r', '\n').lineSequence().forEach { line ->
|
||||
if (line.isNotBlank()) onLog(LogLine(line))
|
||||
}
|
||||
}
|
||||
val exit = process.waitFor()
|
||||
if (exit == 0) {
|
||||
onLog(LogLine(HostStrings.t(currentIdeLocale(), "ext.cli.installOk")))
|
||||
invalidateEnvironmentCache()
|
||||
// 新装的全局 bin 可能不在已缓存的 PATH 中,须让 shell 环境重新解析一次。
|
||||
ShellEnv.invalidate()
|
||||
} else {
|
||||
onLog(
|
||||
LogLine(
|
||||
HostStrings.t(currentIdeLocale(), "ext.cli.installFail", "code" to exit.toString()),
|
||||
LogLevel.ERROR,
|
||||
),
|
||||
)
|
||||
}
|
||||
exit == 0
|
||||
} finally {
|
||||
// 与 runRaw 对齐:异常路径(forEachLine 抛 IOException 等)下进程可能仍存活,树级强杀 + 关流兜底。
|
||||
// 不加 isAlive 守卫:树级强杀对已死进程无害(枚举返回空、destroyForcibly 为 no-op),且检查-枚举之间留竞态窗口不如尽早枚举。
|
||||
destroyTreeForcibly(process)
|
||||
process.closeStreamsQuietly()
|
||||
installProcess.compareAndSet(process, null)
|
||||
}
|
||||
}.getOrElse {
|
||||
onLog(LogLine(it.message ?: it.javaClass.simpleName, LogLevel.ERROR))
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行任意 CLI 参数:stderr 逐行回调,结束返回 stdout 全文;退出码非 0 抛 CliException。
|
||||
* 不走 forShell——args 含用户输入,套 shell 会增加注入面。
|
||||
*/
|
||||
fun runRaw(
|
||||
args: List<String>,
|
||||
cwd: File,
|
||||
onLog: (LogLine) -> Unit,
|
||||
envExtra: Map<String, String> = emptyMap(),
|
||||
cancellation: CliCancellation? = null,
|
||||
): String {
|
||||
cancellation?.checkCancelled()
|
||||
val process = ProcessBuilder(listOf(ShellEnv.resolveBin(cliPath)) + args)
|
||||
.directory(cwd)
|
||||
.withShellEnv(*envExtra.toList().toTypedArray())
|
||||
.start()
|
||||
val stderr = StringBuilder()
|
||||
var stderrThread: Thread? = null
|
||||
var registered = false
|
||||
try {
|
||||
// The session owns this process. Other commands never replace or terminate it.
|
||||
cancellation?.attach { cancelProcess(process) }
|
||||
registered = cancellation != null
|
||||
stderrThread = Thread({
|
||||
runCatching {
|
||||
process.errorStream.bufferedReader().forEachLine { line ->
|
||||
synchronized(stderr) { stderr.appendLine(line) }
|
||||
parseLogLine(line)?.let(onLog)
|
||||
}
|
||||
}
|
||||
}, "ocr-cli-stderr").apply { isDaemon = true; start() }
|
||||
process.outputStream.close() // 在 try 内:close 抛 IOException 时 finally 仍会清理已注册的进程,不致脱管。
|
||||
val stdout = process.inputStream.bufferedReader().readText()
|
||||
val exit = process.waitFor()
|
||||
stderrThread.join(2_000)
|
||||
if (exit != 0) {
|
||||
val text = synchronized(stderr) { stderr.toString() }
|
||||
throw CliException(extractCliError(text).ifBlank { "CLI exited with code $exit" })
|
||||
}
|
||||
return stdout
|
||||
} finally {
|
||||
try {
|
||||
destroyTreeForcibly(process)
|
||||
stderrThread?.join(2_000)
|
||||
} finally {
|
||||
process.closeStreamsQuietly()
|
||||
if (registered) cancellation?.detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun review(
|
||||
opts: CliRunOptions,
|
||||
cwd: File,
|
||||
onLog: (LogLine) -> Unit,
|
||||
cancellation: CliCancellation,
|
||||
): CliResult = parseCliResult(runRaw(buildReviewArgs(opts), cwd, onLog, cancellation = cancellation))
|
||||
|
||||
/**
|
||||
* 执行 `ocr llm test`。传入 [home] / [configPath] 时在隔离环境下执行,
|
||||
* 使"测试连通性"不会破坏用户真正的 ~/.opencodereview/config.json。
|
||||
*/
|
||||
fun testConnection(home: File? = null, configPath: File? = null): Pair<Boolean, String?> {
|
||||
val envExtra = buildMap {
|
||||
home?.let {
|
||||
put("HOME", it.absolutePath)
|
||||
put("USERPROFILE", it.absolutePath)
|
||||
}
|
||||
configPath?.let { put("OCR_CONFIG_PATH", it.absolutePath) }
|
||||
}
|
||||
val cwd = File(System.getProperty("user.dir"))
|
||||
return runCatching {
|
||||
runRaw(listOf("llm", "test"), cwd, {}, envExtra)
|
||||
true to null
|
||||
}.getOrElse { false to (it.message ?: it.javaClass.simpleName) }
|
||||
}
|
||||
|
||||
/** Capture the exact process tree now; delayed cleanup must never target another invocation. */
|
||||
private fun cancelProcess(process: Process) {
|
||||
if (!process.isAlive) return
|
||||
val descendants = destroyGracefully(process)
|
||||
AppExecutorUtil.getAppScheduledExecutorService().schedule(
|
||||
{
|
||||
runCatching {
|
||||
destroyTreeForcibly(process, descendants)
|
||||
// 流正常由 runRaw 的 finally 关闭,此处幂等兜底(双关安全)。
|
||||
process.closeStreamsQuietly()
|
||||
}.onFailure { thisLogger().warn("[ocr] 强制终止进程树失败", it) }
|
||||
},
|
||||
FORCE_KILL_DELAY_MS,
|
||||
TimeUnit.MILLISECONDS,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程树终止。destroy()/destroyForcibly() 只作用于直接子进程;孙进程(npm 全局 ocr 是 Node
|
||||
* launcher,由它再 spawn Go 二进制)在父进程死后被托管到系统根进程,descendants() 便再不可见。
|
||||
* 所以优雅阶段先快照子孙、只信号父进程(新版 launcher 会把 SIGTERM 转发给 Go 自行清理,
|
||||
* 重复信号子孙可能打断其清理);强杀阶段以快照为准并补一次实时枚举,兜住快照后新出现的子孙。
|
||||
* 返回的快照句柄在孙进程被托管后依然有效,是强杀阶段唯一可靠的追杀依据。
|
||||
*/
|
||||
private fun destroyGracefully(process: Process): List<ProcessHandle> {
|
||||
// 枚举失败(极端平台问题)时退回空快照:宁可强杀阶段漏杀,也不能让调用方的关流收尾被异常跳过。
|
||||
val descendants = runCatching { process.toHandle().descendants().toList() }.getOrDefault(emptyList())
|
||||
process.destroy()
|
||||
return descendants
|
||||
}
|
||||
|
||||
/**
|
||||
* 强杀整棵树。顺序有讲究:实时枚举必须在杀父进程之前(父进程死后子孙被托管到系统根进程,
|
||||
* descendants() 便再不可见);杀父优先于杀子孙,防止父进程(npm lifecycle、supervisor 类)
|
||||
* 在子孙被杀后、自己被杀前又 spawn 出新子孙。单个句柄强杀失败不阻断其余。
|
||||
* 无快照调用为 best-effort:进程若在枚举前一瞬刚好退出,子孙已随父进程之死被托管而不可见,
|
||||
* Cancellation passes a snapshot so descendants remain reachable after their parent exits.
|
||||
*/
|
||||
private fun destroyTreeForcibly(process: Process, snapshot: List<ProcessHandle> = emptyList()) {
|
||||
// 实时枚举失败不阻断后续:快照 + 父进程强杀仍须执行,保证本方法不向外抛异常。
|
||||
val live = runCatching { process.toHandle().descendants().toList() }.getOrDefault(emptyList())
|
||||
val tree = (snapshot + live).distinctBy(ProcessHandle::pid)
|
||||
runCatching { process.destroyForcibly() }
|
||||
tree.forEach { runCatching { it.destroyForcibly() } }
|
||||
}
|
||||
|
||||
/** 关掉进程的三路流,吞掉 close() 声明的 IOException,不吞 InterruptedException 等运行期信号。 */
|
||||
private fun Process.closeStreamsQuietly() {
|
||||
try { inputStream.close() } catch (_: IOException) {}
|
||||
try { outputStream.close() } catch (_: IOException) {}
|
||||
try { errorStream.close() } catch (_: IOException) {}
|
||||
}
|
||||
|
||||
/** Give the previous installation time to exit, then clean up its tree and streams. */
|
||||
private fun killStaleInstall(stale: Process) {
|
||||
try {
|
||||
if (stale.isAlive) {
|
||||
thisLogger().warn("[ocr] 上一个 npm install 仍在运行,已终止")
|
||||
val descendants = destroyGracefully(stale)
|
||||
if (!stale.waitFor(FORCE_KILL_DELAY_MS, TimeUnit.MILLISECONDS)) {
|
||||
thisLogger().warn("[ocr] 上一个 npm install ${FORCE_KILL_DELAY_MS}ms 内未退出,强制终止")
|
||||
}
|
||||
// npm 的子孙(lifecycle 脚本、node-gyp 等)不随父进程退出,必须树级收尾。
|
||||
destroyTreeForcibly(stale, descendants)
|
||||
}
|
||||
} finally {
|
||||
// 关流必须在 finally:waitFor 被中断等异常路径下也不能泄漏 stale 进程的 fd。
|
||||
stale.closeStreamsQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ProcessBuilder.withShellEnv(vararg extra: Pair<String, String>): ProcessBuilder = apply {
|
||||
environment().apply {
|
||||
clear()
|
||||
putAll(ShellEnv.env())
|
||||
extra.forEach { (key, value) -> put(key, value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.ConfigEntry
|
||||
import com.alibaba.opencodereview.idea.model.OcrJson
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 在内存中将 config set 条目合并进原始配置 JSON,不落盘。
|
||||
* 配置面板测试连通性依赖此函数:先计算草稿写入临时 HOME、在隔离环境执行测试,用户配置文件全程不变。
|
||||
*/
|
||||
|
||||
/** 原始配置 JSON 的可变表示。值均为不可变的 [JsonElement],整棵子树通过替换来"修改"。 */
|
||||
typealias RawConfig = MutableMap<String, JsonElement>
|
||||
|
||||
fun emptyRawConfig(): RawConfig = mutableMapOf()
|
||||
|
||||
/** 解析配置文件文本;空白或非法 JSON 返回空草稿(调用方语义上等于"当作没有配置")。 */
|
||||
fun parseRawConfig(text: String): RawConfig {
|
||||
if (text.isBlank()) return emptyRawConfig()
|
||||
return runCatching {
|
||||
OcrJson.parseToJsonElement(text).jsonObject.toMutableMap()
|
||||
}.getOrElse {
|
||||
// JSON 解析失败在此记录——readRaw 的外层 runCatching 只能捕获 IO 错误,JSON 错误被这里吞掉后到不了外层。
|
||||
Logger.getInstance("ConfigDraft").warn("[ocr] Failed to parse config JSON, treating as empty", it)
|
||||
emptyRawConfig()
|
||||
}
|
||||
}
|
||||
|
||||
/** 序列化为写盘用的两空格缩进 JSON。 */
|
||||
fun RawConfig.toPrettyJson(): String = PrettyJson.encodeToString(JsonObject.serializer(), JsonObject(this))
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private val PrettyJson = kotlinx.serialization.json.Json {
|
||||
prettyPrint = true
|
||||
prettyPrintIndent = " "
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层浅拷贝即等价深拷贝:[JsonElement] 不可变,改动都通过 [mutateObj] 整体替换子树,
|
||||
* 不存在原地修改嵌套对象的路径,base 不会被写穿。
|
||||
*/
|
||||
fun RawConfig.copyDraft(): RawConfig = toMutableMap()
|
||||
|
||||
/** 读-改-写一个嵌套对象字段:不存在则视为空对象,修改后整体写回。 */
|
||||
private fun RawConfig.mutateObj(key: String, block: (MutableMap<String, JsonElement>) -> Unit) {
|
||||
val inner = (this[key] as? JsonObject)?.toMutableMap() ?: mutableMapOf()
|
||||
block(inner)
|
||||
this[key] = JsonObject(inner)
|
||||
}
|
||||
|
||||
private fun RawConfig.strAt(key: String): String {
|
||||
val prim = this[key] as? JsonPrimitive ?: return ""
|
||||
return if (prim.isString) prim.content else ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 models 字段的用户输入:优先按 JSON 数组解析,失败或不是数组则按逗号分隔。
|
||||
*/
|
||||
internal fun parseModelList(value: String): List<String> {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isEmpty()) return emptyList()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val parsed = runCatching { OcrJson.parseToJsonElement(trimmed) }.getOrNull()
|
||||
if (parsed is JsonArray) {
|
||||
return parsed.mapNotNull { item ->
|
||||
(item as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
// 解析失败则退化为逗号分隔,不报错。
|
||||
}
|
||||
return trimmed.split(',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** 仅识别这 6 个 provider 字段,其余键忽略。 */
|
||||
private fun applyProviderField(entry: MutableMap<String, JsonElement>, field: String, value: String) {
|
||||
when (field) {
|
||||
"api_key" -> entry["api_key"] = JsonPrimitive(value)
|
||||
"url" -> entry["url"] = JsonPrimitive(value)
|
||||
"protocol" -> entry["protocol"] = JsonPrimitive(value)
|
||||
"model" -> entry["model"] = JsonPrimitive(value)
|
||||
"models" -> entry["models"] = JsonArray(parseModelList(value).map(::JsonPrimitive))
|
||||
"auth_header" -> entry["auth_header"] = JsonPrimitive(value)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun RawConfig.setProviderEntryField(
|
||||
bucket: String,
|
||||
name: String,
|
||||
field: String,
|
||||
value: String,
|
||||
) {
|
||||
mutateObj(bucket) { bucketMap ->
|
||||
val entry = (bucketMap[name] as? JsonObject)?.toMutableMap() ?: mutableMapOf()
|
||||
applyProviderField(entry, field, value)
|
||||
bucketMap[name] = JsonObject(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RawConfig.setCustomProviderField(name: String, field: String, value: String) =
|
||||
setProviderEntryField("custom_providers", name, field, value)
|
||||
|
||||
/** `providers.<name>.<field>`:name 是内置 provider 就写 providers,否则写 custom_providers。 */
|
||||
private fun RawConfig.setProviderValue(key: String, value: String) {
|
||||
val parts = key.split('.')
|
||||
if (parts.size != 3) return
|
||||
val name = parts[1]
|
||||
val field = parts[2]
|
||||
if (isPresetProvider(name)) {
|
||||
// 与 isPresetProvider / ConfigUtils 一致:预置键按规范小写存储,避免 "OpenAI" 被判为预置却写进大写键、后续查不到。
|
||||
setProviderEntryField("providers", name.trim().lowercase(Locale.ROOT), field, value)
|
||||
} else {
|
||||
setCustomProviderField(name, field, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RawConfig.ensureProviderBucketEntry(value: String) {
|
||||
if (isPresetProvider(value)) {
|
||||
val key = value.trim().lowercase(Locale.ROOT)
|
||||
mutateObj("providers") { m -> if (m[key] !is JsonObject) m[key] = JsonObject(emptyMap()) }
|
||||
} else if (value.isNotEmpty()) {
|
||||
mutateObj("custom_providers") { m -> if (m[value] !is JsonObject) m[value] = JsonObject(emptyMap()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun RawConfig.setLlmField(field: String, element: JsonElement) {
|
||||
mutateObj("llm") { it[field] = element }
|
||||
}
|
||||
|
||||
private fun RawConfig.setConfigValue(key: String, value: String) {
|
||||
if (key.startsWith("providers.")) {
|
||||
setProviderValue(key, value)
|
||||
return
|
||||
}
|
||||
if (key.startsWith("custom_providers.")) {
|
||||
val parts = key.split('.')
|
||||
if (parts.size == 3) setCustomProviderField(parts[1], parts[2], value)
|
||||
return
|
||||
}
|
||||
|
||||
when (key) {
|
||||
"provider" -> {
|
||||
// 预置 provider 按规范小写存储,与 providers bucket 键一致;自定义 provider 保留用户原名。
|
||||
val providerName = if (isPresetProvider(value)) value.trim().lowercase(Locale.ROOT) else value
|
||||
// 切换 provider 时清空顶层 model:旧 model 通常不属于新 provider(用归一化名比较,避免大小写差异误判为切换)。
|
||||
if (strAt("provider") != providerName) this["model"] = JsonPrimitive("")
|
||||
this["provider"] = JsonPrimitive(providerName)
|
||||
ensureProviderBucketEntry(value)
|
||||
}
|
||||
|
||||
// 已选 provider 时,model 写入该 provider 条目而非顶层——CLI 优先读取 provider 条目中的 model,写顶层不生效。
|
||||
"model" -> {
|
||||
val provider = strAt("provider")
|
||||
if (provider.isNotEmpty()) {
|
||||
if (isPresetProvider(provider)) {
|
||||
setProviderEntryField("providers", provider.trim().lowercase(Locale.ROOT), "model", value)
|
||||
} else {
|
||||
setCustomProviderField(provider, "model", value)
|
||||
}
|
||||
} else {
|
||||
this["model"] = JsonPrimitive(value)
|
||||
}
|
||||
}
|
||||
|
||||
"llm.url" -> setLlmField("url", JsonPrimitive(value))
|
||||
"llm.auth_token" -> setLlmField("auth_token", JsonPrimitive(value))
|
||||
"llm.auth_header" -> setLlmField("auth_header", JsonPrimitive(value))
|
||||
"llm.model" -> setLlmField("model", JsonPrimitive(value))
|
||||
"llm.use_anthropic" -> setLlmField("use_anthropic", JsonPrimitive(value == "true"))
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** 在内存中把 config set 条目合并进原始配置(不写磁盘)。 */
|
||||
fun applyConfigEntries(base: RawConfig, entries: List<ConfigEntry>): RawConfig {
|
||||
val draft = base.copyDraft()
|
||||
for (entry in entries) {
|
||||
draft.setConfigValue(entry.key, entry.value)
|
||||
}
|
||||
return draft
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.LlmConfig
|
||||
import com.alibaba.opencodereview.idea.model.OcrConfig
|
||||
import com.alibaba.opencodereview.idea.model.OcrJson
|
||||
import com.alibaba.opencodereview.idea.model.ProviderEntry
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/**
|
||||
* snake_case 与 camelCase 之间的转换边界:
|
||||
* `~/.opencodereview/config.json` 是 CLI snake_case 格式(`api_key`/`auth_header`/`custom_providers`/`use_anthropic`),插件内部使用 camelCase。
|
||||
* 不要给 [OcrConfig] 加 `@SerialName` 直接反序列化配置文件——那样序列化给前端时字段名仍为 snake_case,前端会读取到 undefined。
|
||||
*/
|
||||
|
||||
/** 取字符串字段,缺失或类型不符一律返回 ""。 */
|
||||
private fun JsonObject?.strAt(key: String): String {
|
||||
val prim = this?.get(key) as? JsonPrimitive ?: return ""
|
||||
return if (prim.isString) prim.content else ""
|
||||
}
|
||||
|
||||
private fun parseProviderEntry(raw: JsonElement?): ProviderEntry {
|
||||
val obj = raw as? JsonObject ?: return ProviderEntry()
|
||||
// models 缺失时保持 null(非空列表):调用方按"有无此字段"决定是否使用预置模型兜底,
|
||||
// 空列表会被当作"该 provider 无任何模型"。
|
||||
val models = (obj["models"] as? kotlinx.serialization.json.JsonArray)?.mapNotNull {
|
||||
(it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content
|
||||
}
|
||||
return ProviderEntry(
|
||||
apiKey = obj.strAt("api_key"),
|
||||
url = obj.strAt("url"),
|
||||
protocol = obj.strAt("protocol"),
|
||||
model = obj.strAt("model"),
|
||||
models = models,
|
||||
authHeader = obj.strAt("auth_header"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseProviderMap(raw: JsonElement?): Map<String, ProviderEntry> {
|
||||
val obj = raw as? JsonObject ?: return emptyMap()
|
||||
return obj.mapValues { (_, entry) -> parseProviderEntry(entry) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析配置文件内容。空白输入返回 null;JSON 非法会抛异常(由 [ConfigService] 捕获后当作"无配置")。
|
||||
*/
|
||||
fun parseConfig(raw: String): OcrConfig? {
|
||||
if (raw.isBlank()) return null
|
||||
val j = OcrJson.parseToJsonElement(raw).jsonObject
|
||||
val llm = j["llm"] as? JsonObject
|
||||
return OcrConfig(
|
||||
provider = j.strAt("provider"),
|
||||
model = j.strAt("model"),
|
||||
providers = parseProviderMap(j["providers"]),
|
||||
customProviders = parseProviderMap(j["custom_providers"]),
|
||||
llm = LlmConfig(
|
||||
url = llm.strAt("url"),
|
||||
authToken = llm.strAt("auth_token"),
|
||||
model = llm.strAt("model"),
|
||||
useAnthropic = !isExplicitFalse(llm?.get("use_anthropic")),
|
||||
authHeader = llm.strAt("auth_header"),
|
||||
),
|
||||
language = j.strAt("language").ifEmpty { "Chinese" },
|
||||
)
|
||||
}
|
||||
|
||||
private fun isExplicitFalse(element: JsonElement?): Boolean {
|
||||
val prim = element as? JsonPrimitive ?: return false
|
||||
return !prim.isString && prim.content == "false"
|
||||
}
|
||||
|
||||
fun toConfigSetArgs(key: String, value: String): List<String> = listOf("config", "set", key, value)
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.ConfigEntry
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.OcrConfig
|
||||
import com.alibaba.opencodereview.idea.model.currentIdeLocale
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlin.jvm.Synchronized
|
||||
import java.io.IOException
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.nio.file.attribute.PosixFilePermissions
|
||||
|
||||
/**
|
||||
* 读写 `~/.opencodereview/config.json`。分工:
|
||||
* 写单个键走 CLI(`ocr config set`),因 CLI 自带校验和规范化;只有"删除自定义 provider"直接改文件——CLI 无对应 unset 子命令。
|
||||
*/
|
||||
class ConfigService(
|
||||
private val cli: CliService,
|
||||
/** `ocr config set` 的工作目录。该子命令不依赖 cwd,取稳定值即可。 */
|
||||
private val cwd: File = File(System.getProperty("user.dir")),
|
||||
) {
|
||||
|
||||
private fun configPath(): File =
|
||||
File(File(System.getProperty("user.home"), ".opencodereview"), "config.json")
|
||||
|
||||
/** 读取并转换为插件内部使用的 camelCase 配置;文件不存在或内容非法返回 null。 */
|
||||
fun read(): OcrConfig? {
|
||||
val path = configPath()
|
||||
if (!path.isFile) return null
|
||||
return runCatching { parseConfig(path.readText()) }.getOrElse {
|
||||
thisLogger().warn("[ocr] Failed to parse config, treating as no config: ${path.absolutePath}", it)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取原始 snake_case JSON,保留所有未知字段。解析失败记日志(与 [read] 一致),返回空配置。 */
|
||||
private fun readRaw(): RawConfig {
|
||||
val path = configPath()
|
||||
if (!path.isFile) return emptyRawConfig()
|
||||
return runCatching { parseRawConfig(path.readText()) }.getOrElse {
|
||||
// 记日志便于排查;返回空配置,下游 writeRaw/deleteCustomProvider 在 bucket 取不到时会 bail,不会覆盖这份坏文件。
|
||||
thisLogger().warn("[ocr] Failed to parse raw config (treated as empty; writes will bail): ${path.absolutePath}", it)
|
||||
emptyRawConfig()
|
||||
}
|
||||
}
|
||||
|
||||
/** 写回原始配置。整份配置为空时删文件而非写 `{}`——保留空文件会让 CLI 认为"已配置过"。
|
||||
* @Synchronized 串行化写盘:避免并发写(删除 provider vs setMany 回滚)的固定 tmp 文件名碰撞与交错。 */
|
||||
@Synchronized
|
||||
private fun writeRaw(raw: RawConfig): OcrConfig? {
|
||||
val path = configPath()
|
||||
if (!raw.hasContent()) {
|
||||
if (path.exists() && !path.delete()) {
|
||||
thisLogger().warn("[ocr] Failed to delete empty config file: ${path.absolutePath}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
val dir = path.parentFile
|
||||
runCatching {
|
||||
if (!dir.isDirectory) {
|
||||
Files.createDirectories(dir.toPath())
|
||||
}
|
||||
// 已存在的目录也收紧(CLI 或旧版可能留 755,目录含 api_key 须 700)。
|
||||
trySetPosixPermissions(dir, "rwx------")
|
||||
// 原子写 + 先收紧权限:唯一名临时文件避免跨实例/跨 IDE 窗口碰撞;空文件先收紧 rw------- 再写 api_key,全程不暴露在可读文件里。
|
||||
val tmpPath = Files.createTempFile(dir.toPath(), "config-", ".tmp")
|
||||
val tmp = tmpPath.toFile()
|
||||
trySetPosixPermissions(tmp, "rw-------")
|
||||
try {
|
||||
tmp.writeText(raw.toPrettyJson())
|
||||
} catch (e: IOException) {
|
||||
// writeText 失败(如盘满)时 tmp 可能已含部分 api_key;600 权限已收紧,但须删掉避免残留泄漏。
|
||||
runCatching { Files.deleteIfExists(tmpPath) }
|
||||
throw e
|
||||
}
|
||||
try {
|
||||
// 优先原子 move;不支持原子 move 的 FS(部分 Windows/网络盘)退化为 REPLACE_EXISTING。
|
||||
try {
|
||||
Files.move(tmpPath, path.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (e: java.nio.file.AtomicMoveNotSupportedException) {
|
||||
Files.move(tmpPath, path.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
runCatching { Files.deleteIfExists(tmpPath) }.onFailure { thisLogger().warn("[ocr] Failed to clean tmp config after move failure", it) }
|
||||
throw e
|
||||
}
|
||||
trySetPosixPermissions(path, "rw-------")
|
||||
}.onFailure {
|
||||
thisLogger().warn("[ocr] Failed to write config file: ${path.absolutePath}", it)
|
||||
return read()
|
||||
}
|
||||
return read()
|
||||
}
|
||||
|
||||
/** 判断配置是否还有实质内容。空字符串不计入——空串在此判定中为 falsy。 */
|
||||
private fun RawConfig.hasContent(): Boolean {
|
||||
fun nonEmptyStr(key: String): Boolean {
|
||||
val prim = this[key] as? kotlinx.serialization.json.JsonPrimitive ?: return false
|
||||
return prim.isString && prim.content.isNotEmpty()
|
||||
}
|
||||
|
||||
fun nonEmptyObj(key: String): Boolean =
|
||||
(this[key] as? JsonObject)?.isNotEmpty() == true
|
||||
|
||||
return nonEmptyStr("provider") ||
|
||||
nonEmptyStr("model") ||
|
||||
nonEmptyObj("providers") ||
|
||||
nonEmptyObj("custom_providers") ||
|
||||
nonEmptyObj("llm")
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一个自定义 provider。连带清理:容器为空则删除 `custom_providers` 键;所删 provider 正是当前选中项时
|
||||
* 一并清除 `provider`/`model`,避免配置指向不存在的 provider。
|
||||
* 整段 read-modify-write 加锁:并发删除(用户连点)会互相覆盖、丢一个删除。
|
||||
*/
|
||||
@Synchronized
|
||||
fun deleteCustomProvider(name: String): OcrConfig? {
|
||||
val raw = readRaw()
|
||||
val bucket = (raw["custom_providers"] as? JsonObject)?.toMutableMap() ?: return read()
|
||||
if (bucket.remove(name) == null) return read()
|
||||
if (bucket.isEmpty()) {
|
||||
raw.remove("custom_providers")
|
||||
} else {
|
||||
raw["custom_providers"] = JsonObject(bucket)
|
||||
}
|
||||
val currentProvider = (raw["provider"] as? kotlinx.serialization.json.JsonPrimitive)
|
||||
?.takeIf { it.isString }?.content
|
||||
if (currentProvider == name) {
|
||||
raw.remove("provider")
|
||||
raw.remove("model")
|
||||
}
|
||||
return writeRaw(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* 在隔离的临时 HOME 上执行 `ocr llm test`,不触及用户真正的配置文件。
|
||||
* 返回 (是否成功, 失败原因)。
|
||||
*/
|
||||
fun testWithEntries(entries: List<ConfigEntry>): Pair<Boolean, String?> {
|
||||
val draft = applyConfigEntries(readRaw(), entries)
|
||||
val testHome = runCatching {
|
||||
Files.createTempDirectory("ocr-test-home-").toFile()
|
||||
}.getOrElse {
|
||||
return false to HostStrings.t(
|
||||
currentIdeLocale(),
|
||||
"ext.config.tempDirFailed",
|
||||
"message" to it.message.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
return try {
|
||||
val configDir = File(testHome, ".opencodereview")
|
||||
Files.createDirectories(configDir.toPath())
|
||||
trySetPosixPermissions(configDir, "rwx------")
|
||||
val configFile = File(configDir, "config.json")
|
||||
configFile.writeText(draft.toPrettyJson())
|
||||
trySetPosixPermissions(configFile, "rw-------")
|
||||
cli.testConnection(home = testHome, configPath = configFile)
|
||||
} catch (e: Exception) {
|
||||
false to (e.message ?: e.javaClass.simpleName)
|
||||
} finally {
|
||||
testHome.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
/** 写单个配置项,返回写后的配置。CLI 退出码非 0 时 [CliService.runRaw] 会抛 [CliException]。 */
|
||||
@Synchronized
|
||||
fun set(key: String, value: String): OcrConfig? {
|
||||
cli.runRaw(toConfigSetArgs(key, value), cwd, {})
|
||||
return read()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按顺序写多个配置项。顺序有意义:`provider` 须在 `model` 前生效,否则 model 写到顶层而非 provider 条目
|
||||
* (见 [applyConfigEntries] 的 model 分支)。中途失败回滚到 setMany 前的快照,避免半应用(如 provider 改了但 api_key 没写)。
|
||||
*/
|
||||
@Synchronized
|
||||
fun setMany(entries: List<ConfigEntry>): OcrConfig? {
|
||||
val snapshot = readRaw()
|
||||
val applied = mutableListOf<ConfigEntry>()
|
||||
try {
|
||||
for (entry in entries) {
|
||||
cli.runRaw(toConfigSetArgs(entry.key, entry.value), cwd, {})
|
||||
applied += entry
|
||||
}
|
||||
return read()
|
||||
} catch (e: Exception) {
|
||||
// applied 只含已成功的条目,失败的是下一条 entries[applied.size];用它的 key 记日志才准确。
|
||||
thisLogger().warn("[ocr] setMany failed at '${entries.getOrNull(applied.size)?.key ?: "?"}', rolling back", e)
|
||||
// 只在快照有内容时回滚写回;snapshot 为空(原配置缺失/损坏)时 writeRaw 会删文件,反而丢用户数据。
|
||||
if (snapshot.hasContent()) writeRaw(snapshot)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Windows 无 POSIX 权限视图,静默跳过;失败仅权限未收紧,不应导致写配置失败。 */
|
||||
private fun trySetPosixPermissions(target: File, spec: String) {
|
||||
// Windows 上 setPosixFilePermissions 必抛 UnsupportedOperationException,每次写都记日志会刷屏——直接跳过。
|
||||
if (SystemInfo.isWindows) return
|
||||
runCatching {
|
||||
Files.setPosixFilePermissions(target.toPath(), PosixFilePermissions.fromString(spec))
|
||||
}.onFailure { thisLogger().warn("[ocr] Failed to set permissions $spec on ${target.absolutePath}", it) }
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.OcrConfig
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 仅提供 [isConfigReady] 一个函数。
|
||||
* 其余辅助函数(`detectInitialTab`/`describeActiveProvider`/`build*SaveEntries`/`listCustomProviderNames`)仅前端使用,
|
||||
* 宿主重复实现会因两套代码分叉而漂移。[isConfigReady] 例外:宿主侧配置面板用它计算 `skipEnvCheck`,须在宿主侧可计算。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 配置是否可用(可直接开始审查、无需走环境引导)。
|
||||
* 选了 provider:预置须有 model;自定义还须 url/protocol/apiKey。未选 provider:退回 `llm` 三件套(url/model/authToken)。
|
||||
*/
|
||||
fun isConfigReady(config: OcrConfig?): Boolean {
|
||||
if (config == null) return false
|
||||
|
||||
if (config.provider.isNotBlank()) {
|
||||
val preset = isPresetProvider(config.provider)
|
||||
// isPresetProvider 按 trim+lowercase(Locale.ROOT) 比对,预置 providers 也按规范小写键存储;
|
||||
// 故预置查找须用同一归一化键,否则 config.provider 为 "OpenAI" 时会被判为预置却在小写键 map 里查不到。
|
||||
val key = if (preset) config.provider.trim().lowercase(Locale.ROOT) else config.provider
|
||||
val entry =
|
||||
if (preset) config.providers[key] else config.customProviders[key]
|
||||
if (entry == null || entry.model.isBlank()) return false
|
||||
if (preset) return true
|
||||
return entry.url.isNotBlank() && entry.protocol.isNotBlank() && entry.apiKey.isNotBlank()
|
||||
}
|
||||
|
||||
return config.llm.url.isNotBlank() &&
|
||||
config.llm.model.isNotBlank() &&
|
||||
config.llm.authToken.isNotBlank()
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.FileChange
|
||||
import com.alibaba.opencodereview.idea.model.FileStatus
|
||||
|
||||
/**
|
||||
* 全为纯函数(git 文本 → 领域模型),不依赖 IDE API,可直接单测。
|
||||
*/
|
||||
|
||||
fun mapStatusCode(code: Char): FileStatus = when (code) {
|
||||
'A' -> FileStatus.ADDED
|
||||
'?' -> FileStatus.ADDED
|
||||
'D' -> FileStatus.DELETED
|
||||
'R' -> FileStatus.RENAMED
|
||||
'M' -> FileStatus.MODIFIED
|
||||
else -> FileStatus.MODIFIED // C(copied)/T(typechange)/U(unmerged)等一律降级为修改,见 GitMapTest
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 `git status --porcelain` 输出。
|
||||
* 每行格式:XY<space>path,X 为暂存区状态,Y 为工作区状态,`??` 表示未跟踪。
|
||||
* 重命名行格式 `R old -> new`,取 new。
|
||||
*/
|
||||
fun parsePorcelain(output: String): List<FileChange> {
|
||||
val files = mutableListOf<FileChange>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (rawLine in output.lineSequence()) {
|
||||
if (rawLine.isBlank() || rawLine.length <= 3) continue
|
||||
val x = rawLine[0]
|
||||
val y = rawLine[1]
|
||||
var path = rawLine.substring(3)
|
||||
val code: Char
|
||||
if (x == '?' && y == '?') {
|
||||
code = '?'
|
||||
} else if (x == 'R' || y == 'R' || x == 'C' || y == 'C') {
|
||||
// 重命名(R)与复制(C)都是 `old -> new` 格式,取 new。
|
||||
code = if (x == 'R' || y == 'R') 'R' else 'C'
|
||||
// 引号格式(旧路径含空格等特殊字符,git 加了引号):分隔符是 `" -> `(闭引号 + 箭头)。
|
||||
// 不用 `" -> "`(多一个引号)——新路径可能没引号(无特殊字符),不要求新路径也带引号。
|
||||
val quotedSep = path.indexOf("\" -> ")
|
||||
if (quotedSep >= 0) {
|
||||
path = path.substring(quotedSep + 5) // 跳过 `" -> `(5 字符),保留新路径
|
||||
} else {
|
||||
// 无引号格式:文件名不含空格,` -> ` 不会出现在文件名里,直接找即可。
|
||||
val arrow = path.indexOf(" -> ")
|
||||
if (arrow >= 0) path = path.substring(arrow + 4)
|
||||
}
|
||||
} else {
|
||||
// 暂存区状态优先,没有再取工作区状态
|
||||
code = if (x != ' ' && x != '?') x else y
|
||||
}
|
||||
path = unquoteGitPath(path)
|
||||
if (!seen.add(path)) continue
|
||||
files += FileChange(path, mapStatusCode(code))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/** 解析 `git ls-files --others --exclude-standard` 输出的未跟踪路径列表。 */
|
||||
fun parseUntrackedList(output: String): List<String> =
|
||||
output.lineSequence().map { unquoteGitPath(it.trim()) }.filter(String::isNotEmpty).toList()
|
||||
|
||||
/** 合并已跟踪变更与未跟踪文件,按路径去重,已跟踪的优先。 */
|
||||
fun mergeWorkspaceFiles(tracked: List<FileChange>, untrackedPaths: List<String>): List<FileChange> {
|
||||
val files = mutableListOf<FileChange>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (file in tracked) {
|
||||
if (seen.add(file.path)) files += file
|
||||
}
|
||||
for (path in untrackedPaths) {
|
||||
if (seen.add(path)) files += FileChange(path, FileStatus.ADDED)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建工作区文件列表。与 OCR CLI 的 workspace 模式一致:
|
||||
* 先 `diff HEAD`,为空则回退 `diff --cached`(首次提交前没有 HEAD),最后合并未跟踪文件。
|
||||
*/
|
||||
fun buildWorkspaceFiles(diffHeadOut: String, diffCachedOut: String, untrackedOut: String): List<FileChange> {
|
||||
var tracked = parseNameStatus(diffHeadOut)
|
||||
if (tracked.isEmpty()) tracked = parseNameStatus(diffCachedOut)
|
||||
return mergeWorkspaceFiles(tracked, parseUntrackedList(untrackedOut))
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 `git branch -a --format=%(refname)` 输出。刻意取完整 refname 而非 `%(refname:short)`:
|
||||
* git 会把 `refs/remotes/origin/HEAD` 缩写为 `origin`——既不以 HEAD 结尾、本身也不等于 HEAD,后置过滤无法拦截,
|
||||
* 下拉列表中会出现选中后报 "unknown revision" 的假分支。完整 refname 可精确排除 `refs/remotes/<remote>/HEAD` 符号引用。
|
||||
*/
|
||||
fun parseBranchList(output: String): List<String> {
|
||||
val branches = mutableListOf<String>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (rawLine in output.lineSequence()) {
|
||||
val ref = rawLine.trim()
|
||||
if (ref.isEmpty()) continue
|
||||
val name = when {
|
||||
ref.startsWith("refs/heads/") -> ref.removePrefix("refs/heads/")
|
||||
ref.startsWith("refs/remotes/") -> ref.removePrefix("refs/remotes/")
|
||||
// 非 heads/remotes 的引用(tags、stash 等)不应出现在 branch -a 输出中,保险起见跳过。
|
||||
ref.startsWith("refs/") -> continue
|
||||
else -> ref
|
||||
}
|
||||
if (name.isEmpty() || name == "HEAD") continue
|
||||
if (seen.add(name)) branches += name
|
||||
}
|
||||
return branches
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `origin/HEAD` 和它指向的默认远程分支([defaultRemote])提到列表最前、紧跟第一个本地分支之后,
|
||||
* 其余条目保持原序(对齐 GitHub 网页端的分支顺序)。[defaultRemote] 为 null(仓库没有 origin/HEAD)
|
||||
* 时不重排,原样返回。位次对齐 VS Code 扩展:本地默认分支、`origin/HEAD`、默认远程分支、其余。
|
||||
*/
|
||||
fun pinDefaultBranches(branches: List<String>, defaultRemote: String?): List<String> {
|
||||
if (defaultRemote.isNullOrBlank()) return branches
|
||||
val pin = listOf("origin/HEAD", defaultRemote).filter { it in branches }.distinct()
|
||||
if (pin.isEmpty()) return branches
|
||||
val rest = branches.filter { it !in pin }
|
||||
if (rest.isEmpty()) return pin
|
||||
return listOf(rest.first()) + pin + rest.drop(1)
|
||||
}
|
||||
|
||||
/** 生成用于 `rev-parse --verify` 的分支引用候选:补 origin/、并把 main 与 master 互换。 */
|
||||
fun branchRefCandidates(ref: String): List<String> {
|
||||
val candidates = mutableListOf(ref)
|
||||
if (!ref.contains('/')) candidates += "origin/$ref"
|
||||
when (ref) {
|
||||
"master" -> candidates += listOf("main", "origin/main")
|
||||
"main" -> candidates += listOf("master", "origin/master")
|
||||
}
|
||||
return candidates.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 git 的 quotepath 转义(`core.quotepath=true` 时中文会变成 `"\344\275\240"`)。
|
||||
* 所有 git 调用均带 `-c core.quotepath=false`,但仓库/全局配置仍可能引入引号形式,
|
||||
* 故保留此层处理。
|
||||
*/
|
||||
fun unquoteGitPath(path: String): String {
|
||||
if (path.length < 2 || !path.startsWith('"') || !path.endsWith('"')) return path
|
||||
|
||||
val bytes = mutableListOf<Byte>()
|
||||
val inner = path.substring(1, path.length - 1)
|
||||
var i = 0
|
||||
while (i < inner.length) {
|
||||
val ch = inner[i]
|
||||
if (ch != '\\' || i + 1 >= inner.length) {
|
||||
bytes += ch.code.toByte()
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// 八进制转义:字符范围收紧为 0-7,避免 toInt(8) 遇 '8'/'9' 抛异常。
|
||||
if (i + 3 < inner.length && inner.substring(i + 1, i + 4).all { it in '0'..'7' }) {
|
||||
bytes += inner.substring(i + 1, i + 4).toInt(8).toByte()
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
i++
|
||||
when (val esc = inner[i]) {
|
||||
'n' -> bytes += 0x0a.toByte()
|
||||
't' -> bytes += 0x09.toByte()
|
||||
'r' -> bytes += 0x0d.toByte()
|
||||
'\\' -> bytes += 0x5c.toByte()
|
||||
'"' -> bytes += 0x22.toByte()
|
||||
else -> bytes += esc.code.toByte()
|
||||
}
|
||||
i++
|
||||
}
|
||||
return String(bytes.toByteArray(), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 `git diff --name-status` / `git show --name-status` 输出。
|
||||
* 制表符分隔:`status<TAB>path`;重命名是 `R<score><TAB>old<TAB>new`,取 new。
|
||||
*/
|
||||
fun parseNameStatus(output: String): List<FileChange> {
|
||||
val files = mutableListOf<FileChange>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (rawLine in output.lineSequence()) {
|
||||
if (rawLine.isBlank()) continue
|
||||
val parts = rawLine.split('\t')
|
||||
if (parts.size < 2) continue
|
||||
val codeChar = parts[0].firstOrNull() ?: continue
|
||||
val path = unquoteGitPath(if (parts.size >= 3) parts.last() else parts[1])
|
||||
if (!seen.add(path)) continue
|
||||
files += FileChange(path, mapStatusCode(codeChar))
|
||||
}
|
||||
return files
|
||||
}
|
||||
+561
@@ -0,0 +1,561 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.CommitInfo
|
||||
import com.alibaba.opencodereview.idea.model.FileChange
|
||||
import com.alibaba.opencodereview.idea.model.FileStatus
|
||||
import com.alibaba.opencodereview.idea.model.GitState
|
||||
import com.alibaba.opencodereview.idea.model.HostStrings
|
||||
import com.alibaba.opencodereview.idea.model.ReviewContext
|
||||
import com.alibaba.opencodereview.idea.model.ReviewMode
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import com.alibaba.opencodereview.idea.model.currentIdeLocale
|
||||
import com.intellij.diff.DiffContentFactory
|
||||
|
||||
import com.intellij.diff.DiffManager
|
||||
import com.intellij.diff.requests.SimpleDiffRequest
|
||||
import com.intellij.diff.util.DiffUserDataKeys
|
||||
import com.intellij.diff.util.Side
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.vfs.VirtualFileManager
|
||||
import com.intellij.openapi.vfs.newvfs.BulkFileListener
|
||||
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
|
||||
import com.intellij.util.concurrency.AppExecutorUtil
|
||||
import java.io.File
|
||||
import java.util.concurrent.ScheduledFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 直接调用命令行 git。所有 git 调用强制加 `-c core.quotepath=false`。
|
||||
*/
|
||||
class GitService(private val project: Project) {
|
||||
|
||||
companion object {
|
||||
private const val GIT_TIMEOUT_MS = 30_000L
|
||||
private const val RECENT_COMMITS = 20
|
||||
private const val BINARY_SAMPLE_BYTES = 8000
|
||||
private const val BINARY_SIZE_THRESHOLD = 512_000L
|
||||
|
||||
/** VFS 事件防抖窗口。一次保存或切换分支会触发数百个事件,执行一次 git 即可。 */
|
||||
private const val WATCH_DEBOUNCE_MS = 500L
|
||||
|
||||
/** `git log --format` 的字段分隔符。用 U+001F(Unit Separator),提交信息里不可能出现。 */
|
||||
private const val UNIT = '\u001F'
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var cache: GitState = GitState()
|
||||
|
||||
@Volatile
|
||||
private var cachedRoot: File? = null
|
||||
|
||||
/** Windows / macOS 默认大小写不敏感 FS;VFS 事件路径大小写可能与 root 不同,匹配前缀时须忽略大小写。OS 运行期不变,构造时算一次。 */
|
||||
private val caseInsensitiveFs: Boolean = System.getProperty("os.name").orEmpty().let {
|
||||
it.startsWith("Win", true) || it.startsWith("Mac", true)
|
||||
}
|
||||
|
||||
/** 本次审查涉及的文件状态,供评论挂载判断"文件是否已删除 / 是否二进制"。 */
|
||||
private val reviewFileStatus = mutableMapOf<String, FileStatus>()
|
||||
|
||||
// ---------------------------------------------------------------- 仓库根
|
||||
|
||||
/** 仓库根目录;不在 git 仓库内返回 null。结果缓存,[invalidate] 后重新解析。 */
|
||||
fun repoRoot(): File? {
|
||||
cachedRoot?.let { return it }
|
||||
val base = project.basePath?.let(::File) ?: return null
|
||||
val out = runGitOrNull(base, "rev-parse", "--show-toplevel")?.trim()
|
||||
if (out.isNullOrEmpty()) return null
|
||||
val root = File(out)
|
||||
if (!root.isDirectory) return null
|
||||
cachedRoot = root
|
||||
return root
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 状态刷新
|
||||
|
||||
/**
|
||||
* 按审查模式刷新并返回 git 状态。
|
||||
* 只查询当前模式所需数据:workspace 模式不需要分支列表,branch 模式不需要提交列表。
|
||||
*/
|
||||
fun getState(mode: ReviewMode): GitState = synchronized(this) {
|
||||
val root = repoRoot() ?: return@synchronized GitState()
|
||||
var state = cache
|
||||
when (mode) {
|
||||
ReviewMode.WORKSPACE -> state = state.copy(
|
||||
currentBranch = refreshCurrentBranch(root),
|
||||
workspaceFiles = refreshWorkspaceFiles(root),
|
||||
)
|
||||
|
||||
ReviewMode.BRANCH -> state = state.copy(
|
||||
currentBranch = refreshCurrentBranch(root),
|
||||
branches = refreshBranches(root),
|
||||
)
|
||||
|
||||
ReviewMode.COMMIT -> state = state.copy(
|
||||
currentBranch = refreshCurrentBranch(root),
|
||||
recentCommits = refreshRecentCommits(root),
|
||||
)
|
||||
}
|
||||
cache = state
|
||||
state
|
||||
}
|
||||
|
||||
/** 最近一次 [getState] 的结果,不触发任何 git 调用。 */
|
||||
fun cachedState(): GitState = cache
|
||||
|
||||
private fun refreshCurrentBranch(root: File): String {
|
||||
val branch = runGitOrNull(root, "rev-parse", "--abbrev-ref", "HEAD")?.trim().orEmpty()
|
||||
// 分离头指针时 --abbrev-ref 返回 "HEAD",退化为短哈希更有意义。
|
||||
if (branch.isEmpty() || branch == "HEAD") {
|
||||
return runGitOrNull(root, "rev-parse", "--short", "HEAD")?.trim().orEmpty()
|
||||
}
|
||||
return branch
|
||||
}
|
||||
|
||||
private fun refreshWorkspaceFiles(root: File): List<FileChange> {
|
||||
val diffHead = runGitOrNull(root, "diff", "--name-status", "HEAD").orEmpty()
|
||||
// 首次提交前没有 HEAD,回退到暂存区。
|
||||
val diffCached = if (diffHead.isBlank()) {
|
||||
runGitOrNull(root, "diff", "--name-status", "--cached").orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val untracked = runGitOrNull(root, "ls-files", "--others", "--exclude-standard").orEmpty()
|
||||
return buildWorkspaceFiles(diffHead, diffCached, untracked)
|
||||
}
|
||||
|
||||
private fun refreshBranches(root: File): List<String> {
|
||||
val out = runGitOrNull(root, "branch", "-a", "--format=%(refname)") ?: return emptyList()
|
||||
val parsed = parseBranchList(out)
|
||||
// origin/HEAD 指向默认远程分支(origin/master 或 origin/main),把它俩提到列表前部、紧跟本地默认分支之后。
|
||||
val defaultRemote = runGitOrNull(root, "symbolic-ref", "refs/remotes/origin/HEAD")
|
||||
?.trim()?.removePrefix("refs/remotes/")?.takeIf { it.isNotBlank() }
|
||||
return pinDefaultBranches(parsed, defaultRemote)
|
||||
}
|
||||
|
||||
private fun refreshRecentCommits(root: File): List<CommitInfo> {
|
||||
val format = "--format=%h$UNIT%s$UNIT%at"
|
||||
val out = runGitOrNull(root, "log", "-$RECENT_COMMITS", format) ?: return emptyList()
|
||||
val now = System.currentTimeMillis()
|
||||
val locale = currentIdeLocale()
|
||||
return out.lineSequence()
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { line ->
|
||||
val parts = line.split(UNIT)
|
||||
if (parts.size < 3) return@mapNotNull null
|
||||
CommitInfo(
|
||||
sha = parts[0].trim(),
|
||||
message = parts[1].trim(),
|
||||
relativeTime = formatRelative(parts[2].trim().toLongOrNull(), now, locale),
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 差异查询
|
||||
|
||||
/**
|
||||
* 分支间变更文件。使用三点差异(`from...to`),即从 merge-base 开始比较,
|
||||
* 使 from 分支上的新提交不会被计入"本次改动"——与 OCR CLI 的 branch 模式一致。
|
||||
*/
|
||||
fun getBranchDiff(from: String, to: String): List<FileChange> {
|
||||
val root = repoRoot() ?: return emptyList()
|
||||
val fromRef = resolveGitRef(root, from) ?: return emptyList()
|
||||
val toRef = if (to.isBlank()) "HEAD" else resolveGitRef(root, to) ?: return emptyList()
|
||||
val out = runGitOrNull(root, "diff", "--name-status", "$fromRef...$toRef") ?: return emptyList()
|
||||
return parseNameStatus(out)
|
||||
}
|
||||
|
||||
/** 单个提交引入的变更文件。 */
|
||||
fun getCommitFiles(sha: String): List<FileChange> {
|
||||
val root = repoRoot() ?: return emptyList()
|
||||
val safeSha = safeRef(sha) ?: return emptyList() // safeRef 已拒空/空白
|
||||
val out = runGitOrNull(root, "show", "--name-status", "--format=", safeSha) ?: return emptyList()
|
||||
return parseNameStatus(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户填写的分支名解析为真实存在的引用。
|
||||
* 依次尝试 [branchRefCandidates] 给出的候选(补 origin/、main 与 master 互换),
|
||||
* 全部不存在则返回 null。
|
||||
*/
|
||||
fun resolveGitRef(root: File, ref: String): String? {
|
||||
val safe = safeRef(ref) ?: return null
|
||||
for (candidate in branchRefCandidates(safe)) {
|
||||
val ok = runGitOrNull(root, "rev-parse", "--verify", "--quiet", candidate)
|
||||
if (!ok.isNullOrBlank()) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 两个引用的 merge-base;失败返回 null。 */
|
||||
fun mergeBase(from: String, to: String): String? {
|
||||
val root = repoRoot() ?: return null
|
||||
val fromRef = safeRef(from) ?: return null
|
||||
val toRef = safeRef(to) ?: return null
|
||||
return runGitOrNull(root, "merge-base", fromRef, toRef)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 文件内容
|
||||
|
||||
/** 读取某个引用下的文件内容;路径不存在于该引用返回 null。 */
|
||||
fun readFileAtRef(ref: String, relPath: String): String? {
|
||||
val root = repoRoot() ?: return null
|
||||
val safe = safeRef(ref) ?: return null
|
||||
return runGitOrNull(root, "show", "$safe:$relPath")
|
||||
}
|
||||
|
||||
/** 读取工作区当前文件内容;文件不存在返回 null。基准用 repoRoot:CLI 输出的评论路径是仓库根相对(git diff 默认),子目录打开项目时也须按仓库根拼。 */
|
||||
fun readWorkspaceFile(relPath: String): String? {
|
||||
val root = repoRoot() ?: return null
|
||||
val file = File(root, relPath)
|
||||
// 防路径遍历:canonicalPath 解析 ../ 和符号链接后的真实路径,确认仍在仓库根内。
|
||||
// runCatching 兜底:canonicalPath 遇 IO 异常不崩,按"不在仓库内"处理(优雅降级)。
|
||||
val inRepo = runCatching { file.canonicalPath.startsWith(root.canonicalPath + File.separator) }.getOrDefault(false)
|
||||
if (!inRepo) return null
|
||||
if (!file.isFile) return null
|
||||
return runCatching { file.readText() }.getOrNull()
|
||||
}
|
||||
|
||||
/** 某路径是否存在于给定引用。用 `cat-file -e`,不读内容。 */
|
||||
fun pathExistsAtRef(ref: String, relPath: String): Boolean {
|
||||
val root = repoRoot() ?: return false
|
||||
val safe = safeRef(ref) ?: return false
|
||||
// cat-file -e 存在时无输出且退出码 0,须区分"null=失败"和"空串=成功"。
|
||||
return runGitOrNull(root, "cat-file", "-e", "$safe:$relPath") != null
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是二进制文件。判据是内容中出现 NUL 字节——与 git 自身的启发式一致。
|
||||
* 按需调用而非在 [prepareReviewFileStatus] 中批量预判,避免为数百个文件空读一遍内容。
|
||||
*/
|
||||
fun isBinaryFile(relPath: String, ctx: ReviewContext): Boolean {
|
||||
val root = repoRoot() ?: return false
|
||||
if (ctx.mode == ReviewMode.WORKSPACE) {
|
||||
val file = File(root, relPath)
|
||||
// 防路径遍历:同 readWorkspaceFile,canonicalPath 解析后确认在仓库根内。
|
||||
val inRepo = runCatching { file.canonicalPath.startsWith(root.canonicalPath + File.separator) }.getOrDefault(false)
|
||||
if (!inRepo || !file.isFile) return false
|
||||
return runCatching {
|
||||
file.inputStream().use { it.readNBytes(BINARY_SAMPLE_BYTES).any { b -> b == 0.toByte() } }
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
// 非 workspace:先查 git 对象大小,超大文件直接判定 binary,不读内容
|
||||
val ref = when (ctx.mode) {
|
||||
ReviewMode.BRANCH -> ctx.to?.takeIf { it.isNotBlank() } ?: "HEAD"
|
||||
ReviewMode.COMMIT -> ctx.commit?.takeIf { it.isNotBlank() } ?: return false
|
||||
else -> return false
|
||||
}
|
||||
val sizeStr = runGitOrNull(root, "cat-file", "-s", "$ref:$relPath")?.trim()
|
||||
val size = sizeStr?.toLongOrNull() ?: return false
|
||||
if (size > BINARY_SIZE_THRESHOLD) return true
|
||||
val text = readFileAtRef(ref, relPath) ?: return false
|
||||
val limit = text.length.coerceAtMost(BINARY_SAMPLE_BYTES)
|
||||
return (0 until limit).any { text[it] == '\u0000' }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 审查文件状态
|
||||
|
||||
/**
|
||||
* 审查开始时记录本次涉及的文件及其状态,供 [getReviewFileStatus] 查询。
|
||||
* 评论挂载依靠此数据区分"文件已删除故无法跳转"和"文件存在但行号不匹配"。
|
||||
*/
|
||||
fun prepareReviewFileStatus(ctx: ReviewContext) {
|
||||
val files = when (ctx.mode) {
|
||||
ReviewMode.WORKSPACE -> repoRoot()?.let(::refreshWorkspaceFiles).orEmpty()
|
||||
ReviewMode.BRANCH -> getBranchDiff(ctx.from.orEmpty(), ctx.to.orEmpty())
|
||||
ReviewMode.COMMIT -> getCommitFiles(ctx.commit.orEmpty())
|
||||
}
|
||||
synchronized(reviewFileStatus) {
|
||||
reviewFileStatus.clear()
|
||||
files.forEach { reviewFileStatus[it.path] = it.status }
|
||||
}
|
||||
}
|
||||
|
||||
/** 本次审查中该路径的变更状态;不在本次范围内返回 null。 */
|
||||
fun getReviewFileStatus(relPath: String): FileStatus? =
|
||||
synchronized(reviewFileStatus) { reviewFileStatus[relPath] }
|
||||
|
||||
// ---------------------------------------------------------------- 差异视图
|
||||
|
||||
/**
|
||||
* diff 打开时为两侧文档挂载内容的钩子。branch/commit 模式两侧内容是
|
||||
* DiffContentFactory 现创建的匿名文档,外部无法获取句柄,只能在创建当口回调。
|
||||
*/
|
||||
@Volatile
|
||||
var diffDecorator: ((relPath: String, side: Side, document: Document) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* diff 视图创建之后的钩子,用于挂载内嵌面板。
|
||||
* 与 diffDecorator 分开:行高亮挂在文档上须在 showDiff 之前,面板挂在编辑器实例上须在之后。
|
||||
*/
|
||||
@Volatile
|
||||
var diffViewerReady: ((relPath: String, side: Side, document: Document, clickedIndex: Int?) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* 在 IDEA 差异视图中打开某文件的本次改动。新增文件左侧、删除文件右侧使用空内容而非报错,
|
||||
* 使 diff 始终能打开、用户看到全增或全删。
|
||||
*
|
||||
* [scrollToSide]/[scrollToLine] 使 diff 打开后自动滚动到并选中指定行——实现评论跳转中"定位到已打开 diff 的评论行"的效果。
|
||||
* [scrollToLine] 为 0-based 文档行号。两者均提供才生效,任一为 null 则为普通打开。
|
||||
*/
|
||||
fun openDiff(
|
||||
relPath: String,
|
||||
status: FileStatus,
|
||||
ctx: ReviewContext,
|
||||
scrollToSide: Side? = null,
|
||||
scrollToLine: Int? = null,
|
||||
clickedIndex: Int? = null,
|
||||
) {
|
||||
val root = repoRoot() ?: return
|
||||
val leftRef = leftRefFor(root, ctx)
|
||||
|
||||
val left = if (status == FileStatus.ADDED) "" else readAtRefOrEmpty(leftRef, relPath)
|
||||
val right = if (status == FileStatus.DELETED) "" else readRightSide(relPath, ctx) ?: ""
|
||||
|
||||
val fileType = FileTypeManager.getInstance().getFileTypeByFileName(File(relPath).name)
|
||||
val factory = DiffContentFactory.getInstance()
|
||||
val leftContent = factory.create(project, left, fileType)
|
||||
val rightContent = factory.create(project, right, fileType)
|
||||
|
||||
val locale = currentIdeLocale()
|
||||
val rightTitle = if (ctx.mode == ReviewMode.WORKSPACE) {
|
||||
HostStrings.t(locale, "ext.git.workspace")
|
||||
} else {
|
||||
ctx.describeRight(locale)
|
||||
}
|
||||
val request = SimpleDiffRequest(
|
||||
relPath,
|
||||
leftContent,
|
||||
rightContent,
|
||||
leftRef ?: HostStrings.t(locale, "ext.git.emptyRef"),
|
||||
rightTitle,
|
||||
)
|
||||
if (scrollToSide != null && scrollToLine != null) {
|
||||
request.putUserData(DiffUserDataKeys.SCROLL_TO_LINE, com.intellij.openapi.util.Pair.create(scrollToSide, scrollToLine))
|
||||
}
|
||||
|
||||
// DiffManager 须在 EDT 上调用,审查流程在后台线程执行。
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
// 挂标记须在 showDiff 之前:viewer 用这两份 document 构建 editor,先挂好、diff 一出现装订线图标即就位,不出现"先空一下再刷出来"。
|
||||
diffDecorator?.let { decorate ->
|
||||
runCatching {
|
||||
decorate(relPath, Side.LEFT, leftContent.document)
|
||||
decorate(relPath, Side.RIGHT, rightContent.document)
|
||||
}.onFailure { thisLogger().warn("[ocr] Failed to decorate diff with comment markers", it) }
|
||||
}
|
||||
DiffManager.getInstance().showDiff(project, request)
|
||||
// 待 viewer 构建完毕再挂内嵌面板——通过 EditorFactory 查询 document 对应的编辑器实例。
|
||||
diffViewerReady?.let { ready ->
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
runCatching {
|
||||
ready(relPath, Side.LEFT, leftContent.document, clickedIndex)
|
||||
ready(relPath, Side.RIGHT, rightContent.document, clickedIndex)
|
||||
}.onFailure { thisLogger().warn("[ocr] Failed to mount inline panels on diff", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 差异左侧(改动前)对应的引用。 */
|
||||
private fun leftRefFor(root: File, ctx: ReviewContext): String? = when (ctx.mode) {
|
||||
ReviewMode.WORKSPACE -> "HEAD"
|
||||
ReviewMode.BRANCH -> {
|
||||
val from = resolveGitRef(root, ctx.from.orEmpty())
|
||||
val to = ctx.to?.takeIf { it.isNotBlank() }?.let { resolveGitRef(root, it) } ?: "HEAD"
|
||||
// 与 getBranchDiff 的三点差异保持一致:左侧取 merge-base,而非 from 的当前位置。
|
||||
// mergeBase 失败时返回 null(让 openDiff 优雅降级为空左侧),而非回退到 from(会与文件列表基准不一致)。
|
||||
if (from != null) mergeBase(from, to) else null
|
||||
}
|
||||
|
||||
ReviewMode.COMMIT -> ctx.commit?.takeIf { it.isNotBlank() }?.let { "$it^" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 供 [com.alibaba.opencodereview.idea.providers.resolveCommentAnchor] 复用的无 root 版本——
|
||||
* 评论挂载和 [openDiff] 须使用同一套引用解析,否则挂载算出的行号与 diff 中实际展示的内容版本不一致。
|
||||
*/
|
||||
internal fun leftRefFor(ctx: ReviewContext): String? {
|
||||
val root = repoRoot() ?: return null
|
||||
return leftRefFor(root, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* 差异右侧(改动后)引用;workspace 无"引用"概念返回 null。与 [readRightSide] 的区别:
|
||||
* 此处不做"to 为空退回工作区文件"的兜底——评论挂载需要的是"此引用能否解析"的判断本身,退回工作区会掩盖"审查未完整运行"本应报 missing-file 的情况。
|
||||
*/
|
||||
internal fun rightRefFor(ctx: ReviewContext): String? {
|
||||
val root = repoRoot() ?: return null
|
||||
return when (ctx.mode) {
|
||||
ReviewMode.WORKSPACE -> null
|
||||
ReviewMode.BRANCH -> ctx.to?.takeIf { it.isNotBlank() }?.let { resolveGitRef(root, it) }
|
||||
ReviewMode.COMMIT -> ctx.commit?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
/** 差异右侧(改动后)内容。workspace 模式读取工作区,其余读取对应引用。 */
|
||||
private fun readRightSide(relPath: String, ctx: ReviewContext): String? = when (ctx.mode) {
|
||||
ReviewMode.WORKSPACE -> readWorkspaceFile(relPath)
|
||||
ReviewMode.BRANCH -> {
|
||||
val to = ctx.to?.takeIf { it.isNotBlank() }
|
||||
if (to == null) readWorkspaceFile(relPath) else readFileAtRef(to, relPath)
|
||||
}
|
||||
|
||||
ReviewMode.COMMIT -> ctx.commit?.takeIf { it.isNotBlank() }?.let { readFileAtRef(it, relPath) }
|
||||
}
|
||||
|
||||
private fun readAtRefOrEmpty(ref: String?, relPath: String): String {
|
||||
if (ref == null) return ""
|
||||
return readFileAtRef(ref, relPath) ?: ""
|
||||
}
|
||||
|
||||
private fun ReviewContext.describeRight(locale: SupportedLocale): String = when (mode) {
|
||||
ReviewMode.WORKSPACE -> HostStrings.t(locale, "ext.git.workspace")
|
||||
ReviewMode.BRANCH -> to?.takeIf { it.isNotBlank() } ?: HostStrings.t(locale, "ext.git.workspace")
|
||||
ReviewMode.COMMIT -> commit.orEmpty()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 变更监听
|
||||
|
||||
/**
|
||||
* 订阅工作区文件变更,防抖后回调新的 workspace 状态。
|
||||
* 不加此监听,用户修改文件后侧栏待审查列表仍是打开时的快照。
|
||||
*
|
||||
* 返回值是此次订阅本身,dispose 即断开(仅侧栏存活时才监听)。
|
||||
* [parent] 仅作兜底(项目关闭时统一清理),调用方应在侧栏关闭时主动 dispose 返回值,
|
||||
* 否则 VFS 事件会在整个项目生命周期内持续触发 `git status` 而无人接收。
|
||||
*/
|
||||
fun watchWorkspaceChanges(parent: Disposable, onChange: (GitState) -> Unit): Disposable {
|
||||
val subscription = Disposer.newDisposable("ocr.gitWatch")
|
||||
Disposer.register(parent, subscription)
|
||||
val pending = AtomicReference<ScheduledFuture<*>?>(null)
|
||||
val connection = project.messageBus.connect(subscription)
|
||||
connection.subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener {
|
||||
override fun after(events: List<VFileEvent>) {
|
||||
val root = cachedRoot ?: repoRoot() ?: return
|
||||
if (!events.any { it.isRelevantTo(root) }) return
|
||||
// 一次保存或切换分支会触发数十乃至上百个 VFS 事件,必须防抖,否则每个事件都会执行一遍 git。
|
||||
pending.getAndSet(
|
||||
AppExecutorUtil.getAppScheduledExecutorService().schedule(
|
||||
{
|
||||
if (!project.isDisposed) {
|
||||
runCatching { onChange(getState(ReviewMode.WORKSPACE)) }
|
||||
.onFailure { thisLogger().warn("[ocr] Failed to refresh workspace state", it) }
|
||||
}
|
||||
},
|
||||
WATCH_DEBOUNCE_MS,
|
||||
TimeUnit.MILLISECONDS,
|
||||
),
|
||||
)?.cancel(false)
|
||||
}
|
||||
})
|
||||
// 断开订阅时将尚未执行的那次防抖任务一并取消,否则会在无人接收后再执行一次 git。
|
||||
Disposer.register(subscription) { pending.getAndSet(null)?.cancel(false) }
|
||||
return subscription
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤掉与仓库无关以及 `.git` 内部的噪声事件。`.git/` 下仅认 `index`(暂存区变化,`git add` 要反映到列表)和 `HEAD`(切分支);
|
||||
* 其余(objects/、logs/、锁文件)改动极频繁且不影响文件列表,放进来会使防抖窗口持续重置、状态始终刷新不出来。
|
||||
*/
|
||||
private fun VFileEvent.isRelevantTo(root: File): Boolean {
|
||||
val path = path.replace(File.separatorChar, '/')
|
||||
val rootPath = root.absolutePath.replace(File.separatorChar, '/')
|
||||
if (!path.startsWith("$rootPath/", ignoreCase = caseInsensitiveFs)) return false
|
||||
// 大小写不敏感 FS 上 path 大小写可能与 rootPath 不同,removePrefix 是精确匹配会失败;用索引截断保证一致。
|
||||
val relative = path.substring(rootPath.length + 1)
|
||||
if (!relative.startsWith(".git/") && relative != ".git") return true
|
||||
val inner = relative.removePrefix(".git/")
|
||||
return inner == "index" || inner == "HEAD"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 缓存
|
||||
|
||||
/** 丢弃仓库根与状态缓存。仓库切换、外部改动后调用。 */
|
||||
fun invalidate() {
|
||||
cachedRoot = null
|
||||
cache = GitState()
|
||||
synchronized(reviewFileStatus) { reviewFileStatus.clear() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 进程
|
||||
|
||||
/** 校验用户填写的 ref/sha:trim 后非空、不以 `-` 开头、不含空白/控制符,否则返回 null(防被 git 当选项或断行注入)。 */
|
||||
private fun safeRef(ref: String): String? {
|
||||
val t = ref.trim()
|
||||
if (t.isEmpty() || t.startsWith("-") || t.any { it.isWhitespace() || it.code < 0x20 }) return null
|
||||
return t
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一条 git 命令,返回 stdout。任何失败(非零退出、超时、git 不存在)均返回 null,
|
||||
* 调用方按"取不到则降级"处理——git 查询失败不应导致整个审查流程崩溃。
|
||||
*
|
||||
* stderr 丢弃:git 的进度和提示信息会混入输出,而所有解析仅认 stdout 的格式。
|
||||
*/
|
||||
private fun runGitOrNull(cwd: File, vararg args: String): String? {
|
||||
val git = ShellEnv.resolveBin("git")
|
||||
val command = listOf(git, "-c", "core.quotepath=false") + args
|
||||
return try {
|
||||
val builder = ProcessBuilder(command)
|
||||
.directory(cwd)
|
||||
.redirectErrorStream(false)
|
||||
.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
builder.environment().putAll(ShellEnv.env())
|
||||
val process = builder.start()
|
||||
process.outputStream.close()
|
||||
// stdout 必须在独立线程读取:当前线程 readText() 读到 EOF 才会 waitFor,进程挂住不关闭 stdout 时会永久阻塞,后续超时判定形同虚设。
|
||||
val stdout = StringBuilder()
|
||||
val reader = Thread({
|
||||
runCatching { process.inputStream.bufferedReader(Charsets.UTF_8).use { stdout.append(it.readText()) } }
|
||||
.onFailure { thisLogger().warn("[ocr] Failed to read git stdout: ${args.joinToString(" ")}", it) }
|
||||
}, "ocr-git-stdout").apply { isDaemon = true; start() }
|
||||
if (!process.waitFor(GIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
process.destroyForcibly()
|
||||
reader.join(1_000) // 强杀后等 reader 收尾,避免 daemon 在反复超时中堆积
|
||||
thisLogger().warn("[ocr] git timed out: ${args.joinToString(" ")}")
|
||||
return null
|
||||
}
|
||||
reader.join()
|
||||
if (process.exitValue() != 0) null else stdout.toString()
|
||||
} catch (e: Exception) {
|
||||
thisLogger().warn("[ocr] git execution failed: ${args.joinToString(" ")}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 unix 秒时间戳格式化为相对时间。[nowMillis] 由调用方传入而非内部取 `System.currentTimeMillis()`,便于测试;
|
||||
* 同理 [locale] 由调用方提供,保持纯函数。
|
||||
* 仅 justNow/hourAgo/hoursAgo/yesterday/daysAgo 五档为基础,此处新增分钟、月、年三档。
|
||||
*/
|
||||
internal fun formatRelative(epochSeconds: Long?, nowMillis: Long, locale: SupportedLocale): String {
|
||||
if (epochSeconds == null || epochSeconds <= 0) return ""
|
||||
val diffMs = nowMillis - epochSeconds * 1000
|
||||
if (diffMs < 0) return HostStrings.t(locale, "ext.git.justNow")
|
||||
val minutes = diffMs / 60_000
|
||||
val hours = minutes / 60
|
||||
val days = hours / 24
|
||||
return when {
|
||||
minutes < 1 -> HostStrings.t(locale, "ext.git.justNow")
|
||||
minutes < 60 -> HostStrings.t(locale, "ext.git.minutesAgo", "m" to minutes.toString())
|
||||
hours == 1L -> HostStrings.t(locale, "ext.git.hourAgo")
|
||||
hours < 24 -> HostStrings.t(locale, "ext.git.hoursAgo", "h" to hours.toString())
|
||||
days == 1L -> HostStrings.t(locale, "ext.git.yesterday")
|
||||
days < 30 -> HostStrings.t(locale, "ext.git.daysAgo", "d" to days.toString())
|
||||
days < 365 -> HostStrings.t(locale, "ext.git.monthsAgo", "mo" to (days / 30).toString())
|
||||
else -> HostStrings.t(locale, "ext.git.yearsAgo", "y" to (days / 365).toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 内置 provider 名字集合。
|
||||
* 仅复制名字不复制 baseUrl/models/protocol 表:宿主侧只需 [isPresetProvider](决定写入 `providers` 还是 `custom_providers`),
|
||||
* 复制整表会造成第二份数据源,两边分叉漂移。
|
||||
* 漂移由 `ProvidersTest` 拦截:该测试从前端共享源码中正则提取 `name:` 字段进行比对。
|
||||
*/
|
||||
private val PRESET_PROVIDER_NAMES: Set<String> = setOf(
|
||||
"anthropic",
|
||||
"openai",
|
||||
"dashscope",
|
||||
"dashscope-tokenplan",
|
||||
"volcengine",
|
||||
"deepseek",
|
||||
"tencent-tokenhub",
|
||||
"hy-tokenplan",
|
||||
"kimi",
|
||||
"z-ai",
|
||||
"z-ai-coding",
|
||||
"mimo",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"baidu-qianfan",
|
||||
)
|
||||
|
||||
/** 按 trim + 小写比对;用 Locale.ROOT 避免 Turkish 等 locale 下 'I'→'ı' 之类意外。 */
|
||||
fun isPresetProvider(name: String): Boolean =
|
||||
PRESET_PROVIDER_NAMES.contains(name.trim().lowercase(Locale.ROOT))
|
||||
|
||||
/** 供单测/诊断用的只读视图。返回防御性拷贝,避免外部 mutate 内部集合。 */
|
||||
fun presetProviderNames(): Set<String> = PRESET_PROVIDER_NAMES.toSet()
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.CliResult
|
||||
import com.alibaba.opencodereview.idea.model.CliRunOptions
|
||||
import com.alibaba.opencodereview.idea.model.LogLevel
|
||||
import com.alibaba.opencodereview.idea.model.LogLine
|
||||
import com.alibaba.opencodereview.idea.model.ReviewState
|
||||
import com.intellij.openapi.progress.ProcessCanceledException
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal val supportedReviewStatuses = setOf(
|
||||
"success", "complete", "completed_with_warnings", "completed_with_errors", "partial", "failed", "skipped",
|
||||
)
|
||||
|
||||
/** Only recognized, non-failing terminal results can report an empty review. */
|
||||
fun resultToState(result: CliResult): ReviewState = when {
|
||||
result.status !in supportedReviewStatuses -> ReviewState.FAILED
|
||||
result.comments.isNotEmpty() -> ReviewState.DONE
|
||||
result.status in setOf("completed_with_errors", "partial", "failed") -> ReviewState.FAILED
|
||||
else -> ReviewState.EMPTY
|
||||
}
|
||||
|
||||
interface SessionCallbacks {
|
||||
fun onState(state: ReviewState, error: String? = null)
|
||||
fun onLog(line: LogLine)
|
||||
fun onDone(result: CliResult)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次审查对应一个 session,状态仅在 session 内(`cancelled` 标记),
|
||||
* 不做跨 session 持久化——webview 重建后由前端重新请求。[run] 为阻塞调用,调用方须在后台线程执行。
|
||||
*/
|
||||
class ReviewSession(private val cli: CliService, private val cwd: File) {
|
||||
|
||||
private val cancellation = CliCancellation()
|
||||
|
||||
@Volatile
|
||||
private var cancelled = false
|
||||
/** Only one caller can cancel this session and publish its cancellation state. */
|
||||
private val cancelEntered = AtomicBoolean(false)
|
||||
|
||||
fun run(opts: CliRunOptions, cb: SessionCallbacks) {
|
||||
// 不复位 cancelled:若 cancel() 在 run 被调度后、真正执行前到达,复位会抹掉这次取消。
|
||||
if (cancelled) { // run 启动前已被取消(被调度但尚未跑):直接 CANCELLED,不先发 RUNNING 制造多余状态跳变
|
||||
cb.onState(ReviewState.CANCELLED)
|
||||
return
|
||||
}
|
||||
cb.onState(ReviewState.RUNNING)
|
||||
try {
|
||||
val result = cli.review(opts, cwd, cb::onLog, cancellation)
|
||||
if (cancelled) {
|
||||
cb.onState(ReviewState.CANCELLED)
|
||||
return
|
||||
}
|
||||
val state = resultToState(result)
|
||||
val incomplete = result.status in setOf("completed_with_errors", "partial", "failed")
|
||||
val error = if (state == ReviewState.FAILED || incomplete) {
|
||||
result.message?.takeIf(String::isNotBlank) ?: "Review did not complete successfully (${result.status})"
|
||||
} else null
|
||||
if (error != null) cb.onLog(LogLine("[ocr] $error", LogLevel.ERROR))
|
||||
cb.onState(state, error.takeIf { state == ReviewState.FAILED })
|
||||
cb.onDone(result)
|
||||
} catch (error: Exception) {
|
||||
// 只接 Exception:OOM/LinkageError 等 Error 不在此吞,让其上抛,避免掩盖致命问题。
|
||||
// ProcessCanceledException 是 IntelliJ 取消信号,不吞。
|
||||
if (error is ProcessCanceledException) throw error
|
||||
if (cancelled) {
|
||||
cb.onState(ReviewState.CANCELLED)
|
||||
} else {
|
||||
val message = error.message ?: error.javaClass.simpleName
|
||||
cb.onLog(LogLine("[ocr] $message", LogLevel.ERROR))
|
||||
cb.onState(ReviewState.FAILED, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(onState: (ReviewState) -> Unit) {
|
||||
// The cancellation handle belongs to this session, including before run() starts.
|
||||
if (!cancelEntered.compareAndSet(false, true)) return
|
||||
cancelled = true
|
||||
cancellation.cancel()
|
||||
onState(ReviewState.CANCELLED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val DELIM = "_OCR_ENV_DELIM_"
|
||||
private const val SHELL_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** 命令名白名单:只允许字母数字 . _ / -,防路径拼接成 shell 注入。 */
|
||||
private val BIN_NAME_REGEX = Regex("^[a-zA-Z0-9._/-]+$")
|
||||
|
||||
/**
|
||||
* 从 Dock/Spotlight 启动的 IDEA 仅继承精简 PATH,不含 nvm/homebrew/npm 全局 bin。
|
||||
* 解法:启动登录交互式 shell 读取真实环境到缓存;再 command -v 将命令名解析为绝对路径。
|
||||
* Windows 下 GUI 与终端环境一致,直接使用当前进程环境。
|
||||
*/
|
||||
object ShellEnv {
|
||||
private val binCache = ConcurrentHashMap<String, String>()
|
||||
|
||||
@Volatile
|
||||
private var cachedEnv: Map<String, String>? = null
|
||||
|
||||
private val isWindows: Boolean
|
||||
get() = System.getProperty("os.name").orEmpty().startsWith("Windows", ignoreCase = true)
|
||||
|
||||
/** 设了 `OCR_SKIP_SHELL_RESOLVE` 则完全跳过登录 shell 解析(CI / 排障用)。 */
|
||||
private val skipResolve: Boolean
|
||||
get() = !System.getenv("OCR_SKIP_SHELL_RESOLVE").isNullOrBlank()
|
||||
|
||||
/** 从登录 shell 的 `env` 输出里取两个分隔标记之间的 key=value。 */
|
||||
fun parseEnvBlock(stdout: String): Map<String, String> {
|
||||
val start = stdout.indexOf(DELIM)
|
||||
val end = stdout.lastIndexOf(DELIM)
|
||||
if (start < 0 || end <= start) return emptyMap()
|
||||
val block = stdout.substring(start + DELIM.length, end)
|
||||
val env = LinkedHashMap<String, String>()
|
||||
for (line in block.lineSequence()) {
|
||||
val eq = line.indexOf('=')
|
||||
if (eq > 0) env[line.substring(0, eq)] = line.substring(eq + 1)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** 当前进程环境叠加登录 shell 环境;解析失败时退回当前进程环境。 */
|
||||
fun env(): Map<String, String> {
|
||||
cachedEnv?.let { return it }
|
||||
val processEnv: Map<String, String> = System.getenv()
|
||||
if (isWindows || skipResolve) {
|
||||
cachedEnv = processEnv
|
||||
return processEnv
|
||||
}
|
||||
val resolved = runCatching {
|
||||
val cap = capture(listOf(shell(), "-ilc", "echo $DELIM; env; echo $DELIM")) ?: return@runCatching processEnv
|
||||
val parsed = parseEnvBlock(cap)
|
||||
if (parsed.isEmpty()) processEnv else processEnv + parsed
|
||||
}.getOrElse {
|
||||
thisLogger().warn("[ocr] Failed to read login shell env, falling back to process env", it)
|
||||
processEnv
|
||||
}
|
||||
cachedEnv = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* 将命令名解析为绝对路径。解析不出来则返回原名,交由 [ProcessBuilder] 在注入的 PATH 中查找。
|
||||
* Windows 直接返回原名。
|
||||
*/
|
||||
fun resolveBin(name: String): String {
|
||||
if (isWindows || skipResolve) return name
|
||||
binCache[name]?.let { return it }
|
||||
// 命令名要进入 shell 命令行,先做字符白名单校验,避免路径拼接变成命令注入。
|
||||
if (!name.matches(BIN_NAME_REGEX)) return name
|
||||
val resolved = runCatching {
|
||||
val quoted = name.replace("'", "'\\''")
|
||||
val cap = capture(listOf(shell(), "-ilc", "command -v '$quoted'")) ?: return@runCatching null
|
||||
cap.lineSequence()
|
||||
.map(String::trim)
|
||||
.lastOrNull { it.startsWith("/") }
|
||||
}.getOrNull()
|
||||
val result = resolved ?: name
|
||||
// 只缓存成功解析的结果;超时/未找到不缓存,下次重试(否则一次超时会永久缓存失败、用 PATH 也找不到)。
|
||||
if (resolved != null) binCache[name] = result
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 为命令添加 shell 前缀(Windows: cmd.exe /c)。仅用于参数固定的命令(--version 等),
|
||||
* 含用户输入的命令不得套 shell。
|
||||
*/
|
||||
fun forShell(command: List<String>): List<String> =
|
||||
if (isWindows) listOf("cmd.exe", "/c") + command else command
|
||||
|
||||
/** CLI 安装完毕或用户修改 shell 配置后调用,下次重新探测。 */
|
||||
fun invalidate() {
|
||||
cachedEnv = null
|
||||
binCache.clear()
|
||||
}
|
||||
|
||||
private fun shell(): String =
|
||||
System.getenv("SHELL")?.takeIf(String::isNotBlank)
|
||||
?: if (System.getProperty("os.name").startsWith("Mac")) "/bin/zsh" else "/bin/bash"
|
||||
|
||||
/** 关闭进程三路流,吞掉异常(与 CliService.closeStreamsQuietly 一致)。 */
|
||||
private fun Process.closeStreamsQuietly() {
|
||||
runCatching { inputStream.close() }
|
||||
runCatching { outputStream.close() }
|
||||
runCatching { errorStream.close() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令收集 stdout,超时强杀。stdin 立即关闭避免交互式 shell 等待输入,
|
||||
* stderr 丢弃避免 rc 文件输出污染结果。
|
||||
*/
|
||||
private fun capture(command: List<String>): String? {
|
||||
val process = ProcessBuilder(command)
|
||||
.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
.start()
|
||||
runCatching { process.outputStream.close() } // stdin 关闭避免交互式 shell 等输入;抛也不影响后续读取。
|
||||
val out = StringBuilder()
|
||||
val reader = Thread({
|
||||
runCatching { process.inputStream.bufferedReader().forEachLine { synchronized(out) { out.appendLine(it) } } }
|
||||
}, "ocr-shell-env").apply { isDaemon = true; start() }
|
||||
try {
|
||||
if (!process.waitFor(SHELL_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
process.destroyForcibly()
|
||||
reader.join(1_000) // 不包 runCatching:抛 InterruptedException 时由外层 catch 恢复中断标志
|
||||
// 超时返回 null,不把残缺 stdout 交给调用方——否则会被 binCache 缓存、长期污染解析结果。
|
||||
return null
|
||||
}
|
||||
reader.join()
|
||||
return synchronized(out) { out.toString() }
|
||||
} catch (_: InterruptedException) {
|
||||
// 被中断时也要收尾已启动的进程,避免脱管泄漏;保留中断状态交给上层。
|
||||
process.destroyForcibly()
|
||||
Thread.currentThread().interrupt()
|
||||
return null
|
||||
} finally {
|
||||
// 三条退出路径(超时/正常/中断)都经过 finally,确保 fd 不泄漏。
|
||||
process.closeStreamsQuietly()
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.toolwindow
|
||||
|
||||
import com.alibaba.opencodereview.idea.jcef.JcefReviewPanel
|
||||
import com.alibaba.opencodereview.idea.jcef.jcefUnsupportedPlaceholder
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.progress.ProcessCanceledException
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.wm.ToolWindow
|
||||
import com.intellij.openapi.wm.ToolWindowFactory
|
||||
import com.intellij.ui.content.ContentFactory
|
||||
|
||||
class OcrToolWindowFactory : ToolWindowFactory, DumbAware {
|
||||
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
|
||||
// JCEF may not be on the classpath in some IDEA versions/runtimes (NoClassDefFoundError).
|
||||
// The try/catch ensures the tool window shows a placeholder instead of crashing.
|
||||
val (panel, disposable) = try {
|
||||
val p = JcefReviewPanel(project)
|
||||
p.component to (p as Disposable)
|
||||
} catch (e: Throwable) {
|
||||
// ProcessCanceledException 是 IntelliJ 取消信号,绝不能吞(否则破坏取消机制);其余(含 NoClassDefFoundError 等 JCEF 缺失)走占位。
|
||||
if (e is ProcessCanceledException) throw e
|
||||
thisLogger().warn("[ocr] JCEF 初始化失败,工具窗走占位", e)
|
||||
jcefUnsupportedPlaceholder() to null
|
||||
}
|
||||
val content = ContentFactory.getInstance().createContent(panel, "", false)
|
||||
disposable?.let { Disposer.register(content, it) }
|
||||
toolWindow.contentManager.addContent(content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<!-- 当 com.intellij.modules.jcef 可用时(IDEA 2026.2+),JCEF 模块的类被加入插件 classloader。
|
||||
本文件留空即可——工具窗工厂仍在主 plugin.xml,在 JCEF 不可用时靠 try-catch 走兜底。 -->
|
||||
<idea-plugin />
|
||||
@@ -0,0 +1,76 @@
|
||||
<!-- Plugin Configuration File. Read more: https://plugins.jetbrains.com/docs/intellij/plugin-configuration-file.html -->
|
||||
<idea-plugin>
|
||||
<!-- Unique identifier of the plugin. It should be FQN. It cannot be changed between the plugin versions. -->
|
||||
<id>com.alibaba.open-code-review-idea</id>
|
||||
|
||||
<!-- Public plugin name should be written in Title Case.
|
||||
Guidelines: https://plugins.jetbrains.com/docs/marketplace/best-practices-for-listing.html#plugin-name -->
|
||||
<name>Open Code Review</name>
|
||||
|
||||
<!-- A displayed Vendor name or Organization ID displayed on the Plugins Page. -->
|
||||
<vendor url="https://github.com/alibaba/open-code-review">Alibaba</vendor>
|
||||
|
||||
<!-- Description of the plugin displayed on the Plugin Page and IDE Plugin Manager.
|
||||
Guidelines: https://plugins.jetbrains.com/docs/marketplace/best-practices-for-listing.html#plugin-description -->
|
||||
<description><![CDATA[
|
||||
AI Code Review — powered by the open-code-review CLI.<br/>
|
||||
Bring AI code review into IntelliJ IDEA: start reviews from the tool window,
|
||||
stream logs live, and act on every comment right in the editor.<br/><br/>
|
||||
|
||||
<b>Features</b>
|
||||
<ul>
|
||||
<li>Three review modes: workspace changes, branch comparison, or a single commit.</li>
|
||||
<li>Files-to-review preview, with one click into the native diff view.</li>
|
||||
<li>Custom review prompt: attach an extra background hint to any review.</li>
|
||||
<li>Streaming logs: watch the CLI output live and cancel anytime.</li>
|
||||
<li>Two-way sync: comment cards in the tool window and inline comment panels in
|
||||
the editor stay in sync — apply, dismiss, or flag as false positive on either side.</li>
|
||||
<li>Dedicated empty / cancelled / failed states; failures are retryable and surface
|
||||
the real CLI error.</li>
|
||||
<li>Built-in configuration panel: edit LLM provider settings, switch models, and
|
||||
test connectivity without leaving the IDE.</li>
|
||||
</ul>
|
||||
|
||||
<b>Prerequisites</b>
|
||||
<ul>
|
||||
<li>Install the ocr CLI: <code>npm i -g @alibaba-group/open-code-review</code></li>
|
||||
<li>Configure an LLM provider (endpoint, API key, model) via the CLI or the plugin's
|
||||
configuration panel.</li>
|
||||
</ul>
|
||||
|
||||
Source code & issue tracker:
|
||||
<a href="https://github.com/alibaba/open-code-review">github.com/alibaba/open-code-review</a>
|
||||
]]></description>
|
||||
|
||||
<!-- Product and plugin compatibility requirements.
|
||||
Read more: https://plugins.jetbrains.com/docs/intellij/plugin-compatibility.html -->
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<depends optional="true" config-file="jcef.xml">com.intellij.modules.jcef</depends>
|
||||
|
||||
<!-- 清单文件里声明式 UI 的文案表,对应上游的 package.nls.json / package.nls.zh-cn.json。
|
||||
运行时代码的文案不走这里,走 model/HostStrings.kt(对应上游 src/shared/i18n.ts)。
|
||||
注意:action 一旦声明了 text= 属性,属性值会盖掉 bundle 里的 key,所以下面的 action
|
||||
刻意不写 text/description——文案全部由 OcrBundle 提供。 -->
|
||||
<resource-bundle>messages.OcrBundle</resource-bundle>
|
||||
|
||||
<!-- Extension points defined by the plugin.
|
||||
Read more: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html -->
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<toolWindow id="Open Code Review"
|
||||
anchor="right"
|
||||
icon="/icons/ocrToolWindow.svg"
|
||||
factoryClass="com.alibaba.opencodereview.idea.toolwindow.OcrToolWindowFactory"/>
|
||||
|
||||
<!-- 宿主侧提示用(例如配置面板还没接入时的降级提示)。
|
||||
displayType=BALLOON 对应 VS Code 的 showInformationMessage 那种一次性气泡。 -->
|
||||
<notificationGroup id="Open Code Review"
|
||||
displayType="BALLOON"/>
|
||||
</extensions>
|
||||
|
||||
<actions>
|
||||
<action id="OpenCodeReview.OpenToolWindow"
|
||||
class="com.alibaba.opencodereview.idea.actions.OpenOcrToolWindowAction">
|
||||
<add-to-group group-id="ToolsMenu" anchor="last"/>
|
||||
</action>
|
||||
</actions>
|
||||
</idea-plugin>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><g transform="scale(0.625)"><path d="M16.011564759375,24.651045500000002C14.199107859375001,26.048609499999998,12.604812959375,27.6949075,11.290276049375,29.5567475C11.202752649375,29.6807115,10.984475776375,29.5737765,11.051337435375,29.4375555C12.365951259375,26.7591675,14.005819559375,24.3629835,15.886684859375,22.313341100000002L15.886684859375,14.0910532C15.886684859375,13.28568906,16.539561259375,12.6328125,17.344925359375,12.6328125L46.644012359375,12.6328125C47.449375359375,12.6328125,48.102253359375,13.28568906,48.102253359375,14.0910536L48.102253359375,24.7461315C43.604339359375,21.2891407,37.941707359375,19.4149718,31.994632359375,19.4149718C26.104784359375,19.4149718,20.491793659375,21.2537947,16.011564759375,24.651045500000002ZM31.994556359375,36.8882465C35.140150359375,36.8882465,37.690157359375,34.3323655,37.690157359375,31.1795235C37.690157359375,30.5937725,37.600223359375,30.0115585,37.423504359375,29.4532315C36.963556359375005,30.4290525,35.982988359375,31.0517195,34.906236359375,31.0517195C33.368648359375,31.0517195,32.122188359375,29.8023895,32.122188359375,28.2612605C32.122188359375,27.1816535,32.743179359375,26.199264499999998,33.717109359375,25.738142500000002C33.160028359375005,25.5609735,32.579012359375,25.4707995,31.994556359375,25.4707995C28.848966359375,25.4707995,26.298958359375,28.026681500000002,26.298958359375,31.1795235C26.298958359375,34.3323655,28.848966359375,36.8882465,31.994556359375,36.8882465ZM47.152950359375,40.7200105C49.430202359375,38.5980795,51.405811359375,36.0023425,52.946987359375,33.029174499999996C53.017212359375,32.893690500000005,52.805626359375,32.7626725,52.716545359375,32.886579499999996C51.366592359375,34.7642615,49.730171359375,36.4189315,47.871692359375,37.8158765C47.873394359375,37.8053205,47.875091359375,37.7947615,47.876773359375,37.7842025C43.466114359375,41.092451499999996,37.971618359375,42.9006615,32.199829359375,42.9434035C32.191536359375,42.9434665,32.183244359374996,42.9435235,32.174951359375,42.9435765C32.114826359375,42.943948500000005,32.054660359375,42.9441415,31.994453359375,42.9441415C26.150606359375,42.9441185,20.577616659375,41.1334405,16.112119659375,37.7839225C17.329820159375,45.4193075,23.367437359375,51.7093845,31.457872359375,54.2405735C31.808345359375,54.3502275,32.180591359375,54.3502275,32.531065359375,54.2405775C39.557903359375004,52.0421445,45.036186359375,47.0081745,47.152950359375,40.7200105Z" fill-rule="evenodd" fill="#000000"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><g transform="scale(0.625)"><path d="M16.011564759375,24.651045500000002C14.199107859375001,26.048609499999998,12.604812959375,27.6949075,11.290276049375,29.5567475C11.202752649375,29.6807115,10.984475776375,29.5737765,11.051337435375,29.4375555C12.365951259375,26.7591675,14.005819559375,24.3629835,15.886684859375,22.313341100000002L15.886684859375,14.0910532C15.886684859375,13.28568906,16.539561259375,12.6328125,17.344925359375,12.6328125L46.644012359375,12.6328125C47.449375359375,12.6328125,48.102253359375,13.28568906,48.102253359375,14.0910536L48.102253359375,24.7461315C43.604339359375,21.2891407,37.941707359375,19.4149718,31.994632359375,19.4149718C26.104784359375,19.4149718,20.491793659375,21.2537947,16.011564759375,24.651045500000002ZM31.994556359375,36.8882465C35.140150359375,36.8882465,37.690157359375,34.3323655,37.690157359375,31.1795235C37.690157359375,30.5937725,37.600223359375,30.0115585,37.423504359375,29.4532315C36.963556359375005,30.4290525,35.982988359375,31.0517195,34.906236359375,31.0517195C33.368648359375,31.0517195,32.122188359375,29.8023895,32.122188359375,28.2612605C32.122188359375,27.1816535,32.743179359375,26.199264499999998,33.717109359375,25.738142500000002C33.160028359375005,25.5609735,32.579012359375,25.4707995,31.994556359375,25.4707995C28.848966359375,25.4707995,26.298958359375,28.026681500000002,26.298958359375,31.1795235C26.298958359375,34.3323655,28.848966359375,36.8882465,31.994556359375,36.8882465ZM47.152950359375,40.7200105C49.430202359375,38.5980795,51.405811359375,36.0023425,52.946987359375,33.029174499999996C53.017212359375,32.893690500000005,52.805626359375,32.7626725,52.716545359375,32.886579499999996C51.366592359375,34.7642615,49.730171359375,36.4189315,47.871692359375,37.8158765C47.873394359375,37.8053205,47.875091359375,37.7947615,47.876773359375,37.7842025C43.466114359375,41.092451499999996,37.971618359375,42.9006615,32.199829359375,42.9434035C32.191536359375,42.9434665,32.183244359374996,42.9435235,32.174951359375,42.9435765C32.114826359375,42.943948500000005,32.054660359375,42.9441415,31.994453359375,42.9441415C26.150606359375,42.9441185,20.577616659375,41.1334405,16.112119659375,37.7839225C17.329820159375,45.4193075,23.367437359375,51.7093845,31.457872359375,54.2405735C31.808345359375,54.3502275,32.180591359375,54.3502275,32.531065359375,54.2405775C39.557903359375004,52.0421445,45.036186359375,47.0081745,47.152950359375,40.7200105Z" fill-rule="evenodd" fill="#C8C8C8"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 64 64"><path d="M16.011564759375,24.651045500000002C14.199107859375001,26.048609499999998,12.604812959375,27.6949075,11.290276049375,29.5567475C11.202752649375,29.6807115,10.984475776375,29.5737765,11.051337435375,29.4375555C12.365951259375,26.7591675,14.005819559375,24.3629835,15.886684859375,22.313341100000002L15.886684859375,14.0910532C15.886684859375,13.28568906,16.539561259375,12.6328125,17.344925359375,12.6328125L46.644012359375,12.6328125C47.449375359375,12.6328125,48.102253359375,13.28568906,48.102253359375,14.0910536L48.102253359375,24.7461315C43.604339359375,21.2891407,37.941707359375,19.4149718,31.994632359375,19.4149718C26.104784359375,19.4149718,20.491793659375,21.2537947,16.011564759375,24.651045500000002ZM31.994556359375,36.8882465C35.140150359375,36.8882465,37.690157359375,34.3323655,37.690157359375,31.1795235C37.690157359375,30.5937725,37.600223359375,30.0115585,37.423504359375,29.4532315C36.963556359375005,30.4290525,35.982988359375,31.0517195,34.906236359375,31.0517195C33.368648359375,31.0517195,32.122188359375,29.8023895,32.122188359375,28.2612605C32.122188359375,27.1816535,32.743179359375,26.199264499999998,33.717109359375,25.738142500000002C33.160028359375005,25.5609735,32.579012359375,25.4707995,31.994556359375,25.4707995C28.848966359375,25.4707995,26.298958359375,28.026681500000002,26.298958359375,31.1795235C26.298958359375,34.3323655,28.848966359375,36.8882465,31.994556359375,36.8882465ZM47.152950359375,40.7200105C49.430202359375,38.5980795,51.405811359375,36.0023425,52.946987359375,33.029174499999996C53.017212359375,32.893690500000005,52.805626359375,32.7626725,52.716545359375,32.886579499999996C51.366592359375,34.7642615,49.730171359375,36.4189315,47.871692359375,37.8158765C47.873394359375,37.8053205,47.875091359375,37.7947615,47.876773359375,37.7842025C43.466114359375,41.092451499999996,37.971618359375,42.9006615,32.199829359375,42.9434035C32.191536359375,42.9434665,32.183244359374996,42.9435235,32.174951359375,42.9435765C32.114826359375,42.943948500000005,32.054660359375,42.9441415,31.994453359375,42.9441415C26.150606359375,42.9441185,20.577616659375,41.1334405,16.112119659375,37.7839225C17.329820159375,45.4193075,23.367437359375,51.7093845,31.457872359375,54.2405735C31.808345359375,54.3502275,32.180591359375,54.3502275,32.531065359375,54.2405775C39.557903359375004,52.0421445,45.036186359375,47.0081745,47.152950359375,40.7200105Z" fill-rule="evenodd" fill="#000000"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 64 64"><path d="M16.011564759375,24.651045500000002C14.199107859375001,26.048609499999998,12.604812959375,27.6949075,11.290276049375,29.5567475C11.202752649375,29.6807115,10.984475776375,29.5737765,11.051337435375,29.4375555C12.365951259375,26.7591675,14.005819559375,24.3629835,15.886684859375,22.313341100000002L15.886684859375,14.0910532C15.886684859375,13.28568906,16.539561259375,12.6328125,17.344925359375,12.6328125L46.644012359375,12.6328125C47.449375359375,12.6328125,48.102253359375,13.28568906,48.102253359375,14.0910536L48.102253359375,24.7461315C43.604339359375,21.2891407,37.941707359375,19.4149718,31.994632359375,19.4149718C26.104784359375,19.4149718,20.491793659375,21.2537947,16.011564759375,24.651045500000002ZM31.994556359375,36.8882465C35.140150359375,36.8882465,37.690157359375,34.3323655,37.690157359375,31.1795235C37.690157359375,30.5937725,37.600223359375,30.0115585,37.423504359375,29.4532315C36.963556359375005,30.4290525,35.982988359375,31.0517195,34.906236359375,31.0517195C33.368648359375,31.0517195,32.122188359375,29.8023895,32.122188359375,28.2612605C32.122188359375,27.1816535,32.743179359375,26.199264499999998,33.717109359375,25.738142500000002C33.160028359375005,25.5609735,32.579012359375,25.4707995,31.994556359375,25.4707995C28.848966359375,25.4707995,26.298958359375,28.026681500000002,26.298958359375,31.1795235C26.298958359375,34.3323655,28.848966359375,36.8882465,31.994556359375,36.8882465ZM47.152950359375,40.7200105C49.430202359375,38.5980795,51.405811359375,36.0023425,52.946987359375,33.029174499999996C53.017212359375,32.893690500000005,52.805626359375,32.7626725,52.716545359375,32.886579499999996C51.366592359375,34.7642615,49.730171359375,36.4189315,47.871692359375,37.8158765C47.873394359375,37.8053205,47.875091359375,37.7947615,47.876773359375,37.7842025C43.466114359375,41.092451499999996,37.971618359375,42.9006615,32.199829359375,42.9434035C32.191536359375,42.9434665,32.183244359374996,42.9435235,32.174951359375,42.9435765C32.114826359375,42.943948500000005,32.054660359375,42.9441415,31.994453359375,42.9441415C26.150606359375,42.9441185,20.577616659375,41.1334405,16.112119659375,37.7839225C17.329820159375,45.4193075,23.367437359375,51.7093845,31.457872359375,54.2405735C31.808345359375,54.3502275,32.180591359375,54.3502275,32.531065359375,54.2405775C39.557903359375004,52.0421445,45.036186359375,47.0081745,47.152950359375,40.7200105Z" fill-rule="evenodd" fill="#C8C8C8"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,11 @@
|
||||
# plugin.xml 里声明式 UI 的文案(action 菜单项、工具窗口标题条)。
|
||||
#
|
||||
# 对应上游 VS Code 扩展的 package.nls.json:那边 package.json 里的 command title、
|
||||
# viewsContainers title 也是走这套「声明式配置 + 独立文案文件」的机制,而不是走 i18n.ts。
|
||||
# 两边的分工是一样的——运行时代码里的文案在 model/HostStrings.kt(对应上游 shared/i18n.ts),
|
||||
# 清单文件里的文案在这里(对应上游 package.nls*.json)。
|
||||
#
|
||||
# 产品名 Open Code Review 不翻译,与上游一致(package.nls.zh-cn.json 里同样保留原文)。
|
||||
action.OpenCodeReview.OpenToolWindow.text=Open Code Review
|
||||
action.OpenCodeReview.OpenToolWindow.description=Open the Open Code Review tool window
|
||||
toolwindow.stripe.Open_Code_Review=Open Code Review
|
||||
@@ -0,0 +1,4 @@
|
||||
# 见 OcrBundle.properties 的说明。key 必须与英文版完全一致(OcrBundleTest 会比对)。
|
||||
action.OpenCodeReview.OpenToolWindow.text=打开 Open Code Review
|
||||
action.OpenCodeReview.OpenToolWindow.description=打开 Open Code Review 工具窗口
|
||||
toolwindow.stripe.Open_Code_Review=Open Code Review
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 定位 `frontend/` 目录,供"宿主与前端是否保持一致"这类用例读取前端源码。
|
||||
*
|
||||
* 这些用例存在的价值:前端同步新增内容(新 provider、新主题变量)后,宿主侧未跟进时,
|
||||
* 界面表现为**静默降级**——不报错、不白屏,仅某块内容或颜色缺失,靠人工观察几乎无法发现。
|
||||
* 此类偏移只能通过构建期校验发现。
|
||||
*/
|
||||
object FrontendSources {
|
||||
|
||||
/** 项目根。Gradle 执行测试时工作目录即为项目根,其他执行方式不一定,因此向上查找一层作为兜底。 */
|
||||
private val projectRoot: File by lazy {
|
||||
var dir: File? = File(System.getProperty("user.dir")).absoluteFile
|
||||
while (dir != null) {
|
||||
if (File(dir, "frontend/src/shared").isDirectory) return@lazy dir
|
||||
dir = dir.parentFile
|
||||
}
|
||||
error("找不到 frontend/ 目录(从 ${System.getProperty("user.dir")} 往上找过了)")
|
||||
}
|
||||
|
||||
val frontendDir: File get() = File(projectRoot, "frontend")
|
||||
|
||||
fun file(relative: String): File = File(frontendDir, relative).also {
|
||||
check(it.isFile) { "前端文件不存在:$it(前端目录结构变了?)" }
|
||||
}
|
||||
|
||||
/** 递归读取某个子目录下的全部源码文本,合并为整体字符串以便正则扫描。 */
|
||||
fun readAllText(relativeDir: String, vararg extensions: String): String {
|
||||
val dir = File(frontendDir, relativeDir)
|
||||
check(dir.isDirectory) { "前端目录不存在:$dir" }
|
||||
return dir.walkTopDown()
|
||||
.filter { it.isFile && extensions.any { ext -> it.name.endsWith(ext) } }
|
||||
.joinToString("\n") { it.readText() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea
|
||||
|
||||
import java.util.Properties
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* `plugin.xml` 声明的文案 key 必须在两份 `OcrBundle` 中都存在。
|
||||
*
|
||||
* 此项必须自动校验:`<resource-bundle>` 一旦声明,action 的文案即完全由 properties 提供,
|
||||
* key 拼写错误或漏掉某一门语言时,IDEA 不会报错——Tools 菜单里会直接显示
|
||||
* `action.OpenCodeReview.OpenToolWindow.text` 这种原始 key,或退回英文。
|
||||
* 而此项目目前只能通过 `runIde` 人工观察,待发现时插件已经发布出去。
|
||||
*/
|
||||
class OcrBundleTest {
|
||||
|
||||
private fun load(name: String): Properties {
|
||||
val stream = javaClass.getResourceAsStream("/messages/$name")
|
||||
?: error("资源不在 classpath 上:/messages/$name")
|
||||
return stream.use { Properties().apply { load(it.reader(Charsets.UTF_8)) } }
|
||||
}
|
||||
|
||||
private val en = load("OcrBundle.properties")
|
||||
private val zh = load("OcrBundle_zh_CN.properties")
|
||||
|
||||
private val pluginXml: String by lazy {
|
||||
javaClass.getResourceAsStream("/META-INF/plugin.xml")
|
||||
?.use { it.reader(Charsets.UTF_8).readText() }
|
||||
?: error("读不到 plugin.xml")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `两份 bundle 的 key 集合完全一致`() {
|
||||
assertEquals(emptySet<Any?>(), en.keys - zh.keys, "只有英文有的 key")
|
||||
assertEquals(emptySet<Any?>(), zh.keys - en.keys, "只有中文有的 key")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin_xml 里每个 action 都有对应的文案 key`() {
|
||||
val ids = Regex("""<action\s+id="([^"]+)"""").findAll(pluginXml).map { it.groupValues[1] }.toList()
|
||||
assertTrue(ids.isNotEmpty(), "没从 plugin.xml 里正则出 action id,用例本身需要更新")
|
||||
ids.forEach { id ->
|
||||
assertTrue(en.containsKey("action.$id.text"), "缺 action.$id.text(英文)")
|
||||
assertTrue(zh.containsKey("action.$id.text"), "缺 action.$id.text(中文)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin_xml 里的 action 不能自带 text 属性`() {
|
||||
// 自带 text= 会覆盖 bundle 里的 key,本地化直接失效且不会有任何提示。
|
||||
val actionBlocks = Regex("""<action\b[^>]*>""", RegexOption.DOT_MATCHES_ALL).findAll(pluginXml)
|
||||
actionBlocks.forEach { block ->
|
||||
assertTrue(!block.value.contains("text="), "action 自带了 text= 属性,会盖掉 OcrBundle:${block.value}")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `每个 toolWindow 都有 stripe 标题 key`() {
|
||||
val ids = Regex("""<toolWindow\s+id="([^"]+)"""").findAll(pluginXml).map { it.groupValues[1] }.toList()
|
||||
assertTrue(ids.isNotEmpty(), "没从 plugin.xml 里正则出 toolWindow id,用例本身需要更新")
|
||||
ids.forEach { id ->
|
||||
// 平台查找规则:id 中的空格替换为下划线。
|
||||
val key = "toolwindow.stripe.${id.replace(' ', '_')}"
|
||||
assertTrue(en.containsKey(key), "缺 $key(英文)")
|
||||
assertTrue(zh.containsKey(key), "缺 $key(中文)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `文案值都不为空`() {
|
||||
listOf("英文" to en, "中文" to zh).forEach { (label, props) ->
|
||||
props.forEach { (key, value) ->
|
||||
assertTrue(value.toString().isNotBlank(), "$label 的 $key 是空值")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.jcef
|
||||
|
||||
import java.awt.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 颜色到 CSS 的转换。运行在无 Application 的普通 JUnit 中,因此仅测 [IdeaTheme.css],
|
||||
* 不涉及 `cssVariables()`(后者需要读取 `UIManager`)。
|
||||
*
|
||||
* 此用例针对一个真实缺陷添加:`css` 的早期实现仅输出 `#rrggbb`,丢弃了 alpha 分量。
|
||||
* IntelliJ 主题中 hover 等颜色大量为半透明叠加层——expUI Dark 的
|
||||
* `ActionButton.hoverBackground` 即为 `#FFFFFF16`(白色 8.6%)——被压缩为实心色后变为纯白,
|
||||
* 而该变量被用作 `.comment-card` 的底色,暗色主题下会渲染为白底配浅灰字。
|
||||
* 此情况下不抛异常、不白屏,只能依靠测试拦截。
|
||||
*/
|
||||
class IdeaThemeTest {
|
||||
|
||||
@Test
|
||||
fun `不透明色输出六位十六进制`() {
|
||||
assertEquals("#2b2d30", IdeaTheme.css(Color(0x2B, 0x2D, 0x30)))
|
||||
assertEquals("#ffffff", IdeaTheme.css(Color(0xFF, 0xFF, 0xFF)))
|
||||
assertEquals("#000000", IdeaTheme.css(Color(0, 0, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `半透明色输出 rgba 且保留透明度`() {
|
||||
// expUI Dark 的 ActionButton.hoverBackground 原值。
|
||||
val hover = Color(0xFF, 0xFF, 0xFF, 0x16)
|
||||
assertEquals("rgba(255, 255, 255, 0.086)", IdeaTheme.css(hover))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `半透明色绝不能塌成实心`() {
|
||||
// 此即该缺陷的表现形式:白色 8.6% 被压缩为 #ffffff,暗色主题下卡片变为纯白。
|
||||
val hover = Color(0xFF, 0xFF, 0xFF, 0x16)
|
||||
val css = IdeaTheme.css(hover)
|
||||
assertTrue("半透明色被压成了实心:$css", css.startsWith("rgba("))
|
||||
assertEquals("#ffffff", IdeaTheme.css(Color(0xFF, 0xFF, 0xFF, 0xFF)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `全透明与几乎不透明都不越界`() {
|
||||
assertEquals("rgba(18, 52, 86, 0.000)", IdeaTheme.css(Color(0x12, 0x34, 0x56, 0)))
|
||||
// 254 不等于 255,仍应走 rgba 分支,不可因四舍五入得到 1.000 以外的值。
|
||||
assertEquals("rgba(18, 52, 86, 0.996)", IdeaTheme.css(Color(0x12, 0x34, 0x56, 254)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `每个变量名都能出现在合法的 CSS 声明里`() {
|
||||
// 变量名若包含空格或分号,注入的 :root 会被浏览器整块丢弃——同样属于静默失败。
|
||||
IdeaTheme.VARIABLE_NAMES.forEach { name ->
|
||||
assertTrue("变量名不合法:$name", Regex("""^--[a-zA-Z0-9-]+$""").matches(name))
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.jcef
|
||||
|
||||
import com.alibaba.opencodereview.idea.FrontendSources
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* HTML 装配与主题变量的一致性检查。
|
||||
*
|
||||
* 上述两项出现问题时表现均为"界面仍可见,但不正确":
|
||||
* 变量缺值会导致某块颜色变为继承的默认值(深色主题下常表现为黑底黑字);
|
||||
* 脚本未转义会导致页面上出现大段源码文本。这两种情况都不会抛异常,只能依靠测试拦截。
|
||||
*/
|
||||
class WebviewHtmlTest {
|
||||
|
||||
private val varRegex = Regex("""--vscode-[A-Za-z0-9-]+""")
|
||||
|
||||
@Test
|
||||
fun `前端用到的每个 vscode 变量宿主都给了值`() {
|
||||
val css = FrontendSources.readAllText("src/webview", ".css", ".tsx", ".ts")
|
||||
val used = varRegex.findAll(css).map { it.value }.toSortedSet()
|
||||
|
||||
assertTrue(
|
||||
"没能从前端源码里正则出任何 --vscode-* 变量,用例本身需要更新",
|
||||
used.size >= 20,
|
||||
)
|
||||
val missing = used - IdeaTheme.VARIABLE_NAMES
|
||||
assertTrue(
|
||||
"前端用到了 IdeaTheme 没提供的变量:$missing\n" +
|
||||
"去 IdeaTheme.SPEC 里补上对应的 IDEA 取色,否则这些地方在 IDEA 里会没有颜色。",
|
||||
missing.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IdeaTheme 没有前端不用的多余变量`() {
|
||||
// 多余变量不会导致错误,但会使人误以为某个颜色已生效而实际并未生效。
|
||||
val css = FrontendSources.readAllText("src/webview", ".css", ".tsx", ".ts")
|
||||
val used = varRegex.findAll(css).map { it.value }.toSet()
|
||||
val extra = IdeaTheme.VARIABLE_NAMES - used
|
||||
assertTrue("IdeaTheme 里有前端不再使用的变量,可以删掉:$extra", extra.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `内联脚本里的 script 结束标记被转义`() {
|
||||
// 未转义时 HTML 解析器会在此处截断脚本,后半段 bundle 内容会变为页面正文。
|
||||
val js = """var s = "</script>"; var t = '</SCRIPT >';"""
|
||||
val escaped = WebviewHtml.escapeForInlineScript(js)
|
||||
assertFalse(escaped.contains("</script", ignoreCase = true))
|
||||
assertTrue(escaped.contains("<\\/script"))
|
||||
// 转义仅处理该序列,其余字符原样保留。
|
||||
assertEquals(js.length + 2, escaped.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `装配出的页面结构完整`() {
|
||||
val html = WebviewHtml.buildPage(
|
||||
lang = "zh-CN",
|
||||
themeCss = ":root { --vscode-foreground: #bbbbbb; }",
|
||||
bridgeScript = "window.cefQuery({request: json});",
|
||||
bundleJs = "console.log('bundle');",
|
||||
)
|
||||
assertTrue(html.startsWith("<!DOCTYPE html>"))
|
||||
assertTrue(html.contains("<html lang=\"zh-CN\">"))
|
||||
// 前端渲染所需的挂载点,缺失则整个页面为空。
|
||||
assertTrue(html.contains("<div id=\"root\"></div>"))
|
||||
assertTrue(html.contains("--vscode-foreground"))
|
||||
assertTrue(html.contains("window.__ocrPost = function (json) {"))
|
||||
assertTrue(html.contains("window.cefQuery({request: json});"))
|
||||
assertTrue(html.contains("console.log('bundle');"))
|
||||
// 桥脚本必须位于 bundle 之前:bundle 一旦执行即可能 post 'ready'。
|
||||
assertTrue(html.indexOf("__ocrPost") < html.indexOf("console.log('bundle')"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `主题变量单独占一个带 id 的 style`() {
|
||||
// OcrWebview.applyTheme 切换主题时按 id 定位此 style 并整体替换 textContent。
|
||||
// 若布局规则也挤在同一个 style 中,每次切换主题后 html/body 的高度设置即会丢失,
|
||||
// 表现为整个面板高度塌缩为 0——因此此处的"拆分为两个 style"是必须固化的不变量。
|
||||
val html = WebviewHtml.buildPage(
|
||||
lang = "en",
|
||||
themeCss = ":root { --vscode-foreground: #bbbbbb; }",
|
||||
bridgeScript = "",
|
||||
bundleJs = "",
|
||||
)
|
||||
val open = "<style id=\"${WebviewHtml.THEME_STYLE_ID}\">"
|
||||
assertTrue("主题 style 没有带 id", html.contains(open))
|
||||
|
||||
val themeBlock = html.substringAfter(open).substringBefore("</style>")
|
||||
assertTrue("主题变量不在带 id 的 style 里", themeBlock.contains("--vscode-foreground"))
|
||||
assertFalse("布局规则挤进了主题 style,换主题会把它冲掉", themeBlock.contains("#root"))
|
||||
assertFalse(themeBlock.contains("html, body"))
|
||||
// 布局规则需放在后续另一个 style 中,避免随主题切换一同丢失。
|
||||
assertTrue(html.contains("#root { height: 100%; }"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bundle 缺失页跟着 locale 走`() {
|
||||
val zh = WebviewHtml.missingBundle("/webview/webview.js", SupportedLocale.ZH_CN)
|
||||
assertTrue(zh.contains("<html lang=\"zh-CN\">"))
|
||||
assertTrue(zh.contains("前端资源缺失"))
|
||||
assertTrue(zh.contains("<code>/webview/webview.js</code>"))
|
||||
|
||||
val en = WebviewHtml.missingBundle("/webview/webview.js", SupportedLocale.EN)
|
||||
assertTrue(en.contains("<html lang=\"en\">"))
|
||||
assertTrue(en.contains("Frontend assets missing"))
|
||||
// 英文 IDE 中不应出现中文——此页面用于协助无法阅读中文的用户排查问题。
|
||||
assertTrue("英文页里还有中文字符", en.none { it.code in 0x4E00..0x9FFF })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `变量清单不需要 UI 环境就能读`() {
|
||||
// VARIABLE_NAMES 仅取 SPEC 的键,不调用取色 lambda——
|
||||
// 此用例运行在无 Application 的普通 JUnit 中,若实际调用会抛出异常。
|
||||
assertTrue(IdeaTheme.VARIABLE_NAMES.contains("--vscode-foreground"))
|
||||
assertTrue(IdeaTheme.VARIABLE_NAMES.all { it.startsWith("--vscode-") })
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.messages
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.CliResult
|
||||
import com.alibaba.opencodereview.idea.model.CommentStatus
|
||||
import com.alibaba.opencodereview.idea.model.CommentSyncState
|
||||
import com.alibaba.opencodereview.idea.model.EnvCheckResult
|
||||
import com.alibaba.opencodereview.idea.model.EnvToolStatus
|
||||
import com.alibaba.opencodereview.idea.model.FileChange
|
||||
import com.alibaba.opencodereview.idea.model.FileStatus
|
||||
import com.alibaba.opencodereview.idea.model.GitState
|
||||
import com.alibaba.opencodereview.idea.model.LogLevel
|
||||
import com.alibaba.opencodereview.idea.model.LogLine
|
||||
import com.alibaba.opencodereview.idea.model.OcrConfig
|
||||
import com.alibaba.opencodereview.idea.model.ReviewComment
|
||||
import com.alibaba.opencodereview.idea.model.ReviewMode
|
||||
import com.alibaba.opencodereview.idea.model.ReviewState
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 出站消息的 JSON 形状。
|
||||
*
|
||||
* 此组断言看似琐碎,但它是唯一能拦截"前端静默空白"问题的手段:
|
||||
* 字段名拼写错误、`type` 字面量不匹配、枚举序列化为 Kotlin 的大写常量,
|
||||
* 编译器均不会报告,运行时也不抛异常,仅表现为前端无法渲染内容。
|
||||
*/
|
||||
class HostMessagesTest {
|
||||
|
||||
private fun parse(json: String) = Json.parseToJsonElement(json).jsonObject
|
||||
|
||||
@Test
|
||||
fun `init 必须带齐 config gitState locale`() {
|
||||
val json = parse(
|
||||
HostToWebview.Init(
|
||||
config = OcrConfig(provider = "kimi", model = "k2"),
|
||||
gitState = GitState(currentBranch = "main"),
|
||||
locale = SupportedLocale.ZH_CN,
|
||||
).toJson(),
|
||||
)
|
||||
assertEquals("init", json["type"].toString().trim('"'))
|
||||
assertTrue(json.containsKey("config"))
|
||||
assertTrue(json.containsKey("gitState"))
|
||||
// config 缺失时前端配置就绪判定恒为 false,界面将停留在配置视图。
|
||||
assertEquals("kimi", json["config"]!!.jsonObject["provider"].toString().trim('"'))
|
||||
assertEquals("main", json["gitState"]!!.jsonObject["currentBranch"].toString().trim('"'))
|
||||
assertEquals("zh-cn", json["locale"].toString().trim('"'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locale 序列化成前端认的字面量`() {
|
||||
val en = parse(
|
||||
HostToWebview.Init(null, GitState(), SupportedLocale.EN).toJson(),
|
||||
)
|
||||
assertEquals("en", en["locale"].toString().trim('"'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `config 为 null 时显式发 null 而非省略`() {
|
||||
val json = parse(HostToWebview.Config(null).toJson())
|
||||
assertEquals("config", json["type"].toString().trim('"'))
|
||||
// explicitNulls = true:null 字段显式发 "config": null,与前端 OcrConfig | null 契约一致。
|
||||
assertTrue(json.containsKey("config"))
|
||||
assertTrue(json["config"] is JsonNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stateChange 无错误时 error 字段为 null`() {
|
||||
val ok = parse(HostToWebview.StateChange(ReviewState.RUNNING).toJson())
|
||||
assertEquals("stateChange", ok["type"].toString().trim('"'))
|
||||
assertEquals("running", ok["state"].toString().trim('"'))
|
||||
// explicitNulls = true:null 字段显式发 "error": null,与前端 String | null 契约一致。
|
||||
assertTrue(ok.containsKey("error"))
|
||||
assertTrue(ok["error"] is JsonNull)
|
||||
|
||||
val failed = parse(HostToWebview.StateChange(ReviewState.FAILED, "炸了").toJson())
|
||||
assertEquals("failed", failed["state"].toString().trim('"'))
|
||||
assertEquals("炸了", failed["error"].toString().trim('"'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modeFiles 的模式与状态都是小写字面量`() {
|
||||
val json = parse(
|
||||
HostToWebview.ModeFiles(
|
||||
ReviewMode.BRANCH,
|
||||
listOf(FileChange("src/a.kt", FileStatus.RENAMED)),
|
||||
).toJson(),
|
||||
)
|
||||
assertEquals("modeFiles", json["type"].toString().trim('"'))
|
||||
assertEquals("branch", json["mode"].toString().trim('"'))
|
||||
val first = json["files"].toString()
|
||||
assertTrue(first.contains("\"renamed\""))
|
||||
assertTrue(first.contains("\"src/a.kt\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `logLine 包一层 line 对象`() {
|
||||
val json = parse(HostToWebview.Log(LogLine("hello", LogLevel.ERROR)).toJson())
|
||||
assertEquals("logLine", json["type"].toString().trim('"'))
|
||||
assertEquals("hello", json["line"]!!.jsonObject["text"].toString().trim('"'))
|
||||
assertEquals("error", json["line"]!!.jsonObject["level"].toString().trim('"'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reviewDone 把结果包在 result 里且评论字段是 camelCase`() {
|
||||
val json = parse(
|
||||
HostToWebview.ReviewDone(
|
||||
CliResult(
|
||||
status = "success",
|
||||
comments = listOf(
|
||||
ReviewComment(
|
||||
path = "a.kt",
|
||||
content = "改这里",
|
||||
suggestionCode = "val x = 1",
|
||||
existingCode = "var x = 1",
|
||||
startLine = 10,
|
||||
endLine = 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
).toJson(),
|
||||
)
|
||||
assertEquals("reviewDone", json["type"].toString().trim('"'))
|
||||
val result = json["result"]!!.jsonObject.toString()
|
||||
// CLI 输出侧为 suggestion_code / existing_code / start_line,这些名称不得透传到前端。
|
||||
assertTrue(result.contains("\"suggestionCode\""))
|
||||
assertTrue(result.contains("\"existingCode\""))
|
||||
assertTrue(result.contains("\"startLine\""))
|
||||
assertFalse(result.contains("suggestion_code"))
|
||||
assertFalse(result.contains("start_line"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commentSync 的状态字面量是 falsePositive`() {
|
||||
val json = parse(
|
||||
HostToWebview.CommentSync(
|
||||
listOf(
|
||||
CommentSyncState(0, CommentStatus.FALSE_POSITIVE, jumpable = false),
|
||||
CommentSyncState(1),
|
||||
),
|
||||
).toJson(),
|
||||
)
|
||||
assertEquals("commentSync", json["type"].toString().trim('"'))
|
||||
val body = json["comments"].toString()
|
||||
assertTrue(body.contains("\"falsePositive\""))
|
||||
assertFalse(body.contains("false_positive"))
|
||||
assertTrue(body.contains("\"pending\""))
|
||||
assertTrue(body.contains("\"jumpable\":false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gitState 消息的 type 是 gitState 而不是 gitStateChanged`() {
|
||||
// Kotlin 侧类名为 GitStateChanged,但消息契约中的字面量为 gitState。
|
||||
val json = parse(HostToWebview.GitStateChanged(GitState()).toJson())
|
||||
assertEquals("gitState", json["type"].toString().trim('"'))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 配置面板
|
||||
|
||||
@Test
|
||||
fun `configPanelInit 带 focus env skipEnvCheck`() {
|
||||
val focus = buildJsonObject { put("step", 2) }
|
||||
val json = parse(
|
||||
ConfigPanelHostToWebview.Init(
|
||||
config = OcrConfig(),
|
||||
focus = focus,
|
||||
env = EnvCheckResult(node = EnvToolStatus(ok = true, version = "v20.0.0")),
|
||||
skipEnvCheck = true,
|
||||
locale = SupportedLocale.EN,
|
||||
).toJson(),
|
||||
)
|
||||
assertEquals("configPanelInit", json["type"].toString().trim('"'))
|
||||
assertEquals(2, json["focus"]!!.jsonObject["step"].toString().toInt())
|
||||
assertTrue(json["env"].toString().contains("\"v20.0.0\""))
|
||||
assertEquals("true", json["skipEnvCheck"].toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `configPanel 的其余出站消息形状`() {
|
||||
assertEquals(
|
||||
"configPanelFocus",
|
||||
parse(ConfigPanelHostToWebview.Focus(null).toJson())["type"].toString().trim('"'),
|
||||
)
|
||||
val conn = parse(ConfigPanelHostToWebview.ConnectionResult(false, "401").toJson())
|
||||
assertEquals("connectionResult", conn["type"].toString().trim('"'))
|
||||
assertEquals("false", conn["ok"].toString())
|
||||
assertEquals("401", conn["message"].toString().trim('"'))
|
||||
|
||||
// 成功时 message 为 null,显式发 "message": null(与前端 String | null 契约一致)。
|
||||
val okConn = parse(ConfigPanelHostToWebview.ConnectionResult(true).toJson())
|
||||
assertTrue(okConn.containsKey("message"))
|
||||
assertTrue(okConn["message"] is JsonNull)
|
||||
|
||||
assertEquals(
|
||||
"environmentResult",
|
||||
parse(ConfigPanelHostToWebview.EnvironmentResult(EnvCheckResult()).toJson())["type"]
|
||||
.toString().trim('"'),
|
||||
)
|
||||
assertEquals(
|
||||
"cliStatus",
|
||||
parse(ConfigPanelHostToWebview.CliStatus(true).toJson())["type"].toString().trim('"'),
|
||||
)
|
||||
assertEquals(
|
||||
"panelError",
|
||||
parse(ConfigPanelHostToWebview.PanelError("x").toJson())["type"].toString().trim('"'),
|
||||
)
|
||||
assertEquals(
|
||||
"installLog",
|
||||
parse(ConfigPanelHostToWebview.InstallLog(LogLine("npm...")).toJson())["type"]
|
||||
.toString().trim('"'),
|
||||
)
|
||||
assertEquals(
|
||||
"installDone",
|
||||
parse(ConfigPanelHostToWebview.InstallDone(true).toJson())["type"].toString().trim('"'),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copyDone 是只有 type 的空消息`() {
|
||||
val json = parse(ConfigPanelHostToWebview.CopyDone.toJson())
|
||||
assertEquals("copyDone", json["type"].toString().trim('"'))
|
||||
assertEquals(1, json.size)
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.messages
|
||||
|
||||
import com.alibaba.opencodereview.idea.FrontendSources
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 前端 ↔ 宿主 消息判别符(`type` 字面量)集合的一致性检查。
|
||||
*
|
||||
* 前端 `frontend/src/shared/messages.ts` 是消息契约的 single source of truth;宿主侧用 Kotlin 重新
|
||||
* 实现了同一份契约——入站 `WebviewToHost` 的 `when` 分支键、出站 `HostToWebview` /
|
||||
* `ConfigPanelHostToWebview` 各 variant 的 `@SerialName`。两端判别符集合必须一致:
|
||||
*
|
||||
* 前端新增一个消息类型而宿主 `when` 未跟进时,该消息落到 `WebviewToHost.Unknown` 被路由层静默忽略,
|
||||
* 不报错;宿主侧 `@SerialName` 发了前端不认识的类型时,前端 `onMessage` 无对应分支,UI 静默不更新。
|
||||
* 两种都是不报错的"静默降级",靠人工观察几乎无法发现,只能靠构建期校验暴露。
|
||||
*
|
||||
* 与 [com.alibaba.opencodereview.idea.services.ProvidersTest] 同构:读前端源码做正则,与宿主侧硬编码
|
||||
* 集合做差集断言。为**不改动经过人工回归的生产代码**,宿主侧集合在此处硬编码,不在生产类上加任何
|
||||
* 函数;这一点与 `ProvidersTest` 的 `presetProviderNames()` 一致。改任一对应实现时必须同步改这里,
|
||||
* 否则测试以差集形式报错。
|
||||
*/
|
||||
class MessageContractTest {
|
||||
|
||||
/** 匹配 union 成员里的 `type: 'xxx'` / `type: "xxx"` 判别符。 */
|
||||
private val typeLiteralRegex = Regex("""\btype:\s*['"]([^'"]+)['"]""")
|
||||
|
||||
// -------------------------------------------------------------- 宿主侧硬编码集合
|
||||
//
|
||||
// 三组分别镜像:
|
||||
// · inbound —— WebviewMessages.parseWebviewMessage 的 `when` 分支键
|
||||
// (Unknown / Malformed 是兜底,不算真实判别符)。
|
||||
// · outboundSidebar —— HostToWebview 各 variant 的 @SerialName。
|
||||
// · outboundConfigPanel —— ConfigPanelHostToWebview 各 variant 的 @SerialName。
|
||||
|
||||
private val inboundTypes: Set<String> = sortedSetOf(
|
||||
"ready", "readyConfigPanel", "closeConfigPanel", "cancelReview", "getConfig",
|
||||
"checkCli", "checkEnvironment", "installCli", "openConfigPanel", "getGitState",
|
||||
"getModeFiles", "openFileDiff", "startReview", "setConfig", "setConfigBatch",
|
||||
"testConnection", "deleteCustomProvider", "activateCustomProvider",
|
||||
"copyToClipboard", "jumpToComment", "commentAction",
|
||||
)
|
||||
|
||||
private val outboundSidebarTypes: Set<String> = sortedSetOf(
|
||||
"init", "gitState", "modeFiles", "logLine", "stateChange", "reviewDone",
|
||||
"config", "commentSync",
|
||||
)
|
||||
|
||||
private val outboundConfigPanelTypes: Set<String> = sortedSetOf(
|
||||
"configPanelInit", "configPanelFocus", "config", "connectionResult",
|
||||
"cliStatus", "environmentResult", "copyDone", "panelError", "installLog",
|
||||
"installDone",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `入站消息判别符与前端 WebviewToHost 完全一致`() {
|
||||
val fromFrontend = typeLiteralsOf("WebviewToHost")
|
||||
assertEquals(
|
||||
"入站消息契约不一致。前端多出 ${fromFrontend - inboundTypes},宿主多出 ${inboundTypes - fromFrontend}",
|
||||
fromFrontend, inboundTypes,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `侧栏出站消息判别符与前端 HostToWebview 完全一致`() {
|
||||
val fromFrontend = typeLiteralsOf("HostToWebview")
|
||||
assertEquals(
|
||||
"侧栏出站消息契约不一致。前端多出 ${fromFrontend - outboundSidebarTypes},宿主多出 ${outboundSidebarTypes - fromFrontend}",
|
||||
fromFrontend, outboundSidebarTypes,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `配置面板出站消息判别符与前端 ConfigPanelHostToWebview 完全一致`() {
|
||||
val fromFrontend = typeLiteralsOf("ConfigPanelHostToWebview")
|
||||
assertEquals(
|
||||
"配置面板出站消息契约不一致。前端多出 ${fromFrontend - outboundConfigPanelTypes},宿主多出 ${outboundConfigPanelTypes - fromFrontend}",
|
||||
fromFrontend, outboundConfigPanelTypes,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 [unionName] 在 `messages.ts` 里的成员判别符集合。
|
||||
*
|
||||
* 按 `export type <Name> =` 切段后仅在该段内正则,避免三个 union 的判别符互相串入。
|
||||
* 自检 `isNotEmpty` 防止上游改写法(例如不再用 `type: 'xxx'` 字面量)导致正则空转却默默通过。
|
||||
*/
|
||||
private fun typeLiteralsOf(unionName: String): Set<String> {
|
||||
val ts = FrontendSources.file("src/shared/messages.ts").readText()
|
||||
val header = "export type $unionName ="
|
||||
val start = ts.indexOf(header)
|
||||
.also { check(it >= 0) { "messages.ts 里找不到 $header(前端目录结构变了?)" } }
|
||||
val end = ts.indexOf("export type ", start + header.length).let { if (it < 0) ts.length else it }
|
||||
val body = ts.substring(start, end)
|
||||
val found = typeLiteralRegex.findAll(body).map { it.groupValues[1] }.toSortedSet()
|
||||
assertTrue(
|
||||
"没能从 $unionName 里 regex 出任何 type 字面量,说明上游改了写法,本测试需要更新",
|
||||
found.isNotEmpty(),
|
||||
)
|
||||
return found
|
||||
}
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.messages
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.FileStatus
|
||||
import com.alibaba.opencodereview.idea.model.ReviewMode
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 入站消息契约的单测。此层不涉及任何 IDE API,因此 21 个类型可全部直接测试。
|
||||
*
|
||||
* 重点不在于"能否解析",而在于**解析失败时不得抛出异常**——前端版本领先宿主一个版本、
|
||||
* 或某字段临时缺失时,都不应导致整条消息通道中断。
|
||||
*/
|
||||
class WebviewMessagesTest {
|
||||
|
||||
/** locale 仅影响 Malformed 的提示文案,绝大多数用例不关心,固定使用中文即可。 */
|
||||
private fun parse(raw: String): WebviewToHost = parseWebviewMessage(raw, SupportedLocale.ZH_CN)
|
||||
|
||||
// ------------------------------------------------------------ 无参数类型
|
||||
|
||||
@Test
|
||||
fun `无参数的消息类型逐一命中`() {
|
||||
val cases = mapOf(
|
||||
"ready" to WebviewToHost.Ready,
|
||||
"readyConfigPanel" to WebviewToHost.ReadyConfigPanel,
|
||||
"closeConfigPanel" to WebviewToHost.CloseConfigPanel,
|
||||
"cancelReview" to WebviewToHost.CancelReview,
|
||||
"getConfig" to WebviewToHost.GetConfig,
|
||||
"checkCli" to WebviewToHost.CheckCli,
|
||||
"checkEnvironment" to WebviewToHost.CheckEnvironment,
|
||||
"installCli" to WebviewToHost.InstallCli,
|
||||
)
|
||||
cases.forEach { (type, expected) ->
|
||||
assertEquals(type, expected, parse("""{"type":"$type"}"""))
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 侧栏
|
||||
|
||||
@Test
|
||||
fun `getGitState 解析模式`() {
|
||||
assertEquals(
|
||||
WebviewToHost.GetGitState(ReviewMode.BRANCH),
|
||||
parse("""{"type":"getGitState","mode":"branch"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
WebviewToHost.GetGitState(ReviewMode.COMMIT),
|
||||
parse("""{"type":"getGitState","mode":"commit"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `未知模式退回 workspace`() {
|
||||
assertEquals(
|
||||
WebviewToHost.GetGitState(ReviewMode.WORKSPACE),
|
||||
parse("""{"type":"getGitState","mode":"nonsense"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
WebviewToHost.GetGitState(ReviewMode.WORKSPACE),
|
||||
parse("""{"type":"getGitState"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getModeFiles 带分支两端`() {
|
||||
val msg = parse(
|
||||
"""{"type":"getModeFiles","mode":"branch","from":"main","to":"dev"}""",
|
||||
)
|
||||
assertEquals(WebviewToHost.GetModeFiles(ReviewMode.BRANCH, "main", "dev", null), msg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getModeFiles 的空串等于没填`() {
|
||||
// 表单未选择分支时发送的是空串而非省略字段;若按空串传递给 git 会被解析为非法 ref。
|
||||
val msg = parse(
|
||||
"""{"type":"getModeFiles","mode":"branch","from":"","to":" "}""",
|
||||
) as WebviewToHost.GetModeFiles
|
||||
assertNull(msg.from)
|
||||
assertNull(msg.to)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openFileDiff 全字段`() {
|
||||
val msg = parse(
|
||||
"""{"type":"openFileDiff","path":"src/a.kt","status":"deleted","mode":"commit","commit":"abc1234"}""",
|
||||
)
|
||||
assertEquals(
|
||||
WebviewToHost.OpenFileDiff("src/a.kt", FileStatus.DELETED, ReviewMode.COMMIT, null, null, "abc1234"),
|
||||
msg,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openFileDiff 的未知状态退回 modified`() {
|
||||
val msg = parse(
|
||||
"""{"type":"openFileDiff","path":"a.kt","status":"copied","mode":"workspace"}""",
|
||||
) as WebviewToHost.OpenFileDiff
|
||||
assertEquals(FileStatus.MODIFIED, msg.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openFileDiff 缺 path 是 Malformed`() {
|
||||
val msg = parse("""{"type":"openFileDiff","status":"added","mode":"workspace"}""")
|
||||
assertTrue(msg is WebviewToHost.Malformed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startReview 解析审查参数`() {
|
||||
val msg = parse(
|
||||
"""{"type":"startReview","options":{"mode":"branch","from":"main","to":"dev","concurrency":4}}""",
|
||||
) as WebviewToHost.StartReview
|
||||
assertEquals(ReviewMode.BRANCH, msg.options.mode)
|
||||
assertEquals("main", msg.options.from)
|
||||
assertEquals("dev", msg.options.to)
|
||||
assertEquals(4, msg.options.concurrency)
|
||||
assertNull(msg.options.customPrompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startReview 缺 options 是 Malformed`() {
|
||||
assertTrue(parse("""{"type":"startReview"}""") is WebviewToHost.Malformed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startReview 的 options 类型不对是 Malformed 而不是抛异常`() {
|
||||
assertTrue(
|
||||
parse("""{"type":"startReview","options":"workspace"}""") is WebviewToHost.Malformed,
|
||||
)
|
||||
// 字段类型错至反序列化失败时同样必须降级,不得让异常穿出解析层。
|
||||
assertTrue(
|
||||
parse("""{"type":"startReview","options":{"concurrency":"many"}}""")
|
||||
is WebviewToHost.Malformed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jumpToComment 与 commentAction`() {
|
||||
assertEquals(
|
||||
WebviewToHost.JumpToComment(3),
|
||||
parse("""{"type":"jumpToComment","index":3}"""),
|
||||
)
|
||||
assertEquals(
|
||||
WebviewToHost.CommentAction(0, CommentActionKind.APPLY),
|
||||
parse("""{"type":"commentAction","index":0,"action":"apply"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
WebviewToHost.CommentAction(1, CommentActionKind.DISCARD),
|
||||
parse("""{"type":"commentAction","index":1,"action":"discard"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
WebviewToHost.CommentAction(2, CommentActionKind.FALSE_POSITIVE),
|
||||
parse("""{"type":"commentAction","index":2,"action":"falsePositive"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `index 缺失或非数字都是 Malformed`() {
|
||||
assertTrue(parse("""{"type":"jumpToComment"}""") is WebviewToHost.Malformed)
|
||||
assertTrue(parse("""{"type":"jumpToComment","index":"abc"}""") is WebviewToHost.Malformed)
|
||||
assertTrue(parse("""{"type":"jumpToComment","index":null}""") is WebviewToHost.Malformed)
|
||||
assertTrue(
|
||||
parse("""{"type":"commentAction","index":1,"action":"nope"}""")
|
||||
is WebviewToHost.Malformed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `数字字符串形式的 index 照样接受`() {
|
||||
// 与 JavaScript 行为一致:`comments["3"]` 在 JS 中也能取到第 4 个元素,
|
||||
// 此处同样保持宽松处理,不为前端不会发送的形态生成 Malformed。
|
||||
assertEquals(
|
||||
WebviewToHost.JumpToComment(3),
|
||||
parse("""{"type":"jumpToComment","index":"3"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openConfigPanel 原样透传 focus`() {
|
||||
val msg = parse(
|
||||
"""{"type":"openConfigPanel","focus":{"step":2,"tab":"custom"}}""",
|
||||
) as WebviewToHost.OpenConfigPanel
|
||||
val focus = msg.focus as JsonObject
|
||||
assertEquals(JsonPrimitive(2), focus["step"])
|
||||
assertEquals(JsonPrimitive("custom"), focus["tab"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openConfigPanel 可以不带 focus`() {
|
||||
val msg = parse("""{"type":"openConfigPanel"}""") as WebviewToHost.OpenConfigPanel
|
||||
assertNull(msg.focus)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 配置面板
|
||||
|
||||
@Test
|
||||
fun `setConfig 允许空值`() {
|
||||
// 清空某个字段即发送空串,不得当作缺字段处理。
|
||||
assertEquals(
|
||||
WebviewToHost.SetConfig("llm.url", ""),
|
||||
parse("""{"type":"setConfig","key":"llm.url","value":""}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setConfig 缺 key 是 Malformed`() {
|
||||
assertTrue(parse("""{"type":"setConfig","value":"x"}""") is WebviewToHost.Malformed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setConfigBatch 与 testConnection 解析条目`() {
|
||||
val json = """{"type":"%s","entries":[{"key":"provider","value":"kimi"},{"key":"model","value":""}]}"""
|
||||
val batch = parse(json.format("setConfigBatch")) as WebviewToHost.SetConfigBatch
|
||||
assertEquals(2, batch.entries.size)
|
||||
assertEquals("provider", batch.entries[0].key)
|
||||
assertEquals("kimi", batch.entries[0].value)
|
||||
assertEquals("", batch.entries[1].value)
|
||||
|
||||
val test = parse(json.format("testConnection")) as WebviewToHost.TestConnection
|
||||
assertEquals(batch.entries, test.entries)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entries 里的坏条目被丢掉而不是整条消息失败`() {
|
||||
val msg = parse(
|
||||
"""{"type":"setConfigBatch","entries":[{"value":"没有key"},"字符串",{"key":"model","value":"m"}]}""",
|
||||
) as WebviewToHost.SetConfigBatch
|
||||
assertEquals(1, msg.entries.size)
|
||||
assertEquals("model", msg.entries[0].key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entries 缺失时是空列表`() {
|
||||
val msg = parse("""{"type":"setConfigBatch"}""") as WebviewToHost.SetConfigBatch
|
||||
assertTrue(msg.entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `自定义 provider 的删除与激活`() {
|
||||
assertEquals(
|
||||
WebviewToHost.DeleteCustomProvider("my-llm"),
|
||||
parse("""{"type":"deleteCustomProvider","name":"my-llm"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
WebviewToHost.ActivateCustomProvider("my-llm"),
|
||||
parse("""{"type":"activateCustomProvider","name":"my-llm"}"""),
|
||||
)
|
||||
assertTrue(parse("""{"type":"deleteCustomProvider"}""") is WebviewToHost.Malformed)
|
||||
assertTrue(
|
||||
parse("""{"type":"activateCustomProvider","name":" "}""") is WebviewToHost.Malformed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copyToClipboard 允许空文本`() {
|
||||
assertEquals(
|
||||
WebviewToHost.CopyToClipboard(""),
|
||||
parse("""{"type":"copyToClipboard"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
WebviewToHost.CopyToClipboard("sk-xxx"),
|
||||
parse("""{"type":"copyToClipboard","text":"sk-xxx"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 兜底
|
||||
|
||||
@Test
|
||||
fun `不认识的类型是 Unknown 而不是 Malformed`() {
|
||||
// 前端版本领先宿主时会多发类型,此为正常情况,不应向用户报错。
|
||||
assertEquals(
|
||||
WebviewToHost.Unknown("somethingNew"),
|
||||
parse("""{"type":"somethingNew","x":1}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `坏 JSON 与缺 type 都是 Malformed 且不抛异常`() {
|
||||
assertTrue(parse("{不是json") is WebviewToHost.Malformed)
|
||||
assertTrue(parse("") is WebviewToHost.Malformed)
|
||||
assertTrue(parse("[1,2,3]") is WebviewToHost.Malformed)
|
||||
assertTrue(parse("""{"foo":"bar"}""") is WebviewToHost.Malformed)
|
||||
// type 不是字符串时同样只能降级。
|
||||
assertTrue(parse("""{"type":42}""") is WebviewToHost.Malformed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Malformed 的提示文案跟着 locale 走`() {
|
||||
// reason 会原样回传给前端展示,因此必须使用 IDE 界面语言,不得写死中文。
|
||||
val raw = """{"type":"setConfig","value":"x"}"""
|
||||
assertEquals(
|
||||
"setConfig 缺少 key",
|
||||
(parseWebviewMessage(raw, SupportedLocale.ZH_CN) as WebviewToHost.Malformed).reason,
|
||||
)
|
||||
assertEquals(
|
||||
"setConfig is missing required field: key",
|
||||
(parseWebviewMessage(raw, SupportedLocale.EN) as WebviewToHost.Malformed).reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.model
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* 守住 [HostStrings] 两张表的一致性。
|
||||
*
|
||||
* 此组用例的来由:先前词条表中 8 个 key 沿用了外部共享词条的 key 名,值却为重写的中文,
|
||||
* 另有 7 个外部共享中存在的 key 整条遗漏;上述两种问题均不会导致编译失败,只能依靠人工逐条比对发现。
|
||||
* key 集合与占位符可自动比对的,即自动比对。
|
||||
*/
|
||||
class HostStringsTest {
|
||||
|
||||
private val tables = HostStrings.tables()
|
||||
private val en = tables.getValue(SupportedLocale.EN)
|
||||
private val zh = tables.getValue(SupportedLocale.ZH_CN)
|
||||
|
||||
/** `{param}` 形式的占位符集合。 */
|
||||
private fun placeholders(template: String): Set<String> =
|
||||
Regex("\\{([a-zA-Z]+)}").findAll(template).map { it.groupValues[1] }.toSet()
|
||||
|
||||
@Test
|
||||
fun `两种语言的 key 集合完全一致`() {
|
||||
assertEquals(emptySet(), en.keys - zh.keys, "只有英文有的 key(漏了中文翻译)")
|
||||
assertEquals(emptySet(), zh.keys - en.keys, "只有中文有的 key(英文兜底会退成 key 原文)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `同一个 key 的占位符在两种语言里一致`() {
|
||||
val mismatched = en.keys.filter { placeholders(en.getValue(it)) != placeholders(zh.getValue(it)) }
|
||||
assertEquals(emptyList(), mismatched, "占位符对不上——换语言之后会漏参数或留下未替换的 {xxx}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `所有词条都非空且都以 ext 前缀开头`() {
|
||||
en.keys.forEach { key ->
|
||||
assertTrue(key.startsWith("ext."), "宿主侧词条必须用 ext. 前缀,与前端的 view./cmp. 分开:$key")
|
||||
}
|
||||
(en + zh).forEach { (key, value) -> assertTrue(value.isNotBlank(), "$key 的值是空的") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `t 会做 param 替换且不留下未替换的占位符`() {
|
||||
val rendered = HostStrings.t(SupportedLocale.ZH_CN, "ext.message.missingField", "type" to "setConfig", "field" to "key")
|
||||
assertEquals("setConfig 缺少 key", rendered)
|
||||
assertTrue(!rendered.contains('{'), "还有没替换掉的占位符:$rendered")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `中文缺词条时退到英文,两边都没有就返回 key 本身`() {
|
||||
// 当前两张表已齐,因此此处仅能验证"完全不存在的 key"这一档。
|
||||
assertEquals("ext.nope.notAKey", HostStrings.t(SupportedLocale.ZH_CN, "ext.nope.notAKey"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `上游已有的词条用的是上游原值`() {
|
||||
// 抽查最容易被改写的几条——这些值必须逐字等于外部共享词条源中的对应值。
|
||||
assertEquals("模型配置", HostStrings.t(SupportedLocale.ZH_CN, "ext.configPanelTitle"))
|
||||
assertEquals("Model Configuration", HostStrings.t(SupportedLocale.EN, "ext.configPanelTitle"))
|
||||
assertEquals("应用失败:代码位置已失效,请刷新后重试。", HostStrings.t(SupportedLocale.ZH_CN, "ext.comment.applyFailedStale"))
|
||||
assertEquals("仅工作区审查模式支持应用建议。", HostStrings.t(SupportedLocale.ZH_CN, "ext.comment.applyWorkspaceOnly"))
|
||||
assertEquals("✗ 安装失败 (exit 2)", HostStrings.t(SupportedLocale.ZH_CN, "ext.cli.installFail", "code" to "2"))
|
||||
assertEquals("✓ 安装完成", HostStrings.t(SupportedLocale.ZH_CN, "ext.cli.installOk"))
|
||||
assertEquals("确定删除自定义 Provider「Foo」?", HostStrings.t(SupportedLocale.ZH_CN, "ext.deleteProviderConfirm", "name" to "Foo"))
|
||||
assertEquals("无法跳转到 a.ts:未能解析行号。", HostStrings.t(SupportedLocale.ZH_CN, "ext.comment.jumpLineUnresolved", "path" to "a.ts"))
|
||||
assertEquals("⏳ [未处理]", HostStrings.t(SupportedLocale.ZH_CN, "ext.comment.pending"))
|
||||
assertEquals("✅ [已应用]", HostStrings.t(SupportedLocale.ZH_CN, "ext.comment.statusApplied"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `noSuggestion 去掉了 markdown 的下划线`() {
|
||||
// 外部共享中为 MarkdownString,值用一对 `_` 包裹以表示斜体;此处为纯文本 popup,包裹会原样显示。
|
||||
val zhText = HostStrings.t(SupportedLocale.ZH_CN, "ext.comment.noSuggestion")
|
||||
assertEquals("💡 无代码建议,请手动处理", zhText)
|
||||
assertTrue(!zhText.startsWith("_") && !zhText.endsWith("_"))
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.providers
|
||||
|
||||
import com.alibaba.opencodereview.idea.model.SupportedLocale
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/** 镜像 commentAnchor 对应用例的输入与期望输出,保持同等覆盖。 */
|
||||
class CommentAnchorTest {
|
||||
|
||||
private val content = listOf("line1", "for (let i = 0; i <= 30, i++) {", " console.log(i);", "}", "line5")
|
||||
.joinToString("\n")
|
||||
|
||||
@Test
|
||||
fun `normalizeLine 去掉 diff 标记`() {
|
||||
assertEquals("added", normalizeLine("+added"))
|
||||
assertEquals("removed", normalizeLine("-removed"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `splitAndNormalize 跳过空行`() {
|
||||
assertEquals(listOf("a", "b"), splitAndNormalize("a\n\n b "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveLinesInContent 行号在范围内直接用`() {
|
||||
val result = resolveLinesInContent(content, 2, 2, null)
|
||||
assertEquals(ResolvedLines(2, 2, relocated = false), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveLinesInContent 行号越界时退到 existingCode 重定位`() {
|
||||
val code = "for (let i = 0; i <= 30, i++) {"
|
||||
val result = resolveLinesInContent(content, 99, 99, code)
|
||||
assertEquals(ResolvedLines(2, 2, relocated = true), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findLinesByExistingCode 匹配连续非空行`() {
|
||||
val found = findLinesByExistingCode(content, "console.log(i);")
|
||||
assertEquals(LineSpan(3, 3), found)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `行号和 existingCode 都解析不出来时返回 null`() {
|
||||
assertNull(resolveLinesInContent(content, 99, 99, null))
|
||||
assertNull(resolveLinesInContent(content, 0, 0, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existingCode 跨越多行时按整段滑窗匹配`() {
|
||||
val result = resolveLinesInContent(content, 99, 99, "for (let i = 0; i <= 30, i++) {\n console.log(i);")
|
||||
assertEquals(ResolvedLines(2, 3, relocated = true), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CRLF 换行不影响匹配`() {
|
||||
val crlfContent = content.replace("\n", "\r\n")
|
||||
val found = findLinesByExistingCode(crlfContent, "console.log(i);")
|
||||
assertEquals(LineSpan(3, 3), found)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatLocateNote 行号变了时点名原始行号`() {
|
||||
assertEquals(
|
||||
"⚠ 原本第 99 行没能匹配上,改为显示第 2 行。",
|
||||
formatLocateNote(99, 2, SupportedLocale.ZH_CN),
|
||||
)
|
||||
assertEquals(
|
||||
"⚠ Line 99 could not be matched, showing line 2 instead.",
|
||||
formatLocateNote(99, 2, SupportedLocale.EN),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatLocateNote 没有原始行号时用通用说明`() {
|
||||
assertEquals("⚠ 行号是根据代码内容重新定位的。", formatLocateNote(0, 2, SupportedLocale.ZH_CN))
|
||||
assertEquals(
|
||||
"⚠ Line number was relocated based on code content.",
|
||||
formatLocateNote(0, 2, SupportedLocale.EN),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- diff 侧挂载
|
||||
|
||||
private val marks = listOf(
|
||||
DiffMark(0, "a.kt", AnchorSide.RIGHT, 10, 12),
|
||||
DiffMark(1, "a.kt", AnchorSide.LEFT, 20, 20),
|
||||
DiffMark(2, "b.kt", AnchorSide.RIGHT, 30, 35),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `selectDiffMarks 只挑路径和侧别都对上的`() {
|
||||
assertEquals(listOf(marks[0]), selectDiffMarks("a.kt", AnchorSide.RIGHT, marks))
|
||||
assertEquals(listOf(marks[1]), selectDiffMarks("a.kt", AnchorSide.LEFT, marks))
|
||||
assertEquals(listOf(marks[2]), selectDiffMarks("b.kt", AnchorSide.RIGHT, marks))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectDiffMarks 侧别不对就不挂`() {
|
||||
// 一次 diff 会同时交出左右两个文档,不区分侧别会导致同一条评论在两侧各挂一个图标。
|
||||
assertEquals(emptyList(), selectDiffMarks("b.kt", AnchorSide.LEFT, marks))
|
||||
assertEquals(emptyList(), selectDiffMarks("c.kt", AnchorSide.RIGHT, marks))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectDiffMarks 带着完整范围一起交出去`() {
|
||||
// 此用例补上一个真实缺陷:早期 DiffMark 仅有一个 line 字段,多行评论
|
||||
// (实测占 CLI 输出的六成以上)在绘制高亮时仅剩起始行,底色仅标记首行。
|
||||
val picked = selectDiffMarks("b.kt", AnchorSide.RIGHT, marks).single()
|
||||
assertEquals(30, picked.startLine)
|
||||
assertEquals(35, picked.endLine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clampLineRange 把 1-based 闭区间换成 0-based`() {
|
||||
assertEquals(0..0, clampLineRange(1, 1, 5))
|
||||
assertEquals(0..4, clampLineRange(1, 5, 5))
|
||||
assertEquals(4..4, clampLineRange(5, 5, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clampLineRange 多行范围不能塌成单行`() {
|
||||
// 此性质此前未被测试覆盖,因此 diff 模式丢失结束行的问题长期未被发现。
|
||||
assertEquals(9..11, clampLineRange(10, 12, 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clampLineRange 越界不抛也不越界`() {
|
||||
// 新增文件的左侧、删除文件的右侧为空串,行数与挂载时计算的不一致;
|
||||
// 不做夹紧处理时 getLineStartOffset 会抛出 IndexOutOfBounds,diff 将无法打开。
|
||||
assertEquals(4..4, clampLineRange(999, 1000, 5))
|
||||
assertEquals(0..4, clampLineRange(0, 999, 5))
|
||||
assertEquals(0..0, clampLineRange(-3, -1, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clampLineRange 结束行越界时首行仍然保留`() {
|
||||
// 仅结束行超出文档(评论覆盖的代码后半段被删除)时:起始行不应被一并丢弃。
|
||||
assertEquals(2..4, clampLineRange(3, 99, 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clampLineRange 反序输入收敬成单行`() {
|
||||
// CLI 偶尔会输出 end < start。夹紧后必须保证 start <= end,
|
||||
// 否则 addRangeHighlighter 收到反向区间会抛出异常。
|
||||
assertEquals(4..4, clampLineRange(5, 2, 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clampLineRange 空文档不抛异常`() {
|
||||
assertEquals(0..0, clampLineRange(7, 9, 0))
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 alibaba/open-code-review Contributors
|
||||
|
||||
package com.alibaba.opencodereview.idea.services
|
||||
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CliCancellationTest {
|
||||
@Test
|
||||
fun `cancellation before registration is not lost`() {
|
||||
val cancellation = CliCancellation()
|
||||
val terminations = AtomicInteger()
|
||||
cancellation.cancel()
|
||||
assertFailsWith<CancellationException> { cancellation.checkCancelled() }
|
||||
cancellation.attach { terminations.incrementAndGet() }
|
||||
cancellation.cancel()
|
||||
assertEquals(1, terminations.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent cancellation and registration terminate exactly once`() {
|
||||
repeat(50) {
|
||||
val cancellation = CliCancellation()
|
||||
val start = CountDownLatch(1)
|
||||
val finished = CountDownLatch(2)
|
||||
val terminations = AtomicInteger()
|
||||
val errors = java.util.concurrent.ConcurrentLinkedQueue<Throwable>()
|
||||
val register = thread {
|
||||
try {
|
||||
start.await()
|
||||
cancellation.attach { terminations.incrementAndGet() }
|
||||
} catch (error: Throwable) {
|
||||
errors.add(error)
|
||||
} finally {
|
||||
finished.countDown()
|
||||
}
|
||||
}
|
||||
val cancel = thread {
|
||||
try {
|
||||
start.await()
|
||||
cancellation.cancel()
|
||||
cancellation.cancel()
|
||||
} catch (error: Throwable) {
|
||||
errors.add(error)
|
||||
} finally {
|
||||
finished.countDown()
|
||||
}
|
||||
}
|
||||
start.countDown()
|
||||
try {
|
||||
assertTrue(finished.await(5, TimeUnit.SECONDS), "Cancellation deadlocked")
|
||||
assertTrue(errors.isEmpty(), errors.toString())
|
||||
assertEquals(1, terminations.get())
|
||||
} finally {
|
||||
register.interrupt()
|
||||
cancel.interrupt()
|
||||
register.join(1_000)
|
||||
cancel.join(1_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completed invocation releases its termination action`() {
|
||||
val cancellation = CliCancellation()
|
||||
var terminations = 0
|
||||
cancellation.attach { terminations++ }
|
||||
cancellation.detach()
|
||||
cancellation.cancel()
|
||||
assertEquals(0, terminations)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user