RingUI + JCEF开发IDEA插件

RingUI

This collection of UI components aims to provide all the necessary building blocks for web-based products built inside JetBrains, as well as third-party plugins developed for JetBrains’ products.

JetBrains团队发布了一套基于Web的、和JetBrains产品风格相同的UI组件库ring-ui,我觉得这个UI组件库有以下使用场景:

  1. 开发一个JetBrains风格的网页应用,比如开发了一个JetBrains插件,提供一个网站来介绍该插件;
  2. 开发一个基于Web的JetBrains插件,比如Markdown预览插件,这类插件如果使用Java Swing来实现,基本不可能,但是直接使用web组件却很方便;
  3. 开发一个JetBrains插件,但是界面非常复杂,开发人员对Java Swing不熟悉,但对react和web ui组件非常熟悉,可直接使用ring-ui来开发用户界面。

知识储备

  • RingUI:ring-ui

  • RingUI文档:ring-ui 文档

  • CEF(Chromium Embedded Framework)/JCEF(Java Chromium Embedded Framework)/JBCEF(JetBrains Chromium Embedded Framework):

    Chromium Embedded Framework: 基于Google Chromium项目的开源Web browser控件,C++实现,支持Windows, Linux, Mac平台,即可以在自己的C++项目中嵌入一个Chrome内核的浏览器,并且支持各种自定义接口,比如自定义响应下载、自定义拦截请求、自定义浏览器控制台等,类似一个非常强大的WebView;
    Java Chromium Embedded Framework: 通过JNI机制,将C++的接口通过Java暴露,并且支持将浏览器嵌入JFrame。
    JetBrains Chromium Embedded Framework: JetBrains在JCEF的基础上进一步作出的封装JetBrains/jcef
    IDEA 2020.1版本中,JCEF作为一个试验性功能,需要通过一些额外操作才能启用JCEF;在IDEA 2020.2+版本中,JCEF默认集成并启用,成为一个标准功能:JCEF - Java Chromium Embedded Framework

  • React: reactjs

    RingUI是一个React组件库,需要对React有一定的了解

  • JOOQ SQL Translation:SQL Translation

    这是一个在线的sql方言翻译工具,可以把一种数据库的SQL语法转换成另一种数据的SQL语法,比如Oracle转成MySQL

示例插件

希望有一个IDEA插件,点击后打开一个弹窗,展示类似于百度翻译的界面,选择源数据库和目标数据库,点击翻译能完成SQL语法的转换,有如下限制和要求:

  • JOOQ有开源版本,并且暴露了SQL转换的方法,但不能用开源版本的JOOQ实现,必须访问https://www.jooq.org/translate/translate接口来实现转换,因为开源版本的JOOQ只支持开源数据库之间的SQL转换,商业数据库需要购买license,而在线的SQL转换支持所有JOOQ支持的数据库,包含Oralce在内的商业数据库;
  • 不允许直接嵌入浏览器,直接访问https://www.jooq.org/translate/,因为默认的界面太丑,而且有很多与转换无关的信息;
  • 界面的风格需要跟随IDEA本身风格,即跟随IDEA的深色和浅色模式;

实现逻辑

  • 界面由单独的React + RingUI前端项目实现,npm run build构建后将build目录的内容拷贝至插件resource目录下;
  • 在IDEA顶部tools菜单下添加一个SQL Translator菜单,点击后弹出一个窗口,使用JCEF渲染resource目录下的index.html,JCEF访问一个虚拟地址,为JCEF添加一个请求资源拦截器,根据访问的URI决定返回resource目录下的静态资源,还是调用远程接口;
  • 风格切换实现逻辑:JCEF访问index.html之前,先获取当前IDEA的风格,然后在URL后面加上Query参数,如index.html?theme=dark,React解析参数,动态设定页面风格。

开发环境

  • IDEA:IntelliJ IDEA 2022.3 (Ultimate Edition)
  • Gradle:7.5.1
  • JDK: 11.0.11
  • React:18.2.0
  • node/npm: v16.18.1/8.19.2

开发流程

新建一个IDEA插件项目

假设项目名称为sql-translator
在这里插入图片描述

创建完成后,系统会自动下载Gradle、IDEA SDK、JBCEF(JetBrains CEF)等,下载时间可能比较长。

新建一个前端项目

假设项目名称为react-client

# 安装必要依赖
npm install --save react react-dom webpack webpack-dev-server html-webpack-plugin
npm install --save --dev babel-loader babel-core babel-preset-react babel-preset-env@next

# 安装create-react-app
npm install -g create-react-app

# 切换到插件项目根目录
cd sql-translator

# 新建项目
create-react-app react-client

# 切换到前端项目根目录
cd react-client

# 安装ring-ui
npm install @jetbrains/ring-ui

验证前端项目

替换前端项目src/App.js内容为

import '@jetbrains/ring-ui/dist/style.css';

import alertService from '@jetbrains/ring-ui/dist/alert-service/alert-service';
import Button from '@jetbrains/ring-ui/dist/button/button';


function App() {
  return (
    <Button onClick={() => alertService.successMessage('Hello world')}>
      Click me
    </Button>
  );
}

export default App;

在前端项目根目录下执行 npm start,查看浏览器是否正确显示按钮,点击按钮后,右下角是否出现消息

丰富前端项目

在插件项目resource目录下新建一个html目录
项目中用到了react-codemirroraxios等,需要安装相关依赖:npm install @uiw/react-codemirror axios @codemirror/lang-sql @jetbrains/icons
修改package.json中build脚本,方便每次构建完成后自动拷贝静态资源到目标位置

"scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build && rm -rf ../src/main/resources/html/* && cp -r build/* ../src/main/resources/html/",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  }

App.js

import './App.css';
import '@jetbrains/ring-ui/dist/style.css';

import React, {Component} from 'react'
import Button from '@jetbrains/ring-ui/dist/button/button';
import compareIcon from '@jetbrains/icons/compare';
import Select from '@jetbrains/ring-ui/dist/select/select';
import Icon from '@jetbrains/ring-ui/dist/icon/icon';
import Input, {Size} from '@jetbrains/ring-ui/dist/input/input';
import Checkbox from '@jetbrains/ring-ui/dist/checkbox/checkbox';
import Theme, {ThemeProvider, ThemedWrapper} from '@jetbrains/ring-ui/dist/global/theme';
import CodeMirror from '@uiw/react-codemirror';
import {sql} from '@codemirror/lang-sql';
import axios from "axios"

export default class App extends Component {

  constructor(props) {
    super(props);
    this.changeHandle = this.changeHandle.bind(this);
    this.selectHandle = this.selectHandle.bind(this);
    const params = new URLSearchParams(window.location.search);
    this.state = {
      "from-dialect": "DEFAULT",
      "from-search-path": "PUBLIC",
      "from-unknown-functions": false,
      "from-date-format": "YYYY-MM-DD",
      "from-timestamp-format": "YYYY-MM-DD HH24:MI:SS.FF",
      "from-retain-comments-between-queries": false,
      "from-ignore-comments": true,
      "from-ignore-comment-start": "[jooq ignore start]",
      "from-ignore-comment-stop": "[jooq ignore stop]",
      "to-dialect": "DEFAULT",
      "to-keywords": "LOWER",
      "to-name-case": "AS_IS",
      "to-name-quoted": "EXPLICIT_DEFAULT_QUOTED",
      "to-param-type": "NAMED",
      "to-patterns": "OFF",
      "to-join-style": "DEFAULT",
      "to-qualify": "WHEN_NEEDED",
      "to-rownum": "WHEN_NEEDED",
      "to-inline-cte": "WHEN_NEEDED",
      "to-group-by-column-index": "WHEN_NEEDED",
      "to-unnecessary-arithmetic": "INTERNAL",
      "to-field-as": "DEFAULT",
      "to-table-as": "DEFAULT",
      "to-inner-keyword": "DEFAULT",
      "to-outer-keyword": "DEFAULT",
      sql: "",
      translateResult: "",
      loading: false,
      theme: params.get("theme")
    }
  }

  fromDialect = [
    {label: 'No specific dialect', key: 'DEFAULT', type: 'from-dialect'},
    {label: 'BigQuery', key: 'BIGQUERY', type: 'from-dialect'},
    {label: 'CockroachDB', key: 'COCKROACHDB', type: 'from-dialect'},
    {label: 'DB2 LUW', key: 'DB2', type: 'from-dialect'},
    {label: 'Derby', key: 'DERBY', type: 'from-dialect'},
    {label: 'Exasol', key: 'EXASOL', type: 'from-dialect'},
    {label: 'Firebird', key: 'FIREBIRD', type: 'from-dialect'},
    {label: 'H2', key: 'H2', type: 'from-dialect'},
    {label: 'HANA', key: 'HANA', type: 'from-dialect'},
    {label: 'HSQLDB', key: 'HSQLDB', type: 'from-dialect'},
    {label: 'Ignite', key: 'IGNITE', type: 'from-dialect'},
    {label: 'Informix', key: 'INFORMIX', type: 'from-dialect'},
    {label: 'Ingres', key: 'INGRES', type: 'from-dialect'},
    {label: 'MariaDB', key: 'MARIADB', type: 'from-dialect'},
    {label: 'MemSQL (SingleStore)', key: 'MEMSQL', type: 'from-dialect'},
    {label: 'MySQL', key: 'MYSQL', type: 'from-dialect'},
    {label: 'MS Access', key: 'ACCESS', type: 'from-dialect'},
    {label: 'Oracle', key: 'ORACLE', type: 'from-dialect'},
    {label: 'PostgreSQL', key: 'POSTGRES', type: 'from-dialect'},
    {label: 'Redshift', key: 'REDSHIFT', type: 'from-dialect'},
    {label: 'Snowflake', key: 'SNOWFLAKE', type: 'from-dialect'},
    {label: 'SQL Data Warehouse (Azure Synapse Analytics)', key: 'SQLDATAWAREHOUSE', type: 'from-dialect'},
    {label: 'SQLite', key: 'SQLITE', type: 'from-dialect'},
    {label: 'SQL Server', key: 'SQLSERVER', type: 'from-dialect'},
    {label: 'Sybase ASE', key: 'ASE', type: 'from-dialect'},
    {label: 'Sybase SQL Anywhere', key: 'SYBASE', type: 'from-dialect'},
    {label: 'Teradata', key: 'TERADATA', type: 'from-dialect'},
    {label: 'Vertica', key: 'VERTICA', type: 'from-dialect'},
    {label: 'YugabyteDB', key: 'YUGABYTEDB', type: 'from-dialect'},
  ];
  toDialect = [
    {label: 'No specific dialect', key: 'DEFAULT', type: 'to-dialect'},
    {label: 'Aurora MySQL', key: 'AURORA_MYSQL', type: 'to-dialect'},
    {label: 'Aurora PostgreSQL', key: 'AURORA_POSTGRES', type: 'to-dialect'},
    {label: 'BigQuery', key: 'BIGQUERY', type: 'to-dialect'},
    {label: 'CockroachDB', key: 'COCKROACHDB', type: 'to-dialect'},
    {label: 'DB2 LUW 9', key: 'DB2_9', type: 'to-dialect'},
    {label: 'DB2 LUW 10', key: 'DB2_10', type: 'to-dialect'},
    {label: 'DB2 LUW 11', key: 'DB2_11', type: 'to-dialect'},
    {label: 'DB2 LUW (latest version)', key: 'DB2', type: 'to-dialect'},
    {label: 'Derby', key: 'DERBY', type: 'to-dialect'},
    {label: 'Exasol', key: 'EXASOL', type: 'to-dialect'},
    {label: 'Firebird 2.5', key: 'FIREBIRD_2_5', type: 'to-dialect'},
    {label: 'Firebird 3.0', key: 'FIREBIRD_3_0', type: 'to-dialect'},
    {label: 'Firebird 4.0', key: 'FIREBIRD_4_0', type: 'to-dialect'},
    {label: 'Firebird (latest version)', key: 'FIREBIRD', type: 'to-dialect'},
    {label: 'H2 1.4', key: 'H2_1_4_200', type: 'to-dialect'},
    {label: 'H2 2.0', key: 'H2_2_0_202', type: 'to-dialect'},
    {label: 'H2 (latest version)', key: 'H2', type: 'to-dialect'},
    {label: 'HANA', key: 'HANA', type: 'to-dialect'},
    {label: 'HSQLDB', key: 'HSQLDB', type: 'to-dialect'},
    {label: 'Ignite', key: 'IGNITE', type: 'to-dialect'},
    {label: 'Informix', key: 'INFORMIX', type: 'to-dialect'},
    {label: 'Ingres', key: 'INGRES', type: 'to-dialect'},
    {label: 'MariaDB 10.0', key: 'MARIADB_10_0', type: 'to-dialect'},
    {label: 'MariaDB 10.1', key: 'MARIADB_10_1', type: 'to-dialect'},
    {label: 'MariaDB 10.2', key: 'MARIADB_10_2', type: 'to-dialect'},
    {label: 'MariaDB 10.3', key: 'MARIADB_10_3', type: 'to-dialect'},
    {label: 'MariaDB 10.4', key: 'MARIADB_10_4', type: 'to-dialect'},
    {label: 'MariaDB 10.5', key: 'MARIADB_10_5', type: 'to-dialect'},
    {label: 'MariaDB 10.6', key: 'MARIADB_10_6', type: 'to-dialect'},
    {label: 'MariaDB (latest version)', key: 'MARIADB', type: 'to-dialect'},
    {label: 'MemSQL (SingleStore)', key: 'MEMSQL', type: 'to-dialect'},
    {label: 'MySQL 5.7', key: 'MYSQL_5_7', type: 'to-dialect'},
    {label: 'MySQL 8.0', key: 'MYSQL_8_0', type: 'to-dialect'},
    {label: 'MySQL 8.0.19', key: 'MYSQL_8_0_19', type: 'to-dialect'},
    {label: 'MySQL (latest version)', key: 'MYSQL', type: 'to-dialect'},
    {label: 'MS Access', key: 'ACCESS', type: 'to-dialect'},
    {label: 'Oracle 10g', key: 'ORACLE10G', type: 'to-dialect'},
    {label: 'Oracle 11g', key: 'ORACLE11G', type: 'to-dialect'},
    {label: 'Oracle 12c', key: 'ORACLE12C', type: 'to-dialect'},
    {label: 'Oracle 18c', key: 'ORACLE18C', type: 'to-dialect'},
    {label: 'Oracle 20c', key: 'ORACLE20C', type: 'to-dialect'},
    {label: 'Oracle (latest version)', key: 'ORACLE', type: 'to-dialect'},
    {label: 'PostgreSQL 9.3', key: 'POSTGRES_9_3', type: 'to-dialect'},
    {label: 'PostgreSQL 9.4', key: 'POSTGRES_9_4', type: 'to-dialect'},
    {label: 'PostgreSQL 9.5', key: 'POSTGRES_9_5', type: 'to-dialect'},
    {label: 'PostgreSQL 10', key: 'POSTGRES_10', type: 'to-dialect'},
    {label: 'PostgreSQL 11', key: 'POSTGRES_11', type: 'to-dialect'},
    {label: 'PostgreSQL 12', key: 'POSTGRES_12', type: 'to-dialect'},
    {label: 'PostgreSQL 13', key: 'POSTGRES_13', type: 'to-dialect'},
    {label: 'PostgreSQL 14', key: 'POSTGRES_14', type: 'to-dialect'},
    {label: 'PostgreSQL 15', key: 'POSTGRES_15', type: 'to-dialect'},
    {label: 'PostgreSQL (latest version)', key: 'POSTGRES', type: 'to-dialect'},
    {label: 'Redshift', key: 'REDSHIFT', type: 'to-dialect'},
    {label: 'Snowflake', key: 'SNOWFLAKE', type: 'to-dialect'},
    {label: 'SQL Data Warehouse (Azure Synapse Analytics)', key: 'SQLDATAWAREHOUSE', type: 'to-dialect'},
    {label: 'SQLite 3.25', key: 'SQLITE_3_25', type: 'to-dialect'},
    {label: 'SQLite 3.28', key: 'SQLITE_3_28', type: 'to-dialect'},
    {label: 'SQLite 3.30', key: 'SQLITE_3_30', type: 'to-dialect'},
    {label: 'SQLite (latest version)', key: 'SQLITE', type: 'to-dialect'},
    {label: 'SQL Server 2008', key: 'SQLSERVER2008', type: 'to-dialect'},
    {label: 'SQL Server 2012', key: 'SQLSERVER2012', type: 'to-dialect'},
    {label: 'SQL Server 2014', key: 'SQLSERVER2014', type: 'to-dialect'},
    {label: 'SQL Server 2016', key: 'SQLSERVER2016', type: 'to-dialect'},
    {label: 'SQL Server 2017', key: 'SQLSERVER2017', type: 'to-dialect'},
    {label: 'SQL Server 2022', key: 'SQLSERVER2022', type: 'to-dialect'},
    {label: 'SQL Server (latest version)', key: 'SQLSERVER', type: 'to-dialect'},
    {label: 'Sybase ASE 12.5', key: 'ASE_12_5', type: 'to-dialect'},
    {label: 'Sybase ASE 15.5', key: 'ASE_15_5', type: 'to-dialect'},
    {label: 'Sybase ASE 15.7', key: 'ASE_15_7', type: 'to-dialect'},
    {label: 'Sybase ASE 16.0', key: 'ASE_16_0', type: 'to-dialect'},
    {label: 'Sybase ASE (latest version)', key: 'ASE', type: 'to-dialect'},
    {label: 'Sybase SQL Anywhere', key: 'SYBASE', type: 'to-dialect'},
    {label: 'Teradata', key: 'TERADATA', type: 'to-dialect'},
    {label: 'Vertica', key: 'VERTICA', type: 'to-dialect'},
    {label: 'YugabyteDB', key: 'YUGABYTEDB', type: 'to-dialect'},
    {label: 'jOOQ (Java)', key: 'JAVA', type: 'to-dialect'},
  ];

  toKeywords = [
    {label: 'lower case', key: 'LOWER', type: 'to-keywords'},
    {label: 'UPPER CASE', key: 'UPPER', type: 'to-keywords'},
    {label: 'Pascal Case', key: 'PASCAL', type: 'to-keywords'},
  ];

  identifierCase = [
    {label: 'Unmodified', key: 'AS_IS', type: 'to-name-case'},
    {label: 'lower case', key: 'LOWER', type: 'to-name-case'},
    {label: 'lower case (if unquoted)', key: 'LOWER_IF_UNQUOTED', type: 'to-name-case'},
    {label: 'UPPER', key: 'UPPER CASE', type: 'to-name-case'},
    {label: 'UPPER CASE (if unquoted)', key: 'UPPER_IF_UNQUOTED', type: 'to-name-case'},
  ];

  identifierQuoting = [
    {label: 'Always', key: 'ALWAYS', type: 'to-name-quoted'},
    {label: 'Unmodified (default quoted)', key: 'EXPLICIT_DEFAULT_QUOTED', type: 'to-name-quoted'},
    {label: 'Unmodified (default unquoted)', key: 'EXPLICIT_DEFAULT_UNQUOTED', type: 'to-name-quoted'},
    {label: 'Never', key: 'NEVER', type: 'to-name-quoted'},
  ];

  bindVariables = [
    {label: 'Named', key: 'NAMED', type: 'to-param-type'},
    {label: 'Indexed', key: 'INDEXED', type: 'to-param-type'},
    {label: 'Force Indexed', key: 'FORCE_INDEXED', type: 'to-param-type'},
  ];

  toPatterns = [
    {label: 'Unmodified', key: 'OFF', type: 'to-patterns'},
    {label: 'Transform patterns', key: 'ON', type: 'to-patterns'},
  ];

  joinStyle = [
    {label: 'Unmodified', key: 'DEFAULT', type: 'to-join-style'},
    {label: 'Oracle style to ANSI join', key: 'ANSI', type: 'to-join-style'},
    {label: 'ANSI join to Oracle style', key: 'ORACLE', type: 'to-join-style'},
  ];

  qualify = [
    {label: 'Transform QUALIFY', key: 'ALWAYS', type: 'to-qualify'},
    {label: 'Transform QUALIFY when not supported', key: 'WHEN_NEEDED', type: 'to-qualify'},
    {label: "Don't transform QUALIFY", key: 'NEVER', type: 'to-qualify'},
  ];

  rownum = [
    {label: 'Transform ROWNUM', key: 'ALWAYS', type: 'to-rownum'},
    {label: 'Transform ROWNUM when not supported', key: 'WHEN_NEEDED', type: 'to-rownum'},
    {label: "Don't transform ROWNUM", key: 'NEVER', type: 'to-rownum'},
  ];

  inlineCte = [
    {label: 'Transform inline CTE', key: 'ALWAYS', type: 'to-inline-cte'},
    {label: 'Transform inline CTE when not supported', key: 'WHEN_NEEDED', type: 'to-inline-cte'},
    {label: "Don't transform inline CTE", key: 'NEVER', type: 'to-inline-cte'},
  ];

  groupByColumnIndex = [
    {label: 'Transform GROUP BY <column index>', key: 'ALWAYS', type: 'to-group-by-column-index'},
    {label: 'Transform GROUP BY <column index> when not supported', key: 'WHEN_NEEDED', type: 'to-group-by-column-index'},
    {label: "Don't transform GROUP BY <column index>", key: 'NEVER', type: 'to-group-by-column-index'},
  ];

  unnecessaryArithmetic = [
    {label: 'Internal (from emulations)', key: 'INTERNAL', type: 'to-unnecessary-arithmetic'},
    {label: 'Never', key: 'NEVER', type: 'to-unnecessary-arithmetic'},
    {label: "Always", key: 'ALWAYS', type: 'to-unnecessary-arithmetic'},
  ];

  toFieldAs = [
    {label: 'Default', key: 'DEFAULT', type: 'to-field-as'},
    {label: 'Off', key: 'OFF', type: 'to-field-as'},
    {label: "On", key: 'ON', type: 'to-field-as'},
  ];

  toTableAs = [
    {label: 'Default', key: 'DEFAULT', type: 'to-table-as'},
    {label: 'Off', key: 'OFF', type: 'to-table-as'},
    {label: "On", key: 'ON', type: 'to-table-as'},
  ];

  toInnerKeyword = [
    {label: 'Default', key: 'DEFAULT', type: 'to-inner-keyword'},
    {label: 'Off', key: 'OFF', type: 'to-inner-keyword'},
    {label: "On", key: 'ON', type: 'to-inner-keyword'},
  ];

  toOuterKeyword = [
    {label: 'Default', key: 'DEFAULT', type: 'to-outer-keyword'},
    {label: 'Off', key: 'OFF', type: 'to-outer-keyword'},
    {label: "On", key: 'ON', type: 'to-outer-keyword'},
  ];

  setStateValue = (e) => {
    const targetName = e.type;
    const targetValue = e.key;
    this.setState({[targetName]: targetValue}, () => {
      console.log(this.state[targetName])
    });
    console.log(this.state);
  }

  translate = () => {
    this.setState({loading: true}, () => {
      const header = {headers: {'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Access-Control-Allow-Origin': '*'}};
      axios.post("translate", {
        ...this.state
      }, header).then((res) => {
        console.log(res)
        this.setState({
          translateResult: res.data,
          loading: false
        })
      }).catch((reason) => {
        this.setState({
          translateResult: reason.message,
          loading: false
        })
      });
    });
  }

  changeHandle(e) {
    e.preventDefault()
    let key = e.target.dataset.key;
    this.setState({[key]: e.target.value}, () => {
      console.log(this.state[key])
    })
  }

  selectHandle(e) {
    console.log(e)
    let key = e.target.dataset.key;
    this.setState({[key]: e.target.checked}, () => {
      console.log(this.state[key])
    })
  }

  render() {
    const snapshot = this.state;
    const theme = this.state.theme && this.state.theme === 'dark' ? "dark" : "light";
    const jbTheme = this.state.theme && this.state.theme === 'dark' ? Theme.DARK : Theme.LIGHT;
    return <ThemeProvider theme={jbTheme} passToPopups>
      <div style={{padding: "10px"}} className="themed-wrapper">
        <Select className={"gap-right"} filter={true} label={"---"} selected={this.fromDialect.filter((i) => i.key === snapshot['from-dialect'])} onChange={(e) => this.setStateValue(e)} data={this.fromDialect}></Select>
        <Icon glyph={compareIcon} className={"gap-large"} color={Icon.Color.BLUE}/>
        <Select className={"gap-right"} filter={true} label={"---"} selected={this.toDialect.filter((i) => i.key === snapshot['to-dialect'])} onChange={(e) => this.setStateValue(e)} data={this.toDialect}></Select>
        <Button primary onClick={this.translate} loader={this.state.loading}>
          Translate
        </Button>
        <div className="ring-loader-inline" style={{marginLeft: "5px", display: this.state.loading ? "inline-block" : "none"}}/>
        <div style={{display: "flex"}}>
          <div style={{flex: "1", marginRight: "5px", marginTop: "15px"}}>
            <CodeMirror
              height="400px"
              extensions={[sql()]}
              theme={theme}
              width="calc(100vw/2 - 25px)"
              onChange={(value, view) => {
                this.setState({sql: value})
              }}
            />
            <ThemeProvider theme={jbTheme} className="dark inputs">
              <Input size={Size.FULL} data-key="from-search-path" label="Search path, comma separated" onChange={this.changeHandle} value={snapshot["from-search-path"]}/>
              <div className="themed-wrapper-with-top-gap">
                <Checkbox data-key="from-unknown-functions" label="Parse unknown functions" onChange={this.selectHandle} checked={snapshot["from-unknown-functions"]}/>
              </div>
              <Input size={Size.FULL} data-key="from-date-format" label="Date Format" onChange={this.changeHandle} value={snapshot["from-date-format"]}/>
              <Input size={Size.FULL} data-key="from-timestamp-format" label="Timestamp Format" onChange={this.changeHandle} value={snapshot["from-timestamp-format"]}/>
              <div className="themed-wrapper-with-top-gap">
                <Checkbox data-key="from-retain-comments-between-queries" label="Retain comments between queries" onChange={this.selectHandle} checked={snapshot["from-retain-comments-between-queries"]}/>
              </div>
              <div className="themed-wrapper-with-top-gap">
                <Checkbox data-key="from-ignore-comments" label="Enable ignore comment syntax" onChange={this.selectHandle} checked={snapshot["from-ignore-comments"]}/>
              </div>
              <Input size={Size.FULL} data-key="from-ignore-comment-start" label="Ignore comment start token" onChange={this.changeHandle} value={snapshot["from-ignore-comment-start"]}/>
              <Input size={Size.FULL} data-key="from-ignore-comment-stop" label="Ignore comment stop token" onChange={this.changeHandle} value={snapshot["from-ignore-comment-stop"]}/>
            </ThemeProvider>
          </div>
          <div style={{flex: "1", marginLeft: "5px", marginTop: "15px"}}>
            <CodeMirror
              value={this.state.translateResult}
              height="400px"
              width="calc(100vw/2 - 25px)"
              extensions={[sql()]}
              theme={theme}
            />
            <ThemeProvider theme={jbTheme} className="dark inputs">
              <Select size={Size.FULL} selectedLabel="Keywords" selected={this.toKeywords.filter((i) => i.key === this.state['to-keywords'])} onChange={(e) => this.setStateValue(e)} data={this.toKeywords}></Select>
              <Select size={Size.FULL} selectedLabel="Identifier Case" selected={this.identifierCase.filter((i) => i.key === this.state['to-name-case'])} onChange={(e) => this.setStateValue(e)} data={this.identifierCase}></Select>
              <Select size={Size.FULL} selectedLabel="Identifier Quoting" selected={this.identifierQuoting.filter((i) => i.key === this.state['to-name-quoted'])} onChange={(e) => this.setStateValue(e)} data={this.identifierQuoting}></Select>
              <Select size={Size.FULL} selectedLabel="Bind variables" selected={this.bindVariables.filter((i) => i.key === this.state['to-param-type'])} onChange={(e) => this.setStateValue(e)} data={this.bindVariables}></Select>
              <Select size={Size.FULL} selectedLabel="Patterns (experimental)" selected={this.toPatterns.filter((i) => i.key === this.state['to-patterns'])} onChange={(e) => this.setStateValue(e)} data={this.toPatterns}></Select>
              <Select size={Size.FULL} selectedLabel="Join style" selected={this.joinStyle.filter((i) => i.key === this.state['to-join-style'])} onChange={(e) => this.setStateValue(e)} data={this.joinStyle}></Select>
              <Select size={Size.FULL} selectedLabel="QUALIFY" selected={this.qualify.filter((i) => i.key === this.state['to-qualify'])} onChange={(e) => this.setStateValue(e)} data={this.qualify}></Select>
              <Select size={Size.FULL} selectedLabel="ROWNUM" selected={this.rownum.filter((i) => i.key === this.state['to-rownum'])} onChange={(e) => this.setStateValue(e)} data={this.rownum}></Select>
              <Select size={Size.FULL} selectedLabel="inline CTE" selected={this.inlineCte.filter((i) => i.key === this.state['to-inline-cte'])} onChange={(e) => this.setStateValue(e)} data={this.inlineCte}></Select>
              <Select size={Size.FULL} selectedLabel="GROUP BY <column index>" selected={this.groupByColumnIndex.filter((i) => i.key === this.state['to-group-by-column-index'])} onChange={(e) => this.setStateValue(e)} data={this.groupByColumnIndex}></Select>
              <Select size={Size.FULL} selectedLabel="Remove unnecessary arithmetic" selected={this.unnecessaryArithmetic.filter((i) => i.key === this.state['to-unnecessary-arithmetic'])} onChange={(e) => this.setStateValue(e)} data={this.unnecessaryArithmetic}></Select>
              <Select size={Size.FULL} selectedLabel="AS keyword in SELECT" selected={this.toFieldAs.filter((i) => i.key === this.state['to-field-as'])} onChange={(e) => this.setStateValue(e)} data={this.toFieldAs}></Select>
              <Select size={Size.FULL} selectedLabel="AS keyword in FROM" selected={this.toTableAs.filter((i) => i.key === this.state['to-table-as'])} onChange={(e) => this.setStateValue(e)} data={this.toTableAs}></Select>
              <Select size={Size.FULL} selectedLabel="INNER keyword in JOIN" selected={this.toInnerKeyword.filter((i) => i.key === this.state['to-inner-keyword'])} onChange={(e) => this.setStateValue(e)} data={this.toInnerKeyword}></Select>
              <Select size={Size.FULL} selectedLabel="OUTER keyword in JOIN" selected={this.toOuterKeyword.filter((i) => i.key === this.state['to-outer-keyword'])} onChange={(e) => this.setStateValue(e)} data={this.toOuterKeyword}></Select>
            </ThemeProvider>
          </div>
        </div>
      </div>
    </ThemeProvider>
  }
  ;
}

App.css

.App {
    text-align: center;
}

.gap-right {
    margin-right: 5px;
}

.gap-large {
    margin-right: 10px;
    margin-left: 5px;
}

.themed-wrapper {
    border: solid 1px var(--ring-borders-color);
    border-radius: var(--ring-border-radius);
    background-color: var(--ring-content-background-color);
    padding: var(--ring-unit);
}

.themed-wrapper-with-top-gap {
    border: solid 1px var(--ring-borders-color);
    border-radius: var(--ring-border-radius);
    background-color: var(--ring-content-background-color);
    margin-top: var(--ring-unit);
    padding: var(--ring-unit);
}

前端主要逻辑为:

  1. 复刻JOOQ SQL Translate页面,左右两侧的SQL输入和展示组件用codemirror,实现SQL代码高亮和提示
  2. 点击翻译按钮,用axios发送/translate请求,参数和JOOQ SQL Translate一样
  3. 请求成功后,将返回值填充到右侧代码展示区

npm start后可看到页面基本和JOOQ SQL Translate页面相差无几,在浏览器地址栏后添加?theme=dark?theme=light可看到风格切换。
npm run build后,插件的resource/html目录下会生成用户界面静态资源。

丰富插件内容

build.gradle.kts添加okhttp3依赖:

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.10.0")
}

新建com.github.clyoudu.sqltrans.frame包,在该包下创建TranslatorFrame.java:

package com.github.clyoudu.sqltrans.frame;

import java.awt.*;

import javax.swing.*;

import com.intellij.ui.jcef.JBCefBrowser;
import com.intellij.ui.jcef.JBCefClient;
import com.intellij.util.ui.UIUtil;

import com.github.clyoudu.sqltrans.jcefhandler.LocalRequestHandler;

/**
 * TranslatorFrame.
 *
 * @author leichen
 * @since 1.0, 2022/12/27 5:28 PM
 */
public class TranslatorFrame extends JFrame {

    private JBCefBrowser jbCefBrowser;

    private JBCefClient jbCefClient;

    private final JPanel dialogPanel;

    private TranslatorFrame() {
        super("SQLTranslator");
        dialogPanel = new JPanel(new BorderLayout());
        add(dialogPanel);

    }

    @Override
    public void setVisible(boolean b) {
        if (b) {
            setSize(1024, 750);
            setLocationRelativeTo(null);
            jbCefBrowser = new JBCefBrowser();
            jbCefClient = jbCefBrowser.getJBCefClient();
            jbCefClient.addRequestHandler(new LocalRequestHandler(), jbCefBrowser.getCefBrowser());
            // 将 JBCefBrowser 的UI控件设置到Panel中
            dialogPanel.add(jbCefBrowser.getComponent(), BorderLayout.CENTER);

            // 刷新组件
            EventQueue.invokeLater(TranslatorFrame.this::repaint);

            String theme = UIUtil.isUnderDarcula() ? "dark" : "light";
            jbCefBrowser.loadURL("http://localhost:7654/index.html?theme=" + theme);
        } else {
            jbCefClient.dispose();
            jbCefBrowser.dispose();
        }
        super.setVisible(b);
    }

    public static TranslatorFrame getInstance() {
        return TranslatorFrame.SingletonRegistryHolder.INSTANCE;
    }

    private static class SingletonRegistryHolder {

        private static final TranslatorFrame INSTANCE = new TranslatorFrame();
    }
}

新建com.github.clyoudu.sqltrans.jecefhandler包,创建
LocalRequestHandler.java

package com.github.clyoudu.sqltrans.jcefhandler;

import org.cef.browser.CefBrowser;
import org.cef.browser.CefFrame;
import org.cef.handler.CefRequestHandlerAdapter;
import org.cef.handler.CefResourceRequestHandler;
import org.cef.misc.BoolRef;
import org.cef.network.CefRequest;

/**
 * LocalRequestHandler.
 *
 * @author leichen
 * @since 1.0, 2022/12/28 5:10 PM
 */
public class LocalRequestHandler extends CefRequestHandlerAdapter {

    @Override
    public CefResourceRequestHandler getResourceRequestHandler(CefBrowser browser, CefFrame frame, CefRequest request,
        boolean isNavigation, boolean isDownload, String requestInitiator, BoolRef disableDefaultHandling) {
        return new LocalResourceRequestHandler();
    }

}

LocalResourceRequestHandler.java

package com.github.clyoudu.sqltrans.jcefhandler;

import org.cef.browser.CefBrowser;
import org.cef.browser.CefFrame;
import org.cef.handler.CefResourceHandler;
import org.cef.handler.CefResourceRequestHandlerAdapter;
import org.cef.network.CefRequest;

/**
 * LocalResourceRequestHandler.
 *
 * @author leichen
 * @since 1.0, 2022/12/28 5:11 PM
 */
public class LocalResourceRequestHandler extends CefResourceRequestHandlerAdapter {

    private static final String TRANSLATE = "translate";

    private static final String LOCALHOST = "http://localhost:7654/";

    @Override
    public CefResourceHandler getResourceHandler(CefBrowser browser, CefFrame frame, CefRequest request) {
        String url = request.getURL();
        String file = url.replace(LOCALHOST, "").replaceAll("\\?.*", "");
        if (file.equals(TRANSLATE)) {
            return new HttpClientResourceHandler(request.getPostData());
        }
        return new LocalStaticResourceHandler(file);
    }

}

StCefResourceHandlerAdapter.java

package com.github.clyoudu.sqltrans.jcefhandler;

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

import org.cef.callback.CefCallback;
import org.cef.handler.CefResourceHandlerAdapter;
import org.cef.misc.IntRef;
import org.cef.network.CefRequest;

/**
 * StCefResourceHandlerAdapter.
 *
 * @author leichen
 * @since 1.0, 2022/12/28 5:16 PM
 */
public class StCefResourceHandlerAdapter extends CefResourceHandlerAdapter {

    String html;

    int startPos = 0;

    @Override
    public boolean processRequest(CefRequest request, CefCallback callback) {
        startPos = 0;
        callback.Continue();
        return true;
    }

    @Override
    public boolean readResponse(byte[] dataOut, int bytesToRead, IntRef intRef, CefCallback callback) {
        byte[] bytes = html.getBytes(StandardCharsets.UTF_8);
        int length = bytes.length;
        if (startPos >= length) {
            return false;
        }

        int endPos = startPos + bytesToRead;
        byte[] dataToSend =
            (endPos > length) ? Arrays.copyOfRange(bytes, startPos, length) : Arrays.copyOfRange(bytes, startPos,
                endPos);

        ByteBuffer result = ByteBuffer.wrap(dataOut);
        result.put(dataToSend);
        intRef.set(dataToSend.length);

        startPos = endPos;
        return true;
    }

}

LocalStaticResourceHandler.java

package com.github.clyoudu.sqltrans.jcefhandler;

import java.io.IOException;
import java.io.InputStream;

import org.apache.commons.compress.utils.IOUtils;
import org.cef.misc.IntRef;
import org.cef.misc.StringRef;
import org.cef.network.CefResponse;

/**
 * StaticResourceHandler.
 *
 * @author leichen
 * @since 1.0, 2022/12/28 5:13 PM
 */
public class LocalStaticResourceHandler extends StCefResourceHandlerAdapter {

    private final String file;

    public LocalStaticResourceHandler(String file) {
        this.file = file;
        InputStream fileInputStream = LocalStaticResourceHandler.class.getClassLoader().getResourceAsStream("html/" + file);
        try {
            html = new String(IOUtils.toByteArray(fileInputStream));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void getResponseHeaders(CefResponse response, IntRef responseLength, StringRef redirectUrl) {
        responseLength.set(html.length());
        String ext = file.substring(file.lastIndexOf('.') + 1);
        switch (ext) {
            case "html":
                response.setMimeType("text/html");
                break;
            case "js":
                response.setMimeType("text/javascript; charset=utf-8");
                break;
            case "css":
                response.setMimeType("text/css; charset=utf-8");
                break;
            default:
                break;
        }
        response.setStatus(200);
    }

}

HttpClientResourceHandler.java

package com.github.clyoudu.sqltrans.jcefhandler;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Vector;
import java.util.stream.Collectors;

import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.cef.misc.IntRef;
import org.cef.misc.StringRef;
import org.cef.network.CefPostData;
import org.cef.network.CefPostDataElement;
import org.cef.network.CefResponse;

/**
 * HttpClientResourceHandler.
 *
 * @author leichen
 * @since 1.0, 2022/12/28 5:18 PM
 */
public class HttpClientResourceHandler extends StCefResourceHandlerAdapter {

    private static final OkHttpClient CLIENT = new OkHttpClient().newBuilder().build();

    public HttpClientResourceHandler(CefPostData postData) {
        Vector<CefPostDataElement> elements = new Vector<>();
        postData.getElements(elements);
        CefPostDataElement el = elements.get(0);
        int numBytes = el.getBytesCount();

        byte[] readBytes = new byte[numBytes];
        el.getBytes(numBytes, readBytes);

        String readString = new String(readBytes).trim();
        String[] stringPairs = readString.split("&");
        java.util.List<String> formData = new ArrayList<>();
        for (String s : stringPairs) {
            String[] params = s.split("=");
            if (params.length <= 1) {
                formData.add(params[0] + "= ");
            } else {
                formData.add(params[0] + "=" + params[1]);
            }

        }

        MediaType mediaType = MediaType.parse("application/x-www-form-urlencoded; charset=UTF-8");
        RequestBody body = RequestBody.create(mediaType, formData.stream().collect(Collectors.joining("&")));
        Request request = new Request.Builder().url("https://www.jooq.org/translate/translate").method("POST", body)
            .addHeader("content-type", "application/x-www-form-urlencoded; charset=UTF-8").build();

        try (Response response = CLIENT.newCall(request).execute();) {
            html = response.body().string();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void getResponseHeaders(CefResponse response, IntRef responseLength, StringRef redirectUrl) {
        responseLength.set(html.length());
        response.setMimeType("text/html; charset=UTF-8");
        response.setHeaderByName("content-encoding", "gzip", true);
        response.setHeaderByName("cache-control", "no-store, no-cache, must-revalidate", true);
        response.setStatus(200);
    }

}

新建com.github.clyoudu.sqltrans.actions包,新建PopupWindowAction.java

package com.github.clyoudu.sqltrans.actions;

import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import org.jetbrains.annotations.NotNull;

import com.github.clyoudu.sqltrans.frame.TranslatorFrame;

/**
 * PopupWindowAction.
 *
 * @author leichen
 * @since 1.0, 2022/12/27 5:33 PM
 */
public class PopupWindowAction extends AnAction {

    @Override
    public void actionPerformed(@NotNull AnActionEvent event) {
        TranslatorFrame translatorFrame = TranslatorFrame.getInstance();
        if (translatorFrame.isVisible()) {
            translatorFrame.requestFocus();
        } else {
            translatorFrame.setVisible(true);
        }
    }

}

修改resource/plugin.xml

<!-- Plugin Configuration File. Read more: https://plugins.jetbrains.com/docs/intellij/plugin-configuration-file.html -->
<idea-plugin>
    <!-- Unique identifier of the plugin. It should be FQN. It cannot be changed between the plugin versions. -->
    <id>com.clyoudu.sqltrans.sql-translator</id>

    <!-- Public plugin name should be written in Title Case.
         Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-name -->
    <name>Sql-translator</name>

    <!-- A displayed Vendor name or Organization ID displayed on the Plugins Page. -->
    <vendor email="1060659155@qq.com" url="https://github.com/clyoudu">clyoudu</vendor>

    <!-- Description of the plugin displayed on the Plugin Page and IDE Plugin Manager.
         Simple HTML elements (text formatting, paragraphs, and lists) can be added inside of <![CDATA[ ]]> tag.
         Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-description -->
    <description><![CDATA[
    Sql dialect translator with jooq online translator.
  ]]></description>

    <!-- Product and plugin compatibility requirements.
         Read more: https://plugins.jetbrains.com/docs/intellij/plugin-compatibility.html -->
    <depends>com.intellij.modules.platform</depends>

    <!-- Extension points defined by the plugin.
         Read more: https://plugins.jetbrains.com/docs/intellij/plugin-extension-points.html -->
    <extensions defaultExtensionNs="com.intellij">

    </extensions>

    <actions>
        <!-- Add your actions here -->
        <action id="com.github.clyoudu.sqltrans.actions.PopupWindowAction"
                class="com.github.clyoudu.sqltrans.actions.PopupWindowAction" text="SQL Translator"
                description="Open SQL translator window">
            <add-to-group group-id="ToolsMenu" anchor="last"/>
            <keyboard-shortcut keymap="$default" first-keystroke="control shift alt T"/>
        </action>
    </actions>
</idea-plugin>

jcefhandler主要逻辑为:

  1. 打开插件时,默认访问虚拟路径:http://localhost:7654/index.html
  2. 拦截所有请求,静态资源一律返回/resource/html路径下相同URI的文件内容
  3. 当请求路径为translate时,使用HttpClient发起代理请求,访问JOOQ SQL Translate接口

整个插件项目结构

.
└── main
    ├── java
    │   └── com
    │       └── szkingdom
    │           └── sqltrans
    │               └── sqltranslator
    │                   ├── actions
    │                   │   └── PopupWindowAction.java
    │                   ├── frame
    │                   │   └── TranslatorFrame.java
    │                   └── jcefhandler
    │                       ├── HttpClientResourceHandler.java
    │                       ├── LocalRequestHandler.java
    │                       ├── LocalResourceRequestHandler.java
    │                       ├── LocalStaticResourceHandler.java
    │                       └── StCefResourceHandlerAdapter.java
    └── resources
        ├── META-INF
        │   ├── plugin.xml
        │   └── pluginIcon.svg
        └── html
            ├── asset-manifest.json
            ├── favicon.ico
            ├── index.html
            ├── logo192.png
            ├── logo512.png
            ├── manifest.json
            ├── robots.txt
            └── static
                ├── css
                │   ├── main.3f1d99eb.css
                │   └── main.3f1d99eb.css.map
                ├── js
                │   ├── 5651.a6ce13cf.chunk.js
                │   ├── 5651.a6ce13cf.chunk.js.map
                │   ├── ...
                │   └── main.ae1dd244.js.map
                └── media
                    └── logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg

双击右侧的Gradle->Run Plugin
在这里插入图片描述
插件效果如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
JCEF 是基于 Chromium Embedded Framework (CEF) 的 Java 库,可以方便地在 Java 应用程序中嵌入 Chrome 浏览器。要在 JCEF 中保存 cookie,可以使用 CEF CookieManager 接口提供的方法。 具体步骤如下: 1. 获取当前网站的 CookieManager 对象:在 JCEF 中,可以通过 `CefCookieManager.getGlobalManager()` 获取全局 CookieManager 对象,也可以使用 `CefRequestContext.getGlobalContext()` 获取全局请求上下文,并通过 `getRequestContext().getCookieManager(null)` 获取当前请求上下文的 CookieManager 对象。 2. 创建一个 Cookie 对象并设置相应的属性:Cookie 对象中包含了 cookie 的各种属性,如名称、值、域名、路径、过期时间等。可以使用 `CefCookie.create()` 方法创建一个新的 Cookie 对象,并使用 `setName()`、`setValue()`、`setDomain()`、`setPath()`、`setExpirationDate()` 等方法设置相应的属性。 3. 将 Cookie 对象添加到 CookieManager 中:使用 `setCookie()` 方法将 Cookie 对象添加到 CookieManager 中即可。 以下是示例代码: ``` // 获取当前网站的 CookieManager 对象 CefCookieManager cookieManager = CefCookieManager.getGlobalManager(); // 创建一个 Cookie 对象并设置相应的属性 CefCookie cookie = CefCookie.create(); cookie.setName("cookie_name"); cookie.setValue("cookie_value"); cookie.setDomain("example.com"); cookie.setPath("/"); cookie.setExpirationDate(expirationDate); // 过期时间 // 将 Cookie 对象添加到 CookieManager 中 cookieManager.setCookie("https://example.com", cookie); ```
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值