如何开发一个摸鱼插件

前言

摸鱼应该是程序员必备的技能了吧,每个人都有自己的一套摸鱼方式。
甚至有些摸鱼软件能让你更加愉悦的上班。前段时间无意接触的Thief,感觉挺有意思的,但是小说阅读体验不是很好。
于是利用摸鱼的时间自己写了个摸鱼的插件。官方文档和例子也不是很完全,在查看flutter,junit,leetcode-editor等相关idea插件源码后才慢慢有点经验。
以下是idea插件开发的相关笔记,感兴趣的通过可以了解下(也可以用下这款插件)。

基于DevKit开发摸鱼插件简易版

开发环境

  • idea2018.2.4
  • devkit

代码地址

ReaderPlugin-devkit分支

ToolWindowFactory

使用idea2018,新建一个devkit插件项目


devkit

然后在resources/META-INF/plugin.xml文件下添加toolWindow结点用于展示文本阅读器界面


    
    

其中factoryClassToolWindowFactory接口的实现类,新建MainUi实现它。

public class MainUI implements ToolWindowFactory {
    @Override
    public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) {
        JPanel mainPanel = new JPanel();
        // ... 添加阅读器布局
        initUI(mainPanel);
        //将mainPanel加入到ToolWindow中
        ContentFactory contentFactory = ContentFactory.SERVICE.getInstance();
        Content content = contentFactory.createContent(mainPanel, "Debug", false);
        toolWindow.getContentManager().addContent(content);
    }
}

我们可以在mainPanel中加入基于Swing的ui控件,并添加相应的事件,最终实现了一个小说阅读器。

基于gradle实现的可运行版阅读插件

基于devkit的版本实现起来简单,我们可以用它来摸鱼了。有一个问题就是不够隐蔽,不够"装模作样"。于是就有了第二种版本:通过写代码的方式运行插件,
这样就能无中生有暗度陈仓凭空想象浑水摸鱼
首先创建一个测试类Test,里面添加一个摸鱼函数,加入要阅读的txt文件。

public class Test {
    public void fishReadTxt(){
        String path = "/Users/xxx/Downloads/凡人修仙传.txt";
    }
}

此时插件自动识别定位该方法,点击左边icon,显示Run fishRead2运行摸鱼程序。

开发环境

  • idea2020.2 CE
  • gradle

代码地址

ReaderPlugin-master分支

gradle配置

使用idea2020新建一个gradle项目,勾选Intellij platform plugin,在build.gradle文件中配置如下

intellij {
    //version = '2020.2' 
    plugins = ['java']  //处理java源码
    updateSinceUntilBuild false //兼容idea旧版本
    localPath "/Users/xxx/Desktop/soft/ideaIC-2018.2.4"  //下载较慢,使用本地idea版本
}

lineMarker显示摸鱼函数

为了能找到以fish开头的摸鱼方法,我们在plugin.xml中添加runLineMarkerContributor,并且添加实现类FishLineMarker


    
    
    
    

public class FishLineMarker extends RunLineMarkerContributor {
    @Override
    public @Nullable Info getInfo(@NotNull PsiElement element) {
        if (element instanceof PsiJavaToken && element.getParent() instanceof PsiMethod) {//指定方法那一行
            PsiMethod psiMethod = (PsiMethod) element.getParent();
            String name = psiMethod.getName();
            if (name.startsWith("fish")) {//标记fish开头的方法
                final Icon icon = ReaderIcons.LOGO;
                PsiClass psiClass = (PsiClass) psiMethod.getParent();
                String classAndMethod = psiClass + "." + name + "()";
                final Function tooltipProvider =
                        psiElement -> "Run '" + classAndMethod + "'";
                return new RunLineMarkerContributor.Info(icon, tooltipProvider, ExecutorAction.getActions());
            }
        }
        return null;
    }
}

getInfo方法会得到java类中的所有元素,包括方法,成员变量,本地变量,表达式。因此你可以标记任意一行代码。这里我们只关注方法,并且是以fish为开头的方法。

linemarker.png

configurationType

通过idea中的Edit Configuration,可以添加,修改各种运行程序。如tomcat,junit,android等。你会在Configration下发现各种模版。
我们通过configurationType添加自己的configuration,用于保存摸鱼插件的相关信息(如txt文件路径),对应的实现类如下:

public class FishConfigType implements ConfigurationType {
    final ConfigurationFactory factory = new Factory(this);

    private static final FishConfigType instance = new FishConfigType();

    public static FishConfigType getInstance() {
        return instance;
    }

   //...其他重写方法,如显示名称,icon,描述等

    @Override
    public ConfigurationFactory[] getConfigurationFactories() {
        return new ConfigurationFactory[]{factory};
    }

    static class Factory extends ConfigurationFactory {
        protected Factory(@NotNull FishConfigType type) {
            super(type);
        }

        @Override
        public @NotNull RunConfiguration createTemplateConfiguration(@NotNull Project project) {
            return new FishRunConfiguration(project, this, "");
        }
    }
}

定义RunConfiguration设置界面

public class FishRunConfiguration extends LocatableConfigurationBase {
    protected FishRunConfiguration(@NotNull Project project, @NotNull ConfigurationFactory factory, @Nullable String name) {
        super(project, factory, name);
    }
    @Override
    public @NotNull SettingsEditor getConfigurationEditor() {
        return new FishRunConfigUI();
    }

    @Override
    public @Nullable RunProfileState getState(@NotNull Executor executor, @NotNull ExecutionEnvironment executionEnvironment) throws ExecutionException {
        return new FishRunState(executionEnvironment, this);//具体插件运行逻辑,界面
    }
}

每个RunConfiguration的设置界面是可以配置的。可通过getConfigurationEditor添加SettingsEditor

public class FishRunConfigUI extends SettingsEditor {
    private JPanel form;

    @Override
    protected void resetEditorFrom(@NotNull FishRunConfiguration config) {
       
    }

    @Override
    protected void applyEditorTo(@NotNull FishRunConfiguration config) throws ConfigurationException {
      
    }

    @Override
    protected @NotNull JComponent createEditor() {
        return form;
    }
}

右键选择new->Swing UI Designer->GUI Form创建一个FishRunConfigUI表单界面,并将它继承自SettingsEditor
有三个重要的方法:

  • createEditor: 创建RunConfiguration设置界面
  • resetEditorFrom:将数据从config中恢复到界面
  • applyEditorTo:将ui中的数据保存到config中。


    run-configuration.png

数据保存

为了能够在下次打开ide时还能运行RunConfigration中的程序,需要将config中的数据保存到磁盘。我们通过FishRunConfiguration中的writeExternalreadExternal来写入或读取数据。

public class FishRunConfiguration extends LocatableConfigurationBase {
    @Override
    public void writeExternal(@NotNull Element element) {
        super.writeExternal(element);
        ElementIO.addOption(element, "bookPath", bookPath);
        ElementIO.addOption(element, "classFile", classFile);

    }

    @Override
    public void readExternal(@NotNull Element element) throws InvalidDataException {
        super.readExternal(element);
        bookPath = ElementIO.readOptions(element).get("bookPath");
        classFile = ElementIO.readOptions(element).get("classFile");
    }
}

/**
 * Utilities for reading and writing IntelliJ run configurations to and from the disk.
 */
public class ElementIO {

  public static void addOption(@NotNull Element element, @NotNull String name, @Nullable String value) {
    if (value == null) return;

    final Element child = new Element("option");
    child.setAttribute("name", name);
    child.setAttribute("value", value);
    element.addContent(child);
  }

  public static Map readOptions(Element element) {
    final Map result = new HashMap<>();
    for (Element child : element.getChildren()) {
      if ("option".equals(child.getName())) {
        final String name = child.getAttributeValue("name");
        final String value = child.getAttributeValue("value");
        if (name != null && value != null) {
          result.put(name, value);
        }
      }
    }
    return result;
  }
}

使用runConfigurationProducer创建RunConfiguration

通过之前的配置,我们可以通过Edit Congigurations方式添加一个RunConfiguration来运行插件。
然后在FishRunConfiguration中的getState中来实现具体插件的运行逻辑,这个逻辑暂且放到后面。这里需要注意的是我们已经通过runLineMarkerContributor标记了摸鱼方法,但是并没有实现对应的操作。
所以需要通过runConfigurationProducer来创建(或修改)RunCongfiguration

public class FishProducer extends RunConfigurationProducer {//指定的Configuration为FishRunConfiguration
    public FishProducer() {
        super(new FishConfigType());//具体
    }

    @Override
    protected boolean setupConfigurationFromContext(//根据上下文判断是否要添加到RunConfiguration
            @NotNull FishRunConfiguration config,
            @NotNull ConfigurationContext context,
            @NotNull Ref ref) {
        final PsiElement element = context.getPsiLocation();
        if (element instanceof PsiJavaToken && element.getParent() instanceof PsiMethod) {//
            PsiMethod psiMethod = (PsiMethod) element.getParent();
            String name = psiMethod.getName();
            if (name.startsWith("fish")) {//当前为fish方法时,添加到RunConfiguration
                //接着获取方法中的一些参数
                return true;
            }
        }
        return false;
    }
    

    @Override
    public boolean isConfigurationFromContext(
            @NotNull FishRunConfiguration config,
            @NotNull ConfigurationContext context) {//判断是否要修改RunConfiguration
        String name = config.getName();
        final PsiElement element = context.getPsiLocation();
        if (element instanceof PsiJavaToken && element.getParent() instanceof PsiMethod) {
            PsiMethod psiMethod = (PsiMethod) element.getParent();
            String methodName = psiMethod.getName();
            if (methodName.startsWith("fish")
                    && methodName.equals(config.getName())) {
                return isInConfigs(context, psiMethod);
            }
        }
        return false;
    }

    private boolean isInConfigs(ConfigurationContext context, PsiMethod psiMethod) {
        List list = context.getRunManager().getAllConfigurationsList();
        String name = psiMethod.getName();
        for (RunConfiguration config : list) {
            if (config instanceof FishRunConfiguration
                    && config.getName().equals(name)) {//如果在RunConfiguration列表中有,则修改相关信息
                FishRunConfiguration rc = (FishRunConfiguration) config;
                return true;
            }
            ;
        }
        return false;
    }
}

RunProfileState具体插件运行逻辑

我们在RunConfiguraion中已经保存了运行插件需要的相关参数,接下来我们实现具体的插件逻辑。还记得之前FishRunConfiguration类中的getState方法吗?
我们需要获取一个RunProfileState实例,里面是插件运行的逻辑。

public class FishRunState implements RunProfileState {
    private FishRunConfiguration config;
    private ExecutionEnvironment environment;

    protected FishRunState(ExecutionEnvironment environment, FishRunConfiguration config) {
        this.config = config;
        this.environment = environment;
    }

    private FishRunConsole console;//插件运行界面

    private void doRun(ProcessHandler handler, FishRunConfiguration config) {
        ProgressManager.getInstance().run(new Task.Backgroundable(environment.getProject(), "build", true) {
            @Override
            public void run(@NotNull ProgressIndicator progressIndicator) {
                SwingUtilities.invokeLater(() -> {
                    console.startBuild(config);
                });
                BookEngine engine = new BookEngine(config.bookVO);
                SwingUtilities.invokeLater(() -> {
                    console.loadWithEngine(engine);
                });
                handler.destroyProcess();//结束运行
            }
        });
    }

    @Nullable
    @Override
    public ExecutionResult execute(Executor executor, @NotNull ProgramRunner programRunner) throws ExecutionException {
        ProcessHandler handler = new NopProcessHandler();
        console = new FishRunConsole();
        DefaultExecutionResult result = new DefaultExecutionResult(console, handler) {
        };
        ProcessTerminatedListener.attach(handler);
        handler.addProcessListener(new ProcessListener() {
            @Override
            public void startNotified(@NotNull ProcessEvent processEvent) {
                doRun(handler, config);
            }

            @Override
            public void processTerminated(@NotNull ProcessEvent processEvent) {}

            @Override
            public void processWillTerminate(@NotNull ProcessEvent processEvent, boolean b) {}

            @Override
            public void onTextAvailable(@NotNull ProcessEvent processEvent, @NotNull Key key) {}
        });
        return result;
    }
}

ExecutionConsole展示插件运行界面

public class FishRunConsole implements ExecutionConsole, KeyListener {
    private JTree tree;
    private JTextArea textArea;
    private JComponent component;
    private DefaultMutableTreeNode root;

    @Override
    public @NotNull JComponent getComponent() {
        if (component == null) {
            component = createComponent();
        }
        return component;
    }

    @Override
    public JComponent getPreferredFocusableComponent() {
        return getComponent();
    }

    public void startBuild(FishRunConfiguration config) {
        root.add(new LoadingNode("...."));
        textArea.requestFocus();
    }

    private JComponent createComponent() {
        SimpleToolWindowPanel panel = new SimpleToolWindowPanel(true);
        SimpleToolWindowPanel top = new SimpleToolWindowPanel(true);
        tree = new SimpleTree() {
            @Override
            protected void paintComponent(Graphics g) {
                super.paintComponent(g);
                DefaultMutableTreeNode root = (DefaultMutableTreeNode) treeModel.getRoot();
            }
        };
        ChapterVO obj = new ChapterVO(0, "build");
        root = new DefaultMutableTreeNode(obj);
        DefaultTreeModel model = new DefaultTreeModel(root);
        tree.setModel(model);
        tree.addTreeSelectionListener(new TreeSelectionListener() {
            @Override
            public void valueChanged(TreeSelectionEvent e) {
                TreePath path = e.getPath();
                DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
                if (engine != null) {
                    engine.selectChapter((ChapterVO) node.getUserObject());
                    textArea.setText(engine.getCurrentContent());
                }
            }
        });
        removeKeyListener(tree);//移除搜索相关事件
        tree.addKeyListener(this);
        tree.setCellRenderer(new CustomTreeRenderer());
        SimpleToolWindowPanel right = new EastToolWindowPanel(false);
        final ActionManager actionManager = ActionManager.getInstance();
        ActionToolbar actionToolbar = actionManager.createActionToolbar("Reader Toolbar",
                (DefaultActionGroup) actionManager.getAction("reader.TextArea"),
                true);
        actionToolbar.setTargetComponent(textArea);
        right.setToolbar(actionToolbar.getComponent());
        textArea = new JTextArea("press 'N' to next");
        textArea.setMargin(new Insets(5, 5, 5, 5));
        textArea.addKeyListener(this);
        textArea.setWrapStyleWord(true);
        textArea.setLineWrap(true);
        textArea.setEditable(false);
        JBScrollPane textScrollPane = new JBScrollPane(textArea);
        right.setContent(textScrollPane);
        JBSplitter splitPane = new OnePixelSplitter(false, "test", 0.3f);
        splitPane.setFirstComponent(top);
        splitPane.setSecondComponent(right);
        Color color = new Color(0, 0, 0, 0);
        splitPane.getDivider().setBackground(color);
        JBScrollPane scrollPane = new JBScrollPane(tree);
        scrollPane.setHorizontalScrollBarPolicy(HORIZONTAL_SCROLLBAR_NEVER);
        top.setContent(scrollPane);
        panel.setContent(splitPane);
        return panel;
    }

    private void removeKeyListener(JComponent component) {
        KeyListener[] listeners = component.getKeyListeners();
        for (KeyListener listener : listeners) {
            component.removeKeyListener(listener);
        }
    }
    
    private int count;

    @Override
    public void keyReleased(KeyEvent e) {
        String str = e.getKeyChar() + "";
        if ("N".equals(str.toUpperCase())) {
            engine.readNext();
            textArea.setText(engine.getCurrentContent());
        }
    }

    private BookEngine engine;
    private int currentChapterIndex = 0;
    private boolean expanded = true;

    private void selectCurrent() {
        if (!expanded) return;
        tree.setSelectionRow(currentChapterIndex);
        tree.scrollRowToVisible(currentChapterIndex);
    }

    public void loadWithEngine(BookEngine engine) {
        this.engine = engine;
        engine.setChapterListener(new BookEngine.ChapterListener() {
            @Override
            public void chapterChanged(int index) {
                currentChapterIndex = index + 1;
                selectCurrent();
            }
        });
        tree.addTreeExpansionListener(new TreeExpansionListener() {
            @Override
            public void treeExpanded(TreeExpansionEvent event) {
                expanded = true;
                selectCurrent();
            }

            @Override
            public void treeCollapsed(TreeExpansionEvent event) {
                expanded = false;
            }
        });
        EventBus.register(() -> {
            engine.readNext();
            textArea.setText(engine.getCurrentContent());
        });
        root.removeAllChildren();
        for (ChapterVO c : engine.getChapterList()) {
            root.add(new DefaultMutableTreeNode(c));
        }
        tree.expandRow(0);
        tree.updateUI();
    }
}

最终运行界面如下:


参考链接

IntelliJ Platform SDK DevGuide
intellij-sdk-code-samples
junit插件
flutter-intellij
leetcode-editor

你可能感兴趣的:(如何开发一个摸鱼插件)