参考文章(不完全)

运行效果

Gitee地址

https://gitee.com/shusy/Planetary-Engine-Idea

插件使用zip本地安装的方式(暂未发布到Jetbrains应用市场中),可在release中下载并安装

插件需求

开发新项目时,常常会使用Mybatis代码生成器生成对应的Controller、Service、Impl、Mapper,这很便捷也很好用,有的公司也会对代码生成器进一步封装,使生成器用起来更加的便捷。

代码生成器的痛点

如此厉害的代码生成器,依然有以下痛点

  • 代码生成器使用的mybatis版本会和项目本身的mybatis版本冲突。
  • 项目开发过程中对单个Class生成代码,需要修改main方法的配置,这时还得考虑整个项目是否能编译通过(笑。
  • 当公司需要使用自定义的代码模板时,代码生成器的模板难以维护(使用代码生成器是为了降低工作量,难道要为了降低工作量而深入学习模板的维护吗?公司人来人往,每个同事都要学习代码模板的维护方式吗?)。

简化下来就是:

  • 代码生成器对项目有入侵性。
  • 代码生成器不能即时执行。
  • 代码模板难以修改。

So~开发一个Idea插件,通过简单的配置以及点击,来生成模板代码的想法就出现了,这样做有以下好处:

  • 项目无入侵:不会对项目的Pom.xml有任何污染。
  • 即时操作:无需离开当前编辑页,通过邮件或多选文件的操作,即可对任意Java文件生成模板代码。
  • 代码模板的维护难度可控:只做最小最核心功能,来减少编写模板的难度。

当然了,也有一些缺点:

  • Idea的插件开发,对于国内来说,是一个朦胧的技术点,学习成本高、没有完整的教学文档(有英文版,可我英文不好😄)
  • 开发周期可能很长,需要调试与适配的情况不少。

当然,对于我来说没有缺点,这么好的平台、这么酷炫的插件技术,为什么不玩玩呢?开发周期长?我又不是给公司做的,在乎这个干嘛😄

插件预期功能

  • 可以指定生成文件时的包名
  • 通过配置,指定含有某个注解时才可以生成模板代码,默认为识别Mybatis-plus的@TableName注解
  • 可以为Controller、Service、Impl、Mapper都配置一个代码模板
  • 可以选择文件或者文件夹,灵活的指定需要生成模板代码的文件

ok,需求大概了解了,开干!

初始化项目

想要开发Idea插件,需要先创建一个基础的项目结构,详细的描述在网上有不少,读者请参考引用文档。

大致有以下注意点:

  • 开发语言:kotlin,Idea插件基于kotlin开发,但由于kotlin和Java都使用的JVM,所以写Java的开发者大可放心(能用Idea写Java项目,结果不能用Java写Idea的插件?笑话~)
  • 依赖管理工具:gradle,对于只使用了pom的我来说,并不习惯,但是还好有GPT,问题不大。
  • 重要文件:plugin.xml,插件、配置、应用等等的配置项,都在这个文件中,类似于spring.xml一般重要。文件路径resources/META-INF/plugin.xml
  • 记得安装Plugin DevKit插件,新版本Idea已默认安装
  • Idea会下载对应版本的Gradle和Idea,必要时候,请搭上梯子并开启全局代理,梯子默认情况下是系统代理,而Idea默认不走系统代理(可以配置,但不在本文的讨论范围内)

Service注入

Idea也有类似IOC的概念,可以将一个自定义Service注入到IOC中,在需要的地方,通过静态方法即可取出使用。

注解方式注入

package com.asteroid.planetary_engine.idea.state;

// 项目级别的注入,每个项目拿到的Service都是新的
@Service(Service.Level.PROJECT)
public final class EntityGenerateStateManage{
    
}

// Idea应用级别的注入,无论哪个项目拿到的Service都是同一个
@Service(Service.Level.APP)
public final class EntityGenerateStateManage{
    
}

xml方式注入

<idea-plugin>
        <extensions defaultExtensionNs="com.intellij">
            <!-- 注册一个应用级别的 service (全局实例化一个)-->
            <applicationService serviceImplementation="com.asteroid.planetary_engine.idea.state.EntityGenerateStateManage"/>

            <!-- 注册一个项目级别的 service(每个窗口实例化一个) -->
            <projectService serviceImplementation="com.asteroid.planetary_engine.idea.state.EntityGenerateStateManage"/>
    </extensions>
</idea-plugin>

获取方式

    public static EntityGenerateStateManage getInstance() {
       // 应用级别
       return ApplicationManager.getApplication().getService(MyApplicationService.class);
       // 项目级别
       Project project = ProjectManager.getInstance().getDefaultProject();
       project.getService(MyProjectService.class); 
       return project;
    }

实现步骤

  • 在Setting中新增配置页,用来配置基础参数和代码模板
  • 增加右键快捷项,触发生成模板代码逻辑
  • 读取持久化的配置项,生成模板文件

绘制配置项UI界面

因为没接触过JSwing,导致这一步踩了不少坑,都是辛酸泪~

解释一下会用到的UI组件,在这一步,如果有点前端基础,会更容易理解一些。

  • JPanel:类似div,画出一个框,最常用的组件。
  • JTextField:类似span,仅为了显示一个固定文本。
  • JBList:类似ul > li,是Jetbrains对JList的增强(具体增强了什么也没研究过,用新不用旧嘛)。
  • JScrollPane:带滚动条的div,内部需要再放一个JPanel,用来限制长文本的宽高时非常好用。
  • Base Layout Manage:类似前端的布局方式,通常直接用GridLayoutManager即可,详情可自行搜索。

新增界面文件

右键新增,输入EntityGenerateUI,点击ok

image-20240505161927989

image-20240505162259006

此时会生成以下文件:

  • EntityGenerateUI:UI文件的目录

  • EntityGenerateUI.java:界面对应的Java对象,用来实现逻辑操作(后端)

  • EntityGenerateUI.form:界面的实际xml参数,用来渲染页面(前端)

image-20240505162500804

在绘画EntityGenerateUI.form时,Idea会将组件对应的Java字段自动写入EntityGenerateUI.java

image-20240505163205544细节点比较繁琐(就是前端活),说得再多,不如实际操作一下!

最终,我们获得了如下的配置界面

image-20240505163813553

自动生成的代码如下

package com.asteroid.planetary_engine.idea.ui;

import com.asteroid.planetary_engine.idea.domain.EntityType;
import com.intellij.ui.components.JBList;
import lombok.Data;

import javax.swing.*;

@Data
public class EntityGenerateUI implements Configurable {
    private JPanel rootPanel;
    private JPanel mvcPanel;
    private JPanel configPanel;
    private JPanel commonPanel;
    private JPanel itemPanel;
    private JTextField packageName;
    private JTextField annotationName;
    private JBList<EntityType> entityTypeList;
    private JPanel itemListPanel;
    private JPanel templatePanel;
    private JSplitPane jSplitPane;
    private JScrollPane editorScrollPanel;
    private JPanel editorPanel;

}

注册进Setting菜单中

在plugin.xml中,将EntityGenerateUI注册

<idea-plugin>
        <extensions defaultExtensionNs="com.intellij">
        <!-- 属性 applicationConfigurable 和 projectConfigurable 
            parentId - 定义当前设置项在设置窗口中的位置,可选值为 https://plugins.jetbrains.com/docs/intellij/settings-guide.html#values-for-parent-id-attribute
            Id - 唯一 ID,建议和类名一致
            instance - Configurable 实现类的全名,和 provider 二选一
            provider - ConfigurableProvider 实现类的全名,和 instance 二选一
            nonDefaultProject - projectConfigurable 专属属性,是否允许用户配置默认配置 true - 该配置默认值写死的, false - 该配置默认值用户可以配置
            nonDefaultProject = false 场景例子:编辑器字体,用户可以改变默认的字体,也可以专门为这个项目设置特定的配置
            displayName - 展示名,不需要本地化场景
            key 和 bundle - 需要本地化场景
            groupWeight - 排序顺序,默认为 0 (权重最低)
            dynamic - 设置项内容是否是动态的计算的,默认 false
            childrenEPName - 如果配置项有多页,可以通过该字段组成树形结构??
        -->
            
        <!-- 注册一个Idea应用级别的配置页(每个窗口实例化一个) -->
        <applicationConfigurable id="planetary-engine"
                                 parentId="tools"
                                 displayName="行星发动机"
                                 instance="com.asteroid.planetary_engine.idea.ui.EntityGenerateUI" />

        <!-- 注册一个项目级别的	配置页(每个窗口实例化一个) -->
        <projectConfigurable id="planetary-engine"
                                 parentId="tools"
                                 displayName="行星发动机"
                                 instance="com.asteroid.planetary_engine.idea.ui.EntityGenerateUI" />
    </extensions>
</idea-plugin>

改造一下EntityGenerateUI,实现Configurable接口,标识为一个配置菜单项。

package com.asteroid.planetary_engine.idea.ui;

import com.asteroid.planetary_engine.idea.domain.EntityType;
import com.intellij.ui.components.JBList;
import com.intellij.openapi.options.Configurable;
import com.intellij.openapi.util.NlsContexts.ConfigurableName;
import lombok.Data;

import javax.swing.*;

@Data
public class EntityGenerateUI implements Configurable {
    private JPanel rootPanel;
    private JPanel mvcPanel;
    private JPanel configPanel;
    private JPanel commonPanel;
    private JPanel itemPanel;
    private JTextField packageName;
    private JTextField annotationName;
    private JBList<EntityType> entityTypeList;
    private JPanel itemListPanel;
    private JPanel templatePanel;
    private JSplitPane jSplitPane;
    private JScrollPane editorScrollPanel;
    private JPanel editorPanel;
    
    // 暂时没找到会在哪里显示
    @Override
    public @Nullable @NonNls String getHelpTopic() {
        return "OH!!! Help me!!!";
    }

    // Setting页显示的配置名称
    @Override
    public @ConfigurableName String getDisplayName() {
        return "PlanetaryEngine";
    }

    // 返回页面的根节点,这样才能显示出页面
    @Override
    public @Nullable JComponent createComponent() {
        return rootPanel;
    }

    // 判断配置是否更改,如果更改了,Apply、Reset按钮会亮
    @Override
    public boolean isModified() {
        return false;
    }

    // 用户点击Apply时的回调操作,通常会将新配置写入磁盘中
    @Override
    public void apply() throws ConfigurationException {
       
    }
    
    // 用户点击Reset按钮时的回调操作,通常会重新读取配置,覆盖临时配置
    @Override
    public void reset() {
      
    }
}

运行预览

此时执行Run Plugin运行项目,会弹出一个新的Idea窗口,使用该窗口打开任意一个新的项目

image-20240505165919436

使用快捷键ctrl + shift + s快速打开设置界面,找到Tools下的行星发动机配置页面,即可看到我们画的页面了。

image-20240505183914504

状态持久化

现在需要做两件事:

  • 将默认的配置读出来,降低第一次使用的难度(resource下新建一个json文件)。
  • 将修改后的配置,写入某个持久化文件中(这一步,Idea帮我们实现了)

Idea提供了如下方法,共同实现了持久化:

  • com.intellij.openapi.components.PersistentStateComponent 接口:重写数据的对比、加载逻辑。

    • 读取对应存储位置中,是否有历史配置,如果有,调用loadState方法,使开发者能拿到默认配置。
    • 业务中调用getState方法,获取内存中的配置。
  • com.intellij.openapi.components.State 注解:标识为需要持久化的对象,并自动将数据回写到磁盘中的文件。

  • com.intellij.openapi.components.Storage 注解:将数据的kv存储到指定的xml中。

存储位置

  • Application级别:为该示例中的相对路径:build/idea-sandbox/config/options/EntityGenerateSetting.xml
  • Project 级别状态,存储在 ~/.idea
    • 如果使用StoragePathMacros.WORKSPACE_FILE常量。则存储在
      • path/to/project/project.iws - for file-based projects
      • path/to/project/.idea/workspace.xml - for directory-based ones
    • StoragePathMacros.WORKSPACE_FILE 是有特殊的含义,表示该状态,不会同步到代码仓库中,是该用户特化的配置而不是团队共享的,参见 .idea gitignore 说明
    • StoragePathMacros.WORKSPACE_FILE 只能在 项目级别使用,如果在 Application 级别使用,将报错

实现基于Idea的持久化能力

我们只需要关注,提供给外部的getState方法和加载历史配置的loadState方法即可。

package com.asteroid.planetary_engine.idea.state;

import cn.hutool.json.JSONUtil;
import com.asteroid.planetary_engine.idea.domain.EntityType;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.components.PersistentStateComponent;
import com.intellij.openapi.components.Service;
import com.intellij.openapi.components.State;
import com.intellij.openapi.components.Storage;
import lombok.Data;
import org.apache.commons.io.IOUtils;
import org.jetbrains.annotations.NotNull;

import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;

@State(
        name = "com.asteroid.planetary_engine.idea.setting.EntityGenerateSettingManage",
        storages = @Storage("EntityGenerateSetting.xml")
)
// 将State存入IOC中,就能在UI界面中读取到值,更好操作
@Service(Service.Level.APP)
public final class EntityGenerateStateManage implements PersistentStateComponent<EntityGenerateStateManage.EntityGenerateState> {
    // 第一步:读出默认配置
    @Override
    public EntityGenerateStateManage.@NotNull EntityGenerateState getState() {
        if (DEFAULT_STATE == null) {
            // EntityGenerate.json放在了resource下,记录基于插件的默认配置
            InputStream inputStream = EntityGenerateStateManage.class.getClassLoader()
                    .getResourceAsStream("EntityGenerate.json");
            try {
                String string = IOUtils.toString(inputStream);
                DEFAULT_STATE = JSONUtil.toBean(string, EntityGenerateState.class);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        return DEFAULT_STATE;
    }
    
    // 第一步也可以是,从历史配置中读出配置
    @Override
    public void loadState(@NotNull EntityGenerateStateManage.EntityGenerateState state) {
        DEFAULT_STATE = state;
    }
    
    public static EntityGenerateState DEFAULT_STATE = null;

    // 对外提供静态方法,调用时会方便点
    public static EntityGenerateStateManage getInstance() {
        return ApplicationManager.getApplication().getService(EntityGenerateStateManage.class);
    }

    // 对UI暴露一个更新方法,使外部可以更新内存中的配置,Idea会自行将state写入Xml中
    public static void update(EntityGenerateState state) {
        DEFAULT_STATE.setPackageName(state.getPackageName());
        DEFAULT_STATE.setAnnotationQualifiedName(state.getAnnotationQualifiedName());
        DEFAULT_STATE.configMap.clear();
        DEFAULT_STATE.configMap.putAll(state.configMap);
    }

    @Data
    public static class EntityGenerateState {
        /**
         * 起始包名
         */
        private String packageName;
        /**
         * 识别的注解的全限定名称
         */
        private String annotationQualifiedName;
        /**
         * 配置项
         */
        private HashMap<EntityType, String> configMap = new HashMap<>();
    }
}

EntityGenerate.json

{
  "packageName": "com.example.emptydemo",
  "annotationQualifiedName": "com.example.emptydemo.at.Entity",
  "configMap": {
    "ServiceImpl": " import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;\n import ${sourcePackage}.${className};\n import ${sourcePackage}.${targetClassName};\n //import ${qualifiedName};\n                             \n @Service\npublic class ${targetClassName} extends ServiceImpl<${className}Mapper, ${className}>\n         implements ${className}Service {\n                \n }\n ",
    "Service": "import com.baomidou.mybatisplus.extension.service.IService;\nimport ${sourcePackage}.${className};\n //import ${qualifiedName};\n\npublic interface ${targetClassName} extends IService<${className}>  {\n\n }\n",
    "Controller": "import ${sourcePackage}.${className};\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RestController\n@RequestMapping(\"/api/common/dict\")\npublic class ${targetClassName} {\n\n@Resource\nprivate ${className}Service ${className}Service;\n\n\n}\n",
    "Mapper": "import com.baomidou.mybatisplus.core.mapper.BaseMapper;\nimport org.apache.ibatis.annotations.Mapper;\nimport ${sourcePackage}.${className};\n//import ${qualifiedName};\n\n@Mapper\npublic interface ${targetClassName} extends BaseMapper<${className}>  {\n\n}\n"
  }
}

该示例的xml序列化后的例子(不重要)

<application>
  <component name="com.asteroid.planetary_engine.idea.setting.EntityGenerateSettingManage">
    <option name="annotationQualifiedName" value="com.example.emptydemo.at.Entity" />
    <option name="configMap">
      <map>
        <entry key="Controller" value="import ${sourcePackage}.${className};&#10;import org.springframework.web.bind.annotation.RequestMapping;&#10;import org.springframework.web.bind.annotation.RestController;&#10;&#10;@RestController&#10;@RequestMapping(&quot;/api/common/dict&quot;)&#10;public class ${targetClassName} {&#10;&#10;@Resource&#10;private ${className}Service ${className}Service;&#10;&#10;&#10;}&#10;" />
        <entry key="Service" value="import com.baomidou.mybatisplus.extension.service.IService;&#10;import ${sourcePackage}.${className};&#10; //import ${qualifiedName};&#10;&#10; public interface ${targetClassName} extends IService&lt;${className}&gt;  {&#10;&#10; }&#10;" />
        <entry key="ServiceImpl" value=" import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;&#10; import ${sourcePackage}.${className};&#10; import ${sourcePackage}.${targetClassName};&#10; //import ${qualifiedName};&#10;                             &#10; @Service&#10; public class ${targetClassName} extends ServiceImpl&lt;${className}Mapper, ${className}&gt;&#10;         implements ${className}Service {&#10;                &#10; }&#10; " />
        <entry key="Mapper" value="import com.baomidou.mybatisplus.core.mapper.BaseMapper;&#10;import org.apache.ibatis.annotations.Mapper;&#10;import ${sourcePackage}.${className};&#10;//import ${qualifiedName};&#10;&#10;@Mapper&#10;public interface ${targetClassName} extends BaseMapper&lt;${className}&gt;  {&#10;&#10;}&#10;" />
      </map>
    </option>
    <option name="packageName" value="com.example.emptydemo" />
  </component>
</application>

Setting界面将配置持久化

在这一步,我们需要实现以下功能:

  • UI从State中复制一个临时的配置,提供给用户操作。
  • 用户在没有点击确认(apply)或者OK之后,才讲临时的配置写入持久化的配置中。
  • 使用一个Map存储文件类型与代码模板的K-V,用户点击文件类型时,右侧的编辑框切换显示为对应的代码模板。
  • 右侧的编辑框更改时,将代码模板的新值存入K-V中。

增加UI中的逻辑

核心修改点:

  • 从State中读取并复制插件配置

     private EntityGenerateStateManage.EntityGenerateState state = new EntityGenerateStateManage.EntityGenerateState();
        {
            // 初始化UI中的临时界面
            EntityGenerateStateManage stateManage = EntityGenerateStateManage.getInstance();
            BeanUtil.copyProperties(stateManage.getState(), state);
            packageName.setText(state.getPackageName());
            annotationName.setText(state.getAnnotationQualifiedName());
        }
    
  • 渲染代码模板,并添加对应的代码高亮:创建一个编辑器、为编辑器绑定高亮规则

    // 为当前对象绑定一个编辑器
    private void initEditor() {
        // 使用Live Template功能的创建Editor工具,初始不传入文本
        myTemplateEditor = TemplateEditorUtil.createEditor(false, "");
        // EditorEx 才有代码高亮功能,还是使用Idea自带的Java高亮规则
        ((EditorEx) myTemplateEditor).setHighlighter(this.getJavaHighLight());
        // 色彩范围,依然使用Idea默认的色彩
        ((EditorEx) myTemplateEditor).setColorsScheme(EditorColorsManager.getInstance().getGlobalScheme());
    
        // 当编辑器中的文本更改时,更新文件类型与代码模板的K-V
        myTemplateEditor.getDocument().addDocumentListener(new com.intellij.openapi.editor.event.DocumentListener() {
            @Override
            public void documentChanged(@NotNull com.intellij.openapi.editor.event.DocumentEvent e) {
                // 获取当前选中的子项
                EntityType entityType = entityTypeList.getSelectedValue();
                if (entityType != null) {
                    // 更新子项对应的 value
                    state.getConfigMap().put(entityType, myTemplateEditor.getDocument().getText());
                }
            }
        });
        editorPanel.add(myTemplateEditor.getComponent());
    }
    
    // 从Live Template功能中copy来的代码,稳妥
    private LayeredLexerEditorHighlighter getJavaHighLight() {
        SyntaxHighlighter originalHighlighter = SyntaxHighlighterFactory.getSyntaxHighlighter(JavaFileType.INSTANCE, null, null);
        if (originalHighlighter == null) {
            originalHighlighter = new JavaFileHighlighter();
        }
        final EditorColorsScheme scheme = EditorColorsManager.getInstance().getGlobalScheme();
        LayeredLexerEditorHighlighter highlighter;
        // 识别Java语言的高亮
        highlighter = new LayeredLexerEditorHighlighter(new JavaFileHighlighter(), scheme);
        highlighter.registerLayer(new IElementType("java.FILE", JavaLanguage.INSTANCE), new LayerDescriptor(originalHighlighter, ""));
        return highlighter;
    }
    
  • 可以修改不同文件类型的代码模板

    public EntityGenerateUI() {
        // 为List绑定初始的可选值
        DefaultListModel<EntityType> model = new DefaultListModel<>();
        model.addAll(List.of(EntityType.values()));
        entityTypeList.setModel(model);
        entityTypeList.addListSelectionListener(e -> {
            // 该检查确保事件不是在值正在调整时触发
            if (!e.getValueIsAdjusting()) {
                EntityType entityType = entityTypeList.getSelectedValue();
                changeItemState(entityType);
            }
        });
        // 设置
        entityTypeList.setSelectedValue(model.firstElement(), false);
    }
    
    // 将右侧编辑器的显示内容更新
    private synchronized void changeItemState(EntityType entityType) {
        String context = state.getConfigMap().get(entityType);
        // Editor中绑定了一个Document,这才是实际的文本。
        // 为了避免对Document进行写操作时出现并发问题,需要用一个异步线程来单独操作
        ApplicationManager.getApplication().runWriteAction(()->{
            myTemplateEditor.getDocument().setText(context);
            // 更新文本后,将滚动框滚动到顶部
            editorScrollPanel.getViewport().setViewPosition(new Point(0, 0));
        });
    }
    
    

代码示例

修改后的EntityGenerateUI为以下内容

package com.asteroid.planetary_engine.idea.ui;

import cn.hutool.core.bean.BeanUtil;
import com.asteroid.planetary_engine.idea.domain.EntityType;
import com.asteroid.planetary_engine.idea.state.EntityGenerateStateManage;
import com.asteroid.planetary_engine.idea.utils.highlight.MyTemplateHighlighter;
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer;
import com.intellij.codeInsight.template.impl.TemplateEditorUtil;
import com.intellij.ide.fileTemplates.impl.FileTemplateHighlighter;
import com.intellij.ide.highlighter.JavaFileHighlighter;
import com.intellij.ide.highlighter.JavaFileType;
import com.intellij.lang.java.JavaLanguage;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.colors.EditorColorsManager;
import com.intellij.openapi.editor.colors.EditorColorsScheme;
import com.intellij.openapi.editor.ex.EditorEx;
import com.intellij.openapi.editor.ex.util.LayerDescriptor;
import com.intellij.openapi.editor.ex.util.LayeredLexerEditorHighlighter;
import com.intellij.openapi.fileTypes.PlainSyntaxHighlighter;
import com.intellij.openapi.fileTypes.SyntaxHighlighter;
import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory;
import com.intellij.openapi.options.Configurable;
import com.intellij.openapi.options.ConfigurationException;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.project.ProjectManager;
import com.intellij.openapi.util.NlsContexts.ConfigurableName;
import com.intellij.psi.JavaCodeFragment;
import com.intellij.psi.JavaCodeFragmentFactory;
import com.intellij.psi.JavaPsiFacade;
import com.intellij.psi.PsiDocumentManager;
import com.intellij.psi.tree.IElementType;
import com.intellij.ui.components.JBList;
import lombok.Data;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import javax.swing.*;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.*;
import java.util.List;

@Data
public class EntityGenerateUI implements Configurable {

    private JPanel rootPanel;
    private JPanel mvcPanel;
    private JPanel configPanel;
    private JPanel commonPanel;
    private JPanel itemPanel;
    private JTextField packageName;
    private JTextField annotationName;
    private JBList<EntityType> entityTypeList;
    private JPanel itemListPanel;
    private JPanel templatePanel;
    private JSplitPane jSplitPane;
    private JScrollPane editorScrollPanel;
    private JPanel editorPanel;
    private EntityGenerateStateManage.EntityGenerateState state = new EntityGenerateStateManage.EntityGenerateState();
    private Editor myTemplateEditor;

    {
        // 初始化UI中的临时界面
        EntityGenerateStateManage stateManage = EntityGenerateStateManage.getInstance();
        BeanUtil.copyProperties(stateManage.getState(), state);
        packageName.setText(state.getPackageName());
        annotationName.setText(state.getAnnotationQualifiedName());
    }

    @Override
    public @Nullable @NonNls String getHelpTopic() {
        return "OH!!! Help me!!!";
    }

    @Override
    public @ConfigurableName String getDisplayName() {
        return "PlanetaryEngine";
    }

    @Override
    public @Nullable JComponent createComponent() {
        return rootPanel;
    }

    // 重写了equals方法,所以直接用临时配置和已加载配置equals就可以知道有没有更新
    @Override
    public boolean isModified() {
        return !state.equals(EntityGenerateStateManage.DEFAULT_STATE);
    }

    // 将临时配置写入磁盘中
    @Override
    public void apply() throws ConfigurationException {
        EntityGenerateStateManage.update(state);
    }
    
    // 为当前对象
    private void initEditor() {
        // 使用Live Template功能的创建Editor工具,初始不传入文本
        myTemplateEditor = TemplateEditorUtil.createEditor(false, "");
        // EditorEx 才有代码高亮功能,还是使用Idea自带的Java高亮规则
        ((EditorEx) myTemplateEditor).setHighlighter(this.getJavaHighLight());
        // 色彩范围,依然使用Idea默认的色彩
        ((EditorEx) myTemplateEditor).setColorsScheme(EditorColorsManager.getInstance().getGlobalScheme());

        // 当编辑器中的文本更改时,更新文件类型与代码模板的K-V
        myTemplateEditor.getDocument().addDocumentListener(new com.intellij.openapi.editor.event.DocumentListener() {
            @Override
            public void documentChanged(@NotNull com.intellij.openapi.editor.event.DocumentEvent e) {
                // 获取当前选中的子项
                EntityType entityType = entityTypeList.getSelectedValue();
                if (entityType != null) {
                    // 更新子项对应的 value
                    state.getConfigMap().put(entityType, myTemplateEditor.getDocument().getText());
                }
            }
        });
        editorPanel.add(myTemplateEditor.getComponent());
    }
    
    // 开发的大头,在构造器中,将各类需要的东西都准备好
    public EntityGenerateUI() {
        // JSwing默认的JTextArea等等组件,都难以配置代码的高亮,
        // 所以在这里,我们使用Idea自带的Editor,来轻松实现Java的代码模板高亮(不高亮也能用,但是不好看)
        this.initEditor();
                
        // 更新、删除、更改起始包名时,将新值更新到临时配置中
        packageName.getDocument().addDocumentListener(new DocumentListener() {
            @Override
            public void insertUpdate(DocumentEvent e) {
                state.setPackageName(packageName.getText());
            }

            @Override
            public void removeUpdate(DocumentEvent e) {
                state.setPackageName(packageName.getText());
            }

            @Override
            public void changedUpdate(DocumentEvent e) {
                state.setPackageName(packageName.getText());
            }

        });
        
        // 更新、删除、更改识注解名时,将新值更新到临时配置中
        annotationName.getDocument().addDocumentListener(new DocumentListener() {
            @Override
            public void insertUpdate(DocumentEvent e) {
                state.setAnnotationQualifiedName(annotationName.getText());
            }

            @Override
            public void removeUpdate(DocumentEvent e) {
                state.setAnnotationQualifiedName(annotationName.getText());
            }

            @Override
            public void changedUpdate(DocumentEvent e) {
                state.setAnnotationQualifiedName(annotationName.getText());
            }

        });
        // 为List绑定初始的可选值
        DefaultListModel<EntityType> model = new DefaultListModel<>();
        model.addAll(List.of(EntityType.values()));
        entityTypeList.setModel(model);
        entityTypeList.addListSelectionListener(e -> {
            // 该检查确保事件不是在值正在调整时触发
            if (!e.getValueIsAdjusting()) {
                EntityType entityType = entityTypeList.getSelectedValue();
                changeItemState(entityType);
            }
        });
        // 设置
        entityTypeList.setSelectedValue(model.firstElement(), false);
    }

    // 将右侧编辑器的显示内容更新
    private synchronized void changeItemState(EntityType entityType) {
        String context = state.getConfigMap().get(entityType);
        // Editor中绑定了一个Document,这才是实际的文本。
        // 为了避免对Document进行写操作时出现并发问题,需要用一个异步线程来单独操作
        ApplicationManager.getApplication().runWriteAction(()->{
            myTemplateEditor.getDocument().setText(context);
            // 更新文本后,将滚动框滚动到顶部
            editorScrollPanel.getViewport().setViewPosition(new Point(0, 0));
        });

    }

    @Override
    public void reset() {
        EntityGenerateStateManage stateManage = EntityGenerateStateManage.getInstance();
        BeanUtil.copyProperties(stateManage.getState(), state);
        packageName.setText(state.getPackageName());
        annotationName.setText(state.getAnnotationQualifiedName());
    }

    // 从Live Template功能中copy来的代码,稳妥
    private LayeredLexerEditorHighlighter getJavaHighLight() {
        SyntaxHighlighter originalHighlighter = SyntaxHighlighterFactory.getSyntaxHighlighter(JavaFileType.INSTANCE, null, null);
        if (originalHighlighter == null) {
            originalHighlighter = new JavaFileHighlighter();
        }
        final EditorColorsScheme scheme = EditorColorsManager.getInstance().getGlobalScheme();
        LayeredLexerEditorHighlighter highlighter;
        // 识别Java语言的高亮
        highlighter = new LayeredLexerEditorHighlighter(new JavaFileHighlighter(), scheme);
        highlighter.registerLayer(new IElementType("java.FILE", JavaLanguage.INSTANCE), new LayerDescriptor(originalHighlighter, ""));
        return highlighter;
    }
}

自此,界面上的配置已经实现了持久化与更新

运行截图

image-20240505191553955

生成Java文件

终于到了最后一步,根据代码模板生成对应的文件,先来分析一下需要做哪些操作:

  • 右键出现自定义的操作栏

    • 新建类EntityGenerateAction继承AnAction并实现actionPerformed方法。
    • plugin.xml中注册EntityGenerateAction为插件。
  • 获取当前编辑器内的文件或者project栏选中的多个文件

        @Override
        public void actionPerformed(@NotNull AnActionEvent anActionEvent) {
            // 方法一:可以直接拿到选中的虚拟文件(包含编辑器正在打开的文件、左侧Project选中的文件)
            VirtualFile[] virtualFiles = anActionEvent.getData(CommonDataKeys.VIRTUAL_FILE_ARRAY);
            
            
            // 方法二:只能拿到当前编辑器打开的文件
            // 先获取当前的编辑器对象,如果没有编辑器对象,代表没有打开文件
            Editor editor = anActionEvent.getData(CommonDataKeys.EDITOR);
            if (null == editor) {
                return null;
            }
            // 获取当前编辑的文件,通过Document寻找结构化文件的方式
            PsiFile psiFile = PsiDocumentManager.getInstance(anActionEvent.getProject()).getPsiFile(editor.getDocument());
        }
    
  • 解析Java文件,判断是否标注了指定的注解,没标注的不能生成模板代码

    • 使用Idea的PSI机制,详情请查看参考文档

    • // 用法有点类似于Class的读取方式,API很简洁,很好用
      private void generateByPsiJavaFile(Project project, PsiJavaFile psiJavaFile) {
          PsiClass[] classes = psiJavaFile.getClasses();
          PsiClass psiClass = classes[0];
          PsiAnnotation annotation = psiClass.getAnnotation(annotationQualifiedName);
      }
      
  • 读取插件配置

    // 直接调用之前准备的静态方式即可
    private final EntityGenerateStateManage.EntityGenerateState generateState = EntityGenerateStateManage.getInstance().getState();
    
  • 根据配置的代码模板,拼接真实的文件(太过简单,不解析了)

  • 生成Java文件,注:此操作需要在异步的情况下执行

    // project:当前打开的项目
    // targetFileName:目标文件名
    // JavaFileType.INSTANCE:文件类型
    // content:文件的内容
    public static void generateClassFile(Project project, String content, String targetFileName, String realPackageName, PsiFile psiFile) {
        // 使用Idea自带的写入命令处理器,还是很好用  
        WriteCommandAction.runWriteCommandAction(project, () -> {
            PsiFile targetFile = PsiFileFactory.getInstance(project).createFileFromText(targetFileName, JavaFileType.INSTANCE, content);
            PsiDirectory nestedDirectories  = ...;
            // 将文件添加到某个目录下  
            nestedDirectories.add(targetFile);
        });
    }
    

综上,创建EntityGenerateAction.javaClassCreateUtil.java

实例代码

package com.asteroid.planetary_engine.idea.action;

import cn.hutool.json.JSONUtil;
import com.asteroid.planetary_engine.idea.domain.EntityType;
import com.asteroid.planetary_engine.idea.state.EntityGenerateStateManage;
import com.asteroid.planetary_engine.idea.utils.ClassCreateUtil;
import com.asteroid.planetary_engine.idea.utils.NotifyUtil;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.actionSystem.CommonDataKeys;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.MessageType;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiAnnotation;
import com.intellij.psi.PsiClass;
import com.intellij.psi.PsiJavaFile;
import com.intellij.psi.PsiManager;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

public class EntityGenerateAction extends AnAction {

    private final EntityGenerateStateManage.EntityGenerateState generateState = EntityGenerateStateManage.getInstance().getState();

    @Override
    public void actionPerformed(@NotNull AnActionEvent anActionEvent) {
        // 获取当前项目
        Project project = anActionEvent.getProject();
        if (null == project) {
            return;
        }
        VirtualFile[] virtualFiles = anActionEvent.getData(CommonDataKeys.VIRTUAL_FILE_ARRAY);
        if (virtualFiles == null || virtualFiles.length == 0) {
            NotifyUtil.notify("未找到.java文件", MessageType.INFO);
            return;
        }
        Set<PsiJavaFile> psiJavaFiles = findPsiJavaFile(project, virtualFiles);
        if (psiJavaFiles.isEmpty()) {
            NotifyUtil.notify("未找到.java文件", MessageType.INFO);
            return;
        }
        if (StringUtils.isEmpty(generateState.getPackageName())) {
            NotifyUtil.notify("起始包名未配置", MessageType.WARNING);
            return;
        }
        if (StringUtils.isEmpty(generateState.getAnnotationQualifiedName())) {
            NotifyUtil.notify("注解的全限定名称未配置", MessageType.WARNING);
            return;
        }
        for (PsiJavaFile psiJavaFile : psiJavaFiles) {
            generateByPsiJavaFile(project, psiJavaFile);
        }
    }

    private void generateByPsiJavaFile(Project project, PsiJavaFile psiJavaFile) {
        PsiClass[] classes = psiJavaFile.getClasses();
        if (classes.length == 0) {
            NotifyUtil.notify("当前Java文件没有Class信息", MessageType.WARNING);
            return;
        }
        PsiClass psiClass = classes[0];
        if (psiClass == null) {
            NotifyUtil.notify("当前文件不是.java文件", MessageType.WARNING);
            return;
        }
        String packageName = generateState.getPackageName();
        String annotationQualifiedName = generateState.getAnnotationQualifiedName();
        HashMap<EntityType, String> configMap = generateState.getConfigMap();

        PsiAnnotation annotation = psiClass.getAnnotation(annotationQualifiedName);
        if (annotation == null) {
            // Messages.showMessageDialog(psiJavaFile.getName() + "未标注@Entity注解", "未找到指定注解", Messages.getWarningIcon());
            NotifyUtil.notify(psiJavaFile.getName() + "未标注@Entity注解", MessageType.WARNING);
            // Messages.showMessageDialog("未标注@com.example.emptydemo.at.Entity注解", "未找到指定注解",  UIUtil.getWarningIcon());
            return;
        }
        if (StringUtils.isEmpty(packageName)) {
            return;
        }
        for (Map.Entry<EntityType, String> entry : configMap.entrySet()) {
            EntityType entityType = entry.getKey();
            String content = entry.getValue();
            HashMap<String, String> replaceKV = new HashMap<>();
            replaceKV.put("className", psiClass.getName());
            replaceKV.put("lowClassName", getLowClassName(psiClass.getName()));
            replaceKV.put("qualifiedName", psiClass.getQualifiedName());
            replaceKV.put("sourcePackage", psiJavaFile.getPackageName());
            replaceKV.put("startPackage", packageName);
            String targetClassName = psiClass.getName() + entityType.name();
            replaceKV.put("targetClassName", targetClassName);
            System.out.println(JSONUtil.toJsonPrettyStr(replaceKV));
            // 将entityType.getCode()的第一个字符转为小写
            String code = entityType.getCode();
            String name = getLowClassName(code);
            String realPackageName = packageName + "." + name;
            replaceKV.put("targetPackageName", realPackageName);
            content = "package " + realPackageName + ";\n" + content;
            for (Map.Entry<String, String> subEntry : replaceKV.entrySet()) {
                content = content.replace("${" + subEntry.getKey() + "}", subEntry.getValue() == null ? "" : subEntry.getValue());
            }
            System.out.println("content = " + content);
            ClassCreateUtil.generateClassFile(project, content, targetClassName + ".java", realPackageName, psiJavaFile);
        }
    }

    private Set<PsiJavaFile> findPsiJavaFile(Project project, VirtualFile[] virtualFiles) {
        Set<PsiJavaFile> list = new HashSet<>();
        for (VirtualFile virtualFile : virtualFiles) {
            if (virtualFile.isDirectory()) {
                VirtualFile[] children = virtualFile.getChildren();
                if (children != null && children.length > 0) {
                    list.addAll(findPsiJavaFile(project, children));
                }
            } else {
                if (virtualFile.getName().endsWith(".java")) {
                    System.out.println(virtualFile.getName() + "isJava");
                    list.add((PsiJavaFile) PsiManager.getInstance(project).findFile(virtualFile));
                }
            }
        }
        return list;
    }

    private String getLowClassName(@Nullable String name) {
        if (StringUtils.isEmpty(name)) {
            return "";
        }
        return name.substring(0, 1).toLowerCase() + name.substring(1);
    }

    private String getUpClassName(@Nullable String name) {
        if (StringUtils.isEmpty(name)) {
            return "";
        }
        return name.substring(0, 1).toUpperCase() + name.substring(1);
    }
}

生成文件的工具类如下

package com.asteroid.planetary_engine.idea.utils;

import com.intellij.ide.highlighter.JavaFileType;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.MessageType;
import com.intellij.openapi.ui.Messages;
import com.intellij.psi.*;
import com.intellij.util.ui.UIUtil;

import java.util.concurrent.atomic.AtomicReference;

public class ClassCreateUtil {

    private static PsiDirectory createNestedDirectories(PsiDirectory baseDirectory, String[] packageParts) {
        AtomicReference<PsiDirectory> currentDirectory = new AtomicReference<>(baseDirectory);
        ApplicationManager.getApplication().runWriteAction(() -> {
            currentDirectory.set(baseDirectory);
            for (String packageNamePart : packageParts) {
                PsiDirectory subdirectory = currentDirectory.get().findSubdirectory(packageNamePart);
                if (subdirectory == null) {
                    subdirectory = currentDirectory.get().createSubdirectory(packageNamePart);
                }
                currentDirectory.set(subdirectory);
            }
        });
        return currentDirectory.get(); // 返回最终创建或找到的目录
    }

    private static PsiDirectory findJavaDir(PsiDirectory directory) {
        if (directory.getName().equals("java")) {
            return directory;
        }
        PsiDirectory parentDirectory = directory.getParentDirectory();
        if (parentDirectory == null) {
            return null;
        }
        return findJavaDir(parentDirectory);
    }

    public static void generateClassFile(Project project, String content, String targetFileName, String realPackageName, PsiFile psiFile) {
        WriteCommandAction.runWriteCommandAction(project, () -> {
            PsiDirectory javaDir = findJavaDir(psiFile.getContainingDirectory());
            if (javaDir == null) {
                NotifyUtil.notify("未找到java目录", MessageType.WARNING);
                Messages.showMessageDialog("未找到java根目录", "生成" + targetFileName + "失败", UIUtil.getWarningIcon());
                return;
            }
            PsiDirectory nestedDirectories = createNestedDirectories(javaDir, realPackageName.split("\\."));
            if (nestedDirectories.findFile(targetFileName) != null) {
                NotifyUtil.notify("文件:" + targetFileName + "已存在", MessageType.WARNING);
                return;
            }
            PsiFile targetFile = PsiFileFactory.getInstance(project).createFileFromText(targetFileName, JavaFileType.INSTANCE, content);
            nestedDirectories.add(targetFile);
        });
    }

文章作者: Administrator
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 AE86小行星
编程之路
喜欢就支持一下吧