1. 烂项目里最缺的不是重构,是「看得见改动」的内联审查
接手一个跑了三年、Kotlin 和 Java 混写、git 历史里全是「fix bug」「临时提交」的项目时,我最先崩溃的不是代码质量,而是改完之后根本不知道自己改了什么。AI 工具(Claude Code、Copilot、终端里的脚本)一晚上能往磁盘上写十几个文件,第二天打开 IntelliJ IDEA,编辑器里干干净净,只有 git status 里一堆 M 标记。你得挨个打开 Diff 窗、挨个点 gutter 上的小箭头、挨个判断「这块要不要留」。
Cursor 用户可能觉得这很荒谬——他们早就习惯了绿底新增、红删标移除、每个改动块旁边两个按钮 [Reject] [Keep],点一下就能决策。但在 IntelliJ IDEA 里,官方只给了你 Diff 窗和 gutter 上的 Rollback,没有块级内联视觉。这就是我想复刻的东西。
这篇文章面向三类人:一是正在做 IntelliJ IDEA 插件开发、想搞懂 VFS 与 LineStatusTracker 架构关系的工程师;二是被 AI 改代码改到怀疑人生、想给自己 IDE 加一层审查 UI 的普通开发者;三是想通过 TaoToken 统一 Key/API 通道接入模型能力、把「AI 改代码 + 内联审查」串成一条流水线的团队。我会从 VFS 事件监听切入,交付可复制的 plugin.xml 配置、LineStatusTracker 监听代码和 Kotlin 验证步骤,最后说明怎么用 TaoToken 把模型调用通道统一起来。
先说结论:不要自己造 diff 引擎。IntelliJ 平台自带的LineStatusTracker(简称 LST)已经帮你算好了「当前文档 vs git HEAD」的每一处新增、删除、修改,还提供了getRanges()和rollbackChanges(range)两个关键 API。你要做的只有三件事:听 LST 说 ranges 变了、画 UI、用户点按钮时调 LST 回滚。我试过自己维护 hunk store 和 baseline,60 多个版本之后依然会在「同一份 zip 包、不同打开顺序」下复现不同结果——那不是 bug,是架构的必然产物。
2. 前置准备:TaoToken 统一 Key 通道与 IDEA 插件工程骨架
在写第一行 Kotlin 之前,先把两件事准备好:模型调用的 Key 通道,以及插件工程骨架。很多人卡在第一步——每个 AI 工具都要单独配 Key、单独填 Base URL,Claude Code 一套、Cline 一套、自己写的脚本又一套,改起来到处找。我的做法是用 TaoToken 做统一入口,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api (这个不加 UTM)。它把 Key 和模型路由收敛到一个地方,插件里只需要读一份配置。
具体操作:登录后进控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 生成一个 Key,记下来。模型 ID 建议先用claude-sonnet-4-5这类通用编码模型,具体可用列表在文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里查。如果你后面要跑长期编码 Agent,可以看 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ;只是临时验证模型通不通,用模型对话 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 更快。
注意:Key 只存在本地环境变量或 IDE 的 Password Safe 里,不要硬编码进 plugin.xml 或提交到 git。插件读取时用
CredentialStore或System.getenv("TAOTOKEN_API_KEY")。
工程骨架这边,用 IntelliJ Platform Gradle Plugin 2.x 起一个最小插件项目。build.gradle.kts关键片段如下,注意sinceBuild我锁在 261(对应 IDEA 2026.1.x),因为 LST 的 API 在不同大版本间有签名变化:
plugins { id("org.jetbrains.intellij.platform") version "2.2.1" kotlin("jvm") version "1.9.24" } intellijPlatform { pluginConfiguration { id = "com.example.cc-review-lst" name = "CC Review LST" version = "0.3.9" ideaVersion { sinceBuild = "261" untilBuild = "263.*" } } } dependencies { intellijPlatform { intellijIdeaCommunity("2026.1") bundledPlugin("Git4Idea") } }plugin.xml里必须声明对 Git4Idea 的依赖,否则LineStatusTracker拿不到 VCS 上下文,getRanges()会一直返回空。最小配置:
<idea-plugin> <id>com.example.cc-review-lst</id> <name>CC Review LST</name> <depends>com.intellij.modules.platform</depends> <depends>Git4Idea</depends> <extensions defaultExtensionNs="com.intellij"> <editorFactoryListener implementation="com.example.review.ReviewEditorFactoryListener"/> <editorNotificationProvider implementation="com.example.review.ReviewHeaderProvider"/> </extensions> <actions> <action id="CCReview.ForceRefresh" class="com.example.review.ForceRefreshAction" text="Force Refresh Review UI" keyboard-shortcut="ctrl alt shift R"/> </actions> </idea-plugin>这里两个扩展点对应架构里的两层:editorFactoryListener负责在编辑器创建/释放时绑定和解绑 LST,editorNotificationProvider负责顶栏。ForceRefreshAction是给 LST 异步延迟兜底的——AI 改完代码 1 到 5 秒内 UI 可能还没出来,按一下强制刷新。
环境变量建议这样设,方便插件和外部脚本共用同一份 Key:
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_MODEL="claude-sonnet-4-5"到这一步,工程能./gradlew buildPlugin出 zip,Key 通道也通了。接下来才是真正的核心:怎么把 LST 接进来。
3. 可复制配置:LineStatusTracker 监听与内联渲染代码
这一节是全文技术密度最高的部分,我会把ReviewService、ReviewDecorator和ReviewEditorFactoryListener三个类的关键代码给全,你复制过去改包名就能跑。
先说架构关系。VFS(Virtual File System)是 IDEA 对磁盘文件的抽象层,任何外部写盘——Claude Code 在终端改文件、Copilot 补全落盘、你自己用 vim 保存——都会触发VFileContentChangeEvent。而LineStatusTracker是挂在具体文档上的,它内部监听 document 和 git 的变化,异步重算 ranges。所以正确的链路是:VFS 事件负责「激活」,LST 负责「算差异」,Decorator 负责「画」。三者职责不能混。
ReviewService是项目级服务,维护每个文件的 ACTIVE/INACTIVE 状态。核心逻辑:
@Service(Service.Level.PROJECT) class ReviewService(private val project: Project) { private val activeFiles = ConcurrentHashMap.newKeySet<VirtualFile>() fun activate(vf: VirtualFile) { if (activeFiles.add(vf)) { ApplicationManager.getApplication().invokeLater { ReviewDecorator.refresh(project, vf) } } } fun deactivate(vf: VirtualFile) { activeFiles.remove(vf) ApplicationManager.getApplication().invokeLater { ReviewDecorator.clear(project, vf) } } fun isActive(vf: VirtualFile) = vf in activeFiles }绑定 LST 的关键在ReviewEditorFactoryListener,它在编辑器创建时拿到FileEditor,再通过LineStatusTrackerManager取 tracker:
class ReviewEditorFactoryListener : EditorFactoryListener { override fun editorCreated(event: EditorFactoryEvent) { val editor = event.editor val project = editor.project ?: return val vf = FileDocumentManager.getInstance().getFile(editor.document) ?: return val tracker = LineStatusTrackerManager.getInstance(project) .getLineStatusTracker(editor.document) ?: return // 监听 ranges 变化,LST 异步算完后回调 tracker.addListener(object : LineStatusTracker.Listener { override fun onRangesChanged() { ApplicationManager.getApplication().invokeLater { ReviewDecorator.refresh(project, vf) } } }, project) } }注意onRangesChanged回调可能在非 EDT 线程触发,所有 UI 操作必须包在invokeLater里。这是踩过的坑之一,不包的话轻则 UI 不刷新,重则直接抛Access is allowed from event dispatch thread only。
ReviewDecorator负责渲染,核心是拿 ranges 画绿底和红删:
object ReviewDecorator { private const val GREEN = 0xE6F4EA private const val RED = 0xFFE8EE fun refresh(project: Project, vf: VirtualFile) { val editor = FileEditorManager.getInstance(project) .getAllEditors(vf).filterIsInstance<FileEditor>().firstOrNull()?.editor ?: return val tracker = LineStatusTrackerManager.getInstance(project) .getLineStatusTracker(editor.document) ?: return if (!tracker.isValid) return val ranges = tracker.getRanges() ?: emptyList() if (ranges.isEmpty()) { ReviewService.getInstance(project).deactivate(vf) return } val markup = editor.markupModel markup.removeAllHighlighters() ranges.forEach { range -> if (range.line2 > range.line1) { val start = editor.document.getLineStartOffset(range.line1) val end = editor.document.getLineEndOffset(range.line2 - 1) markup.addRangeHighlighter( start, end, 0, EditorColorsManager.getInstance().globalScheme .getAttributes(HighlighterColors.TEXT).let { TextAttributes().apply { backgroundColor = Color(GREEN) } }, HighlighterTargetArea.LINES_IN_RANGE ) } } } }回滚逻辑单独抽出来,因为「边回滚边改 ranges」是最大的陷阱。正确做法是每次回滚后重新取最新 ranges,且从底向上回滚:
fun rejectFile(project: Project, editor: Editor) { val tracker = LineStatusTrackerManager.getInstance(project) .getLineStatusTracker(editor.document) ?: return var guard = 0 while (guard++ < 200) { val ranges = tracker.getRanges() ?: break val last = ranges.maxByOrNull { it.line1 } ?: break WriteCommandAction.runWriteCommandAction(project) { tracker.rollbackChanges(last) } } }WriteCommandAction保证支持 Ctrl+Z 撤销,guard防死循环。这套代码实测下来,12 个文件、约 1500 行就能撑起完整功能,对比我早期 72 个文件的版本,复杂度降了一个数量级。
4. 验证请求:Kotlin 侧跑通模型调用与内联审查闭环
配置写完,得验证两件事:一是 LST 监听真的能捕获外部写盘,二是通过 TaoToken 的模型调用能正常返回。先验证 LST。
在 IDEA 里开一个 git 管理的 Kotlin 文件,用终端执行:
echo "// test line" >> src/main/kotlin/Demo.kt如果插件正常,1 到 5 秒内编辑器里应该出现绿底,gutter 上出现块按钮。没出现就按Ctrl+Alt+Shift+R强制刷新。这一步验证的是 VFS → LST → Decorator 链路。
再验证模型调用。写一个最小的 Kotlin 函数,用 OkHttp 打 TaoToken 的 API:
fun callModel(prompt: String): String { val client = OkHttpClient() val json = """ { "model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "$prompt"}], "max_tokens": 1024 } """.trimIndent() val request = Request.Builder() .url("https://taotoken.net/api/v1/messages") .addHeader("Authorization", "Bearer ${System.getenv("TAOTOKEN_API_KEY")}") .addHeader("Content-Type", "application/json") .post(json.toRequestBody("application/json".toMediaType())) .build() client.newCall(request).execute().use { resp -> check(resp.isSuccessful) { "HTTP ${resp.code}: ${resp.body?.string()}" } return resp.body!!.string() } }跑通后返回的 JSON 里content数组第一项的text就是模型输出。如果返回 401,说明 Key 没读到,检查环境变量是否在 IDEA 启动前就设好了——IDEA 从桌面图标启动时不会继承 shell 的 export,得在~/.profile里设或者用launchctl setenv(macOS)。
把这两步串起来,就是完整的闭环:AI 通过 TaoToken 改代码 → 写盘触发 VFS → LST 算 ranges → Decorator 画绿底红删 → 你点 [Reject] 调rollbackChanges回滚,或点 [Keep] 加入 dismissed 集合。整个流程里插件不存任何 diff 状态,LST 是唯一真相。
验证成功的结果长这样:编辑器左侧出现浅绿背景的新增行,删除的行以~~前缀的 inlay 显示在改动块上方,每个块下方一行按钮▲ 3 of 11 ▼ [Reject] [Keep],顶栏显示CC Review: 5 block(s) in this file, 2 other file(s)。全部处理完后 UI 自动消失,文件回到 INACTIVE。
5. 本篇常见错排查:401、local proxy failed 与 reading choices
这一节按真实报错来,每个都给出定位路径和修法。
401 Unauthorized。最常见,两种原因:Key 没读到,或者 Base URL 写错。先确认echo $TAOTOKEN_API_KEY有输出,再确认请求 URL 是https://taotoken.net/api/v1/messages而不是带 UTM 的官网地址。UTM 参数只用于网页跳转,API 端点必须干净。如果用的是 Claude Code 或 Cline 这类工具,检查它们的配置文件里 Base URL 是否填成了https://taotoken.net/api,Key 是否和 API Keys 页面生成的一致。
local proxy failed / connection refused。这个报错通常出现在你本地配了代理但代理没起来,或者工具配置里写了http://127.0.0.1:xxxx但端口不对。排查顺序:先curl -v https://taotoken.net/api/v1/messages看能不能通,通了说明网络没问题,问题在工具配置;不通就检查 DNS 和防火墙。注意不要在插件代码里硬编码任何代理地址,让系统环境变量去管。
reading choices / unexpected response shape。这个报错说明你拿到的响应不是预期的 JSON 结构。两种可能:一是模型 ID 写错了,服务端返回了错误对象而不是正常响应;二是你用的 SDK 期望 OpenAI 格式但端点返回的是 Anthropic 格式(或反过来)。TaoToken 的/api/v1/messages走 Anthropic 格式,content是数组;如果你用 OpenAI SDK,得换成/api/v1/chat/completions并解析choices[0].message.content。先curl一次看原始返回,比对着改解析代码。
OAuth token expired / invalid_grant。如果你用的是 Claude Code 的 OAuth 流程而不是 API Key,token 过期会报这个。解决方式是重新走一遍授权,或者干脆切到 API Key 模式——在 Claude Code 的配置里把认证方式改成 Key,Base URL 指向 TaoToken。这也是我推荐统一用 Key 通道的原因,OAuth 的刷新逻辑在插件里处理起来很烦。
UI 不刷新 / 绿底闪回。不是报错但很常见。原因通常是缓存了 ranges 或者多入口写了 store。检查你的 Decorator 是不是每次都从tracker.getRanges()现查,而不是从自己维护的列表读。另外确认onRangesChanged回调里做了invokeLater,否则 EDT 线程问题会让 UI 静默失败。
块按钮锚点错位。删除行(line1 == line2)的按钮锚点要用range.line1,新增/修改行用range.line2 - 1,两者不能混。写错的话按钮会飘到相邻块上,点 [Reject] 回滚错行。
排查时记住一个原则:先 curl 验证 API 通道,再看插件日志。通道问题占报错的七成,插件本身的问题反而少。
6. 把 Key 通道和内联审查串成日常流水线
到这里,插件能跑、模型能调、报错能查。最后说怎么把它变成日常习惯。
我的工作流是这样的:Claude Code 在终端里改代码,走 TaoToken 的 Key 通道(Base URL 填https://taotoken.net/api,Model ID 填claude-sonnet-4-5),改完的文件在 IDEA 里自动弹出绿底和块按钮,我逐块 [Keep] 或 [Reject]。需要长期跑 Agent 的任务,用 Coding Plan 的额度更划算;只是临时问模型一个问题,直接开模型对话页面就行。所有 Key 都在 API Keys 页面统一管理,换模型只改一个 Model ID,不用满世界找配置。
如果你要 fork 这个插件或者自己写一个类似的,记住三条不变量:diff 状态只问 LST,不引入第二套 store;ranges 不缓存,需要时现查;Keep 是纯 UI 操作,不推进 baseline。违反任何一条,你都会重走我 72 个文件的老路。
接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,API Keys 在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。先把 Key 配好,再把 plugin.xml 里的 Git4Idea 依赖加上,然后从ReviewEditorFactoryListener开始写——不要从ComparisonManager开始,那是我用两个月换来的教训。