插件初始化

This commit is contained in:
2026-05-08 17:14:51 +08:00
commit ee10253716
43 changed files with 20645 additions and 0 deletions
@@ -0,0 +1,156 @@
package com.huangzj.protoplugin;
import com.intellij.openapi.fileChooser.FileChooserDescriptor;
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.DialogWrapper;
import com.intellij.openapi.ui.TextFieldWithBrowseButton;
import com.intellij.openapi.ui.ValidationInfo;
import com.intellij.openapi.vfs.LocalFileSystem;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.ui.components.JBScrollPane;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JCheckBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import org.jetbrains.annotations.Nullable;
public class GenerateCmdDialog extends DialogWrapper {
private final Project project;
private final ProtoParseResult parseResult;
private final String cmdRpcFullText;
private final Map<Integer, String> pbServiceByNumber;
private final List<JCheckBox> checkBoxes = new ArrayList<>();
private final TextFieldWithBrowseButton outputDirField = new TextFieldWithBrowseButton();
public GenerateCmdDialog(
@Nullable Project project,
ProtoParseResult parseResult,
String cmdRpcFullText,
Map<Integer, String> pbServiceByNumber) {
super(project);
this.project = project;
this.parseResult = parseResult;
this.cmdRpcFullText = cmdRpcFullText;
this.pbServiceByNumber = pbServiceByNumber;
setTitle("Proto转Java");
init();
}
@Override
protected JComponent createCenterPanel() {
JPanel root = new JPanel(new BorderLayout(8, 8));
String jp = parseResult.getJavaPackageOption();
JLabel hint =
new JLabel("<html>proto <code>java_package</code>: <b>"
+ (jp != null ? jp : "(未解析到 option java_package)")
+ "</b><br/>勾选要生成的 RPC;输出目录建议选模块下 <code>src/main/java/.../cmd/cmds</code>。</html>");
root.add(hint, BorderLayout.NORTH);
JPanel listPanel = new JPanel();
listPanel.setLayout(new BoxLayout(listPanel, BoxLayout.Y_AXIS));
for (RpcMethodInfo rpc : parseResult.getRpcMethods()) {
JCheckBox cb = new JCheckBox(rpc.toString(), true);
cb.putClientProperty("rpc", rpc);
checkBoxes.add(cb);
listPanel.add(cb);
listPanel.add(Box.createVerticalStrut(4));
}
JBScrollPane scroll = new JBScrollPane(listPanel);
scroll.setPreferredSize(new Dimension(560, 220));
root.add(scroll, BorderLayout.CENTER);
JPanel south = new JPanel(new BorderLayout(4, 4));
south.add(new JLabel("输出目录:"), BorderLayout.WEST);
FileChooserDescriptor desc = FileChooserDescriptorFactory.createSingleFolderDescriptor();
desc.setTitle("选择生成 Java 的根目录(通常为 .../cmd/cmds");
outputDirField.addBrowseFolderListener("选择目录", null, project, desc);
south.add(outputDirField, BorderLayout.CENTER);
root.add(south, BorderLayout.SOUTH);
return root;
}
@Nullable
@Override
protected ValidationInfo doValidate() {
String path = outputDirField.getText().trim();
if (path.isEmpty()) {
return new ValidationInfo("请选择输出目录", outputDirField);
}
VirtualFile dir = LocalFileSystem.getInstance().findFileByPath(path);
if (dir == null || !dir.isDirectory()) {
return new ValidationInfo("输出路径不是有效目录", outputDirField);
}
String pkg = PackageInference.inferPackage(dir);
if (pkg.isEmpty()) {
return new ValidationInfo(
"无法推断 Java 包名:请选择位于 .../src/main/java/<包路径>/ 下的目录。", outputDirField);
}
List<RpcMethodInfo> sel = getSelectedRpcs();
if (sel.isEmpty()) {
return new ValidationInfo("请至少勾选一条 RPC");
}
for (RpcMethodInfo rpc : sel) {
List<String> methods =
ProtoEnumResolver.orderedMethodConstantNames(cmdRpcFullText, rpc.getServiceIndex());
if (rpc.getRpcIndexInService() >= methods.size()) {
return new ValidationInfo(
"RPC "
+ rpc.getRpcName()
+ " 在 service "
+ rpc.getServiceIndex()
+ " 中为第 "
+ (rpc.getRpcIndexInService() + 1)
+ " 条,但 proto 中 Service"
+ rpc.getServiceIndex()
+ "_Method 可用枚举项不足(请检查 Method 定义顺序与 RPC 顺序是否一致)。");
}
}
return null;
}
public List<RpcMethodInfo> getSelectedRpcs() {
List<RpcMethodInfo> list = new ArrayList<>();
for (JCheckBox cb : checkBoxes) {
if (cb.isSelected()) {
list.add((RpcMethodInfo) cb.getClientProperty("rpc"));
}
}
return list;
}
public VirtualFile getOutputDirectory() {
String path = outputDirField.getText().trim();
return LocalFileSystem.getInstance().findFileByPath(path);
}
public String getOutputPackage() {
VirtualFile dir = getOutputDirectory();
return PackageInference.inferPackage(dir);
}
public String getProtoJavaPackage() {
String p = parseResult.getJavaPackageOption();
return p != null ? p : "com.huangzj.cmd.gen";
}
public String resolvePbServiceEnumName(RpcMethodInfo rpc) {
return ProtoEnumResolver.resolvePbServiceConstant(pbServiceByNumber, rpc.getServiceIndex());
}
public String resolveMethodEnumConstant(RpcMethodInfo rpc) {
List<String> names =
ProtoEnumResolver.orderedMethodConstantNames(cmdRpcFullText, rpc.getServiceIndex());
return names.get(rpc.getRpcIndexInService());
}
}
@@ -0,0 +1,86 @@
package com.huangzj.protoplugin;
/** 生成与 comm-framework 风格一致的 {@link com.huangzj.base.cmd.AbstractTypedPbCommand} 子类源码。 */
public final class JavaCommandGenerator {
private JavaCommandGenerator() {}
public static String generate(
RpcMethodInfo rpc,
String outputPackage,
String protoJavaPackage,
String pbServiceEnumConstant,
String methodEnumConstant) {
String cts = rpc.getRequestType();
String stc = rpc.getResponseType();
String className = rpc.getRpcName();
String methodEnumClass = rpc.methodEnumClass();
String imports =
"import "
+ protoJavaPackage
+ "."
+ cts
+ ";\n"
+ "import "
+ protoJavaPackage
+ "."
+ stc
+ ";\n"
+ "import "
+ protoJavaPackage
+ ".PbService;\n"
+ "import "
+ protoJavaPackage
+ "."
+ methodEnumClass
+ ";\n";
return "package "
+ outputPackage
+ ";\n\n"
+ "import com.huangzj.base.cmd.AbstractTypedPbCommand;\n"
+ "import com.huangzj.base.cmd.Cmd;\n"
+ "import com.huangzj.base.cmd.CommandContext;\n"
+ "import com.huangzj.base.cmd.CommandSource;\n"
+ imports
+ "import org.springframework.stereotype.Component;\n\n"
+ "/**\n"
+ " * Generated from proto rpc: "
+ rpc.getServiceName()
+ "."
+ rpc.getRpcName()
+ "\n"
+ " * Request: "
+ cts
+ ", Response: "
+ stc
+ ".\n"
+ " */\n"
+ "@Component\n"
+ "@Cmd(source = CommandSource.Client_TCP, serviceId = PbService."
+ pbServiceEnumConstant
+ "_VALUE, methodId = "
+ methodEnumClass
+ "."
+ methodEnumConstant
+ "_VALUE)\n"
+ "public class "
+ className
+ " extends AbstractTypedPbCommand<"
+ cts
+ ", "
+ stc
+ ".Builder> {\n\n"
+ " @Override\n"
+ " protected void run(CommandContext ctx, "
+ cts
+ " cts, "
+ stc
+ ".Builder stcBuilder) throws Exception {\n"
+ " // TODO: implement business logic\n"
+ " }\n"
+ "}\n";
}
}
@@ -0,0 +1,40 @@
package com.huangzj.protoplugin;
import com.intellij.openapi.actionSystem.ActionPlaces;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.actionSystem.CommonDataKeys;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import com.huangzj.protoplugin.mysql.MysqlEntityDialog;
import org.jetbrains.annotations.NotNull;
/** 在项目目录上打开「从 MySQL 生成 JPA 实体」对话框。 */
public class MysqlEntityFromFolderAction extends AnAction {
@Override
public void update(@NotNull AnActionEvent e) {
Project project = e.getProject();
VirtualFile vf = e.getData(CommonDataKeys.VIRTUAL_FILE);
boolean dir = vf != null && vf.isDirectory();
String place = e.getPlace();
if (ActionPlaces.isPopupPlace(place)) {
e.getPresentation().setEnabledAndVisible(dir);
} else {
e.getPresentation().setVisible(true);
e.getPresentation().setEnabled(project != null);
}
}
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
Project project = e.getProject();
if (project == null) {
return;
}
VirtualFile vf = e.getData(CommonDataKeys.VIRTUAL_FILE);
VirtualFile folder = vf != null && vf.isDirectory() ? vf : null;
MysqlEntityDialog dialog = new MysqlEntityDialog(project, folder);
dialog.showAndGet();
}
}
@@ -0,0 +1,31 @@
package com.huangzj.protoplugin;
import com.intellij.openapi.vfs.VirtualFile;
/** 根据输出目录路径推断 Java package(优先 {@code src/main/java} 之后的路径)。 */
public final class PackageInference {
private PackageInference() {}
public static String inferPackage(VirtualFile directory) {
if (directory == null) {
return "com.example.cmd.cmds";
}
String path = directory.getPath().replace('\\', '/');
String marker = "/src/main/java/";
int idx = path.indexOf(marker);
if (idx >= 0) {
String sub = path.substring(idx + marker.length());
if (sub.isEmpty()) {
return "";
}
return sub.replace('/', '.');
}
int j = path.lastIndexOf("/java/");
if (j >= 0) {
String sub = path.substring(j + "/java/".length());
return sub.replace('/', '.');
}
return "com.example.cmd.cmds";
}
}
@@ -0,0 +1,134 @@
package com.huangzj.protoplugin;
import com.intellij.lang.java.JavaLanguage;
import com.intellij.openapi.actionSystem.ActionPlaces;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.actionSystem.CommonDataKeys;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiDirectory;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiFileFactory;
import com.intellij.psi.PsiManager;
import com.intellij.openapi.ui.Messages;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.jetbrains.annotations.NotNull;
public class ProtoCmdGeneratorAction extends AnAction {
@Override
public void update(@NotNull AnActionEvent e) {
VirtualFile vf = e.getData(CommonDataKeys.VIRTUAL_FILE);
if (vf == null) {
PsiFile pf = e.getData(CommonDataKeys.PSI_FILE);
if (pf != null) {
vf = pf.getVirtualFile();
}
}
boolean proto = vf != null && "proto".equalsIgnoreCase(vf.getExtension());
// 主菜单(如 Tools):始终显示条目,非 proto 时禁用,避免用户找不到入口。
// 编辑器/项目树右键:仅在看 .proto 时显示,减少菜单噪音。
String place = e.getPlace();
if (ActionPlaces.isPopupPlace(place)) {
e.getPresentation().setEnabledAndVisible(proto);
} else {
e.getPresentation().setVisible(true);
e.getPresentation().setEnabled(proto);
}
}
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
Project project = e.getProject();
VirtualFile protoFile = e.getData(CommonDataKeys.VIRTUAL_FILE);
if (protoFile == null) {
PsiFile pf = e.getData(CommonDataKeys.PSI_FILE);
if (pf != null) {
protoFile = pf.getVirtualFile();
}
}
if (project == null || protoFile == null) {
return;
}
String text;
try {
text = new String(protoFile.contentsToByteArray(), StandardCharsets.UTF_8);
} catch (Exception ex) {
Messages.showErrorDialog(project, ex.getMessage(), "读取 proto 失败");
return;
}
ProtoParseResult parsed = ProtoFileParser.parse(text);
if (parsed.getRpcMethods().isEmpty()) {
Messages.showWarningDialog(
project,
"当前文件未解析到 service/rpc。\n需 service 命名为 Service1Service、Service2Service…,且 rpc 为标准 proto3 语法。",
"未找到 RPC");
return;
}
Map<Integer, String> pbMap = Map.of();
VirtualFile parent = protoFile.getParent();
if (parent != null) {
VirtualFile pbSvc = parent.findChild("pb_service.proto");
if (pbSvc != null) {
try {
String pbText = new String(pbSvc.contentsToByteArray(), StandardCharsets.UTF_8);
pbMap = ProtoEnumResolver.parsePbServiceByNumber(pbText);
} catch (Exception ignored) {
}
}
}
GenerateCmdDialog dialog = new GenerateCmdDialog(project, parsed, text, pbMap);
if (!dialog.showAndGet()) {
return;
}
List<RpcMethodInfo> selected = dialog.getSelectedRpcs();
VirtualFile outDir = dialog.getOutputDirectory();
String outPkg = dialog.getOutputPackage();
String protoJavaPkg = dialog.getProtoJavaPackage();
if (outDir == null) {
return;
}
int[] created = {0};
int[] skipped = {0};
WriteCommandAction.runWriteCommandAction(project, () -> {
PsiDirectory psiDir = PsiManager.getInstance(project).findDirectory(outDir);
if (psiDir == null) {
return;
}
PsiFileFactory factory = PsiFileFactory.getInstance(project);
for (RpcMethodInfo rpc : selected) {
String className = rpc.getRpcName() + ".java";
if (psiDir.findFile(className) != null) {
skipped[0]++;
continue;
}
String pbConst = dialog.resolvePbServiceEnumName(rpc);
String methodConst = dialog.resolveMethodEnumConstant(rpc);
String src =
JavaCommandGenerator.generate(
rpc, outPkg, protoJavaPkg, pbConst, methodConst);
PsiFile psiFile =
factory.createFileFromText(className, JavaLanguage.INSTANCE, src);
psiDir.add(psiFile);
created[0]++;
}
});
Messages.showInfoMessage(
project,
"新建 " + created[0] + " 个文件,跳过已存在 " + skipped[0] + " 个。",
"Proto转Java 完成");
}
}
@@ -0,0 +1,76 @@
package com.huangzj.protoplugin;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 从 proto 文本解析 {@code PbService} 与 {@code ServiceN_Method},供生成 {@code PbService.Service_k_VALUE} /
* {@code ServiceN_Method.MethodJ_VALUE}。
*/
public final class ProtoEnumResolver {
private static final Pattern ENUM_ENTRY = Pattern.compile("(\\w+)\\s*=\\s*(\\d+)\\s*;?");
private ProtoEnumResolver() {}
/** service 序号(如 1)→ PbService 枚举常量名(如 Service_1)。 */
public static Map<Integer, String> parsePbServiceByNumber(String pbServiceProtoContent) {
Map<Integer, String> map = new HashMap<>();
Matcher em = Pattern.compile("enum\\s+PbService\\s*\\{([^}]+)\\}", Pattern.DOTALL)
.matcher(pbServiceProtoContent);
if (!em.find()) {
return map;
}
String body = em.group(1);
Matcher m = ENUM_ENTRY.matcher(body);
while (m.find()) {
String name = m.group(1);
int num = Integer.parseInt(m.group(2));
if ("Default".equals(name) || num == 0) {
continue;
}
map.put(num, name);
}
return map;
}
/**
* 解析 {@code ServiceN_Method}:去掉 Default*,按数值排序,第 i 个(从 0 起)对应同 service 内第 i 条 rpc。
*/
public static List<String> orderedMethodConstantNames(String cmdRpcContent, int serviceNumber) {
Pattern p = Pattern.compile(
"enum\\s+Service" + serviceNumber + "_Method\\s*\\{([^}]+)\\}", Pattern.DOTALL);
Matcher em = p.matcher(cmdRpcContent);
if (!em.find()) {
return List.of();
}
String body = em.group(1);
List<NameValue> list = new ArrayList<>();
Matcher m = ENUM_ENTRY.matcher(body);
while (m.find()) {
String name = m.group(1);
int val = Integer.parseInt(m.group(2));
if (name.startsWith("Default")) {
continue;
}
list.add(new NameValue(name, val));
}
list.sort(Comparator.comparingInt(nv -> nv.value));
return list.stream().map(nv -> nv.name).toList();
}
public static String resolvePbServiceConstant(Map<Integer, String> byNumber, int serviceIndex) {
String name = byNumber.get(serviceIndex);
if (name != null) {
return name;
}
return "Service_" + serviceIndex;
}
private record NameValue(String name, int value) {}
}
@@ -0,0 +1,81 @@
package com.huangzj.protoplugin;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 轻量解析 proto3:提取 {@code option java_package}、{@code service} 块内的 {@code rpc ... (Req) returns (Res)}。
*/
public final class ProtoFileParser {
private static final Pattern JAVA_PACKAGE =
Pattern.compile("option\\s+java_package\\s*=\\s*\"([^\"]+)\"\\s*;", Pattern.MULTILINE);
private static final Pattern SERVICE_HEADER =
Pattern.compile("\\bservice\\s+(\\w+)\\s*\\{");
private static final Pattern RPC_LINE = Pattern.compile(
"\\brpc\\s+(\\w+)\\s*\\(\\s*(\\w+)\\s*\\)\\s*returns\\s*\\(\\s*(\\w+)\\s*\\)\\s*;",
Pattern.MULTILINE);
private ProtoFileParser() {}
public static ProtoParseResult parse(String content) {
String javaPkg = null;
Matcher jm = JAVA_PACKAGE.matcher(content);
if (jm.find()) {
javaPkg = jm.group(1);
}
List<RpcMethodInfo> out = new ArrayList<>();
Matcher sm = SERVICE_HEADER.matcher(content);
while (sm.find()) {
String serviceName = sm.group(1);
Integer idx = parseServiceIndex(serviceName);
if (idx == null) {
continue;
}
int blockStart = sm.end() - 1;
int blockEnd = findMatchingBrace(content, blockStart);
if (blockEnd < 0) {
continue;
}
String block = content.substring(blockStart + 1, blockEnd);
Matcher rm = RPC_LINE.matcher(block);
int rpcIndex = 0;
while (rm.find()) {
String rpcName = rm.group(1);
String req = rm.group(2);
String res = rm.group(3);
out.add(new RpcMethodInfo(serviceName, idx, rpcIndex, rpcName, req, res));
rpcIndex++;
}
}
return new ProtoParseResult(javaPkg, out);
}
/** 识别 {@code Service1Service} → 1;其它命名返回 null 并跳过该 service。 */
static Integer parseServiceIndex(String serviceName) {
Matcher m = Pattern.compile("^Service(\\d+)Service$").matcher(serviceName);
if (m.matches()) {
return Integer.parseInt(m.group(1));
}
return null;
}
static int findMatchingBrace(String s, int openBraceIndex) {
int depth = 0;
for (int i = openBraceIndex; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '{') {
depth++;
} else if (c == '}') {
depth--;
if (depth == 0) {
return i;
}
}
}
return -1;
}
}
@@ -0,0 +1,23 @@
package com.huangzj.protoplugin;
import java.util.Collections;
import java.util.List;
public final class ProtoParseResult {
private final String javaPackageOption;
private final List<RpcMethodInfo> rpcMethods;
public ProtoParseResult(String javaPackageOption, List<RpcMethodInfo> rpcMethods) {
this.javaPackageOption = javaPackageOption;
this.rpcMethods = rpcMethods;
}
public String getJavaPackageOption() {
return javaPackageOption;
}
public List<RpcMethodInfo> getRpcMethods() {
return Collections.unmodifiableList(rpcMethods);
}
}
@@ -0,0 +1,89 @@
package com.huangzj.protoplugin;
import java.util.Objects;
/** 一条 RPC:所属 service、方法名、请求/响应消息类型(proto 中的 message 名)。 */
public final class RpcMethodInfo {
private final String serviceName;
private final int serviceIndex;
private final int rpcIndexInService;
private final String rpcName;
private final String requestType;
private final String responseType;
public RpcMethodInfo(
String serviceName,
int serviceIndex,
int rpcIndexInService,
String rpcName,
String requestType,
String responseType) {
this.serviceName = serviceName;
this.serviceIndex = serviceIndex;
this.rpcIndexInService = rpcIndexInService;
this.rpcName = rpcName;
this.requestType = requestType;
this.responseType = responseType;
}
public String getServiceName() {
return serviceName;
}
public int getServiceIndex() {
return serviceIndex;
}
public int getRpcIndexInService() {
return rpcIndexInService;
}
public String getRpcName() {
return rpcName;
}
public String getRequestType() {
return requestType;
}
public String getResponseType() {
return responseType;
}
public String methodEnumClass() {
return "Service" + serviceIndex + "_Method";
}
@Override
public String toString() {
return serviceName
+ "."
+ rpcName
+ " ("
+ requestType
+ ""
+ responseType
+ ")";
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
RpcMethodInfo that = (RpcMethodInfo) o;
return serviceIndex == that.serviceIndex
&& rpcIndexInService == that.rpcIndexInService
&& Objects.equals(serviceName, that.serviceName)
&& Objects.equals(rpcName, that.rpcName);
}
@Override
public int hashCode() {
return Objects.hash(serviceName, serviceIndex, rpcIndexInService, rpcName);
}
}
@@ -0,0 +1,67 @@
package com.huangzj.protoplugin.json;
import com.intellij.codeInsight.intention.PsiElementBaseIntentionAction;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.Messages;
import com.intellij.psi.PsiClass;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiIdentifier;
import com.intellij.util.IncorrectOperationException;
import org.jetbrains.annotations.NotNull;
/** 光标在类名标识符上时,确认后生成默认 JSON。 */
public final class ClassToJsonIntention extends PsiElementBaseIntentionAction {
@Override
public @NotNull String getFamilyName() {
return "Idea-Plugin JSON";
}
@Override
public @NotNull String getText() {
return "是否生成 JSON(默认值)…";
}
@Override
public boolean isAvailable(
@NotNull Project project, Editor editor, @NotNull PsiElement element) {
if (!(element instanceof PsiIdentifier)) {
return false;
}
PsiElement parent = element.getParent();
if (!(parent instanceof PsiClass)) {
return false;
}
PsiClass cls = (PsiClass) parent;
PsiIdentifier id = cls.getNameIdentifier();
return id != null && id.equals(element);
}
@Override
public void invoke(
@NotNull Project project, Editor editor, @NotNull PsiElement element)
throws IncorrectOperationException {
PsiElement parent = element.getParent();
if (!(parent instanceof PsiClass)) {
return;
}
PsiClass cls = (PsiClass) parent;
int r =
Messages.showYesNoDialog(
project,
"是否根据当前类「" + cls.getName() + "」生成带默认值的 JSON",
"生成 JSON",
Messages.getQuestionIcon());
if (r != Messages.YES) {
return;
}
String json = JavaClassToJsonBuilder.buildPrettyJson(cls);
new ShowJsonResultDialog(project, "JSON — " + cls.getName(), json).show();
}
@Override
public boolean startInWriteAction() {
return false;
}
}
@@ -0,0 +1,68 @@
package com.huangzj.protoplugin.json;
import com.intellij.openapi.actionSystem.ActionPlaces;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.actionSystem.CommonDataKeys;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.Messages;
import com.intellij.psi.PsiClass;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiJavaFile;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.openapi.editor.Editor;
import org.jetbrains.annotations.NotNull;
/** 编辑器右键:对光标所在类生成默认 JSON(与意图行为一致)。 */
public final class GenerateClassJsonEditorAction extends AnAction {
@Override
public void update(@NotNull AnActionEvent e) {
Project project = e.getProject();
PsiFile file = e.getData(CommonDataKeys.PSI_FILE);
Editor editor = e.getData(CommonDataKeys.EDITOR);
boolean javaFile = file instanceof PsiJavaFile && editor != null;
PsiClass cls = null;
if (javaFile) {
int off = editor.getCaretModel().getOffset();
PsiElement at = file.findElementAt(off);
cls = PsiTreeUtil.getParentOfType(at, PsiClass.class);
}
boolean ok = project != null && cls != null;
String place = e.getPlace();
if (ActionPlaces.isPopupPlace(place)) {
e.getPresentation().setEnabledAndVisible(ok);
} else {
e.getPresentation().setVisible(true);
e.getPresentation().setEnabled(ok);
}
}
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
Project project = e.getProject();
PsiFile file = e.getData(CommonDataKeys.PSI_FILE);
Editor editor = e.getData(CommonDataKeys.EDITOR);
if (project == null || !(file instanceof PsiJavaFile) || editor == null) {
return;
}
int off = editor.getCaretModel().getOffset();
PsiElement at = file.findElementAt(off);
PsiClass cls = PsiTreeUtil.getParentOfType(at, PsiClass.class);
if (cls == null) {
return;
}
int r =
Messages.showYesNoDialog(
project,
"是否根据当前类「" + cls.getName() + "」生成带默认值的 JSON",
"生成 JSON",
Messages.getQuestionIcon());
if (r != Messages.YES) {
return;
}
String json = JavaClassToJsonBuilder.buildPrettyJson(cls);
new ShowJsonResultDialog(project, "JSON — " + cls.getName(), json).show();
}
}
@@ -0,0 +1,205 @@
package com.huangzj.protoplugin.json;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.intellij.psi.util.InheritanceUtil;
import com.intellij.psi.PsiArrayType;
import com.intellij.psi.PsiClass;
import com.intellij.psi.PsiClassType;
import com.intellij.psi.PsiEnumConstant;
import com.intellij.psi.PsiField;
import com.intellij.psi.PsiModifier;
import com.intellij.psi.PsiPrimitiveType;
import com.intellij.psi.PsiType;
import com.intellij.psi.PsiTypes;
import com.intellij.psi.PsiWildcardType;
import java.util.HashSet;
import java.util.Set;
import org.jetbrains.annotations.Nullable;
/** 根据 {@link PsiClass} 生成带类型默认值的 JSON 文本。 */
public final class JavaClassToJsonBuilder {
private JavaClassToJsonBuilder() {}
public static String buildPrettyJson(PsiClass psiClass) {
JsonObject root = new JsonObject();
for (PsiField field : psiClass.getAllFields()) {
if (field.hasModifierProperty(PsiModifier.STATIC)) {
continue;
}
if (field instanceof PsiEnumConstant) {
continue;
}
if ("serialVersionUID".equals(field.getName())) {
continue;
}
JsonElement value =
typeToDefaultJson(field.getType(), new HashSet<>(), psiClass);
root.add(field.getName(), value);
}
return new GsonBuilder().setPrettyPrinting().serializeNulls().create().toJson(root);
}
private static JsonElement typeToDefaultJson(
PsiType type, Set<String> visitingQualifiedNames, @Nullable PsiClass contextClass) {
if (type == null) {
return JsonNull.INSTANCE;
}
if (type instanceof PsiWildcardType) {
PsiType bound = ((PsiWildcardType) type).getExtendsBound();
if (bound != null) {
return typeToDefaultJson(bound, visitingQualifiedNames, contextClass);
}
PsiType superBound = ((PsiWildcardType) type).getSuperBound();
if (superBound != null) {
return typeToDefaultJson(superBound, visitingQualifiedNames, contextClass);
}
return JsonNull.INSTANCE;
}
if (type instanceof PsiArrayType) {
return new JsonArray();
}
if (type instanceof PsiPrimitiveType) {
return primitiveDefault((PsiPrimitiveType) type);
}
if (!(type instanceof PsiClassType)) {
return JsonNull.INSTANCE;
}
PsiClassType classType = (PsiClassType) type;
PsiClass resolved = classType.resolve();
if (resolved == null) {
return JsonNull.INSTANCE;
}
String qn = resolved.getQualifiedName();
if (qn != null && qn.equals("java.lang.Object")) {
return JsonNull.INSTANCE;
}
if (resolved.isEnum()) {
for (PsiField f : resolved.getFields()) {
if (f instanceof PsiEnumConstant) {
return new JsonPrimitive(f.getName());
}
}
return JsonNull.INSTANCE;
}
if (qn != null && qn.startsWith("java.")) {
return javaLangDefault(classType, resolved, visitingQualifiedNames, contextClass);
}
if (InheritanceUtil.isInheritor(resolved, "java.util.Collection")) {
return new JsonArray();
}
if (InheritanceUtil.isInheritor(resolved, "java.util.Map")) {
return new JsonObject();
}
if (qn == null) {
return JsonNull.INSTANCE;
}
if (visitingQualifiedNames.contains(qn)) {
return JsonNull.INSTANCE;
}
visitingQualifiedNames.add(qn);
try {
JsonObject obj = new JsonObject();
for (PsiField field : resolved.getAllFields()) {
if (field.hasModifierProperty(PsiModifier.STATIC)) {
continue;
}
if (field instanceof PsiEnumConstant) {
continue;
}
if ("serialVersionUID".equals(field.getName())) {
continue;
}
obj.add(
field.getName(),
typeToDefaultJson(field.getType(), visitingQualifiedNames, resolved));
}
return obj;
} finally {
visitingQualifiedNames.remove(qn);
}
}
private static JsonElement primitiveDefault(PsiPrimitiveType p) {
if (p.equals(PsiTypes.booleanType())) {
return new JsonPrimitive(false);
}
if (p.equals(PsiTypes.charType())) {
return new JsonPrimitive("\u0000");
}
if (p.equals(PsiTypes.floatType()) || p.equals(PsiTypes.doubleType())) {
return new JsonPrimitive(0.0);
}
if (p.equals(PsiTypes.longType())) {
return new JsonPrimitive(0L);
}
if (p.equals(PsiTypes.intType())
|| p.equals(PsiTypes.shortType())
|| p.equals(PsiTypes.byteType())) {
return new JsonPrimitive(0);
}
return JsonNull.INSTANCE;
}
private static JsonElement javaLangDefault(
PsiClassType classType,
PsiClass resolved,
Set<String> visitingQualifiedNames,
@Nullable PsiClass contextClass) {
String qn = resolved.getQualifiedName();
if (qn == null) {
return JsonNull.INSTANCE;
}
switch (qn) {
case "java.lang.String":
return new JsonPrimitive("");
case "java.lang.Boolean":
return new JsonPrimitive(false);
case "java.lang.Character":
return new JsonPrimitive("\u0000");
case "java.lang.Byte":
case "java.lang.Short":
case "java.lang.Integer":
return new JsonPrimitive(0);
case "java.lang.Long":
return new JsonPrimitive(0L);
case "java.lang.Float":
case "java.lang.Double":
return new JsonPrimitive(0.0);
case "java.math.BigDecimal":
case "java.math.BigInteger":
return new JsonPrimitive("0");
case "java.util.UUID":
return new JsonPrimitive("00000000-0000-0000-0000-000000000000");
case "java.util.Optional":
{
PsiType[] p = classType.getParameters();
if (p.length == 1) {
return typeToDefaultJson(p[0], visitingQualifiedNames, contextClass);
}
return JsonNull.INSTANCE;
}
default:
if (InheritanceUtil.isInheritor(resolved, "java.util.Collection")) {
return new JsonArray();
}
if (InheritanceUtil.isInheritor(resolved, "java.util.Map")) {
return new JsonObject();
}
if (qn.startsWith("java.time.")
|| "java.util.Date".equals(qn)
|| "java.util.Calendar".equals(qn)) {
return new JsonPrimitive("");
}
if (Number.class.getName().equals(qn)) {
return new JsonPrimitive(0);
}
return JsonNull.INSTANCE;
}
}
}
@@ -0,0 +1,38 @@
package com.huangzj.protoplugin.json;
import com.intellij.openapi.actionSystem.ActionPlaces;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.actionSystem.CommonDataKeys;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import org.jetbrains.annotations.NotNull;
/** 在目录上打开「Json 转 Java」对话框。 */
public final class JsonToJavaAction extends AnAction {
@Override
public void update(@NotNull AnActionEvent e) {
Project project = e.getProject();
VirtualFile vf = e.getData(CommonDataKeys.VIRTUAL_FILE);
boolean dir = vf != null && vf.isDirectory();
String place = e.getPlace();
if (ActionPlaces.isPopupPlace(place)) {
e.getPresentation().setEnabledAndVisible(dir);
} else {
e.getPresentation().setVisible(true);
e.getPresentation().setEnabled(project != null);
}
}
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
Project project = e.getProject();
if (project == null) {
return;
}
VirtualFile vf = e.getData(CommonDataKeys.VIRTUAL_FILE);
VirtualFile folder = vf != null && vf.isDirectory() ? vf : null;
new JsonToJavaDialog(project, folder).showAndGet();
}
}
@@ -0,0 +1,158 @@
package com.huangzj.protoplugin.json;
import com.intellij.lang.java.JavaLanguage;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.DialogWrapper;
import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiDirectory;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiFileFactory;
import com.intellij.psi.PsiManager;
import com.huangzj.protoplugin.PackageInference;
import com.intellij.ui.components.JBLabel;
import com.intellij.ui.components.JBScrollPane;
import com.intellij.ui.components.JBTextArea;
import com.intellij.util.ui.JBUI;
import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import javax.swing.JComponent;
import javax.swing.JPanel;
import javax.swing.JTextField;
import org.jetbrains.annotations.Nullable;
/** 输入 JSON 与类名,在选中目录生成 Java 源文件。 */
public final class JsonToJavaDialog extends DialogWrapper {
private final Project project;
private final @Nullable VirtualFile folder;
private final JBTextArea jsonArea;
private final JTextField classNameField;
private final JTextField packageField;
public JsonToJavaDialog(Project project, @Nullable VirtualFile folder) {
super(project);
this.project = project;
this.folder = folder;
setTitle("Json 转 Java");
jsonArea = new JBTextArea(12, 48);
jsonArea.setLineWrap(true);
jsonArea.setWrapStyleWord(true);
classNameField = new JTextField(32);
String pkg = PackageInference.inferPackage(folder);
packageField = new JTextField(pkg, 32);
init();
}
@Override
protected @Nullable JComponent createCenterPanel() {
JPanel root = new JPanel(new GridBagLayout());
root.setBorder(JBUI.Borders.empty(8));
GridBagConstraints gc = new GridBagConstraints();
gc.insets = JBUI.insets(4);
gc.anchor = GridBagConstraints.WEST;
gc.fill = GridBagConstraints.HORIZONTAL;
gc.gridx = 0;
gc.gridy = 0;
root.add(new JBLabel("Java 类名(不含 .java:"), gc);
gc.gridx = 1;
gc.weightx = 1.0;
root.add(classNameField, gc);
gc.gridy++;
gc.gridx = 0;
gc.weightx = 0;
root.add(new JBLabel("包名:"), gc);
gc.gridx = 1;
gc.weightx = 1.0;
root.add(packageField, gc);
gc.gridy++;
gc.gridx = 0;
gc.gridwidth = 2;
gc.weightx = 1.0;
gc.weighty = 0;
root.add(new JBLabel("JSON(根须为对象 {}:"), gc);
gc.gridy++;
gc.fill = GridBagConstraints.BOTH;
gc.weighty = 1.0;
JBScrollPane scroll = new JBScrollPane(jsonArea);
scroll.setPreferredSize(new Dimension(480, 220));
root.add(scroll, gc);
return root;
}
@Override
protected void doOKAction() {
String rawName = classNameField.getText().trim();
if (rawName.isEmpty()) {
Messages.showWarningDialog(project, "请填写类名。", "Json 转 Java");
return;
}
if (!isValidJavaIdentifier(rawName)) {
Messages.showWarningDialog(project, "类名不是合法 Java 标识符。", "Json 转 Java");
return;
}
String json = jsonArea.getText().trim();
if (json.isEmpty()) {
Messages.showWarningDialog(project, "请填写 JSON。", "Json 转 Java");
return;
}
if (folder == null || !folder.isDirectory()) {
Messages.showWarningDialog(project, "请在项目树中选择输出目录。", "Json 转 Java");
return;
}
String pkg = packageField.getText().trim();
String src;
try {
src = JsonToJavaGenerator.generate(json, rawName, pkg);
} catch (Exception ex) {
Messages.showErrorDialog(project, ex.getMessage(), "JSON 解析失败");
return;
}
String fileName = rawName.endsWith(".java") ? rawName : rawName + ".java";
int[] created = {0};
int[] skipped = {0};
WriteCommandAction.runWriteCommandAction(
project,
() -> {
PsiDirectory psiDir = PsiManager.getInstance(project).findDirectory(folder);
if (psiDir == null) {
return;
}
if (psiDir.findFile(fileName) != null) {
skipped[0]++;
return;
}
PsiFileFactory factory = PsiFileFactory.getInstance(project);
PsiFile psiFile =
factory.createFileFromText(fileName, JavaLanguage.INSTANCE, src);
psiDir.add(psiFile);
created[0]++;
});
if (skipped[0] > 0) {
Messages.showWarningDialog(
project, "文件已存在,未覆盖: " + fileName, "Json 转 Java");
return;
}
if (created[0] > 0) {
Messages.showInfoMessage(project, "已生成: " + fileName, "Json 转 Java");
}
super.doOKAction();
}
private static boolean isValidJavaIdentifier(String name) {
if (name.isEmpty()) {
return false;
}
if (!Character.isJavaIdentifierStart(name.charAt(0))) {
return false;
}
for (int i = 1; i < name.length(); i++) {
if (!Character.isJavaIdentifierPart(name.charAt(i))) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,248 @@
package com.huangzj.protoplugin.json;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import com.google.gson.JsonPrimitive;
import com.huangzj.protoplugin.mysql.JavaNames;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/** 将 JSON 对象推断为简单 Java Bean(含静态内部类与 {@link List} 字段)。 */
public final class JsonToJavaGenerator {
private JsonToJavaGenerator() {}
public static String generate(String json, String rootClassName, String packageName)
throws JsonParseException {
JsonElement root = JsonParser.parseString(json);
if (!root.isJsonObject()) {
throw new JsonParseException("根节点必须是 JSON 对象 {}");
}
NameAllocator names = new NameAllocator();
String safeRoot = names.unique(safeClassName(rootClassName));
ClassModel model = buildClassModel(root.getAsJsonObject(), safeRoot, names);
return render(model, packageName);
}
private static String safeClassName(String raw) {
if (raw == null || raw.isBlank()) {
return "Generated";
}
String t = raw.trim();
if (!Character.isJavaIdentifierStart(t.charAt(0))) {
return "Generated";
}
for (int i = 1; i < t.length(); i++) {
if (!Character.isJavaIdentifierPart(t.charAt(i))) {
return "Generated";
}
}
return t.substring(0, 1).toUpperCase(Locale.ROOT) + t.substring(1);
}
private static ClassModel buildClassModel(JsonObject obj, String className, NameAllocator names) {
ClassModel m = new ClassModel(className);
for (Map.Entry<String, JsonElement> e : obj.entrySet()) {
String key = e.getKey();
String field = JavaNames.toFieldName(key);
TypeRef tr = inferType(e.getValue(), key, names);
m.fields.add(new FieldModel(field, tr.typeName, tr.comment));
m.innerClasses.addAll(tr.newInners);
}
return m;
}
private static TypeRef inferType(JsonElement el, String keyHint, NameAllocator names) {
List<ClassModel> inners = new ArrayList<>();
if (el == null || el.isJsonNull()) {
return new TypeRef("Object", " // JSON null,类型需手动调整", inners);
}
if (el.isJsonPrimitive()) {
return new TypeRef(primitiveJavaType(el.getAsJsonPrimitive()), "", inners);
}
if (el.isJsonArray()) {
JsonArray arr = el.getAsJsonArray();
if (arr.size() == 0) {
return new TypeRef("java.util.List<Object>", "", inners);
}
JsonElement first = arr.get(0);
TypeRef elem = inferType(first, keyHint, names);
inners.addAll(elem.newInners);
return new TypeRef("java.util.List<" + elem.typeName + ">", "", inners);
}
if (el.isJsonObject()) {
String inner =
names.unique(JavaNames.toPascalCaseField(keyHint) + "Model");
ClassModel nested = buildClassModel(el.getAsJsonObject(), inner, names);
inners.add(nested);
return new TypeRef(inner, "", inners);
}
return new TypeRef("Object", "", inners);
}
private static String primitiveJavaType(JsonPrimitive p) {
if (p.isBoolean()) {
return "boolean";
}
if (p.isString()) {
return "String";
}
if (p.isNumber()) {
try {
BigDecimal bd = p.getAsBigDecimal();
if (bd.scale() <= 0) {
try {
long v = bd.longValueExact();
if (v >= Integer.MIN_VALUE && v <= Integer.MAX_VALUE) {
return "int";
}
return "long";
} catch (ArithmeticException ex) {
return "java.math.BigInteger";
}
}
} catch (Exception ignored) {
}
return "double";
}
return "String";
}
private static String render(ClassModel root, String packageName) {
StringBuilder sb = new StringBuilder();
if (packageName != null && !packageName.isBlank()) {
sb.append("package ").append(packageName.trim()).append(";\n\n");
}
if (needsListImport(root)) {
sb.append("import java.util.List;\n\n");
}
emitClass(sb, root, 0);
return sb.toString();
}
private static boolean needsListImport(ClassModel m) {
for (FieldModel f : m.fields) {
if (f.type.startsWith("java.util.List")) {
return true;
}
}
for (ClassModel inner : m.innerClasses) {
if (needsListImport(inner)) {
return true;
}
}
return false;
}
private static void emitClass(StringBuilder sb, ClassModel m, int indent) {
String pad = " ".repeat(indent);
sb.append(pad).append("public ");
if (indent > 0) {
sb.append("static ");
}
sb.append("class ").append(m.name).append(" {\n");
for (FieldModel f : m.fields) {
sb.append(pad)
.append(" private ")
.append(f.type)
.append(" ")
.append(f.name)
.append(";")
.append(f.comment)
.append("\n");
}
if (!m.fields.isEmpty()) {
sb.append("\n");
}
for (FieldModel f : m.fields) {
String cap = capitalize(f.name);
sb.append(pad).append(" public ").append(f.type).append(" get").append(cap).append("() {\n");
sb.append(pad).append(" return ").append(f.name).append(";\n");
sb.append(pad).append(" }\n\n");
sb.append(pad)
.append(" public void set")
.append(cap)
.append("(")
.append(f.type)
.append(" ")
.append(f.name)
.append(") {\n");
sb.append(pad).append(" this.").append(f.name).append(" = ").append(f.name).append(";\n");
sb.append(pad).append(" }\n\n");
}
TreeMap<String, ClassModel> innerByName = new TreeMap<>();
for (ClassModel c : m.innerClasses) {
innerByName.put(c.name, c);
}
for (ClassModel inner : innerByName.values()) {
sb.append("\n");
emitClass(sb, inner, indent + 1);
}
sb.append(pad).append("}\n");
}
private static String capitalize(String s) {
if (s == null || s.isEmpty()) {
return s;
}
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
private static final class NameAllocator {
private final Set<String> used = new HashSet<>();
String unique(String base) {
String b = base == null || base.isEmpty() ? "Nested" : base;
int i = 0;
String c = b;
while (used.contains(c)) {
c = b + (++i);
}
used.add(c);
return c;
}
}
private static final class TypeRef {
final String typeName;
final String comment;
final List<ClassModel> newInners;
TypeRef(String typeName, String comment, List<ClassModel> newInners) {
this.typeName = typeName;
this.comment = comment;
this.newInners = newInners;
}
}
private static final class FieldModel {
final String name;
final String type;
final String comment;
FieldModel(String name, String type, String comment) {
this.name = name;
this.type = type;
this.comment = comment == null ? "" : comment;
}
}
private static final class ClassModel {
final String name;
final List<FieldModel> fields = new ArrayList<>();
final List<ClassModel> innerClasses = new ArrayList<>();
ClassModel(String name) {
this.name = name;
}
}
}
@@ -0,0 +1,56 @@
package com.huangzj.protoplugin.json;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.DialogWrapper;
import com.intellij.ui.components.JBScrollPane;
import com.intellij.ui.components.JBTextArea;
import com.intellij.util.ui.JBUI;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Toolkit;
import java.awt.datatransfer.StringSelection;
import javax.swing.Action;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JPanel;
import org.jetbrains.annotations.Nullable;
/** 展示 JSON 文本并支持一键复制。 */
public final class ShowJsonResultDialog extends DialogWrapper {
private final JBTextArea textArea;
public ShowJsonResultDialog(Project project, String title, String json) {
super(project);
setTitle(title);
textArea = new JBTextArea(json);
textArea.setEditable(false);
textArea.setCaretPosition(0);
init();
}
@Override
protected @Nullable JComponent createCenterPanel() {
JPanel panel = new JPanel(new BorderLayout());
JBScrollPane scroll = new JBScrollPane(textArea);
scroll.setPreferredSize(new Dimension(560, 360));
panel.add(scroll, BorderLayout.CENTER);
JPanel south = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton copy = new JButton("复制到剪贴板");
copy.addActionListener(
e ->
Toolkit.getDefaultToolkit()
.getSystemClipboard()
.setContents(new StringSelection(textArea.getText()), null));
south.add(copy);
panel.add(south, BorderLayout.SOUTH);
panel.setBorder(JBUI.Borders.empty(8));
return panel;
}
@Override
protected Action @Nullable [] createActions() {
return new Action[] {getOKAction()};
}
}
@@ -0,0 +1,26 @@
package com.huangzj.protoplugin.memo;
/** 备忘录条目(字段需可被持久化组件序列化)。 */
public class MemoItem {
public String id = "";
public String title = "";
public String content = "";
public long updated = 0L;
public MemoItem() {}
public MemoItem(String id, String title, String content, long updated) {
this.id = id;
this.title = title;
this.content = content;
this.updated = updated;
}
public String displayTitle() {
if (title != null && !title.trim().isEmpty()) {
return title.trim();
}
return "(无标题)";
}
}
@@ -0,0 +1,465 @@
package com.huangzj.protoplugin.memo;
import com.intellij.openapi.project.Project;
import com.intellij.ui.components.JBList;
import com.intellij.ui.components.JBScrollPane;
import com.intellij.ui.components.JBTextArea;
import com.intellij.ui.components.JBTextField;
import com.intellij.util.ui.JBUI;
import com.intellij.util.ui.UIUtil;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.util.List;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.ListSelectionModel;
import javax.swing.SwingConstants;
import javax.swing.UIManager;
import javax.swing.border.Border;
import javax.swing.border.LineBorder;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import org.jetbrains.annotations.NotNull;
public final class MemoPanel extends JPanel {
private final Project project;
private final DefaultListModel<MemoItem> listModel = new DefaultListModel<>();
private final JBList<MemoItem> list = new JBList<>(listModel);
private final JBTextField titleField = new JBTextField();
private final JBTextArea contentArea = new JBTextArea();
private final JButton saveBtn = new JButton("保存");
private final JButton newBtn = new JButton("新建");
private final JButton deleteBtn = new JButton("删除");
private final JLabel themeChooserLabel = new JLabel("界面风格");
private final JComboBox<MemoUiTheme> themeCombo = new JComboBox<>(MemoUiTheme.values());
private final JLabel titleLabel = new JLabel("标题");
private final JLabel contentLabel = new JLabel("内容");
private final JLabel flairLabel = new JLabel(" ", SwingConstants.CENTER);
private final JPanel toolbar = new JPanel(new BorderLayout(8, 0));
private final JPanel leftButtonBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
private final JPanel themeChooserBar = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
private final JPanel topStack = new JPanel(new BorderLayout());
private final JSplitPane split;
private final JBScrollPane listScroll;
private final JBScrollPane contentScroll;
private final JPanel titleBlock = new JPanel(new BorderLayout(4, 4));
private final JPanel contentBlock = new JPanel(new BorderLayout(4, 4));
private final JPanel right = new JPanel(new BorderLayout(8, 8));
private boolean loadingSelection;
private boolean themeComboSuppress;
private MemoUiTheme currentTheme = MemoUiTheme.IDE_DEFAULT;
private final Font defaultLabelFont;
public MemoPanel(@NotNull Project project) {
super(new BorderLayout(8, 8));
this.project = project;
defaultLabelFont = titleLabel.getFont();
setBorder(JBUI.Borders.empty(8));
leftButtonBar.setOpaque(false);
leftButtonBar.add(newBtn);
leftButtonBar.add(saveBtn);
leftButtonBar.add(deleteBtn);
themeChooserBar.setOpaque(false);
themeChooserBar.add(themeChooserLabel);
themeChooserBar.add(themeCombo);
toolbar.setOpaque(false);
toolbar.add(leftButtonBar, BorderLayout.WEST);
toolbar.add(themeChooserBar, BorderLayout.EAST);
flairLabel.setOpaque(true);
flairLabel.setFont(flairLabel.getFont().deriveFont(Font.PLAIN, 12f));
topStack.setOpaque(false);
topStack.add(flairLabel, BorderLayout.NORTH);
topStack.add(toolbar, BorderLayout.CENTER);
add(topStack, BorderLayout.NORTH);
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
installListRenderer();
listScroll = new JBScrollPane(list);
listScroll.setPreferredSize(new Dimension(200, 300));
contentArea.setLineWrap(true);
contentArea.setWrapStyleWord(true);
contentScroll = new JBScrollPane(contentArea);
titleBlock.setOpaque(false);
titleBlock.add(titleLabel, BorderLayout.NORTH);
titleBlock.add(titleField, BorderLayout.CENTER);
contentBlock.setOpaque(false);
contentBlock.add(contentLabel, BorderLayout.NORTH);
contentBlock.add(contentScroll, BorderLayout.CENTER);
right.setOpaque(false);
right.add(titleBlock, BorderLayout.NORTH);
right.add(contentBlock, BorderLayout.CENTER);
titleField.setPreferredSize(new Dimension(200, 28));
split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, listScroll, right);
split.setResizeWeight(0.28);
split.setOpaque(false);
add(split, BorderLayout.CENTER);
MemoUiTheme initial = MemoProjectService.getInstance(project).getMemoTheme();
themeComboSuppress = true;
themeCombo.setSelectedItem(initial);
themeComboSuppress = false;
applyTheme(initial);
themeCombo.addActionListener(
e -> {
if (themeComboSuppress) {
return;
}
MemoUiTheme t = (MemoUiTheme) themeCombo.getSelectedItem();
if (t != null) {
MemoProjectService.getInstance(project).setMemoTheme(t);
applyTheme(t);
}
});
reloadListModel();
list.addListSelectionListener(
new ListSelectionListener() {
@Override
public void valueChanged(ListSelectionEvent e) {
if (e.getValueIsAdjusting()) {
return;
}
loadSelectedIntoEditor();
}
});
newBtn.addActionListener(e -> createMemo());
saveBtn.addActionListener(e -> saveCurrent());
deleteBtn.addActionListener(e -> deleteCurrent());
titleField
.getDocument()
.addDocumentListener(
new javax.swing.event.DocumentListener() {
@Override
public void insertUpdate(javax.swing.event.DocumentEvent e) {
refreshSelectedTitleInList();
}
@Override
public void removeUpdate(javax.swing.event.DocumentEvent e) {
refreshSelectedTitleInList();
}
@Override
public void changedUpdate(javax.swing.event.DocumentEvent e) {
refreshSelectedTitleInList();
}
});
}
private void installListRenderer() {
list.setCellRenderer(
new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(
JList<?> jList,
Object value,
int index,
boolean isSelected,
boolean cellHasFocus) {
super.getListCellRendererComponent(
jList, value, index, isSelected, cellHasFocus);
if (value instanceof MemoItem) {
setText(((MemoItem) value).displayTitle());
}
if (currentTheme != MemoUiTheme.IDE_DEFAULT) {
MemoThemeSpec spec = MemoThemeSpec.forTheme(currentTheme);
setOpaque(true);
if (isSelected) {
setBackground(spec.listSelectedBg);
setForeground(spec.listSelectedFg);
} else {
setBackground(spec.listBg);
setForeground(spec.text);
}
}
return this;
}
});
}
private void applyTheme(@NotNull MemoUiTheme theme) {
currentTheme = theme;
installListRenderer();
list.repaint();
if (theme == MemoUiTheme.IDE_DEFAULT) {
applyIdeTheme();
return;
}
MemoThemeSpec spec = MemoThemeSpec.forTheme(theme);
Border softField =
JBUI.Borders.compound(
new LineBorder(spec.fieldBorder, 1, true),
JBUI.Borders.empty(4, 6));
Color panelBg = spec.panelBg;
setBackground(panelBg);
topStack.setOpaque(true);
topStack.setBackground(panelBg);
toolbar.setBackground(panelBg);
toolbar.setOpaque(true);
leftButtonBar.setOpaque(true);
leftButtonBar.setBackground(panelBg);
themeChooserBar.setOpaque(true);
themeChooserBar.setBackground(panelBg);
themeChooserLabel.setForeground(spec.text);
flairLabel.setBackground(panelBg);
flairLabel.setForeground(spec.text);
split.setBackground(panelBg);
listScroll.setBackground(panelBg);
listScroll.getViewport().setBackground(spec.listBg);
list.setBackground(spec.listBg);
list.setForeground(spec.text);
right.setBackground(panelBg);
titleBlock.setBackground(panelBg);
contentBlock.setBackground(panelBg);
titleLabel.setForeground(spec.text);
contentLabel.setForeground(spec.text);
if (theme == MemoUiTheme.CUTE_CANDY) {
flairLabel.setVisible(true);
flairLabel.setText("✨ 今天也要甜甜哒~ 选一条备忘,把想法留下来吧 💕");
titleLabel.setText("📝 标题");
contentLabel.setText("💗 正文");
titleLabel.setFont(defaultLabelFont.deriveFont(Font.BOLD, defaultLabelFont.getSize() + 1f));
contentLabel.setFont(
defaultLabelFont.deriveFont(Font.BOLD, defaultLabelFont.getSize() + 1f));
} else {
flairLabel.setVisible(false);
flairLabel.setText(" ");
titleLabel.setText("标题");
contentLabel.setText("内容");
titleLabel.setFont(defaultLabelFont);
contentLabel.setFont(defaultLabelFont);
}
styleTextField(titleField, spec, softField);
styleTextArea(contentArea, contentScroll, spec, softField);
styleAccentButton(newBtn, spec);
styleAccentButton(saveBtn, spec);
styleAccentButton(deleteBtn, spec);
themeCombo.setBackground(spec.editorBg);
themeCombo.setForeground(spec.text);
revalidate();
repaint();
}
private void applyIdeTheme() {
Color panelBg = UIUtil.getPanelBackground();
Color editorBg = UIUtil.getTextFieldBackground();
Color fg = UIUtil.getTextFieldForeground();
Color listBg = UIUtil.getListBackground();
setBackground(panelBg);
topStack.setOpaque(false);
topStack.setBackground(panelBg);
toolbar.setBackground(panelBg);
toolbar.setOpaque(false);
leftButtonBar.setOpaque(false);
themeChooserBar.setOpaque(false);
themeChooserLabel.setForeground(UIUtil.getLabelForeground());
flairLabel.setVisible(false);
flairLabel.setText(" ");
flairLabel.setBackground(panelBg);
flairLabel.setForeground(UIUtil.getLabelForeground());
split.setBackground(panelBg);
listScroll.setBackground(panelBg);
listScroll.getViewport().setBackground(listBg);
list.setBackground(listBg);
list.setForeground(UIUtil.getTreeForeground());
right.setBackground(panelBg);
titleBlock.setBackground(panelBg);
contentBlock.setBackground(panelBg);
titleLabel.setText("标题");
contentLabel.setText("内容");
titleLabel.setFont(defaultLabelFont);
contentLabel.setFont(defaultLabelFont);
titleLabel.setForeground(UIUtil.getLabelForeground());
contentLabel.setForeground(UIUtil.getLabelForeground());
resetFieldLook(titleField, editorBg, fg);
resetFieldLook(contentArea, editorBg, fg);
contentScroll.getViewport().setBackground(editorBg);
contentArea.setCaretColor(fg);
titleField.setCaretColor(fg);
resetButton(newBtn);
resetButton(saveBtn);
resetButton(deleteBtn);
resetCombo(themeCombo);
revalidate();
repaint();
}
private static void resetFieldLook(javax.swing.text.JTextComponent c, Color bg, Color fg) {
c.setOpaque(true);
c.setBackground(bg);
c.setForeground(fg);
c.setBorder(
JBUI.Borders.compound(
JBUI.Borders.customLine(UIUtil.getBoundsColor(), 1),
JBUI.Borders.empty(4, 6)));
Color selBg = UIManager.getColor("TextArea.selectionBackground");
if (selBg == null) {
selBg = UIUtil.getListSelectionBackground();
}
Color selFg = UIManager.getColor("TextArea.selectionForeground");
if (selFg == null) {
selFg = UIUtil.getListSelectionForeground();
}
c.setSelectionColor(selBg);
c.setSelectedTextColor(selFg);
}
private static void resetButton(JButton b) {
b.setOpaque(false);
b.setBackground(null);
b.setForeground(null);
b.setBorder(JBUI.Borders.empty(4, 12));
}
private static void resetCombo(JComboBox<?> combo) {
combo.setOpaque(true);
combo.setBackground(null);
combo.setForeground(null);
}
private static void styleTextField(JBTextField field, MemoThemeSpec spec, Border border) {
field.setOpaque(true);
field.setBackground(spec.editorBg);
field.setForeground(spec.text);
field.setBorder(border);
field.setCaretColor(spec.text);
field.setSelectedTextColor(spec.listSelectedFg);
field.setSelectionColor(spec.listSelectedBg);
}
private static void styleTextArea(
JBTextArea area, JScrollPane scroll, MemoThemeSpec spec, Border border) {
area.setOpaque(true);
area.setBackground(spec.editorBg);
area.setForeground(spec.text);
area.setBorder(JBUI.Borders.empty());
area.setCaretColor(spec.text);
area.setSelectedTextColor(spec.listSelectedFg);
area.setSelectionColor(spec.listSelectedBg);
scroll.setBorder(border);
scroll.getViewport().setBackground(spec.editorBg);
}
private static void styleAccentButton(JButton b, MemoThemeSpec spec) {
b.setOpaque(true);
b.setBackground(spec.buttonBg);
b.setForeground(spec.buttonFg);
b.setBorder(
JBUI.Borders.compound(
new LineBorder(spec.accent, 1, true), JBUI.Borders.empty(5, 14)));
b.setFocusPainted(false);
}
private void reloadListModel() {
listModel.clear();
List<MemoItem> items = MemoProjectService.getInstance(project).getItems();
for (MemoItem m : items) {
listModel.addElement(m);
}
if (!items.isEmpty() && list.getSelectedIndex() < 0) {
list.setSelectedIndex(0);
}
}
private void createMemo() {
MemoItem m = MemoProjectService.getInstance(project).createMemo();
listModel.addElement(m);
list.setSelectedValue(m, true);
titleField.requestFocusInWindow();
}
private void saveCurrent() {
MemoItem m = list.getSelectedValue();
if (m == null) {
return;
}
m.title = titleField.getText();
m.content = contentArea.getText();
m.updated = System.currentTimeMillis();
list.repaint();
}
private void deleteCurrent() {
MemoItem m = list.getSelectedValue();
if (m == null) {
return;
}
int idx = list.getSelectedIndex();
MemoProjectService.getInstance(project).removeById(m.id);
listModel.removeElement(m);
if (!listModel.isEmpty()) {
int next = Math.min(idx, listModel.getSize() - 1);
list.setSelectedIndex(Math.max(0, next));
} else {
clearEditor();
}
}
private void loadSelectedIntoEditor() {
MemoItem m = list.getSelectedValue();
loadingSelection = true;
try {
if (m == null) {
clearEditor();
} else {
titleField.setText(m.title != null ? m.title : "");
contentArea.setText(m.content != null ? m.content : "");
}
} finally {
loadingSelection = false;
}
}
private void clearEditor() {
titleField.setText("");
contentArea.setText("");
}
private void refreshSelectedTitleInList() {
if (loadingSelection) {
return;
}
javax.swing.SwingUtilities.invokeLater(() -> list.repaint());
}
}
@@ -0,0 +1,86 @@
package com.huangzj.protoplugin.memo;
import com.intellij.openapi.components.PersistentStateComponent;
import com.intellij.openapi.components.State;
import com.intellij.openapi.components.Storage;
import com.intellij.openapi.project.Project;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.UUID;
import org.jetbrains.annotations.NotNull;
@State(name = "ProtoPluginMemos", storages = @Storage("protoPluginMemos.xml"))
public final class MemoProjectService implements PersistentStateComponent<MemoProjectService.State> {
public static MemoProjectService getInstance(@NotNull Project project) {
return project.getService(MemoProjectService.class);
}
public static final class State {
public List<MemoItem> items = new ArrayList<>();
/** 见 {@link MemoUiTheme#getPersistenceId()} */
public String memoThemeId = MemoUiTheme.IDE_DEFAULT.getPersistenceId();
}
private State state = new State();
@NotNull
@Override
public State getState() {
return state;
}
@Override
public void loadState(@NotNull State loaded) {
this.state = loaded;
if (this.state.items == null) {
this.state.items = new ArrayList<>();
}
if (this.state.memoThemeId == null || this.state.memoThemeId.isEmpty()) {
this.state.memoThemeId = MemoUiTheme.IDE_DEFAULT.getPersistenceId();
}
}
public List<MemoItem> getItems() {
return state.items;
}
public MemoItem createMemo() {
MemoItem m = new MemoItem();
m.id = UUID.randomUUID().toString();
m.title = "";
m.content = "";
m.updated = System.currentTimeMillis();
state.items.add(m);
return m;
}
public void removeById(@NotNull String id) {
for (Iterator<MemoItem> it = state.items.iterator(); it.hasNext(); ) {
MemoItem m = it.next();
if (id.equals(m.id)) {
it.remove();
return;
}
}
}
public MemoItem findById(@NotNull String id) {
for (MemoItem m : state.items) {
if (id.equals(m.id)) {
return m;
}
}
return null;
}
@NotNull
public MemoUiTheme getMemoTheme() {
return MemoUiTheme.fromPersistenceId(state.memoThemeId);
}
public void setMemoTheme(@NotNull MemoUiTheme theme) {
state.memoThemeId = theme.getPersistenceId();
}
}
@@ -0,0 +1,102 @@
package com.huangzj.protoplugin.memo;
import java.awt.Color;
/** 非 IDE 默认主题下的色板(IDE 默认走 {@link com.intellij.util.ui.UIUtil})。 */
final class MemoThemeSpec {
final Color panelBg;
final Color editorBg;
final Color text;
final Color accent;
final Color listBg;
final Color listSelectedBg;
final Color listSelectedFg;
final Color buttonBg;
final Color buttonFg;
final Color fieldBorder;
private MemoThemeSpec(
Color panelBg,
Color editorBg,
Color text,
Color accent,
Color listBg,
Color listSelectedBg,
Color listSelectedFg,
Color buttonBg,
Color buttonFg,
Color fieldBorder) {
this.panelBg = panelBg;
this.editorBg = editorBg;
this.text = text;
this.accent = accent;
this.listBg = listBg;
this.listSelectedBg = listSelectedBg;
this.listSelectedFg = listSelectedFg;
this.buttonBg = buttonBg;
this.buttonFg = buttonFg;
this.fieldBorder = fieldBorder;
}
static MemoThemeSpec forTheme(MemoUiTheme theme) {
switch (theme) {
case IDE_DEFAULT:
throw new IllegalArgumentException("IDE 默认请走 UIUtil");
case DARK_FOCUS:
return new MemoThemeSpec(
c(0x1E293B),
c(0x0F172A),
c(0xE2E8F0),
c(0x22D3EE),
c(0x334155),
c(0x0E7490),
c(0xECFEFF),
c(0x22D3EE),
c(0x0F172A),
c(0x475569));
case PAPER:
return new MemoThemeSpec(
c(0xF5F0E6),
c(0xFFFBF5),
c(0x3D3428),
c(0x8B6914),
c(0xEDE4D3),
c(0xD4C4A8),
c(0x2C2416),
c(0x8B6914),
c(0xFFFBF5),
c(0xC4B59A));
case OCEAN:
return new MemoThemeSpec(
c(0xE0F2FE),
c(0xFFFFFF),
c(0x0C4A6E),
c(0x0284C7),
c(0xF0F9FF),
c(0xBAE6FD),
c(0x0C4A6E),
c(0x0284C7),
c(0xFFFFFF),
c(0x7DD3FC));
case CUTE_CANDY:
return new MemoThemeSpec(
c(0xFFF5F9),
c(0xFFFFFF),
c(0x6B2D5C),
c(0xFF6B9D),
c(0xFFE4F0),
c(0xFFC8E6),
c(0x4A1942),
c(0xFF8FAB),
c(0xFFFFFF),
c(0xFFB3D1));
default:
throw new IllegalStateException();
}
}
private static Color c(int rgb) {
return new Color(rgb);
}
}
@@ -0,0 +1,32 @@
package com.huangzj.protoplugin.memo;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.wm.ToolWindow;
import com.intellij.openapi.wm.ToolWindowFactory;
import com.intellij.openapi.wm.ex.ToolWindowEx;
import com.intellij.ui.content.ContentFactory;
import org.jetbrains.annotations.NotNull;
public final class MemoToolWindowFactory implements ToolWindowFactory {
/** 与 {@code plugin.xml} 中 {@code <toolWindow id>} 一致 */
public static final String TOOL_WINDOW_ID = "MyCustomMemo";
/** 侧边栏与 View → Tool Windows 中显示的名称 */
public static final String TOOL_WINDOW_STRIPE_TITLE = "我的自定义备忘录";
@Override
public void init(@NotNull ToolWindow toolWindow) {
if (toolWindow instanceof ToolWindowEx ex) {
ex.setStripeTitle(TOOL_WINDOW_STRIPE_TITLE);
}
}
@Override
public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) {
MemoPanel panel = new MemoPanel(project);
var content =
ContentFactory.getInstance().createContent(panel, TOOL_WINDOW_STRIPE_TITLE, false);
toolWindow.getContentManager().addContent(content);
}
}
@@ -0,0 +1,51 @@
package com.huangzj.protoplugin.memo;
import org.jetbrains.annotations.NotNull;
/** 备忘录工具窗口可选界面风格(持久化使用 {@link #persistenceId})。 */
public enum MemoUiTheme {
/** 跟随 IDE 浅色/深色主题 */
IDE_DEFAULT("IDE 默认", "IDE"),
/** 深色背景 + 青色点缀,适合长时间编辑 */
DARK_FOCUS("深色专注", "DARK_FOCUS"),
/** 米黄纸张、暖色文字,偏阅读向 */
PAPER("护眼纸张", "PAPER"),
/** 浅蓝面板 + 海蓝强调色 */
OCEAN("海洋", "OCEAN"),
/** 粉紫 pastel、柔和选区与装饰标签 */
CUTE_CANDY("可爱糖果", "CUTE_CANDY");
private final String displayName;
private final String persistenceId;
MemoUiTheme(String displayName, String persistenceId) {
this.displayName = displayName;
this.persistenceId = persistenceId;
}
public String getDisplayName() {
return displayName;
}
public String getPersistenceId() {
return persistenceId;
}
@NotNull
public static MemoUiTheme fromPersistenceId(String id) {
if (id == null || id.isEmpty()) {
return IDE_DEFAULT;
}
for (MemoUiTheme t : values()) {
if (t.persistenceId.equals(id)) {
return t;
}
}
return IDE_DEFAULT;
}
@Override
public String toString() {
return displayName;
}
}
@@ -0,0 +1,29 @@
package com.huangzj.protoplugin.memo;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.wm.ToolWindow;
import com.intellij.openapi.wm.ToolWindowManager;
import org.jetbrains.annotations.NotNull;
public final class OpenMemoToolWindowAction extends AnAction {
@Override
public void update(@NotNull AnActionEvent e) {
e.getPresentation().setEnabled(e.getProject() != null);
}
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
Project project = e.getProject();
if (project == null) {
return;
}
ToolWindow tw =
ToolWindowManager.getInstance(project).getToolWindow(MemoToolWindowFactory.TOOL_WINDOW_ID);
if (tw != null) {
tw.show(null);
}
}
}
@@ -0,0 +1,58 @@
package com.huangzj.protoplugin.mysql;
/** JDBC 列元数据(用于生成实体字段)。 */
public final class ColumnMeta {
private final String name;
private final int sqlType;
private final String typeName;
private final int columnSize;
private final int decimalDigits;
private final boolean nullable;
private final boolean autoIncrement;
public ColumnMeta(
String name,
int sqlType,
String typeName,
int columnSize,
int decimalDigits,
boolean nullable,
boolean autoIncrement) {
this.name = name;
this.sqlType = sqlType;
this.typeName = typeName != null ? typeName : "";
this.columnSize = columnSize;
this.decimalDigits = decimalDigits;
this.nullable = nullable;
this.autoIncrement = autoIncrement;
}
public String getName() {
return name;
}
public int getSqlType() {
return sqlType;
}
public String getTypeName() {
return typeName;
}
public int getColumnSize() {
return columnSize;
}
public int getDecimalDigits() {
return decimalDigits;
}
public boolean isNullable() {
return nullable;
}
public boolean isAutoIncrement() {
return autoIncrement;
}
}
@@ -0,0 +1,94 @@
package com.huangzj.protoplugin.mysql;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/** 表名/列名到合法 Java 标识符的转换。 */
public final class JavaNames {
private static final Set<String> KEYWORDS = new HashSet<>();
static {
String[] k =
("abstract assert boolean break byte case catch char class const "
+ "continue default do double else enum extends final finally float "
+ "for goto if implements import instanceof int interface long native "
+ "new package private protected public return short static strictfp "
+ "super switch synchronized this throw throws transient try void "
+ "volatile while true false null record sealed permits yields")
.split("\\s+");
for (String s : k) {
KEYWORDS.add(s);
}
}
private JavaNames() {}
public static String toClassNameFromTable(String tableName) {
String pascal = toPascalCase(splitIdentifiers(tableName));
return pascal.isEmpty() ? "Entity" : pascal;
}
public static String toFieldName(String columnName) {
String camel = toCamelCase(splitIdentifiers(columnName));
if (camel.isEmpty()) {
return "field";
}
camel = Character.toLowerCase(camel.charAt(0)) + camel.substring(1);
return safeIdentifier(camel);
}
public static String toPascalCaseField(String columnName) {
String camel = toCamelCase(splitIdentifiers(columnName));
if (camel.isEmpty()) {
return "Field";
}
return Character.toUpperCase(camel.charAt(0)) + camel.substring(1);
}
private static String safeIdentifier(String name) {
if (KEYWORDS.contains(name.toLowerCase(Locale.ROOT))) {
return name + "Value";
}
return name;
}
private static String[] splitIdentifiers(String raw) {
if (raw == null || raw.isEmpty()) {
return new String[0];
}
String s = raw.replace('`', ' ').trim();
return s.split("[_\\s-]+");
}
private static String toCamelCase(String[] parts) {
StringBuilder sb = new StringBuilder();
for (String p : parts) {
if (p == null || p.isEmpty()) {
continue;
}
String low = p.toLowerCase(Locale.ROOT);
if (sb.length() == 0) {
sb.append(low);
} else {
if (low.isEmpty()) {
continue;
}
sb.append(Character.toUpperCase(low.charAt(0)));
if (low.length() > 1) {
sb.append(low, 1, low.length());
}
}
}
return sb.toString();
}
private static String toPascalCase(String[] parts) {
String camel = toCamelCase(parts);
if (camel.isEmpty()) {
return "";
}
return Character.toUpperCase(camel.charAt(0)) + camel.substring(1);
}
}
@@ -0,0 +1,150 @@
package com.huangzj.protoplugin.mysql;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
/** 读取 MySQL 表名与列元数据。 */
public final class JdbcMySqlMetadata {
private static volatile boolean driverLoaded;
private JdbcMySqlMetadata() {}
/**
* IntelliJ 插件使用独立 ClassLoader 时,JDBC 4 的 SPI 往往不会自动注册 MySQL 驱动,
* 会出现 “No suitable driver”。连接前显式加载驱动类可避免该问题。
*/
public static void ensureMySqlDriverLoaded() throws SQLException {
if (driverLoaded) {
return;
}
synchronized (JdbcMySqlMetadata.class) {
if (driverLoaded) {
return;
}
try {
Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) {
throw new SQLException(
"未找到 MySQL JDBC 驱动类 com.mysql.cj.jdbc.Driver。"
+ "请使用 Gradle 执行 buildPlugin 重新打包插件,并确认已依赖 mysql-connector-j。",
e);
}
driverLoaded = true;
}
}
/**
* 简写地址须包含库名,例如 host:3306/dbname;否则元数据中的 catalog 为空,无法列出表。
*/
public static boolean hasDatabaseInUrl(String raw) {
String url = normalizeJdbcUrl(raw);
if (!url.startsWith("jdbc:mysql://")) {
return true;
}
String rest = url.substring("jdbc:mysql://".length());
int q = rest.indexOf('?');
String pathPart = q >= 0 ? rest.substring(0, q) : rest;
int slash = pathPart.indexOf('/');
return slash >= 0 && slash < pathPart.length() - 1;
}
public static String normalizeJdbcUrl(String raw) {
String s = raw == null ? "" : raw.trim();
if (s.isEmpty()) {
return "";
}
if (s.startsWith("jdbc:mysql:")) {
return s;
}
// host:port/db 或 host/db
if (!s.contains("://")) {
return "jdbc:mysql://" + s;
}
return s;
}
public static Connection openConnection(String jdbcUrl, String user, String password)
throws SQLException {
ensureMySqlDriverLoaded();
String url = normalizeJdbcUrl(jdbcUrl);
Properties props = new Properties();
if (user != null && !user.isEmpty()) {
props.setProperty("user", user);
}
if (password != null) {
props.setProperty("password", password);
}
return DriverManager.getConnection(url, props);
}
public static List<String> listTableNames(Connection conn) throws SQLException {
DatabaseMetaData md = conn.getMetaData();
String catalog = conn.getCatalog();
List<String> names = new ArrayList<>();
try (ResultSet rs = md.getTables(catalog, null, "%", new String[] {"TABLE"})) {
while (rs.next()) {
String t = rs.getString("TABLE_NAME");
if (t != null && !t.isEmpty()) {
names.add(t);
}
}
}
return names;
}
public static List<ColumnMeta> listColumns(Connection conn, String tableName)
throws SQLException {
DatabaseMetaData md = conn.getMetaData();
String catalog = conn.getCatalog();
List<ColumnMeta> cols = new ArrayList<>();
try (ResultSet rs = md.getColumns(catalog, null, tableName, "%")) {
while (rs.next()) {
String col = rs.getString("COLUMN_NAME");
int dataType = rs.getInt("DATA_TYPE");
String typeName = rs.getString("TYPE_NAME");
int size = rs.getInt("COLUMN_SIZE");
int digits = rs.getInt("DECIMAL_DIGITS");
boolean nullable = rs.getInt("NULLABLE") == DatabaseMetaData.columnNullable;
String ai = rs.getString("IS_AUTOINCREMENT");
boolean autoInc = "YES".equalsIgnoreCase(ai);
cols.add(new ColumnMeta(col, dataType, typeName, size, digits, nullable, autoInc));
}
}
return cols;
}
/** 列名 -> 是否主键(用于单列/复合主键)。 */
public static Set<String> primaryKeyColumns(Connection conn, String tableName)
throws SQLException {
DatabaseMetaData md = conn.getMetaData();
String catalog = conn.getCatalog();
Map<String, Integer> order = new LinkedHashMap<>();
try (ResultSet rs = md.getPrimaryKeys(catalog, null, tableName)) {
while (rs.next()) {
String col = rs.getString("COLUMN_NAME");
int seq = rs.getInt("KEY_SEQ");
if (col != null) {
order.put(col, seq);
}
}
}
List<Map.Entry<String, Integer>> entries = new ArrayList<>(order.entrySet());
entries.sort(Map.Entry.comparingByValue());
Set<String> set = new LinkedHashSet<>();
for (Map.Entry<String, Integer> e : entries) {
set.add(e.getKey());
}
return set;
}
}
@@ -0,0 +1,307 @@
package com.huangzj.protoplugin.mysql;
import java.sql.Types;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/** 根据列元数据生成带 JPA 注解的实体源码(jakarta.persistence)。 */
public final class JpaEntitySourceGenerator {
private JpaEntitySourceGenerator() {}
public static String generate(String packageName, String tableName, List<ColumnMeta> columns,
Set<String> pkColumns) {
String className = JavaNames.toClassNameFromTable(tableName);
List<ColumnMeta> ordered = new ArrayList<>(columns);
Set<String> pk = pkColumns != null ? pkColumns : Set.of();
boolean compositePk = pk.size() > 1;
String pkClassName = className + "Pk";
StringBuilder imports = new StringBuilder();
imports.append("import jakarta.persistence.Column;\n");
imports.append("import jakarta.persistence.Entity;\n");
imports.append("import jakarta.persistence.Table;\n");
if (!pk.isEmpty()) {
imports.append("import jakarta.persistence.Id;\n");
}
boolean needGenValue = false;
if (pk.size() == 1) {
String only = pk.iterator().next();
ColumnMeta pkCol = findColumn(ordered, only);
if (pkCol != null && pkCol.isAutoIncrement()) {
imports.append("import jakarta.persistence.GeneratedValue;\n");
imports.append("import jakarta.persistence.GenerationType;\n");
needGenValue = true;
}
}
if (compositePk) {
imports.append("import jakarta.persistence.IdClass;\n");
imports.append("import java.io.Serializable;\n");
imports.append("import java.util.Objects;\n");
}
Set<String> extraImports = new LinkedHashSet<>();
for (ColumnMeta c : ordered) {
collectTypeImport(c, extraImports);
}
for (String imp : extraImports) {
imports.append("import ").append(imp).append(";\n");
}
StringBuilder sb = new StringBuilder();
sb.append("package ").append(packageName).append(";\n\n");
sb.append(imports).append("\n");
sb.append("/**\n");
sb.append(" * Generated from MySQL table: ").append(safeJavadoc(tableName)).append("\n");
sb.append(" */\n");
sb.append("@Entity\n");
sb.append("@Table(name = \"").append(escapeJavaString(tableName)).append("\")\n");
if (compositePk) {
sb.append("@IdClass(").append(className).append(".").append(pkClassName).append(".class)\n");
}
sb.append("public class ").append(className).append(" {\n");
if (pk.isEmpty()) {
sb.append(" // TODO: 未从数据库元数据检测到主键,请手工补充 @Id 字段\n");
}
sb.append("\n");
for (ColumnMeta c : ordered) {
boolean isPk = pk.contains(c.getName());
String field = JavaNames.toFieldName(c.getName());
String javaType = mapJavaType(c);
sb.append(" ");
if (isPk) {
sb.append("@Id\n ");
if (needGenValue && pk.size() == 1 && pk.contains(c.getName())) {
sb.append("@GeneratedValue(strategy = GenerationType.IDENTITY)\n ");
}
}
sb.append("@Column(name = \"").append(escapeJavaString(c.getName())).append("\"");
if (!c.isNullable() && !isPk) {
sb.append(", nullable = false");
}
if (c.getColumnSize() > 0 && isStringType(c)) {
sb.append(", length = ").append(c.getColumnSize());
}
if (c.getDecimalDigits() > 0 && (javaType.equals("BigDecimal") || javaType.equals("Double"))) {
sb.append(", precision = ").append(c.getColumnSize());
sb.append(", scale = ").append(c.getDecimalDigits());
}
sb.append(")\n");
sb.append(" private ").append(javaType).append(" ").append(field).append(";\n\n");
}
// getters / setters
for (ColumnMeta c : ordered) {
String field = JavaNames.toFieldName(c.getName());
String pascal = JavaNames.toPascalCaseField(c.getName());
String javaType = mapJavaType(c);
sb.append(" public ").append(javaType).append(" get").append(pascal).append("() {\n");
sb.append(" return ").append(field).append(";\n");
sb.append(" }\n\n");
sb.append(" public void set").append(pascal).append("(").append(javaType).append(" ").append(field)
.append(") {\n");
sb.append(" this.").append(field).append(" = ").append(field).append(";\n");
sb.append(" }\n\n");
}
if (compositePk) {
sb.append(generatePkClass(pkClassName, ordered, pk));
}
sb.append("}\n");
return sb.toString();
}
private static String generatePkClass(String pkClassName, List<ColumnMeta> columns, Set<String> pk) {
StringBuilder sb = new StringBuilder();
sb.append(" /**\n");
sb.append(" * Composite primary key.\n");
sb.append(" */\n");
sb.append(" public static class ").append(pkClassName).append(" implements Serializable {\n\n");
for (ColumnMeta c : columns) {
if (!pk.contains(c.getName())) {
continue;
}
String field = JavaNames.toFieldName(c.getName());
String javaType = mapJavaType(c);
sb.append(" private ").append(javaType).append(" ").append(field).append(";\n");
}
sb.append("\n");
for (ColumnMeta c : columns) {
if (!pk.contains(c.getName())) {
continue;
}
String field = JavaNames.toFieldName(c.getName());
String pascal = JavaNames.toPascalCaseField(c.getName());
String javaType = mapJavaType(c);
sb.append(" public ").append(javaType).append(" get").append(pascal).append("() {\n");
sb.append(" return ").append(field).append(";\n");
sb.append(" }\n\n");
sb.append(" public void set").append(pascal).append("(").append(javaType).append(" ")
.append(field).append(") {\n");
sb.append(" this.").append(field).append(" = ").append(field).append(";\n");
sb.append(" }\n\n");
}
sb.append(" @Override\n");
sb.append(" public boolean equals(Object o) {\n");
sb.append(" if (this == o) return true;\n");
sb.append(" if (o == null || getClass() != o.getClass()) return false;\n");
sb.append(" ").append(pkClassName).append(" that = (").append(pkClassName).append(") o;\n");
sb.append(" return ");
boolean first = true;
for (ColumnMeta c : columns) {
if (!pk.contains(c.getName())) {
continue;
}
String field = JavaNames.toFieldName(c.getName());
if (!first) {
sb.append("\n && ");
} else {
first = false;
}
sb.append("Objects.equals(").append(field).append(", that.").append(field).append(")");
}
if (first) {
sb.append("true");
}
sb.append(";\n");
sb.append(" }\n\n");
sb.append(" @Override\n");
sb.append(" public int hashCode() {\n");
sb.append(" return Objects.hash(");
first = true;
for (ColumnMeta c : columns) {
if (!pk.contains(c.getName())) {
continue;
}
if (!first) {
sb.append(", ");
}
first = false;
sb.append(JavaNames.toFieldName(c.getName()));
}
sb.append(");\n");
sb.append(" }\n");
sb.append(" }\n\n");
return sb.toString();
}
private static ColumnMeta findColumn(List<ColumnMeta> cols, String name) {
for (ColumnMeta c : cols) {
if (c.getName().equalsIgnoreCase(name)) {
return c;
}
}
return null;
}
private static void collectTypeImport(ColumnMeta c, Set<String> extraImports) {
String t = mapJavaType(c);
switch (t) {
case "BigDecimal":
extraImports.add("java.math.BigDecimal");
break;
case "LocalDate":
extraImports.add("java.time.LocalDate");
break;
case "LocalTime":
extraImports.add("java.time.LocalTime");
break;
case "LocalDateTime":
extraImports.add("java.time.LocalDateTime");
break;
default:
break;
}
}
private static boolean isStringType(ColumnMeta c) {
int st = c.getSqlType();
return st == Types.CHAR
|| st == Types.VARCHAR
|| st == Types.LONGVARCHAR
|| st == Types.NCHAR
|| st == Types.NVARCHAR
|| st == Types.LONGNVARCHAR;
}
private static String mapJavaType(ColumnMeta c) {
int st = c.getSqlType();
String tn = c.getTypeName() != null ? c.getTypeName().toLowerCase(Locale.ROOT) : "";
switch (st) {
case Types.BIT:
if (c.getColumnSize() == 1) {
return "Boolean";
}
return "byte[]";
case Types.TINYINT:
if (c.getColumnSize() == 1 && (tn.contains("bool") || tn.contains("bit"))) {
return "Boolean";
}
return "Integer";
case Types.SMALLINT:
return "Integer";
case Types.INTEGER:
return "Integer";
case Types.BIGINT:
return "Long";
case Types.FLOAT:
case Types.REAL:
return "Float";
case Types.DOUBLE:
return "Double";
case Types.NUMERIC:
case Types.DECIMAL:
return "BigDecimal";
case Types.CHAR:
case Types.VARCHAR:
case Types.LONGVARCHAR:
case Types.NCHAR:
case Types.NVARCHAR:
case Types.LONGNVARCHAR:
return "String";
case Types.DATE:
return "LocalDate";
case Types.TIME:
case Types.TIME_WITH_TIMEZONE:
return "LocalTime";
case Types.TIMESTAMP:
case Types.TIMESTAMP_WITH_TIMEZONE:
return "LocalDateTime";
case Types.BINARY:
case Types.VARBINARY:
case Types.LONGVARBINARY:
case Types.BLOB:
return "byte[]";
case Types.BOOLEAN:
return "Boolean";
default:
if (tn.contains("json")) {
return "String";
}
if (tn.contains("geometry") || tn.contains("blob")) {
return "byte[]";
}
return "String";
}
}
private static String escapeJavaString(String s) {
if (s == null) {
return "";
}
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static String safeJavadoc(String s) {
if (s == null) {
return "";
}
return s.replace("*/", "* /");
}
}
@@ -0,0 +1,395 @@
package com.huangzj.protoplugin.mysql;
import com.intellij.lang.java.JavaLanguage;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.fileChooser.FileChooserDescriptor;
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.ProgressManager;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.DialogWrapper;
import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.ui.TextFieldWithBrowseButton;
import com.intellij.openapi.ui.ValidationInfo;
import com.intellij.openapi.vfs.LocalFileSystem;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiDirectory;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiFileFactory;
import com.intellij.psi.PsiManager;
import com.intellij.ui.components.JBScrollPane;
import com.intellij.util.ui.JBUI;
import com.huangzj.protoplugin.PackageInference;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.swing.*;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class MysqlEntityDialog extends DialogWrapper {
private final Project project;
private final JTextField jdbcUrlField = new JTextField(40);
private final JTextField userField = new JTextField(20);
private final JPasswordField passwordField = new JPasswordField(20);
private final JTextField filterField = new JTextField(20);
private final TextFieldWithBrowseButton outputDirField = new TextFieldWithBrowseButton();
private final JPanel tableListPanel = new JPanel();
private final Map<String, javax.swing.JCheckBox> tableChecks = new LinkedHashMap<>();
private List<String> loadedTables = Collections.emptyList();
public MysqlEntityDialog(@Nullable Project project, @Nullable VirtualFile presetFolder) {
super(project);
this.project = project;
setTitle("mysql转Jpa");
if (presetFolder != null) {
outputDirField.setText(presetFolder.getPath());
}
FileChooserDescriptor desc = FileChooserDescriptorFactory.createSingleFolderDescriptor();
desc.setTitle("选择实体类输出目录(通常为 .../src/main/java/...");
outputDirField.addBrowseFolderListener("选择目录", null, project, desc);
tableListPanel.setLayout(new BoxLayout(tableListPanel, BoxLayout.Y_AXIS));
tableListPanel.setBorder(JBUI.Borders.empty(4));
filterField.getDocument()
.addDocumentListener(
new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
applyFilter();
}
@Override
public void removeUpdate(DocumentEvent e) {
applyFilter();
}
@Override
public void changedUpdate(DocumentEvent e) {
applyFilter();
}
});
init();
}
@Override
protected JComponent createCenterPanel() {
JPanel root = new JPanel(new BorderLayout(8, 8));
JPanel form = new JPanel(new GridBagLayout());
GridBagConstraints gc = new GridBagConstraints();
gc.insets = new Insets(4, 4, 4, 4);
gc.anchor = GridBagConstraints.WEST;
gc.fill = GridBagConstraints.HORIZONTAL;
gc.weightx = 0;
gc.gridx = 0;
gc.gridy = 0;
form.add(new JLabel("JDBC URL:"), gc);
gc.gridx = 1;
gc.weightx = 1;
jdbcUrlField.setToolTipText("例: jdbc:mysql://127.0.0.1:3306/dbname?useSSL=false&serverTimezone=UTC");
form.add(jdbcUrlField, gc);
gc.gridx = 0;
gc.gridy = 1;
gc.weightx = 0;
form.add(new JLabel("用户名:"), gc);
gc.gridx = 1;
gc.weightx = 1;
form.add(userField, gc);
gc.gridx = 0;
gc.gridy = 2;
gc.weightx = 0;
form.add(new JLabel("密码:"), gc);
gc.gridx = 1;
gc.weightx = 1;
form.add(passwordField, gc);
gc.gridx = 0;
gc.gridy = 3;
gc.weightx = 0;
form.add(new JLabel("输出目录:"), gc);
gc.gridx = 1;
gc.weightx = 1;
form.add(outputDirField, gc);
JLabel hint =
new JLabel(
"<html>提示:<b>必须包含库名</b>,例如 <code>host:3306/my_database</code> 或完整 JDBC URL。"
+ "<br/>仅填 <code>host:3306</code> 无法列出表,且易导致连接异常。"
+ "<br/>生成类使用 <code>jakarta.persistence</code> 注解。</html>");
hint.setBorder(BorderFactory.createEmptyBorder(0, 0, 8, 0));
JPanel top = new JPanel(new BorderLayout(0, 8));
top.add(hint, BorderLayout.NORTH);
top.add(form, BorderLayout.CENTER);
root.add(top, BorderLayout.NORTH);
JPanel mid = new JPanel(new BorderLayout(4, 4));
JPanel toolbar = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
JButton queryBtn = new JButton("查询表");
queryBtn.addActionListener(e -> runQueryTables());
toolbar.add(queryBtn);
toolbar.add(new JLabel("搜索表名:"));
toolbar.add(filterField);
mid.add(toolbar, BorderLayout.NORTH);
JBScrollPane scroll = new JBScrollPane(tableListPanel);
scroll.setPreferredSize(new Dimension(560, 260));
mid.add(scroll, BorderLayout.CENTER);
root.add(mid, BorderLayout.CENTER);
JPanel south = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
JButton genBtn = new JButton("生成 Java 实体");
genBtn.addActionListener(e -> runGenerateEntities());
south.add(genBtn);
root.add(south, BorderLayout.SOUTH);
return root;
}
@Override
protected Action @NotNull [] createActions() {
return new Action[] {getCancelAction()};
}
@Override
public void doCancelAction() {
super.doCancelAction();
}
@Nullable
@Override
public JComponent getPreferredFocusedComponent() {
return jdbcUrlField;
}
private void runQueryTables() {
ValidationInfo v = validateConnectionFields();
if (v != null) {
Messages.showErrorDialog(project, v.message, "校验失败");
return;
}
ProgressManager.getInstance()
.run(
new Task.Backgroundable(project, "查询 MySQL 表", false) {
@Override
public void run(@NotNull ProgressIndicator indicator) {
indicator.setIndeterminate(true);
try (Connection conn =
JdbcMySqlMetadata.openConnection(
jdbcUrlField.getText().trim(),
userField.getText().trim(),
new String(passwordField.getPassword()))) {
List<String> names = JdbcMySqlMetadata.listTableNames(conn);
ApplicationManager.getApplication()
.invokeLater(
() -> {
loadedTables = new ArrayList<>(names);
rebuildTableChecks();
Messages.showInfoMessage(
project,
"共读取 " + names.size() + " 张表。",
"查询完成");
});
} catch (Exception ex) {
String msg = ex.getMessage() != null ? ex.getMessage() : ex.toString();
ApplicationManager.getApplication()
.invokeLater(
() ->
Messages.showErrorDialog(
project, msg, "连接或查询失败"));
}
}
});
}
private void rebuildTableChecks() {
tableChecks.clear();
tableListPanel.removeAll();
String q = filterField.getText().trim().toLowerCase();
for (String t : loadedTables) {
if (!q.isEmpty() && !t.toLowerCase().contains(q)) {
continue;
}
javax.swing.JCheckBox cb = new javax.swing.JCheckBox(t, false);
cb.putClientProperty("table", t);
tableChecks.put(t, cb);
tableListPanel.add(cb);
tableListPanel.add(Box.createVerticalStrut(2));
}
tableListPanel.revalidate();
tableListPanel.repaint();
}
private void applyFilter() {
if (loadedTables.isEmpty()) {
return;
}
Map<String, Boolean> selected = new LinkedHashMap<>();
for (Map.Entry<String, javax.swing.JCheckBox> e : tableChecks.entrySet()) {
selected.put(e.getKey(), e.getValue().isSelected());
}
rebuildTableChecks();
for (Map.Entry<String, Boolean> e : selected.entrySet()) {
javax.swing.JCheckBox cb = tableChecks.get(e.getKey());
if (cb != null) {
cb.setSelected(Boolean.TRUE.equals(e.getValue()));
}
}
}
private ValidationInfo validateConnectionFields() {
String raw = jdbcUrlField.getText().trim();
if (raw.isEmpty()) {
return new ValidationInfo("请填写 JDBC URL 或 host:port/库名", jdbcUrlField);
}
if (!JdbcMySqlMetadata.hasDatabaseInUrl(raw)) {
return new ValidationInfo(
"请在地址中带上数据库名,例如 43.155.238.153:3306/your_db 或完整 jdbc:mysql://host:3306/your_db",
jdbcUrlField);
}
return null;
}
private ValidationInfo validateOutputAndSelection() {
String path = outputDirField.getText().trim();
if (path.isEmpty()) {
return new ValidationInfo("请选择输出目录", outputDirField);
}
VirtualFile dir = LocalFileSystem.getInstance().findFileByPath(path);
if (dir == null || !dir.isDirectory()) {
return new ValidationInfo("输出路径不是有效目录", outputDirField);
}
String pkg = PackageInference.inferPackage(dir);
if (pkg.isEmpty()) {
return new ValidationInfo(
"无法推断 Java 包名:请选择位于 .../src/main/java/<包路径>/ 下的目录。", outputDirField);
}
List<String> sel = getSelectedTables();
if (sel.isEmpty()) {
return new ValidationInfo("请至少选择一张表(可先点击「查询表」)", outputDirField);
}
return null;
}
private List<String> getSelectedTables() {
List<String> list = new ArrayList<>();
for (javax.swing.JCheckBox cb : tableChecks.values()) {
if (cb.isSelected()) {
Object t = cb.getClientProperty("table");
if (t instanceof String) {
list.add((String) t);
}
}
}
return list;
}
private void runGenerateEntities() {
ValidationInfo v1 = validateConnectionFields();
if (v1 != null) {
Messages.showErrorDialog(project, v1.message, "校验失败");
return;
}
ValidationInfo v2 = validateOutputAndSelection();
if (v2 != null) {
Messages.showErrorDialog(project, v2.message, "校验失败");
return;
}
VirtualFile outDir = LocalFileSystem.getInstance().findFileByPath(outputDirField.getText().trim());
String pkg = PackageInference.inferPackage(outDir);
List<String> tables = getSelectedTables();
ProgressManager.getInstance()
.run(
new Task.Backgroundable(project, "生成 JPA 实体", false) {
@Override
public void run(@NotNull ProgressIndicator indicator) {
indicator.setIndeterminate(false);
Map<String, String> sources = new LinkedHashMap<>();
try (Connection conn =
JdbcMySqlMetadata.openConnection(
jdbcUrlField.getText().trim(),
userField.getText().trim(),
new String(passwordField.getPassword()))) {
int i = 0;
for (String table : tables) {
indicator.setFraction((double) i / Math.max(1, tables.size()));
indicator.setText2(table);
List<ColumnMeta> cols = JdbcMySqlMetadata.listColumns(conn, table);
Set<String> pk = JdbcMySqlMetadata.primaryKeyColumns(conn, table);
String src = JpaEntitySourceGenerator.generate(pkg, table, cols, pk);
String className = JavaNames.toClassNameFromTable(table) + ".java";
sources.put(className, src);
i++;
}
indicator.setFraction(1.0);
} catch (Exception ex) {
String msg = ex.getMessage() != null ? ex.getMessage() : ex.toString();
ApplicationManager.getApplication()
.invokeLater(
() ->
Messages.showErrorDialog(
project, msg, "读取表结构失败"));
return;
}
ApplicationManager.getApplication()
.invokeLater(
() ->
writeFilesOnEdt(
outDir, pkg, sources));
}
});
}
private void writeFilesOnEdt(
VirtualFile outDir, String pkg, Map<String, String> sources) {
if (project == null || outDir == null) {
return;
}
int[] created = {0};
int[] skipped = {0};
WriteCommandAction.runWriteCommandAction(
project,
() -> {
PsiDirectory psiDir = PsiManager.getInstance(project).findDirectory(outDir);
if (psiDir == null) {
return;
}
PsiFileFactory factory = PsiFileFactory.getInstance(project);
for (Map.Entry<String, String> e : sources.entrySet()) {
String name = e.getKey();
if (psiDir.findFile(name) != null) {
skipped[0]++;
continue;
}
PsiFile psiFile =
factory.createFileFromText(name, JavaLanguage.INSTANCE, e.getValue());
psiDir.add(psiFile);
created[0]++;
}
});
Messages.showInfoMessage(
project,
"新建 " + created[0] + " 个实体文件,跳过已存在 " + skipped[0] + " 个。\n包名: " + pkg,
"生成完成");
}
}
@@ -0,0 +1,20 @@
package com.huangzj.protoplugin.vcs;
import com.intellij.openapi.vcs.CheckinProjectPanel;
import com.intellij.openapi.vcs.changes.CommitContext;
import com.intellij.openapi.vcs.checkin.CheckinHandler;
import com.intellij.openapi.vcs.checkin.CheckinHandlerFactory;
import org.jetbrains.annotations.NotNull;
/**
* 注册 {@link TimeSkewCommitCheckHandler}:以 {@link com.intellij.openapi.vcs.checkin.CommitCheck.ExecutionOrder#EARLY}
* 执行时间校验,避免非模态提交在 LATE 阶段失败触发 Cancelled/文档恢复(表现为文件被回退)。
*/
public final class CommitTimeCheckinHandlerFactory extends CheckinHandlerFactory {
@Override
public @NotNull CheckinHandler createHandler(
@NotNull CheckinProjectPanel panel, @NotNull CommitContext commitContext) {
return new TimeSkewCommitCheckHandler(panel.getProject());
}
}
@@ -0,0 +1,58 @@
package com.huangzj.protoplugin.vcs;
import java.net.HttpURLConnection;
import java.net.URL;
import org.jetbrains.annotations.Nullable;
/** 通过 HTTP 响应头 Date 获取参考时间(UTC),用于与本机 {@link System#currentTimeMillis()} 对比。 */
public final class TimeSkewChecker {
private static final int CONNECT_MS = 5_000;
private static final int READ_MS = 5_000;
/** 国内可访问的站点,依次尝试 HEAD。 */
private static final String[] HEAD_URLS = {
"https://www.aliyun.com",
"https://www.qq.com",
"https://www.baidu.com",
};
private TimeSkewChecker() {}
/**
* @return 服务器认为的当前纪元毫秒;全部失败时返回 {@code null}(调用方宜放行提交)。
*/
@Nullable
public static Long fetchReferenceEpochMillis() {
for (String spec : HEAD_URLS) {
Long t = tryHeadDate(spec);
if (t != null && t > 0) {
return t;
}
}
return null;
}
@Nullable
private static Long tryHeadDate(String spec) {
HttpURLConnection c = null;
try {
URL url = new URL(spec);
c = (HttpURLConnection) url.openConnection();
c.setRequestMethod("HEAD");
c.setInstanceFollowRedirects(true);
c.setConnectTimeout(CONNECT_MS);
c.setReadTimeout(READ_MS);
c.setRequestProperty("User-Agent", "ProtoPlugin-TimeCheck/1.0");
c.connect();
long d = c.getDate();
return d > 0 ? d : null;
} catch (Exception ignored) {
return null;
} finally {
if (c != null) {
c.disconnect();
}
}
}
}
@@ -0,0 +1,91 @@
package com.huangzj.protoplugin.vcs
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.vcs.checkin.CheckinHandler
import com.intellij.openapi.vcs.checkin.CommitCheck
import com.intellij.openapi.vcs.checkin.CommitInfo
import com.intellij.openapi.vcs.checkin.CommitProblem
import com.intellij.openapi.vcs.checkin.TextCommitProblem
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.abs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 直接实现 [CommitCheck] 且顺序为 [CommitCheck.ExecutionOrder.EARLY],避免落在默认 [CheckinHandler] 的 LATE 阶段。
*
* 非模态提交里 LATE 失败会走 ABORTED → Cancelled,可能伴随文档/部分提交状态的恢复,表现为「文件被回退」;
* EARLY 失败为 EARLY_FAILED → CommitChecksResult.Failed(),在跑完 MODIFICATION/saveAllDocuments 之前即中止,一般不触达该恢复路径。
*/
class TimeSkewCommitCheckHandler(private val project: Project?) : CheckinHandler(), CommitCheck, DumbAware {
companion object {
const val MAX_SKEW_MS: Long = 60_000L
}
override fun getExecutionOrder(): CommitCheck.ExecutionOrder = CommitCheck.ExecutionOrder.EARLY
override fun isEnabled(): Boolean = true
override suspend fun runCheck(commitInfo: CommitInfo): CommitProblem? {
val reference =
withContext(Dispatchers.IO) {
try {
TimeSkewChecker.fetchReferenceEpochMillis()
} catch (_: Exception) {
null
}
}
if (reference == null) {
return null
}
val local = System.currentTimeMillis()
if (abs(local - reference) <= MAX_SKEW_MS) {
return null
}
val app = ApplicationManager.getApplication()
val picked =
if (app.isDispatchThread) {
showSkewDialog(project)
} else {
val choice = AtomicInteger(0)
app.invokeAndWait(
{ choice.set(showSkewDialog(project)) },
ModalityState.defaultModalityState(),
)
choice.get()
}
return if (picked == 0) {
null
} else {
TextCommitProblem("已取消提交:系统时间与网络时间相差超过 1 分钟。")
}
}
private fun showSkewDialog(project: Project?): Int {
val title = "系统时间可能不准确"
val message =
"""
本机时间与网络标准时间相差超过 1 分钟,可能导致提交记录时间异常。
建议校准:Windows「设置 → 时间和语言 → 日期和时间」中开启自动设置时间。
「确定」:继续本次提交;「取消」:中止本次提交(本地修改保留)。
""".trimIndent()
return Messages.showDialog(
project,
message,
title,
arrayOf("确定", "取消"),
0,
Messages.getWarningIcon(),
null,
)
}
}
+69
View File
@@ -0,0 +1,69 @@
<idea-plugin>
<id>com.huangzj.protoPlugin</id>
<name>Idea-Plugin</name>
<vendor email="support@example.com" url="https://example.com">huangzj</vendor>
<description><![CDATA[
Parse <code>service</code> / <code>rpc</code> from Protocol Buffers files and generate
Spring <code>@Cmd</code> command classes (e.g. <code>AbstractTypedPbCommand</code>) similar to comm-framework.
Also: JPA from MySQL, JSON sample from Java class, JSON to Java POJO, project memo tool window,
and pre-commit system time vs network time check.
]]></description>
<depends>com.intellij.modules.platform</depends>
<depends>com.intellij.java</depends>
<extensions defaultExtensionNs="com.intellij">
<checkinHandlerFactory implementation="com.huangzj.protoplugin.vcs.CommitTimeCheckinHandlerFactory"/>
<projectService serviceImplementation="com.huangzj.protoplugin.memo.MemoProjectService"/>
<!-- id 与 MemoToolWindowFactory.TOOL_WINDOW_ID 保持一致 -->
<toolWindow id="MyCustomMemo"
factoryClass="com.huangzj.protoplugin.memo.MemoToolWindowFactory"
anchor="right"
secondary="true"
canCloseContents="false"/>
<intentionAction>
<language>JAVA</language>
<className>com.huangzj.protoplugin.json.ClassToJsonIntention</className>
<category>Idea-Plugin</category>
<descriptionDirectoryName>ClassToJsonIntention</descriptionDirectoryName>
</intentionAction>
</extensions>
<actions>
<action id="ProtoPlugin.GenerateCmdFromProto"
class="com.huangzj.protoplugin.ProtoCmdGeneratorAction"
text="Proto转Java"
description="解析当前 .proto 中的 service/rpc,生成带 @Cmd 的 Java 命令类">
<add-to-group group-id="EditorPopupMenu" anchor="last"/>
<add-to-group group-id="ProjectViewPopupMenu" anchor="last"/>
<add-to-group group-id="ToolsMenu" anchor="last"/>
</action>
<action id="ProtoPlugin.GenerateJpaFromMysql"
class="com.huangzj.protoplugin.MysqlEntityFromFolderAction"
text="mysql转Jpa"
description="连接 MySQL,勾选表并生成带 jakarta.persistence 注解的实体类到所选目录">
<add-to-group group-id="ProjectViewPopupMenu" anchor="last"/>
<add-to-group group-id="ToolsMenu" anchor="last"/>
</action>
<action id="ProtoPlugin.JsonToJava"
class="com.huangzj.protoplugin.json.JsonToJavaAction"
text="Json转java"
description="输入 JSON 与类名,在选中目录生成 Java Bean 源文件">
<add-to-group group-id="ProjectViewPopupMenu" anchor="last"/>
<add-to-group group-id="ToolsMenu" anchor="last"/>
</action>
<action id="ProtoPlugin.GenerateClassJson"
class="com.huangzj.protoplugin.json.GenerateClassJsonEditorAction"
text="生成类JSON(默认值)"
description="根据光标所在类的字段生成带默认值的 JSON(与类名处意图相同)">
<add-to-group group-id="EditorPopupMenu" anchor="last"/>
</action>
<action id="ProtoPlugin.OpenMemo"
class="com.huangzj.protoplugin.memo.OpenMemoToolWindowAction"
text="我的自定义备忘录"
description="打开「我的自定义备忘录」工具窗口(按项目保存)">
<add-to-group group-id="ToolsMenu" anchor="last"/>
</action>
</actions>
</idea-plugin>
@@ -0,0 +1,5 @@
<html>
<body>
在类名上调用:确认后根据当前类的字段生成带默认值的 JSON,并在可复制的弹窗中展示。
</body>
</html>