如何开发一个摸鱼插件

前言

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

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

开发环境

  • idea2018.2.4
  • devkit

代码地址

ReaderPlugin-devkit分支

ToolWindowFactory

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


devkit

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

<extensions defaultExtensionNs="com.intellij">
    <!-- Add your extensions here -->
    <toolWindow id="iReader" anchor="bottom" canCloseContents="true"
                factoryClass="com.iamyours.reader.MainUi"/>
</extensions>

其中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

<extensions defaultExtensionNs="com.intellij">
    <!-- Add your extensions here -->
    <runLineMarkerContributor language="JAVA" implementationClass="com.iamyours.reader.run.FishLineMarker"/>
    <runConfigurationProducer implementation="com.iamyours.reader.run.FishProducer"/>
    <configurationType implementation="com.iamyours.reader.run.FishConfigType"/>
</extensions>
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<PsiElement, String> 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<? extends RunConfiguration> 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<FishRunConfiguration> {
    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<String, String> readOptions(Element element) {
    final Map<String, String> 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<FishRunConfiguration> {//指定的Configuration为FishRunConfiguration
    public FishProducer() {
        super(new FishConfigType());//具体
    }

    @Override
    protected boolean setupConfigurationFromContext(//根据上下文判断是否要添加到RunConfiguration
            @NotNull FishRunConfiguration config,
            @NotNull ConfigurationContext context,
            @NotNull Ref<PsiElement> 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<RunConfiguration> 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,137评论 6 511
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,824评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,465评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,131评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,140评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,895评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,535评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,435评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,952评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,081评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,210评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,896评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,552评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,089评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,198评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,531评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,209评论 2 357