文章目录
- RingUI
- 知识储备
- 示例插件
- 实现逻辑
- 开发环境
- 开发流程
- 新建一个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组件库有以下使用场景:
- 开发一个JetBrains风格的网页应用,比如开发了一个JetBrains插件,提供一个网站来介绍该插件;
- 开发一个基于Web的JetBrains插件,比如Markdown预览插件,这类插件如果使用Java Swing来实现,基本不可能,但是直接使用web组件却很方便;
- 开发一个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-codemirror
、axios
等,需要安装相关依赖: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);
}
前端主要逻辑为:
- 复刻JOOQ SQL Translate页面,左右两侧的SQL输入和展示组件用codemirror,实现SQL代码高亮和提示
- 点击翻译按钮,用axios发送/translate请求,参数和JOOQ SQL Translate一样
- 请求成功后,将返回值填充到右侧代码展示区
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主要逻辑为:
- 打开插件时,默认访问虚拟路径:http://localhost:7654/index.html
- 拦截所有请求,静态资源一律返回/resource/html路径下相同URI的文件内容
- 当请求路径为
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
插件效果如下: