插件初始化
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user