IDEA插件开发:文件选择器、类选择器与包选择器实战 做IDEA插件开发的很多朋友做到UI交互这一步时会发现一个尴尬情况弹窗能弹出来但用户需要在你的插件里选择一个文件、一个类或者一个包的时候不知道用什么组件。自己手写一个文件树面板重造轮子不说过滤逻辑、多选逻辑、图标渲染全都得自己操心出来效果还不一定像IDE原生的。实际上IDEA的IntelliJ Platform SDK里已经把这三类选择器都封装好了功能完整和IDE原生交互一致。这是这个系列教程的第四篇主题就是UI里的文件选择器、类选择器和包选择器我会给出最小可运行的代码、参数拆解和使用场景同时把线程约束、过滤条件这些容易踩坑的地方一并说清楚。1. 三类选择器分别解决什么问题1.1 选择器为什么是插件的标准动作插件最终要和用户产生交互最常见的交互不是复杂的表单而是让用户在项目里挑一个东西。这个东西可以是配置文件、源代码目录、一个类、一个包名、甚至一个模块。只要你的插件不是纯后台任务就几乎绕不开这个动作。IDEA把这一类交互做成了通用组件核心就是FileChooserDescriptor和几个封装好的选择器。它们的好处在于用户看到的对话框和IDEA自带的打开文件、查找类外观一致不需要重新学习对插件开发者来说也不需要处理平台相关的文件对话框因为IDEA的FileChooser封装的是自己的虚拟文件系统不是操作系统原生的那套。我见过一些插件作者用Swing的JFileChooser来选文件结果弹出来的对话框风格和IDE完全割裂路径处理和模块内虚拟文件也会出各种问题。正确做法就是用SDK提供的FileChooser、PackageChooser这类组件。1.2 文件、类、包三种选择粒度的差异这三者表面看都是选择一个资源但粒度差异很大适用场景也完全不同。文件选择器面向VirtualFile可以选文件、选目录、选jar包里的文件粒度最底层。常用于选择输出目录、选择模板文件、指定日志文件这类需求。类选择器面向PsiClass弹出的是类名搜索框支持按类名模糊匹配能直接搜到项目依赖里的类。常用于选择父类、选择目标实体类、指定一个需要生成的类这类场景。包选择器面向Java的包比如com.example.project。通常在选择包路径生成新文件、指定一个根包做扫描的时候用。一句话总结文件选择器管的是物理资源类选择器管的是代码结构包选择器管的是包层级。选错粒度就会出现很蠢的交互比如让用户从整个文件树里去找一个类而不是直接输入类名搜索。下面分别展开。2. 文件选择器从FileChooser到VirtualFile的最短路径2.1 最小可运行示例一个能返回路径的文件选择弹窗先给一个最精简的Kotlin版本后面再逐步拆解参数。这段代码可以在任意Action中被调用import com.intellij.openapi.fileChooser.FileChooser import com.intellij.openapi.fileChooser.FileChooserDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile fun chooseSingleFile(project: Project?) { val descriptor FileChooserDescriptor( true, // allowFiles false, // allowFolders false, // allowJars false, // allowMultiple 选择一个文件, 请选择项目中的任意一个文件, null ) FileChooser.chooseFiles(descriptor, project, null) { files - val file: VirtualFile? files.firstOrNull() if (file ! null) { println(选中文件: ${file.path}) } } }Java版本在SDK里也一样只是冗长一些Kotlin写起来更顺手。注意FileChooser.chooseFiles的最后一个参数是一个Consumer这个回调在文件选择完成后执行里面拿到的是VirtualFile数组而不是java.io.File。这是很多新手最开始会愣一下的地方。这里有一个小经验如果只是想要单个文件用FileChooser.chooseFile方法也是一样的效果但是多选场景只能走chooseFiles建议直接统一用chooseFiles配合descriptor的allowMultiple参数来控制个数。2.2 descriptor参数拆解允许文件还是目录允许多选FileChooserDescriptor的构造函数里有5个布尔参数很多人看到这5个参数就头大其实含义很直接参数含义使用典型allowFiles允许选择普通文件选配置文件、日志文件allowFolders允许选择目录选输出目录、源码根目录allowJars允许jar包及jar内部路径选依赖包、移动maven坐标allowMultiple允许多选批量导入文件title/description弹窗标题和描述给用户明确提示一个常见需求是只能选目录禁止选文件。你把第一个参数设为false第二个设为true就可以。但是这里有个隐藏坑如果你把allowFiles设为false那么在文件树里所有文件都会显示为灰色不可选但是仍然可以被浏览。用户依然可以点进某个目录只是不能选中文件而已。这个行为由另一个方法isFileSelectable控制后面讲到进阶定制的部分会详细说。还有一个细节值得注意descriptor的allowMultiple参数必须在创建时定死不要试图在回调里去判断选了几个因为对话框内部的多选交互比如checkbox和列表多选模式会受这个参数影响不只是回调阶段简单的数量过滤。2.3 拿到VirtualFile之后路径转换与内容读取VirtualFile是IDEA虚拟文件系统的核心抽象它可能对应磁盘上的一个真实文件也可能对应jar包内的一个条目。拿到它之后你需要知道怎么转成自己常用的类型。最简单的路径转换方式是直接调用file.path返回的是类似/Users/xxx/project/src/main/java/Test.java的绝对路径。如果一定要用java.io.File可以借助VfsUtilCore.virtualToIoFile(file)注意jar内文件转换会返回一个不存在的路径因为jar内条目本来就不是磁盘文件。读取文件内容我有两种常用方式按场景来// 方式一直接读适合小文件、模板文件 val content String(file.contentsToByteArray(), Charsets.UTF_8) // 方式二通过文档系统适合需要进一步处理PSI的场景 val document FileDocumentManager.getInstance().getDocument(file) val text document?.text方式一简单粗暴但只适合读如果你需要修改文件内容并让IDEA感知到变化必须用方式二因为Document参与了文件变更监听和vcs联动。之前我遇到过用方式一改完文件IDEA编辑器里打开的还是旧内容的情况就是没走Document系统的原因。3. 类选择器让用户在插件里优雅地挑PsiClass3.1 触发类名搜索弹窗的两种方式类选择器的体验和IDEA自带的查找类CtrlN几乎一样可以输入类名模糊搜索会显示完整包路径命中项带图标。实现方式有两种我分别说。第一种是直接使用ClassChooser的静态方法import com.intellij.openapi.ui.ClassChooser val baseClass: PsiClass? ClassChooser.chooseClass( project, 选择目标类, null, null, null )这个方法会启动一个模态对话框在用户选择完成后返回选中的PsiClass取消则返回null。参数不多最核心的是title和baseClass。baseClass很有用如果传一个父类或者接口进去对话框会自动过滤出它的子类/实现类这是让用户从继承体系里选一个子类时最省事的方式。第二种是使用GotoClassOrSymbolPopup这个更接近查找类弹窗的体验。它稍复杂一些但好处是支持默认选中项、支持enter/double-click自定义行为适合嵌入到你自己的Dialog里。我常用的方式是直接new一个GotoClassOrSymbolPopup然后在回调里对选中的GotoTarget进行类型判断。如果只是需要一个弹窗选类的效果ClassChooser.chooseClass就够了代码量少很多。3.2 限定范围过滤条件和命名空间约束选择类的时候几乎都要做范围限定。比如你要选一个实体类用户不应该能选中一个接口或者枚举你要选的类是项目内的就不应该展示依赖jar包里的上千个类。这些都要通过过滤条件来做。ClassChooser的chooseClass方法里有一个filterType参数可以传PsiClassType或者ClassFilterBase的子类。更灵活的是自己实现一个过滤逻辑。我一般这样处理import com.intellij.psi.PsiClass import com.intellij.psi.search.GlobalSearchScope val scope GlobalSearchScope.projectScope(project) val processor { candidate: PsiClass - candidate.isValid candidate.name?.endsWith(Service) true candidate.containingFile.virtualFile ! null scope.contains(candidate.containingFile.virtualFile) } val cls ClassChooser.chooseClass( project, 选择Service类, null, null, processor )记得一个关键点过滤逻辑里一定要先判断candidate.isValid因为异步加载场景下PsiClass可能已经失效比如用户改动了文件导致PSI被重建。不判断isValid的话某些情况下会抛异常或者抽到已经不存在的类这种偶发问题排查起来非常痛苦。如果只是需要过滤到某一个包下面的类用GlobalSearchScope配合包名前缀判断也可以不过这样遍历的类会比较多。更精确的做法是用JavaPsiFacade.findPackage拿到PsiPackage再用package.getClasses(scope)拿到这个包下的类列表直接传给UI层是最可控的。3.3 返回的PsiClass怎么用拿到PsiClass之后可以做的事情非常多拿到类名和包名生成新类的源码或模板通过psiClass.navigationElement跳转到类定义处通过psiClass.allMethods分析类的方法列表通过PsiDirectory找到类所在的包目录用于生成同目录文件。这里提醒一个点PsiClass对象不要长期持有并缓存到插件状态里。用户可能在选择完之后又改了代码PSI会失效拿旧引用去操作就会报PSI document is null之类的错。稳妥的做法是选择完成之后立刻读取你需要的信息类名、限定名、目录路径然后把PsiClass引用丢掉或者只在当前业务流里使用。另外一个实用的API是JavaPsiFacade.getInstance(project).findClass(qualifiedName, scope)这个是用限定名重新找到PsiClass的方法。当你在回调里拿到的PsiClass因为异步问题失效了可以通过这个方式重新定位。4. 包选择器PackageChooser的调用与组合场景4.1 PackageChooser的基本用法包选择器的典型用途是让用户选定一个包然后你的插件在这个包下生成新的类、资源文件或者做包级扫描。SDK里提供的是PackageChooser用起来很像一个DialogWrapper用法如下import com.intellij.openapi.ui.PackageChooser val packageChooser PackageChooser.createDialog( 选择包, project, null ) if (packageChooser.showAndGet()) { val selectedPackage: PsiPackage? packageChooser.selectedPackage println(选中的包: ${selectedPackage?.qualifiedName}) }createDialog的第三个参数是一个PsiPackage可以用来指定初始展开的包比如你希望对话框打开时自动定位到当前类所在的包。这里有个体验细节值得做如果你的插件入口是从某个类触发最好把当前类的包传进去否则用户每次都要在一长串包路径里手动展开体验非常差。selectedPackage返回的类型是PsiPackage而不是字符串这一点容易绕一下。PsiPackage的qualifiedName就是com.example.project这样的字符串你也可以用psiPackage.directories拿到这个包对应的物理目录列表。4.2 包选择器和文件选择器的组合套路在实际插件中包选择器很少单独使用它经常和文件选择器配合。最典型的需求是请选择目标包 请选择一个模板文件然后插件根据模板在当前包下创建新文件。我的做法是分层调用先弹出包选择器确认包之后再弹出文件选择器去选模板val pkgChooser PackageChooser.createDialog(选择包, project, initialPackage) if (!pkgChooser.showAndGet()) return val targetPackage pkgChooser.selectedPackage ?: return val descriptor FileChooserDescriptor( true, false, false, false, 选择模板文件, 建议选择 .ftl 或 .java 模板, null ) descriptor.withFileFilter { file: VirtualFile - file.extension in listOf(ftl, java, txt) } FileChooser.chooseFiles(descriptor, project, null) { files - val template files.firstOrNull() ?: returnchooseFiles val targetDir targetPackage.directories.firstOrNull() ?: returnchooseFiles generateFromTemplate(project, template, targetDir) }这里用到了withFileFilter给文件选择器加扩展名过滤这个会在对话框里直接把不符合条件的文件灰掉。组合起来的交互流程非常自然用户先在包树里选一个包注意力被收窄到一个目标然后才面对文件选择思路不会被打乱。5. FileChooserDescriptor进阶定制过滤、默认路径和显示细节5.1 isFileVisible与isFileSelectable的区别FileChooserDescriptor最容易被误解的地方就是这两个方法。简单说isFileVisible决定一个文件/目录是否在文件树里显示。返回false的时候这个条目直接看不到。isFileSelectable决定一个条目即使显示了可不可以被选中。返回false的话条目会以灰色呈现用户点不了。最经典的组合是文件可见但不可选。比如让用户选择目录但目录里出现一个target构建目录你不想让他选中这个目录却也不想让他完全看不到因为目录还在。这种情况下就是isFileVisible返回trueisFileSelectable返回false。反过来想隐藏一堆无关文件用isFileVisible过滤掉就好。日常开发中我习惯默认把node_modules、target、.git这些目录隐藏掉文件选择器会清爽很多val descriptor object : FileChooserDescriptor( true, true, false, false, 选择任意文件或目录, null, null ) { override fun isFileVisible(file: VirtualFile): Boolean { if (file.name in listOf(target, node_modules, .git, build)) { return false } return super.isFileVisible(file) } override fun isFileSelectable(file: VirtualFile): Boolean { return true } }注意isFileVisible这个函数项目比较大时会被调用很多次不要在里边做太多重量级操作尤其不要做网络请求或者全文扫描不然文件树每次展开都卡一下用户能明显感知。5.2 默认选中路径和多选模式调用FileChooser.chooseFiles的时候第三个参数fileToSelect可以传入一个VirtualFile对话框弹出时就会定位并选中这个文件。这对用户从项目视图右键一个文件触发插件动作插件需要记住用户当前文件的场景非常有用val contextFile e.getData(CommonDataKeys.VIRTUAL_FILE) // 在Action里获得当前文件 FileChooser.chooseFiles(descriptor, project, contextFile) { files - ... }注意contextFile可能为null比如从菜单栏的全局入口触发传null不报错IDEA会默认定位到项目根目录。另外即使你指定了默认选中文件如果它不符合descriptor的过滤条件比如你在allowFilesfalse的情况下传了一个文件那IDEA会忽略这个默认定位定位到它的父目录这个是正常行为不要当成bug去排查。多选模式是allowMultipletrue这时候回调里的files数组是用户实际选择的顺序但这个顺序基本是文件树里的排序顺序不要假设它等于用户点击的先后顺序。如果需要顺序可以在选择完成后自己根据路径排序。5.3 文件类型过滤的正确写法直接改descriptor的构造参数能做的只是允许文件还是目录这种粗粒度控制。要限制具体的扩展名有几种做法。一种是构造后调用withFileFilter传入一个Predicate 。这种过滤方式和isFileSelectable一样会让不符合的文件显示为灰色用户还是能看到文件列表的全貌。另一种是重写isFileSelectable效果一样但可以用到descriptor内部的更多状态。如果你的过滤逻辑需要根据当前选中目录动态变化比如目录A下允许选.java目录B下允许选.kt就一定要走isFileSelectable重写因为withFileFilter拿不到上下文只有一个文件对象。我自己的习惯是文件数量少、过滤条件一眼能看完的用withFileFilter需要动态逻辑的建议用isFileSelectable。两个方法别混用容易产生覆盖关系最后哪个生效还得靠调试反而浪费时间。6. 踩坑记录线程约束与性能细节6.1 EDT与模态回调用法为什么总是IllegalStateException选择器相关的所有UI操作都必须在事件分发线程EDT上执行。如果你的Action是异步执行的后台任务里面直接调FileChooser.chooseFiles十有八九会抛异常。常见的触发场景是你有一个后台线程在做扫描扫描结束后想弹出文件选择器让用户挑文件直接在后台线程调用选择器就崩了。正确做法是把弹选择器的动作放回到EDT上ApplicationManager.getApplication().invokeLater { FileChooser.chooseFiles(descriptor, project, null) { files - ... } }如果是在写代码生成这类任务中你可能是在ReadAction里要小心ReadAction内部的锁和UI弹窗的模态循环会冲突。真的需要从后台切回UI去弹窗建议用ApplicationManager.getApplication().invokeAndWait或invokeLater的EDT版本不要自己创新线程调度。调试这类问题有一个刻板印象看到IllegalStateException但是堆栈信息不明确。一个简单有效的做法是打开IDEA的Logging Options里把com.intellij.openapi.ui相关日志调成DEBUG能帮助定位是哪个UI组件不在EDT调用。6.2 大项目里的类/包选择性能思考在大项目里类选择器一旦不做任何范围限制弹出搜索框时可能要遍历整个classpath的类用户输入一个字母就会触发一次大量扫描处理器函数被调用很多次。这个场景下处理器一定要轻量不要做PSI解析、不要做引用搜索。我遇到过这样一个案例某插件在类选择器的过滤条件里对每个PsiClass调用isInheritor(commonClass, true)来判断是否是某个基类的子类。功能没问题但每次弹窗至少卡3到5秒因为isInheritor的深层实现需要遍历整个继承链还会触发大量PSI内容加载几千个类跑下来直接卡死。后来改成两步先用类名匹配做粗过滤类名都不满足的直接放弃只有粗过滤通过的候选用isInheritor精判性能瞬间好了。这个思路很通用能用字符串判断的绝不用PSI结构判断能用scope过滤的绝不用完整扫描。6.3 选择结果如何组织给下游逻辑最后一个很实际的问题选择器拿到的结果是VirtualFile、PsiClass、PsiPackage这样的对象但你的插件核心逻辑可能需要的是路径字符串、限定名、或者一组文件的相对路径。这个转换不要散落在各个Action里我建议把所有选择器统一封装成一个工具类对外只暴露业务语义明确的返回数据类型。比如你自己定义一个ResultDatadata class SelectionResult( val path: String?, val qualifiedName: String?, val virtualFile: VirtualFile? )然后在工具类里面对这三个选择器分别做转换。好处是以后如果要改交互逻辑比如从类选择器换成更强大的GotoClassPopup只需要改工具类内部实现业务代码完全不动。这也是写插件工程化一点的体现不然每个需求都要改一堆调用点。最后一个经验选择器返回的数据在传递过程中尽量不要带PsiClass对象跨方法因为你无法预判用户会不会在操作期间改动代码导致PSI失效。我的习惯是在回调里同步提取字符串和路径立即封装成自己的数据模型后续逻辑全部基于字符串和路径而不是PSI对象。这样代码不容易出现PSI invalid类的偶发问题排查起来也清晰得多。