给编辑器标签页加上像 Chrome 一样的颜色分组:右键标记、整组移动、快捷键秒分配;以及中间踩过的「内部 API 被封」的坑。
目录
一、起因:几十个标签页的痛
写业务代码的时候,编辑器里经常同时开着十几二十个文件:几个 Controller、几个 Service、几个 XML、几个测试类……标签页挤成一排,想找一个文件全靠一个个扫。
Chrome 的「标签页分组」很好地解决了这个问题:右键标签页 → 起个名字、选个颜色 → 同组标签自动聚在一起,一眼就能区分。但 IntelliJ IDEA 到今天也没有原生的标签页分组。
于是就有了这个插件:label-sticker(标签贴纸),把 Chrome 那套分组体验搬到 IDEA 的编辑器标签页上。
二、它长什么样
核心能力(对齐 Chrome 的分组逻辑):
右键标签页 → 新建分组:弹出对话框输入组名 + 从 8 色色板选颜色,标签页立刻被着色
右键 → 加入分组:动态子菜单列出所有分组,带颜色圆点和「已在组内」的 ✓ 标记
取消分组 / 重命名分组 / 更改分组颜色 / 删除分组
加入即归位:标签页加入某个分组后,自动移动到该组最后一个标签页的右侧
拖动保持整组:拖动组内任一标签页,整组保持连续一起移动
头标签 = 整组把手:拖动组内第一个标签页时,整组作为一个整体移动(组内顺序不变)
重启自动归位:分组数据按项目持久化,重开 IDEA 后自动恢复并重新聚拢
快捷键:
Ctrl+1~Ctrl+9把当前标签页分配到第 N 个分组(按创建顺序);Ctrl+0取消分组
三、技术实现
技术栈:纯 Java 17 + Gradle(org.jetbrains.intellij 插件),目标 IDE 为 IntelliJ IDEA 2024.1(sinceBuild=241,向上兼容)。
3.1 项目结构
src/main/java/com/tabgroups/
├── TabGroupData.java 分组模型(id、名称、颜色 RGB)
├── TabGroupService.java 项目级服务:持久化 + 生命周期钩子
├── TabGroupColorProvider.java 标签页着色扩展点实现
├── TabGroupDragListener.java 全局鼠标监听:拖动后整组重排
├── TabMover.java 标签页重排 / 颜色刷新 / 重启归位
├── actions/ 右键菜单与快捷键动作
└── ui/ 色板圆点图标 + 分组编辑对话框组件的组织遵循 IntelliJ 平台的惯例:数据放 Service、着色放扩展点、交互放 Action、UI 放 Dialog。
3.2 标签页着色:一个扩展点搞定
IntelliJ 提供了 com.intellij.editorTabColorProvider 扩展点,实现 getEditorTabColor(Project, VirtualFile) 即可为标签页返回背景色,平台会自动把它渲染到标签页上:
public final class TabGroupColorProvider implements EditorTabColorProvider {
@Override
public Color getEditorTabColor(Project project, VirtualFile file) {
TabGroupService service = project.getService(TabGroupService.class);
TabGroupData group = service.getGroupOf(file);
return group == null ? null : new Color(group.colorRgb);
}
}数据持久化用 PersistentStateComponent,按项目存到 .idea/tabGroups.xml:
@State(name = "TabGroups", storages = @Storage("tabGroups.xml"))
public final class TabGroupService
implements PersistentStateComponent<TabGroupService.State> {
public static final class State {
public List<TabGroupData> groups = new ArrayList<>();
public Map<String, String> fileToGroupId = new HashMap<>();
}
// createGroup / assignToGroup / removeFromGroup / deleteGroup ...
}小细节:这个 EP 只在标签页创建时被查询一次。所以分组颜色变更后,需要手动遍历已打开的标签页,用
TabInfo#setTabColor(...)刷新,否则要重开标签页才变色。
3.3 右键菜单:挂到 EditorTabPopupMenu
编辑器标签页的右键菜单对应的动作组是 EditorTabPopupMenu(平台常量 IdeActions.GROUP_EDITOR_TAB_POPUP = "EditorTabPopupMenu")。把自己的动作 add-to-group 进去即可:
<group id="TabGroups.EditorTabMenu" popup="false">
<add-to-group group-id="EditorTabPopupMenu" anchor="first"/>
<action id="TabGroups.NewGroup" class="com.tabgroups.actions.NewGroupAction" text="新建分组…"/>
<group id="TabGroups.AddToGroup" class="com.tabgroups.actions.AddToGroupMenuAction"
text="加入分组" popup="true"/>
<action id="TabGroups.RemoveFromGroup" class="com.tabgroups.actions.RemoveFromGroupAction" text="取消分组"/>
<separator/>
<action id="TabGroups.RenameGroup" class="com.tabgroups.actions.RenameGroupAction" text="重命名分组…"/>
<action id="TabGroups.ChangeColor" class="com.tabgroups.actions.ChangeColorAction" text="更改分组颜色…"/>
<action id="TabGroups.DeleteGroup" class="com.tabgroups.actions.DeleteGroupAction" text="删除分组"/>
</group>右键菜单的上下文里能拿到 CommonDataKeys.VIRTUAL_FILE,也就是被右键的那个文件。「加入分组」是一个动态子菜单,getChildren() 里根据当前所有分组实时构建子项:
@Override
public AnAction[] getChildren(@Nullable AnActionEvent e) {
List<TabGroupData> groups = TabGroupService.getInstance(e.getProject()).getGroups();
AnAction[] children = new AnAction[groups.size()];
for (int i = 0; i < groups.size(); i++) {
children[i] = new AssignToGroupAction(groups.get(i).id);
}
return children;
}3.4 最难的部分:程序化「移动标签页」
整个插件的核心难点在这里:IntelliJ 平台没有公开 API 可以重排编辑器标签页。
我们要做的事情——「把某个标签页移到组内最后一个标签页右边」「拖动后让整组聚拢」——本质上都是改变标签页顺序,而平台没有暴露这样的方法。
能拿到的入口是 EditorWindow#getTabbedPane()#getTabs(),它返回 JBTabs。JBTabs 提供了读取类接口(getTabs()、getTabIndex()、findInfo()……),而真正的排序能力在它的扩展接口 JBTabsEx 上——sortTabs(Comparator<TabInfo>)。
于是思路变成:不直接「移动某个标签」,而是算出「目标顺序」,然后让 sortTabs 按这个顺序重排。 用一张「排名表」把目标顺序翻译成比较器:
/** 按给定顺序重排标签 */
private static void sortByOrder(JBTabsEx tabs, List<TabInfo> targetOrder) {
Map<TabInfo, Integer> rank = new HashMap<>();
for (int i = 0; i < targetOrder.size(); i++) {
rank.put(targetOrder.get(i), i);
}
tabs.sortTabs(Comparator.comparingInt(
info -> rank.getOrDefault(info, Integer.MAX_VALUE)));
}有了这个原语,各种「移动」都只是构造 targetOrder 的问题。例如「把 file 移到 anchor 右边」:
List<TabInfo> order = new ArrayList<>(tabs.getTabs());
order.remove(source);
order.add(order.indexOf(target) + 1, source); // 插到目标右侧
sortByOrder(tabs, order);3.5 拖动时让整组保持连续
标签页拖动是平台自己的行为,插件没法直接介入。方案是用全局 AWT 鼠标事件监听:
Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK);按下时:通过
JBTabs#findInfo(MouseEvent)找到被按下的标签页;如果它在某个分组里,记录「该组在本窗口内的原始顺序」,并判断它是否是头标签(组内第一个)。松开后:用
invokeLater等平台把拖拽落位完成,再执行重排。
if (isHead && groupOrder.size() > 1) {
TabMover.moveWholeGroupAfterDrag(project, file, groupOrder); // 整组按原顺序整体移动
} else {
TabMover.repairContiguityAfterDrag(project, file); // 让同组保持连续
}两种重排语义:
头标签:整组作为一个刚性块移动,组内顺序不变、头标签始终在最前。落在哪,整组就跟着到哪。
其它标签:把被拖标签「围绕自身」重新聚拢成连续块——已连续就不动,避免无谓抖动。
「揪住组内第一个标签就能把整组拖走」这个交互,用起来非常接近 Chrome 的组标签把手。
3.6 重启后自动归位
分组信息是持久化的,但标签页顺序是 IDEA 自己恢复的,不保证恢复后各组还连续。所以需要在编辑器恢复完成后重新聚拢一次。
触发点用 FileEditorManagerListener#fileOpened(启动恢复编辑器时会逐个触发),并用一个 AtomicBoolean 合并抖动,等这一波文件都打开完再统一重排:
project.getMessageBus().connect(this).subscribe(
FileEditorManagerListener.FILE_EDITOR_MANAGER,
new FileEditorManagerListener() {
@Override
public void fileOpened(@NotNull FileEditorManager source, @NotNull VirtualFile file) {
scheduleRegroup(); // 合并去重后调用 TabMover.regroupAll(project)
}
});
scheduleRegroup(); // 兜底:服务实例化时可能编辑器已恢复完毕聚拢算法用一个稳定排序:以「每个分组首个成员当前所在位置」为该组的排序键,未分组标签以自身位置为键。这样各组会滑动到首个成员的位置并保持连续,组内相对顺序、未分组标签顺序都不变。
3.7 快捷键 Ctrl+1~9 / Ctrl+0
10 个动作共用一个实现类,用动作 id 的末位区分数字:
<action id="TabGroups.KeyAssign1" class="com.tabgroups.actions.AssignGroupByShortcutAction"
text="将当前标签页分配到第 1 组">
<keyboard-shortcut keymap="$default" first-keystroke="ctrl 1"/>
</action>
<!-- ... 2~9、以及 KeyAssign0 对应 ctrl 0 -->运行时通过 ActionManager 反查自身 id 拿到数字:
private int numberFromActionId() {
String id = ActionManager.getInstance().getId(this);
char c = id.charAt(id.length() - 1);
return (c >= '0' && c <= '9') ? (c - '0') : -1;
}Ctrl+1~9:groups.get(n - 1)就是第 n 组(列表按创建顺序追加),加入即可Ctrl+0:取消当前标签页分组快捷键针对的是「当前激活的编辑器标签页」(
FileEditorManagerEx#getCurrentFile()),而不是右键命中的文件
提醒:
Ctrl+1~9在 IDEA 默认键位里可能和书签跳转冲突,安装后如被抢占,可在Settings → Keymap搜索插件动作重新绑定。
四、踩坑记录
4.1 插件 Logo 必须是 SVG
第一个坑很小但很典型:我往 META-INF/ 放了一个 pluginIcon.png(甚至做了个 1.9MB 的高清图),结果装完插件列表里根本没有 Logo。
翻了平台源码才明白,加载插件 Logo 的 PluginLogo 里写死了文件名:
private const val PLUGIN_ICON = "pluginIcon.svg"
private const val PLUGIN_ICON_DARK = "pluginIcon_dark.svg"也就是说平台只认 pluginIcon.svg,压根不读 .png。那个 1.9MB 的 PNG 不仅不生效,还让插件包白白膨胀到 1.9MB。
结论:插件 Logo 用 SVG,命名为 META-INF/pluginIcon.svg;深色主题可选再加 pluginIcon_dark.svg。
4.2 被插件市场以「使用内部 API」驳回
这是真正的大坑。插件上传后收到 JetBrains 的邮件:
The Plugin Verifier found issues with this update. Your plugin uses the Internal API, which is private and must not be used outside the IntelliJ Platform itself.
具体被标记的是三处:
这正是 3.4 节里那条「平台没有公开 API」的路带来的副作用——一开始的实现直接用了内部实现类:
// 被驳回的写法
JBTabsImpl tabs = (JBTabsImpl) window.getTabbedPane().getTabs();
List<TabInfo> infos = tabs.getVisibleInfos();
Method m = JBTabsImpl.class.getDeclaredMethod("reorderTab", TabInfo.class, int.class); // 反射
m.setAccessible(true);
m.invoke(tabs, info, newIndex);好在每一项都有公开替代,而且都不难:
顺带把那个 reorderTab 的反射调用也删了——改用公开的 JBTabsEx#sortTabs(Comparator) 一样能完成「把标签移到指定位置」:
// 修复后的写法
JBTabsEx tabs = (JBTabsEx) window.getTabbedPane().getTabs();
List<TabInfo> infos = tabs.getTabs();
// ... 构造 targetOrder ...
sortByOrder(tabs, targetOrder); // 内部就是 tabs.sortTabs(comparator)最终 TabMover 里对平台的全部调用只剩 JBTabs / JBTabsEx 的公开方法。
几个教训:
验证器做的是字节码静态分析,它会抓住你对内部类/方法的直接引用(包括
JBTabsImpl.class这种反射锚点)。impl包名是危险信号,但并非所有impl类都会被判内部——EditorWindow(公开方法的返回类型)和EditorTabColorProvider(官方扩展点接口)就没被标记。遇到驳回别急着「用字符串反射绕过」,先去查有没有公开替代——这次的 3 项全都有,只是不显眼。
上架前一定要跑一次
runPluginVerifier,本地拦下来比被官方打回高效得多。
五、构建、安装与发布
环境:JDK 17+,首次构建需联网(Gradle 会自动下载 Gradle 发行版与 IDEA SDK)。
# 打包(产物在 build/distributions/)
.\gradlew.bat buildPlugin
# 本地起一个装了插件的 IDE 实例做冒烟测试
.\gradlew.bat runIde
# 兼容性校验(对多个 IDE 版本跑验证器)
.\gradlew.bat runPluginVerifier安装:Settings → Plugins → ⚙ → Install Plugin from Disk…,选择 build/distributions/label-sticker-<version>.zip。
发布:插件市场的上传包必须签名。在 build.gradle.kts 里配置好三个任务即可形成完整流水线:
tasks {
patchPluginXml { sinceBuild.set("241"); untilBuild.set("251.*") }
signPlugin { // 签名(证书放 certificate/,已 gitignore)
certificateChainFile.set(file("certificate/chain.crt"))
privateKeyFile.set(file("certificate/private.pem"))
password.set(providers.environmentVariable("PRIVATE_KEY_PASSWORD"))
}
publishPlugin { // 上传(token 从环境变量读,别写进代码)
token.set(providers.environmentVariable("PUBLISH_TOKEN"))
}
runPluginVerifier { // 上架前兼容性自检
ideVersions.set(listOf("IC-2024.1", "IC-2024.2", "IC-2024.3", "IC-2025.1"))
}
}然后 .\gradlew.bat publishPlugin 即可。PUBLISH_TOKEN 在 JetBrains 账号的 My Tokens 页面生成。
六、总结
这个插件功能不多,但把 IntelliJ 插件开发里几个典型问题都碰了一遍:
扩展点驱动:着色用
editorTabColorProvider、菜单挂EditorTabPopupMenu、状态用PersistentStateComponent平台能力缺口:标签页重排没有公开 API,只能用
JBTabsEx#sortTabs这种「曲线救国」的方式IDE 交互的时机:拖动是平台行为,得靠全局鼠标事件 +
invokeLater捕捉落位上架合规:内部 API、弃用 API 都会被验证器拦,Logo 的格式也有硬要求
写插件最大的感受是:平台 API 的「能不能用」和「该不该用」是两回事。很多看起来能跑的写法(内部类、反射)在本地一切正常,但过不了上架那一关;而官方给出的公开替代,往往只是藏在另一个不那么显眼的接口里。
如果你也在给 IDEA 写插件,希望这篇踩坑记录能帮你少走几步弯路。
插件名:label-sticker | 适配:IntelliJ IDEA 2024.1+ | 技术栈:Java 17 + Gradle