From ae76ba37e6e7e6a98cd06672060444a0a19c3ef9 Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Tue, 13 Oct 2026 11:15:13 +0800 Subject: [PATCH 01/13] Add MCP regenerate_form_code(path) to regenerate modified swing-based forms in netbeans. Signed-off-by: Muhammad Rizky Rizaldi --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- docs/user-manual.md | 1 + pom.xml | 2 +- .../claudecodegui/mcp/NetBeansMCPHandler.java | 11 + .../mcp/tools/RegenerateFormCode.java | 380 ++++++++++++++++++ .../claudecodegui/process/ClaudeProcess.java | 3 +- .../tools/schemas/regenerate_form_code.json | 10 + .../tools/RegenerateFormCodeEditorTest.java | 144 +++++++ .../mcp/tools/RegenerateFormCodeTest.java | 138 +++++++ 10 files changed, 689 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java create mode 100644 src/main/resources/org/openbeans/claude/netbeans/tools/schemas/regenerate_form_code.json create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f27d0618..9725126e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Added the `regenerate_form_code` MCP tool: after Claude edits a Swing GUI Builder `.form` file, it regenerates the form's `initComponents()` and variable declarations in the `.java` file exactly as the NetBeans GUI Builder does and saves the form, instead of Claude hand-editing the generated code; an open GUI Builder designer is reloaded from the edited `.form`, and Claude is told when the generated code was already up to date - Improved the IDE log (messages.log): per-request and per-click diagnostics (tab label polling, PreToolUse hooks with file contents, diff tabs, choice menus, permission prompts) are now written only when debug logging is enabled, so the log is no longer flooded and no longer contains edited code # 1.3.28 (2026-10-07) diff --git a/CLAUDE.md b/CLAUDE.md index bf068393..36fa6ebf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,7 +176,7 @@ The plugin follows an MVC structure for session management: | `ui/common/` | Shared input components: `AtCompletionPopup` — @-triggered path popup; `AtPathHighlighter` — blue token highlight; `FileDropHandler` — DnD + Ctrl+V → @path insertion; `ShortcutMatcher` — key→shortcut match with KEY_TYPED suppression; `TextComponentDecorator` — wires all the above; `DecoratedTextArea/TextField`, `TextContextMenu`, `RangeHighlightable` | | `settings/` | `ClaudeCodePreferences` — NbPreferences wrapper (keys: `contextMenuSessionMode` default CONTINUE_LAST, `sessionListLimit` default 30); `ClaudeCodeOptionsPanelController` / `ClaudeCodeOptionsPanel` — Tools→Options (General + Profiles tabs); `ClaudeProfile`, `ClaudeProfileStore` — named profiles with isolated `CLAUDE_CONFIG_DIR`, auth, proxy, extra env vars; `ClaudeProjectProperties` — per-project profile assignment | | `actions/` | `ClaudeCodeAction` — toolbar button; `OpenWithClaudeAction` — project node context menu; `PromptFavoriteAction` — dynamically registered action per favorite for Keymap API | -| `io.github.nbplugins.claudecodegui.mcp` | `MCPSseServer` — Jetty `/sse` `/messages` `/hook` `/openai-proxy/*`; `NetBeansMCPHandler` — MCP dispatcher + PreToolUse hook; `tools/` — `OpenDiff`, `PermissionPromptTool`, `DiffTabTracker`, `GetDiagnostics`, `GetOpenEditors`, `GetCurrentSelection`, `OpenFile` | +| `io.github.nbplugins.claudecodegui.mcp` | `MCPSseServer` — Jetty `/sse` `/messages` `/hook` `/openai-proxy/*`; `NetBeansMCPHandler` — MCP dispatcher + PreToolUse hook; `tools/` — `OpenDiff`, `PermissionPromptTool`, `DiffTabTracker`, `GetDiagnostics`, `GetOpenEditors`, `GetCurrentSelection`, `OpenFile`, `RegenerateFormCode` (drives the non-API form module via reflection to regenerate a `.form`'s guarded code) | | `io.github.nbplugins.claudecodegui.openaiproxy` | `OpenAIProxyConfig` — per-session config snapshot; `OpenAIProxyServlet` — translates Anthropic → OpenAI requests and responses; `AnthropicToOpenAITranslator` — stateless translation logic including streaming SSE state machine | | `org.openbeans.claude.netbeans` | **Legacy package — do not add new code here and avoid modifying existing code.** Contains: `ClaudeCodeStatusService`, `ClaudeCodeStatusLineElement`, `EditorUtils`, `NbUtils`; `tools/` — `AsyncHandler`, `AsyncResponse`, `GetWorkspaceFolders`, `CheckDocumentDirty`, `SaveDocument`, `CloseTab`, `CloseAllDiffTabs` | diff --git a/docs/user-manual.md b/docs/user-manual.md index 15246f88..84569b20 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -782,6 +782,7 @@ The plugin exposes a set of IDE tools to Claude via an MCP (Model Context Protoc | `permission_prompt` | Shows proposed file changes in the diff panel and waits for your approval | | `show_markdown` | Renders markdown text directly in a Markdown Preview tab; accepts the markdown content plus optional title and file path | | `show_markdown_file` | Opens a live-updating Markdown Preview tab for an existing `.md` file on disk; the tab automatically refreshes when the file changes | +| `regenerate_form_code` | Regenerates the GUI Builder code (`initComponents()` and the variables declaration) of a Swing form's `.java` file from its edited `.form` file, exactly as the NetBeans GUI Builder does, and saves the form (NetBeans re-saves the `.form` in its own formatting). Claude calls it after changing a `.form` file. An open designer is reloaded from it. Refused if the form has unsaved changes in the IDE | ### Prompts that work well with IDE tools diff --git a/pom.xml b/pom.xml index ca0c1a1d..305ca472 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.53-SNAPSHOT + 1.3.55-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java index 6fc78ea3..72daa3a3 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java @@ -59,6 +59,7 @@ import io.github.nbplugins.claudecodegui.mcp.tools.OpenDiff; import io.github.nbplugins.claudecodegui.mcp.tools.OpenFile; import io.github.nbplugins.claudecodegui.mcp.tools.PermissionPromptTool; +import io.github.nbplugins.claudecodegui.mcp.tools.RegenerateFormCode; import io.github.nbplugins.claudecodegui.mcp.tools.ShowMarkdown; import io.github.nbplugins.claudecodegui.mcp.tools.ShowMarkdownFile; import org.netbeans.api.project.ui.OpenProjects; @@ -102,6 +103,7 @@ public class NetBeansMCPHandler { private final OpenDiff openDiffTool; private final OpenFile openFileTool; private final PermissionPromptTool permissionPromptTool; + private final RegenerateFormCode regenerateFormCodeTool; private final SaveDocument saveDocument; private final ShowMarkdown showMarkdownTool; private final ShowMarkdownFile showMarkdownFileTool; @@ -119,6 +121,7 @@ public NetBeansMCPHandler() { this.openDiffTool = new OpenDiff(); this.openFileTool = new OpenFile(); this.permissionPromptTool = new PermissionPromptTool(); + this.regenerateFormCodeTool = new RegenerateFormCode(); this.saveDocument = new SaveDocument(); this.showMarkdownTool = new ShowMarkdown(); this.showMarkdownFileTool = new ShowMarkdownFile(); @@ -266,6 +269,9 @@ private JsonNode handleToolsList() { "Open a live-updating Markdown Preview tab for a .md file. " + "The tab refreshes automatically when the file changes on disk.", "show_markdown_file")); + tools.add(createToolDefinition(regenerateFormCodeTool.getName(), + regenerateFormCodeTool.getDescription(), + "regenerate_form_code")); ObjectNode result = responseBuilder.objectNode(); result.set("tools", tools); @@ -344,6 +350,11 @@ private JsonNode handleToolsCall(JsonNode params, Integer requestId, this.showMarkdownFileTool.parseArguments(arguments)); break; + case "regenerate_form_code": + result = this.regenerateFormCodeTool.run( + this.regenerateFormCodeTool.parseArguments(arguments)); + break; + default: throw new IllegalArgumentException("Unknown tool: " + toolName); } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java new file mode 100644 index 00000000..370513a3 --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java @@ -0,0 +1,380 @@ +package io.github.nbplugins.claudecodegui.mcp.tools; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.awt.EventQueue; +import java.io.File; +import java.io.FileNotFoundException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import javax.swing.text.BadLocationException; +import javax.swing.text.Document; +import org.openbeans.claude.netbeans.NbUtils; +import org.openbeans.claude.netbeans.tools.Tool; +import org.openide.cookies.EditorCookie; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.loaders.DataObject; + +/** + * MCP tool that regenerates the GUI Builder guarded code ({@code initComponents()} + * and the variables declaration block) of a {@code .java} file from its + * {@code .form} file, exactly as the NetBeans form editor does. + * + *

The form module ({@code org.netbeans.modules.form}) has no public API, so the + * form editor support is driven via reflection on the live objects. The call + * sequence mirrors the one the form refactoring module uses: + *

+ * "Mark changed" is {@code FormModel.fireFormChanged(true)}: the code generator treats + * a freshly loaded form as up to date, so without a modifying form event + * {@code regenerateCodeIfNeeded()} does nothing. The event must be delivered + * immediately ({@code true}); a queued one arrives after this EDT task. Since the + * event marks the form modified, both files are saved like the IDE's Save does, + * which re-serializes the {@code .form} file. + */ +public class RegenerateFormCode implements Tool { + + private static final Logger LOGGER = Logger.getLogger(RegenerateFormCode.class.getName()); + + /** How long to wait for an open {@code .java} editor to pick up an on-disk change. */ + private static final long DOCUMENT_SYNC_TIMEOUT_MS = 5000; + + /** Guarded-section markers that the guarded reader strips from the editor document. */ + private static final Pattern GUARD_MARKER = Pattern.compile("//GEN-[A-Z]+:\\S*"); + + private static final Pattern WHITESPACE = Pattern.compile("\\s+"); + + /** Tool parameters. */ + public static class Params { + @JsonProperty("filePath") + private String filePath; + + /** Creates empty parameters (used by Jackson). */ + public Params() {} + + /** + * Creates parameters for the given file. + * + * @param filePath absolute path of the {@code .form} or {@code .java} file + */ + public Params(String filePath) { this.filePath = filePath; } + + /** @return absolute path of the {@code .form} or {@code .java} file */ + public String getFilePath() { return filePath; } + } + + /** The {@code .java} / {@code .form} pair of one GUI form. */ + record FormFiles(File javaFile, File formFile) {} + + /** + * Result of a regeneration in the form editor. + * + * @param designerOpen whether the GUI Builder designer was open + * @param codeChanged whether the regenerated code differs from the previous code + * @param generatedText the {@code .java} document text after regeneration + */ + record Outcome(boolean designerOpen, boolean codeChanged, String generatedText) {} + + /** Creates a new instance of this tool. */ + public RegenerateFormCode() {} + + @Override + public String getName() { return "regenerate_form_code"; } + + @Override + public String getDescription() { + return "Regenerate the NetBeans GUI Builder generated code (initComponents() and the " + + "variables declaration block, i.e. the //GEN-BEGIN/GEN-END guarded sections) of a " + + "Swing form's .java file from its .form file, exactly as the NetBeans form editor does, " + + "and save it. ALWAYS call this after creating or modifying a .form file " + + "instead of editing the guarded sections in the .java file by hand. " + + "NetBeans also re-saves the .form file in its own formatting. " + + "Re-read both files afterwards before editing them again."; + } + + @Override + public Class getParameterClass() { return Params.class; } + + @Override + public String run(Params params) throws Exception { + String filePath = params.getFilePath(); + if (filePath == null || filePath.isBlank()) { + throw new IllegalArgumentException("filePath is required"); + } + FormFiles files = resolveFormFiles(filePath); + checkAccess(files.javaFile()); + checkAccess(files.formFile()); + LOGGER.fine("regenerate_form_code: " + files.javaFile()); + return regenerate(files); + } + + /** + * Resolves the {@code .java} / {@code .form} pair for a path pointing to either of them. + * + * @param filePath path of the {@code .form} or {@code .java} file + * @return both files of the form + * @throws IllegalArgumentException if the path is not a {@code .form}/{@code .java} + * file or either file of the pair does not exist + */ + static FormFiles resolveFormFiles(String filePath) { + File file = new File(filePath).getAbsoluteFile(); + String name = file.getName(); + String baseName; + if (name.endsWith(".form")) { + baseName = name.substring(0, name.length() - ".form".length()); + } else if (name.endsWith(".java")) { + baseName = name.substring(0, name.length() - ".java".length()); + } else { + throw new IllegalArgumentException("Expected a .form or .java file: " + filePath); + } + File javaFile = new File(file.getParentFile(), baseName + ".java"); + File formFile = new File(file.getParentFile(), baseName + ".form"); + if (!javaFile.isFile()) { + throw new IllegalArgumentException("Java source of the form does not exist: " + javaFile); + } + if (!formFile.isFile()) { + throw new IllegalArgumentException("No .form file next to " + javaFile.getName() + + " - it is not a NetBeans GUI Builder form: " + formFile); + } + return new FormFiles(javaFile, formFile); + } + + /** + * Returns {@code true} when the editor document text matches the file content on + * disk, ignoring whitespace and the {@code //GEN-...} guard markers that the + * guarded-section reader strips when loading the document. + * + * @param documentText text of the loaded editor document + * @param diskText content of the file on disk + * @return whether the document reflects the file on disk + */ + static boolean documentMatchesDisk(String documentText, String diskText) { + return normalize(documentText).equals(normalize(diskText)); + } + + private static String normalize(String text) { + return WHITESPACE.matcher(GUARD_MARKER.matcher(text).replaceAll("")).replaceAll(""); + } + + /** + * Rejects files outside the open projects. + * Protected so tests can bypass the open-projects lookup. + * + * @param file file to check + */ + protected void checkAccess(File file) { + if (!NbUtils.isPathWithinOpenProjects(file.getPath())) { + throw new SecurityException("Form regeneration denied: Path is not within any open project directory: " + file); + } + } + + /** + * Loads the form in the NetBeans form editor, regenerates the guarded code and + * saves the {@code .java} file. Protected so tests can bypass the IDE runtime. + * + * @param files the form's {@code .java} / {@code .form} pair + * @return result message for Claude + * @throws Exception if the form cannot be loaded or the code cannot be regenerated + */ + protected String regenerate(FormFiles files) throws Exception { + String javaName = files.javaFile().getName(); + String formName = files.formFile().getName(); + FileObject javaFo = FileUtil.toFileObject(FileUtil.normalizeFile(files.javaFile())); + FileObject formFo = FileUtil.toFileObject(FileUtil.normalizeFile(files.formFile())); + if (javaFo == null || formFo == null) { + throw new FileNotFoundException(javaFo == null ? files.javaFile().getPath() : files.formFile().getPath()); + } + // Claude edits the files behind the IDE's back - let the filesystem notice the change. + formFo.refresh(); + javaFo.refresh(); + + if (!javaFo.asText().contains("//GEN-BEGIN:initComponents")) { + throw new IllegalStateException(javaName + " has no generated initComponents() guarded block " + + "(//GEN-BEGIN:initComponents ... //GEN-END:initComponents); the form editor cannot generate code into it"); + } + + DataObject dataObject = DataObject.find(javaFo); + Object editorSupport = formEditorSupport(dataObject); + if (editorSupport == null) { + throw new IllegalStateException(javaName + " is not recognized by NetBeans as a GUI Builder form " + + "(is the Swing GUI Builder / form module enabled?)"); + } + if (dataObject.isModified()) { + throw new IllegalStateException(javaName + " has unsaved changes in the IDE. " + + "Save them (saveDocument) or ask the user to revert them, then call regenerate_form_code again."); + } + waitForDocumentInSync(dataObject.getLookup().lookup(EditorCookie.class), javaFo, javaName); + + Outcome outcome = onEdt(() -> regenerateInFormEditor(editorSupport, javaName, formName)); + String designerNote = outcome.designerOpen() + ? " The open GUI Builder was reloaded from the edited .form." : ""; + if (!outcome.codeChanged()) { + return "The generated code in " + javaName + " already matches " + formName + + "; nothing was regenerated." + designerNote; + } + if (!documentMatchesDisk(outcome.generatedText(), javaFo.asText())) { + throw new IllegalStateException("The code was regenerated in the IDE but " + javaName + + " on disk was not updated. Ask the user to save it in the IDE."); + } + return "Regenerated initComponents() and the variables declaration of " + javaName + + " from " + formName + " and saved both files." + designerNote + + " Re-read " + javaName + " and " + formName + " before editing them again."; + } + + /** + * Loads (or reloads) the form, regenerates its guarded code and saves it. + * Must run on the EDT. Package-private so tests can drive it with fake + * form-editor objects that expose the same public methods. + * + * @param editorSupport the form module's {@code FormEditorSupport} + * @param javaName name of the {@code .java} file, for messages + * @param formName name of the {@code .form} file, for messages + * @return what happened + * @throws Exception if the form cannot be loaded or the code cannot be regenerated + */ + static Outcome regenerateInFormEditor(Object editorSupport, String javaName, String formName) throws Exception { + boolean open = (Boolean) call(editorSupport, "isOpened"); + Object formEditor; + if (open) { + formEditor = call(editorSupport, "reloadFormEditor"); + } else { + call(editorSupport, "loadForm"); + formEditor = call(editorSupport, "getFormEditor"); + } + if (formEditor == null || !(Boolean) call(formEditor, "isFormLoaded") + || (Boolean) call(formEditor, "anyPersistenceError")) { + if (!open && formEditor != null) { + call(formEditor, "closeForm"); + } + throw new IllegalStateException("NetBeans could not load " + formName + + " without errors. Check the .form XML, or open the form in the GUI Builder (Design view) to see the errors."); + } + Object formModel = call(formEditor, "getFormModel"); + if (formModel == null || (Boolean) call(formModel, "isReadOnly")) { + if (!open) { + call(formEditor, "closeForm"); + } + throw new IllegalStateException(formName + " was loaded read-only (missing or damaged guarded " + + "sections in " + javaName + ", or a read-only file); code was not regenerated."); + } + Document document = (Document) call(editorSupport, "getDocument"); + String before = documentText(document); + call(formModel, "fireFormChanged", boolean.class, true); + call(formEditor, "regenerateCodeIfNeeded"); + String after = documentText(document); + call(editorSupport, "saveDocument"); + if (!open) { + call(formEditor, "closeForm"); + } + return new Outcome(open, !before.equals(after), after); + } + + /** Returns the form module's {@code EditorSupport}, or {@code null} for a non-form data object. */ + private static Object formEditorSupport(DataObject dataObject) throws Exception { + try { + dataObject.getClass().getMethod("getFormEditorSupport"); + } catch (NoSuchMethodException e) { + return null; + } + return call(dataObject, "getFormEditorSupport"); + } + + /** + * An unmodified open editor reloads on its own after an external change, but + * asynchronously. Waits until it shows the content on disk so the regenerated + * code is not merged into, and saved over, a stale document. + */ + private static void waitForDocumentInSync(EditorCookie editorCookie, FileObject javaFo, String javaName) + throws Exception { + Document document = editorCookie == null ? null : editorCookie.getDocument(); + if (document == null) { + return; // not loaded - the form editor reads it from disk + } + String diskText = javaFo.asText(); + long deadline = System.currentTimeMillis() + DOCUMENT_SYNC_TIMEOUT_MS; + while (!documentMatchesDisk(documentText(document), diskText)) { + if (System.currentTimeMillis() > deadline) { + throw new IllegalStateException("The open editor of " + javaName + " still differs from the file on disk. " + + "Ask the user to reload or close it in the IDE, then call regenerate_form_code again."); + } + Thread.sleep(100); + } + // Let a form reload queued by that document reload run before ours. + onEdt(() -> null); + } + + private static String documentText(Document document) throws BadLocationException { + AtomicReference text = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + document.render(() -> { + try { + text.set(document.getText(0, document.getLength())); + } catch (BadLocationException e) { + error.set(e); + } + }); + if (error.get() != null) { + throw error.get(); + } + return text.get(); + } + + /** Invokes a public no-arg method on an object of a non-API form module class. */ + private static Object call(Object target, String methodName) throws Exception { + return call(target, methodName, null, null); + } + + /** Invokes a public method with at most one parameter on an object of a non-API form module class. */ + private static Object call(Object target, String methodName, Class paramType, Object arg) throws Exception { + Method method; + try { + method = paramType == null + ? target.getClass().getMethod(methodName) + : target.getClass().getMethod(methodName, paramType); + } catch (NoSuchMethodException e) { + throw new IllegalStateException("Unsupported NetBeans form editor version: " + + target.getClass().getName() + "." + methodName + "() not found", e); + } + try { + return paramType == null ? method.invoke(target) : method.invoke(target, arg); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception ex) { + throw ex; + } + if (cause instanceof Error err) { + throw err; + } + throw e; + } + } + + private static T onEdt(Callable task) throws Exception { + if (EventQueue.isDispatchThread()) { + return task.call(); + } + AtomicReference result = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + EventQueue.invokeAndWait(() -> { + try { + result.set(task.call()); + } catch (Exception e) { + error.set(e); + } + }); + if (error.get() != null) { + throw error.get(); + } + return result.get(); + } +} diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java index 137ef3c3..918afef0 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java @@ -64,7 +64,8 @@ public final class ClaudeProcess { /** MCP tools the plugin adds to {@code permissions.allow}. */ static final List OUR_ALLOWED_TOOLS = List.of( "mcp__netbeans__show_markdown", - "mcp__netbeans__show_markdown_file" + "mcp__netbeans__show_markdown_file", + "mcp__netbeans__regenerate_form_code" ); /** Creates a new, idle {@code ClaudeProcess} instance. */ diff --git a/src/main/resources/org/openbeans/claude/netbeans/tools/schemas/regenerate_form_code.json b/src/main/resources/org/openbeans/claude/netbeans/tools/schemas/regenerate_form_code.json new file mode 100644 index 00000000..e7feb28d --- /dev/null +++ b/src/main/resources/org/openbeans/claude/netbeans/tools/schemas/regenerate_form_code.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Absolute path to the GUI Builder form's .form file (or its .java file)" + } + }, + "required": ["filePath"] +} diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java new file mode 100644 index 00000000..b14853e1 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java @@ -0,0 +1,144 @@ +package io.github.nbplugins.claudecodegui.mcp.tools; + +import javax.swing.text.BadLocationException; +import javax.swing.text.Document; +import javax.swing.text.PlainDocument; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Drives {@link RegenerateFormCode#regenerateInFormEditor} with fakes that expose + * the same public methods as the NetBeans form module classes and reproduce their + * behavior (verified against the NetBeans 27 bytecode): + *
    + *
  • {@code JavaCodeGenerator.codeUpToDate} starts {@code true}, so right after a + * form is loaded {@code regenerateCodeIfNeeded()} does nothing;
  • + *
  • only a modifying form event clears it - {@code fireFormChanged(true)} delivers + * it immediately, {@code fireFormChanged(false)} queues it via invokeLater, i.e. + * after the caller's EDT task;
  • + *
  • a form change marks the form modified; {@code saveSourceOnly()} saves just the + * .java, {@code saveDocument()} saves .form and .java.
  • + *
+ */ +public class RegenerateFormCodeEditorTest { + + private static final String OLD_CODE = "class MyFrame { /* old initComponents */ }"; + private static final String NEW_CODE = "class MyFrame { /* initComponents with jButton1 */ }"; + + /** Fake {@code FormModel}. */ + public static class FakeFormModel { + FakeFormEditor editor; + boolean queuedEvent; + + public boolean isReadOnly() { return false; } + + public Object fireFormChanged(boolean immediately) { + if (immediately) { + editor.codeUpToDate = false; + editor.support.formModified = true; + } else { + queuedEvent = true; // delivered only after the current EDT task + } + return null; + } + } + + /** Fake {@code FormEditor} with the {@code JavaCodeGenerator} up-to-date flag. */ + public static class FakeFormEditor { + final FakeEditorSupport support; + final FakeFormModel model = new FakeFormModel(); + boolean loaded; + boolean closed; + boolean codeUpToDate = true; // JavaCodeGenerator's constructor default + String generatedCode = NEW_CODE; + + FakeFormEditor(FakeEditorSupport support) { + this.support = support; + model.editor = this; + } + + public boolean isFormLoaded() { return loaded; } + public boolean anyPersistenceError() { return false; } + public Object getFormModel() { return model; } + public void closeForm() { closed = true; loaded = false; } + + public void regenerateCodeIfNeeded() throws BadLocationException { + if (!codeUpToDate) { + codeUpToDate = true; + support.document.remove(0, support.document.getLength()); + support.document.insertString(0, generatedCode, null); + } + } + } + + /** Fake {@code FormEditorSupport}. */ + public static class FakeEditorSupport { + final PlainDocument document = new PlainDocument(); + final FakeFormEditor formEditor = new FakeFormEditor(this); + final boolean designerOpen; + String savedJava = OLD_CODE; + boolean formModified; + boolean formSaved; + boolean reloaded; + + FakeEditorSupport(boolean designerOpen) throws BadLocationException { + this.designerOpen = designerOpen; + document.insertString(0, OLD_CODE, null); + formEditor.loaded = designerOpen; + } + + public boolean isOpened() { return formEditor.loaded; } + public boolean loadForm() { formEditor.loaded = true; return true; } + public Object getFormEditor() { return formEditor; } + public Object reloadFormEditor() { reloaded = true; formEditor.codeUpToDate = true; return formEditor; } + public Document getDocument() { return document; } + + public void saveSourceOnly() throws BadLocationException { + savedJava = document.getText(0, document.getLength()); + } + + public void saveDocument() throws BadLocationException { + saveSourceOnly(); + formSaved = true; + formModified = false; + } + } + + @Test + public void designerClosedRegeneratesAndSavesJava() throws Exception { + FakeEditorSupport support = new FakeEditorSupport(false); + + RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form"); + + assertEquals(NEW_CODE, support.savedJava, + "a freshly loaded form is considered up to date - the form must be marked changed " + + "(immediately, not queued) before regenerating, or nothing is written"); + assertTrue(support.formEditor.closed, "a form loaded only for regeneration must be closed again"); + assertFalse(support.formModified, "the form must not be left with unsaved changes"); + } + + @Test + public void designerOpenReloadsRegeneratesAndSavesJava() throws Exception { + FakeEditorSupport support = new FakeEditorSupport(true); + + RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form"); + + assertTrue(support.reloaded, "an open designer must be reloaded from the edited .form"); + assertEquals(NEW_CODE, support.savedJava); + assertFalse(support.formEditor.closed, "an open designer must stay open"); + assertFalse(support.formModified, "the open designer must not be left with unsaved changes"); + } + + @Test + public void reportsUnchangedCodeInsteadOfSuccess() throws Exception { + FakeEditorSupport support = new FakeEditorSupport(false); + support.formEditor.generatedCode = OLD_CODE; + + RegenerateFormCode.Outcome outcome = + RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form"); + + assertFalse(outcome.codeChanged()); + assertEquals(OLD_CODE, outcome.generatedText()); + } +} diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java new file mode 100644 index 00000000..e94d24a4 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java @@ -0,0 +1,138 @@ +package io.github.nbplugins.claudecodegui.mcp.tools; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link RegenerateFormCode}: resolution of the .java/.form pair, + * the stale-document check, and the run() wiring. The form editor itself + * needs a NetBeans runtime and is not exercised here. + */ +public class RegenerateFormCodeTest { + + @TempDir + Path dir; + + private void createForm(String baseName) throws Exception { + Files.writeString(dir.resolve(baseName + ".java"), "class " + baseName + " {}"); + Files.writeString(dir.resolve(baseName + ".form"), "
"); + } + + @Test + public void resolvesPairFromFormFile() throws Exception { + createForm("MyFrame"); + RegenerateFormCode.FormFiles files = + RegenerateFormCode.resolveFormFiles(dir.resolve("MyFrame.form").toString()); + assertEquals(dir.resolve("MyFrame.java").toFile().getAbsoluteFile(), files.javaFile()); + assertEquals(dir.resolve("MyFrame.form").toFile().getAbsoluteFile(), files.formFile()); + } + + @Test + public void resolvesPairFromJavaFile() throws Exception { + createForm("MyFrame"); + RegenerateFormCode.FormFiles files = + RegenerateFormCode.resolveFormFiles(dir.resolve("MyFrame.java").toString()); + assertEquals(dir.resolve("MyFrame.form").toFile().getAbsoluteFile(), files.formFile()); + } + + @Test + public void rejectsJavaFileWithoutForm() throws Exception { + Files.writeString(dir.resolve("Plain.java"), "class Plain {}"); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> RegenerateFormCode.resolveFormFiles(dir.resolve("Plain.java").toString())); + assertTrue(e.getMessage().contains("not a NetBeans GUI Builder form"), e.getMessage()); + } + + @Test + public void rejectsFormWithoutJava() throws Exception { + Files.writeString(dir.resolve("Orphan.form"), ""); + assertThrows(IllegalArgumentException.class, + () -> RegenerateFormCode.resolveFormFiles(dir.resolve("Orphan.form").toString())); + } + + @Test + public void rejectsOtherExtensions() { + assertThrows(IllegalArgumentException.class, + () -> RegenerateFormCode.resolveFormFiles(dir.resolve("notes.txt").toString())); + } + + @Test + public void documentWithoutGuardMarkersMatchesDisk() { + String disk = " // //GEN-BEGIN:initComponents\r\n" + + " private void initComponents() {\r\n" + + " jButton1 = new javax.swing.JButton();\r\n" + + " }// //GEN-END:initComponents\r\n" + + " private void jButton1ActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_jButton1ActionPerformed\r\n" + + " }//GEN-LAST:event_jButton1ActionPerformed\r\n"; + String document = " // \n" + + " private void initComponents() {\n" + + " jButton1 = new javax.swing.JButton();\n" + + " }// \n" + + " private void jButton1ActionPerformed(java.awt.event.ActionEvent evt) {\n" + + " }\n"; + assertTrue(RegenerateFormCode.documentMatchesDisk(document, disk)); + } + + @Test + public void staleDocumentDoesNotMatchDisk() { + String disk = "private void initComponents() {\n jButton1 = new javax.swing.JButton();\n}\n"; + String stale = "private void initComponents() {\n}\n"; + assertFalse(RegenerateFormCode.documentMatchesDisk(stale, disk)); + } + + @Test + public void runChecksAccessToBothFilesBeforeRegenerating() throws Exception { + createForm("MyFrame"); + List checked = new ArrayList<>(); + AtomicReference regenerated = new AtomicReference<>(); + RegenerateFormCode tool = new RegenerateFormCode() { + @Override + protected void checkAccess(File file) { checked.add(file); } + + @Override + protected String regenerate(FormFiles files) { + regenerated.set(files); + return "ok"; + } + }; + + assertEquals("ok", tool.run(new RegenerateFormCode.Params(dir.resolve("MyFrame.form").toString()))); + assertEquals(2, checked.size()); + assertTrue(checked.contains(regenerated.get().javaFile())); + assertTrue(checked.contains(regenerated.get().formFile())); + } + + @Test + public void runDoesNotRegenerateWhenAccessDenied() throws Exception { + createForm("MyFrame"); + AtomicReference regenerated = new AtomicReference<>(false); + RegenerateFormCode tool = new RegenerateFormCode() { + @Override + protected void checkAccess(File file) { throw new SecurityException("denied"); } + + @Override + protected String regenerate(FormFiles files) { + regenerated.set(true); + return "ok"; + } + }; + + assertThrows(SecurityException.class, + () -> tool.run(new RegenerateFormCode.Params(dir.resolve("MyFrame.form").toString()))); + assertFalse(regenerated.get()); + } + + @Test + public void runRequiresFilePath() { + assertThrows(IllegalArgumentException.class, + () -> new RegenerateFormCode().run(new RegenerateFormCode.Params(" "))); + } +} From 42fed1fda6891a6363e96c0804e27b4835e08b26 Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 13:55:34 +0800 Subject: [PATCH 02/13] Remove duplicate NbBundle resource Signed-off-by: Muhammad Rizky Rizaldi --- .../nbplugins/claudecodegui/actions/OpenWithClaudeAction.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/actions/OpenWithClaudeAction.java b/src/main/java/io/github/nbplugins/claudecodegui/actions/OpenWithClaudeAction.java index a69115aa..92f25e23 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/actions/OpenWithClaudeAction.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/actions/OpenWithClaudeAction.java @@ -16,7 +16,6 @@ import org.openide.util.ContextAwareAction; import org.openide.util.ImageUtilities; import org.openide.util.Lookup; -import org.openide.util.NbBundle.Messages; import org.openide.util.actions.Presenter; /** @@ -37,7 +36,6 @@ @ActionReferences({ @ActionReference(path = "Projects/Actions", position = 100) }) -@Messages("CTL_OpenWithClaudeAction=Open with Claude Code") public final class OpenWithClaudeAction extends AbstractAction implements ContextAwareAction { From 3ff268eb67f86f2ed0ccca0453f234954ef79ce9 Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 14:00:15 +0800 Subject: [PATCH 03/13] Remove `mcp__netbeans__regenerate_form_code` from allow list Signed-off-by: Muhammad Rizky Rizaldi --- .../github/nbplugins/claudecodegui/process/ClaudeProcess.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java index d805ce26..d5f9d0aa 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ClaudeProcess.java @@ -64,8 +64,7 @@ public final class ClaudeProcess { /** MCP tools the plugin adds to {@code permissions.allow}. */ static final List OUR_ALLOWED_TOOLS = List.of( "mcp__netbeans__show_markdown", - "mcp__netbeans__show_markdown_file", - "mcp__netbeans__regenerate_form_code" + "mcp__netbeans__show_markdown_file" ); /** Creates a new, idle {@code ClaudeProcess} instance. */ From f8a75ec99c1094e7bafd3d97b59942fa3037f911 Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 14:07:40 +0800 Subject: [PATCH 04/13] Clarify limitation Signed-off-by: Muhammad Rizky Rizaldi --- docs/user-manual.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/user-manual.md b/docs/user-manual.md index 84569b20..243f3059 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -784,6 +784,9 @@ The plugin exposes a set of IDE tools to Claude via an MCP (Model Context Protoc | `show_markdown_file` | Opens a live-updating Markdown Preview tab for an existing `.md` file on disk; the tab automatically refreshes when the file changes | | `regenerate_form_code` | Regenerates the GUI Builder code (`initComponents()` and the variables declaration) of a Swing form's `.java` file from its edited `.form` file, exactly as the NetBeans GUI Builder does, and saves the form (NetBeans re-saves the `.form` in its own formatting). Claude calls it after changing a `.form` file. An open designer is reloaded from it. Refused if the form has unsaved changes in the IDE | +> [!NOTE] +> **`regenerate_form_code` limitations:** the tool relies on internals of the NetBeans form module (which has no public API), so it may stop working after an IDE update; it then fails with an "Unsupported NetBeans form editor version" error without changing any file. It requires the Swing GUI Builder to be enabled. Because it writes the `.java` and `.form` files, Claude Code asks for your permission before each call. + ### Prompts that work well with IDE tools **Project and file context:** From d52351fcf4a4616bbfc8bc708dea7aa53473512e Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 14:13:08 +0800 Subject: [PATCH 05/13] Shorten changelog description Signed-off-by: Muhammad Rizky Rizaldi --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 395d8989..28e6762f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -- Added the `regenerate_form_code` MCP tool: after Claude edits a Swing GUI Builder `.form` file, it regenerates the form's `initComponents()` and variable declarations in the `.java` file exactly as the NetBeans GUI Builder does and saves the form, instead of Claude hand-editing the generated code; an open GUI Builder designer is reloaded from the edited `.form`, and Claude is told when the generated code was already up to date +- Added the `regenerate_form_code` MCP tool: after Claude edits a Swing form (`.form`), it can regenerate the form's code in the IDE instead of editing the generated code by hand # 1.3.42 (2026-10-22) - Fixed OpenAI-compatible models with strict chat templates (e.g. vLLM behind LiteLLM) failing every request with "System message must be at the beginning" # 1.3.39 (2026-10-18) From dc77aa300b2af69bf63c3c6259d56b05283b377d Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 14:14:21 +0800 Subject: [PATCH 06/13] wip Signed-off-by: Muhammad Rizky Rizaldi --- .../mcp/tools/RegenerateFormCode.java | 174 +++++++++++++----- .../tools/RegenerateFormCodeEditorTest.java | 80 +++++++- 2 files changed, 204 insertions(+), 50 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java index 370513a3..fc7dc3d4 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java @@ -6,6 +6,7 @@ import java.io.FileNotFoundException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; @@ -233,8 +234,18 @@ protected String regenerate(FormFiles files) throws Exception { /** * Loads (or reloads) the form, regenerates its guarded code and saves it. - * Must run on the EDT. Package-private so tests can drive it with fake - * form-editor objects that expose the same public methods. + * Package-private so tests can drive it with fake form-editor objects that + * expose the same public methods. + * + *

Must run on the EDT, including the disk I/O of {@code saveDocument()}: the form + * module confines the form model to the EDT and does not switch threads itself - + * {@code FormEditorSupport.saveDocument()} serializes the model into the {@code .form} + * file ({@code FormEditor.saveFormData()}) and may report saving errors in dialogs. + * Validation and waiting for the editor are done by the caller, off the EDT. + * + *

All form module methods are resolved before the first one is called (see + * {@link FormEditorApi}). A form loaded only for the regeneration (designer closed) + * is always closed again, also when the regeneration fails. * * @param editorSupport the form module's {@code FormEditorSupport} * @param javaName name of the {@code .java} file, for messages @@ -243,56 +254,139 @@ protected String regenerate(FormFiles files) throws Exception { * @throws Exception if the form cannot be loaded or the code cannot be regenerated */ static Outcome regenerateInFormEditor(Object editorSupport, String javaName, String formName) throws Exception { - boolean open = (Boolean) call(editorSupport, "isOpened"); + FormEditorApi api = FormEditorApi.resolve(editorSupport.getClass()); + boolean open = (Boolean) invoke(api.isOpened(), editorSupport); Object formEditor; if (open) { - formEditor = call(editorSupport, "reloadFormEditor"); + formEditor = invoke(api.reloadFormEditor(), editorSupport); } else { - call(editorSupport, "loadForm"); - formEditor = call(editorSupport, "getFormEditor"); + invoke(api.loadForm(), editorSupport); + formEditor = invoke(api.getFormEditor(), editorSupport); } - if (formEditor == null || !(Boolean) call(formEditor, "isFormLoaded") - || (Boolean) call(formEditor, "anyPersistenceError")) { - if (!open && formEditor != null) { - call(formEditor, "closeForm"); - } - throw new IllegalStateException("NetBeans could not load " + formName - + " without errors. Check the .form XML, or open the form in the GUI Builder (Design view) to see the errors."); + if (formEditor == null) { + throw formLoadError(formName); } - Object formModel = call(formEditor, "getFormModel"); - if (formModel == null || (Boolean) call(formModel, "isReadOnly")) { + try { + if (!(Boolean) invoke(api.isFormLoaded(), formEditor) + || (Boolean) invoke(api.anyPersistenceError(), formEditor)) { + throw formLoadError(formName); + } + Object formModel = invoke(api.getFormModel(), formEditor); + if (formModel == null || (Boolean) invoke(api.isReadOnly(), formModel)) { + throw new IllegalStateException(formName + " was loaded read-only (missing or damaged guarded " + + "sections in " + javaName + ", or a read-only file); code was not regenerated."); + } + Document document = (Document) invoke(api.getDocument(), editorSupport); + String before = documentText(document); + invoke(api.fireFormChanged(), formModel, true); + invoke(api.regenerateCodeIfNeeded(), formEditor); + String after = documentText(document); + invoke(api.saveDocument(), editorSupport); + return new Outcome(open, !before.equals(after), after); + } finally { if (!open) { - call(formEditor, "closeForm"); + invoke(api.closeForm(), formEditor); } - throw new IllegalStateException(formName + " was loaded read-only (missing or damaged guarded " - + "sections in " + javaName + ", or a read-only file); code was not regenerated."); } - Document document = (Document) call(editorSupport, "getDocument"); - String before = documentText(document); - call(formModel, "fireFormChanged", boolean.class, true); - call(formEditor, "regenerateCodeIfNeeded"); - String after = documentText(document); - call(editorSupport, "saveDocument"); - if (!open) { - call(formEditor, "closeForm"); + } + + private static IllegalStateException formLoadError(String formName) { + return new IllegalStateException("NetBeans could not load " + formName + + " without errors. Check the .form XML, or open the form in the GUI Builder (Design view) to see the errors."); + } + + /** + * The form module methods this tool invokes. They are all resolved, with their + * return types checked, before any of them is called, so that an incompatible + * NetBeans version fails with "Unsupported NetBeans form editor version" before + * the form is touched. The {@code FormEditor} and {@code FormModel} classes are + * taken from the declared return types of {@code getFormEditor()} and + * {@code getFormModel()}. + * + *

Verified against NetBeans 31 ({@code org.netbeans.modules.nbform.FormEditorSupport}, + * {@code org.netbeans.modules.form.FormEditor}, {@code org.netbeans.modules.form.FormModel}). + */ + record FormEditorApi(Method isOpened, Method loadForm, Method reloadFormEditor, Method getFormEditor, + Method getDocument, Method saveDocument, + Method isFormLoaded, Method anyPersistenceError, Method getFormModel, + Method regenerateCodeIfNeeded, Method closeForm, + Method isReadOnly, Method fireFormChanged) { + + /** + * Resolves the form module methods starting from the runtime class of the form editor support. + * + * @param supportClass runtime class of the form module's {@code FormEditorSupport} + * @return the resolved methods + * @throws IllegalStateException if a method is missing, not accessible or has an unexpected return type + */ + static FormEditorApi resolve(Class supportClass) { + Method getFormEditor = method(supportClass, "getFormEditor", Object.class); + Class editorClass = getFormEditor.getReturnType(); + Method getFormModel = method(editorClass, "getFormModel", Object.class); + Class modelClass = getFormModel.getReturnType(); + return new FormEditorApi( + method(supportClass, "isOpened", boolean.class), + method(supportClass, "loadForm", null), + method(supportClass, "reloadFormEditor", editorClass), + getFormEditor, + method(supportClass, "getDocument", Document.class), + method(supportClass, "saveDocument", null), + method(editorClass, "isFormLoaded", boolean.class), + method(editorClass, "anyPersistenceError", boolean.class), + getFormModel, + method(editorClass, "regenerateCodeIfNeeded", null), + method(editorClass, "closeForm", null), + method(modelClass, "isReadOnly", boolean.class), + method(modelClass, "fireFormChanged", null, boolean.class)); + } + + /** + * Looks up a public method of a public class. + * + * @param returnType required return type, or {@code null} if the result is not used + */ + private static Method method(Class owner, String name, Class returnType, Class... paramTypes) { + Method method; + try { + method = owner.getMethod(name, paramTypes); + } catch (NoSuchMethodException e) { + throw unsupported(owner, name, "not found", e); + } + if (!Modifier.isPublic(method.getDeclaringClass().getModifiers())) { + throw unsupported(owner, name, "is not accessible", null); + } + if (returnType != null && !returnType.isAssignableFrom(method.getReturnType())) { + throw unsupported(owner, name, "returns " + method.getReturnType().getName(), null); + } + return method; } - return new Outcome(open, !before.equals(after), after); + } + + private static IllegalStateException unsupported(Class owner, String methodName, String problem, Exception cause) { + return new IllegalStateException("Unsupported NetBeans form editor version: " + + owner.getName() + "." + methodName + "() " + problem, cause); } /** Returns the form module's {@code EditorSupport}, or {@code null} for a non-form data object. */ private static Object formEditorSupport(DataObject dataObject) throws Exception { + Method getFormEditorSupport; try { - dataObject.getClass().getMethod("getFormEditorSupport"); + getFormEditorSupport = dataObject.getClass().getMethod("getFormEditorSupport"); } catch (NoSuchMethodException e) { return null; } - return call(dataObject, "getFormEditorSupport"); + return invoke(getFormEditorSupport, dataObject); } /** * An unmodified open editor reloads on its own after an external change, but * asynchronously. Waits until it shows the content on disk so the regenerated * code is not merged into, and saved over, a stale document. + * + *

The comparison ignores whitespace (see {@link #documentMatchesDisk}), so an + * external edit that changed only whitespace counts as in sync; the form editor + * then regenerates into the document without that change. Polls with + * {@code Thread.sleep} and waits for the EDT, so it must never be called on the EDT. */ private static void waitForDocumentInSync(EditorCookie editorCookie, FileObject javaFo, String javaName) throws Exception { @@ -329,24 +423,12 @@ private static String documentText(Document document) throws BadLocationExceptio return text.get(); } - /** Invokes a public no-arg method on an object of a non-API form module class. */ - private static Object call(Object target, String methodName) throws Exception { - return call(target, methodName, null, null); - } - - /** Invokes a public method with at most one parameter on an object of a non-API form module class. */ - private static Object call(Object target, String methodName, Class paramType, Object arg) throws Exception { - Method method; - try { - method = paramType == null - ? target.getClass().getMethod(methodName) - : target.getClass().getMethod(methodName, paramType); - } catch (NoSuchMethodException e) { - throw new IllegalStateException("Unsupported NetBeans form editor version: " - + target.getClass().getName() + "." + methodName + "() not found", e); - } + /** Invokes a method of a non-API form module class, rethrowing what the method threw. */ + private static Object invoke(Method method, Object target, Object... args) throws Exception { try { - return paramType == null ? method.invoke(target) : method.invoke(target, arg); + return method.invoke(target, args); + } catch (IllegalAccessException e) { + throw unsupported(target.getClass(), method.getName(), "is not accessible", e); } catch (InvocationTargetException e) { Throwable cause = e.getCause(); if (cause instanceof Exception ex) { diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java index b14853e1..7457b3da 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java @@ -10,7 +10,9 @@ /** * Drives {@link RegenerateFormCode#regenerateInFormEditor} with fakes that expose * the same public methods as the NetBeans form module classes and reproduce their - * behavior (verified against the NetBeans 27 bytecode): + * behavior (verified against the NetBeans 31 bytecode). Like the real classes, the + * getters declare the concrete form editor / form model types, from which + * {@link RegenerateFormCode.FormEditorApi} resolves the methods up front: *

    *
  • {@code JavaCodeGenerator.codeUpToDate} starts {@code true}, so right after a * form is loaded {@code regenerateCodeIfNeeded()} does nothing;
  • @@ -51,6 +53,7 @@ public static class FakeFormEditor { boolean loaded; boolean closed; boolean codeUpToDate = true; // JavaCodeGenerator's constructor default + boolean failRegeneration; String generatedCode = NEW_CODE; FakeFormEditor(FakeEditorSupport support) { @@ -60,10 +63,13 @@ public static class FakeFormEditor { public boolean isFormLoaded() { return loaded; } public boolean anyPersistenceError() { return false; } - public Object getFormModel() { return model; } + public FakeFormModel getFormModel() { return model; } public void closeForm() { closed = true; loaded = false; } public void regenerateCodeIfNeeded() throws BadLocationException { + if (failRegeneration) { + throw new IllegalStateException("code generation failed"); + } if (!codeUpToDate) { codeUpToDate = true; support.document.remove(0, support.document.getLength()); @@ -90,8 +96,8 @@ public static class FakeEditorSupport { public boolean isOpened() { return formEditor.loaded; } public boolean loadForm() { formEditor.loaded = true; return true; } - public Object getFormEditor() { return formEditor; } - public Object reloadFormEditor() { reloaded = true; formEditor.codeUpToDate = true; return formEditor; } + public FakeFormEditor getFormEditor() { return formEditor; } + public FakeFormEditor reloadFormEditor() { reloaded = true; formEditor.codeUpToDate = true; return formEditor; } public Document getDocument() { return document; } public void saveSourceOnly() throws BadLocationException { @@ -141,4 +147,70 @@ public void reportsUnchangedCodeInsteadOfSuccess() throws Exception { assertFalse(outcome.codeChanged()); assertEquals(OLD_CODE, outcome.generatedText()); } + + @Test + public void closesFormLoadedForRegenerationWhenRegenerationFails() throws Exception { + FakeEditorSupport support = new FakeEditorSupport(false); + support.formEditor.failRegeneration = true; + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form")); + + assertEquals("code generation failed", e.getMessage()); + assertTrue(support.formEditor.closed, "a form loaded only for regeneration must be closed even on failure"); + } + + @Test + public void keepsOpenDesignerOpenWhenRegenerationFails() throws Exception { + FakeEditorSupport support = new FakeEditorSupport(true); + support.formEditor.failRegeneration = true; + + assertThrows(IllegalStateException.class, + () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form")); + + assertFalse(support.formEditor.closed, "the user's open designer must not be closed"); + } + + /** Fake of a form editor support from an incompatible NetBeans version: {@code saveDocument()} is missing. */ + public static class IncompatibleEditorSupport { + final FakeEditorSupport delegate; + boolean touched; + + IncompatibleEditorSupport() throws BadLocationException { + delegate = new FakeEditorSupport(false); + } + + public boolean isOpened() { touched = true; return false; } + public boolean loadForm() { touched = true; return delegate.loadForm(); } + public FakeFormEditor getFormEditor() { touched = true; return delegate.formEditor; } + public FakeFormEditor reloadFormEditor() { touched = true; return delegate.formEditor; } + public Document getDocument() { touched = true; return delegate.document; } + } + + @Test + public void incompatibleVersionFailsBeforeTouchingTheForm() throws Exception { + IncompatibleEditorSupport support = new IncompatibleEditorSupport(); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form")); + + assertTrue(e.getMessage().startsWith("Unsupported NetBeans form editor version"), e.getMessage()); + assertTrue(e.getMessage().contains("saveDocument"), e.getMessage()); + assertFalse(support.touched, "no form editor method may be called before all are resolved"); + assertFalse(support.delegate.formEditor.loaded); + } + + /** Non-public class: its public methods cannot be invoked reflectively from outside. */ + static class NonPublicEditorSupport { + public FakeFormEditor getFormEditor() { return null; } + } + + @Test + public void nonPublicClassIsReportedAsUnsupportedVersion() { + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> RegenerateFormCode.regenerateInFormEditor(new NonPublicEditorSupport(), "MyFrame.java", "MyFrame.form")); + + assertTrue(e.getMessage().startsWith("Unsupported NetBeans form editor version"), e.getMessage()); + assertTrue(e.getMessage().contains("is not accessible"), e.getMessage()); + } } From 3aa4ee10de37bad3f3696cf97096abb228009134 Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 14:14:49 +0800 Subject: [PATCH 07/13] Keeping this +1 of target branch Signed-off-by: Muhammad Rizky Rizaldi --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 53b79107..c5251405 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.67-SNAPSHOT + 1.3.68-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. From 414faf55624e0396223fc09a4cf7746c15e2e2a8 Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 15:25:21 +0800 Subject: [PATCH 08/13] Integration test setup Signed-off-by: Muhammad Rizky Rizaldi --- pom.xml | 253 +++++++++++++++++- .../ui/ClaudeCodeTopComponentTest.java | 15 +- 2 files changed, 265 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index c5251405..cdb01bc2 100644 --- a/pom.xml +++ b/pom.xml @@ -145,6 +145,67 @@ + + + patch-nb-test-modules + process-test-classes + execute + + + import java.util.jar.JarEntry + import java.util.jar.JarFile + import java.util.jar.JarOutputStream + import java.util.jar.Manifest + + def toPatch = ['org.netbeans.modules:org-netbeans-modules-form', + 'org.netbeans.api:org-netbeans-modules-autoupdate-services', + 'org.netbeans.modules:org-netbeans-libs-freemarker'] + def isModule = { String entry -> + entry.startsWith('org.netbeans.api:') || entry.startsWith('org.netbeans.modules:') + } + def out = new File(project.build.directory, 'nb-patched-modules') + out.mkdirs() + + project.artifacts.findAll { it.type == 'jar' }.each { artifact -> + def jar = new JarFile(artifact.file) + def manifest = jar.manifest + def mavenCP = manifest?.mainAttributes?.getValue('Maven-Class-Path') + if (mavenCP == null || !mavenCP.split(' ').any(isModule)) { + jar.close() + return + } + String ga = "${artifact.groupId}:${artifact.artifactId}" + if (!toPatch.contains(ga)) { + throw new IllegalStateException("${ga} lists modules in Maven-Class-Path: " + + "add it to patch-nb-test-modules and to the failsafe classpath excludes/additions") + } + def patched = new Manifest(manifest) + patched.mainAttributes.putValue('Maven-Class-Path', + mavenCP.split(' ').findAll { !isModule(it) }.join(' ')) + new File(out, artifact.file.name).withOutputStream { os -> + def jos = new JarOutputStream(os, patched) + jar.entries().each { entry -> + if (entry.name.equalsIgnoreCase('META-INF/MANIFEST.MF')) { + return + } + jos.putNextEntry(new JarEntry(entry.name)) + jar.getInputStream(entry).withStream { jos << it } + jos.closeEntry() + } + jos.finish() + } + jar.close() + log.info("Patched Maven-Class-Path of ${artifact.file.name} for the integration tests") + } + + + @@ -218,6 +279,39 @@ + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + nb-platform-lib + process-test-classes + copy-dependencies + + test + org-netbeans-bootstrap,org-openide-modules,org-openide-util,org-openide-util-lookup,org-openide-util-ui + ${project.build.directory}/nb-clusters/platform/lib + + + + nb-platform-core + process-test-classes + copy-dependencies + + test + org-netbeans-core-startup,org-netbeans-core-startup-base,org-openide-filesystems,org-netbeans-libs-asm,asm,asm-tree,asm-commons + ${project.build.directory}/nb-clusters/platform/core + + + + org.apache.maven.plugins maven-surefire-plugin @@ -225,12 +319,87 @@ **/*Test.java - **/*IT.java 1 false + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + org.apache.maven.surefire + surefire-junit4 + 3.2.5 + + + + ${project.build.directory}/${project.build.finalName}.jar + + + org.netbeans.modules:org-netbeans-modules-form + org.netbeans.api:org-netbeans-modules-autoupdate-services + org.netbeans.modules:org-netbeans-libs-freemarker + + + ${project.build.directory}/nb-patched-modules/org-netbeans-modules-form-${netbeans.version}.jar + ${project.build.directory}/nb-patched-modules/org-netbeans-modules-autoupdate-services-${netbeans.version}.jar + ${project.build.directory}/nb-patched-modules/org-netbeans-libs-freemarker-${netbeans.version}.jar + + 1 + false + + false + + ${project.build.directory}/nb-clusters + + + + --add-opens=java.base/java.lang=ALL-UNNAMED + --add-opens=java.base/java.lang.ref=ALL-UNNAMED + --add-opens=java.base/java.net=ALL-UNNAMED + --add-opens=java.base/java.nio=ALL-UNNAMED + --add-opens=java.base/java.security=ALL-UNNAMED + --add-opens=java.base/java.util=ALL-UNNAMED + --add-opens=java.desktop/java.awt=ALL-UNNAMED + --add-opens=java.desktop/java.awt.event=ALL-UNNAMED + --add-opens=java.desktop/javax.swing=ALL-UNNAMED + --add-opens=java.desktop/javax.swing.text=ALL-UNNAMED + --add-opens=java.prefs/java.util.prefs=ALL-UNNAMED + --add-exports=java.desktop/sun.awt=ALL-UNNAMED + --add-exports=java.desktop/com.sun.beans.editors=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED + + + + + package + + integration-test + verify + + + + org.apache.maven.plugins maven-source-plugin @@ -491,6 +660,88 @@ 5.10.2 test + + + junit + junit + 4.13.2 + test + + + + org.netbeans.modules + org-netbeans-bootstrap + ${netbeans.version} + test + + + org.netbeans.modules + org-netbeans-core-startup + ${netbeans.version} + test + + + org.netbeans.modules + org-netbeans-modules-masterfs + ${netbeans.version} + test + + + org.netbeans.modules + org-netbeans-modules-editor-mimelookup-impl + ${netbeans.version} + test + + + org.netbeans.modules + org-netbeans-modules-editor-settings-storage + ${netbeans.version} + test + + + + org.netbeans.modules + org-netbeans-modules-form-nb + ${netbeans.version} + test + + + org.netbeans.modules + org-netbeans-modules-java-guards + ${netbeans.version} + test + + + + org.netbeans.modules + org-netbeans-modules-editor-actions + ${netbeans.version} + test + + + org.netbeans.modules + org-netbeans-libs-freemarker + ${netbeans.version} + test + + + org.netbeans.modules + org-netbeans-modules-java-source-queriesimpl + ${netbeans.version} + test + + + org.netbeans.modules + org-netbeans-modules-parsing-nb + ${netbeans.version} + test + + + org.netbeans.modules + org-netbeans-modules-projectapi-nb + ${netbeans.version} + test + diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeCodeTopComponentTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeCodeTopComponentTest.java index 7574198a..4acb2977 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeCodeTopComponentTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeCodeTopComponentTest.java @@ -143,12 +143,23 @@ void testContextActionEnabledForExistingDirectory() throws IOException { org.junit.jupiter.api.Assumptions.assumeTrue(fo != null, "FileUtil.toFileObject returned null — skipping in headless env"); + org.netbeans.api.project.Project project = new org.netbeans.api.project.Project() { + @Override + public org.openide.filesystems.FileObject getProjectDirectory() { + return fo; + } + + @Override + public org.openide.util.Lookup getLookup() { + return org.openide.util.Lookup.EMPTY; + } + }; io.github.nbplugins.claudecodegui.actions.OpenWithClaudeAction action = new io.github.nbplugins.claudecodegui.actions.OpenWithClaudeAction(); javax.swing.Action ctx = action.createContextAwareInstance( - org.openide.util.lookup.Lookups.fixed(fo)); + org.openide.util.lookup.Lookups.fixed(project)); assertTrue(ctx.isEnabled(), - "Context action must be enabled for an existing directory FileObject"); + "Context action must be enabled for a project with an existing directory"); } finally { tmpDir.delete(); } From 3bdf5f47c29ac807b283960eb268fc5e7240be8a Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 15:28:38 +0800 Subject: [PATCH 09/13] Added auto-generated event handler Signed-off-by: Muhammad Rizky Rizaldi --- CHANGELOG.md | 2 +- .../mcp/tools/RegenerateFormCode.java | 127 +++++++++++++++--- .../tools/RegenerateFormCodeEditorTest.java | 85 ++++++++++-- .../mcp/tools/RegenerateFormCodeTest.java | 12 ++ 4 files changed, 199 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e6762f..1a586fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -- Added the `regenerate_form_code` MCP tool: after Claude edits a Swing form (`.form`), it can regenerate the form's code in the IDE instead of editing the generated code by hand +- Added the `regenerate_form_code` MCP tool: after Claude edits a Swing form (`.form`), it can regenerate the form's code in the IDE, including new event handler methods, instead of editing the generated code by hand # 1.3.42 (2026-10-22) - Fixed OpenAI-compatible models with strict chat templates (e.g. vLLM behind LiteLLM) failing every request with "System message must be at the beginning" # 1.3.39 (2026-10-18) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java index fc7dc3d4..149d4263 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java @@ -7,9 +7,15 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; +import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.swing.text.BadLocationException; import javax.swing.text.Document; @@ -42,6 +48,14 @@ * immediately ({@code true}); a queued one arrives after this EDT task. Since the * event marks the form modified, both files are saved like the IDE's Save does, * which re-serializes the {@code .form} file. + * + *

    Event handler methods are not part of the regenerated code: the form editor writes + * a handler method only when an event handler is added to the form. So before marking + * the form changed, {@code FormModel.fireEventHandlerAdded()} is fired for each handler + * of the {@code .form} that has no {@code //GEN-FIRST:event_} section in the + * {@code .java} yet, as the GUI Builder does when the user adds an event. On the EDT + * these events are queued; {@code fireFormChanged(true)} delivers them in one batch, + * which generates the handler methods with the IDE's default body. */ public class RegenerateFormCode implements Tool { @@ -55,6 +69,9 @@ public class RegenerateFormCode implements Tool addedHandlers) {} /** Creates a new instance of this tool. */ public RegenerateFormCode() {} @@ -99,6 +117,8 @@ public String getDescription() { + "Swing form's .java file from its .form file, exactly as the NetBeans form editor does, " + "and save it. ALWAYS call this after creating or modifying a .form file " + "instead of editing the guarded sections in the .java file by hand. " + + "Event handler methods of the .form that are missing in the .java are generated " + + "with a TODO body (//GEN-FIRST/GEN-LAST guarded header and footer); existing handler bodies are kept. " + "NetBeans also re-saves the .form file in its own formatting. " + "Re-read both files afterwards before editing them again."; } @@ -167,6 +187,22 @@ private static String normalize(String text) { return WHITESPACE.matcher(GUARD_MARKER.matcher(text).replaceAll("")).replaceAll(""); } + /** + * Returns the names of the event handler methods that have a guarded + * {@code //GEN-FIRST:event_} header in the {@code .java} file. + * + * @param javaText content of the {@code .java} file on disk + * @return the handler names + */ + static Set existingEventHandlers(String javaText) { + Set handlers = new HashSet<>(); + Matcher matcher = EVENT_HANDLER_HEADER.matcher(javaText); + while (matcher.find()) { + handlers.add(matcher.group(1)); + } + return handlers; + } + /** * Rejects files outside the open projects. * Protected so tests can bypass the open-projects lookup. @@ -199,7 +235,8 @@ protected String regenerate(FormFiles files) throws Exception { formFo.refresh(); javaFo.refresh(); - if (!javaFo.asText().contains("//GEN-BEGIN:initComponents")) { + String javaText = javaFo.asText(); + if (!javaText.contains("//GEN-BEGIN:initComponents")) { throw new IllegalStateException(javaName + " has no generated initComponents() guarded block " + "(//GEN-BEGIN:initComponents ... //GEN-END:initComponents); the form editor cannot generate code into it"); } @@ -216,7 +253,8 @@ protected String regenerate(FormFiles files) throws Exception { } waitForDocumentInSync(dataObject.getLookup().lookup(EditorCookie.class), javaFo, javaName); - Outcome outcome = onEdt(() -> regenerateInFormEditor(editorSupport, javaName, formName)); + Set existingHandlers = existingEventHandlers(javaText); + Outcome outcome = onEdt(() -> regenerateInFormEditor(editorSupport, javaName, formName, existingHandlers)); String designerNote = outcome.designerOpen() ? " The open GUI Builder was reloaded from the edited .form." : ""; if (!outcome.codeChanged()) { @@ -227,8 +265,11 @@ protected String regenerate(FormFiles files) throws Exception { throw new IllegalStateException("The code was regenerated in the IDE but " + javaName + " on disk was not updated. Ask the user to save it in the IDE."); } + String handlersNote = outcome.addedHandlers().isEmpty() ? "" + : " Added the missing event handler methods " + String.join(", ", outcome.addedHandlers()) + + " with a TODO body; write their code between the //GEN-FIRST and //GEN-LAST lines."; return "Regenerated initComponents() and the variables declaration of " + javaName - + " from " + formName + " and saved both files." + designerNote + + " from " + formName + " and saved both files." + handlersNote + designerNote + " Re-read " + javaName + " and " + formName + " before editing them again."; } @@ -247,13 +288,15 @@ protected String regenerate(FormFiles files) throws Exception { * {@link FormEditorApi}). A form loaded only for the regeneration (designer closed) * is always closed again, also when the regeneration fails. * - * @param editorSupport the form module's {@code FormEditorSupport} - * @param javaName name of the {@code .java} file, for messages - * @param formName name of the {@code .form} file, for messages + * @param editorSupport the form module's {@code FormEditorSupport} + * @param javaName name of the {@code .java} file, for messages + * @param formName name of the {@code .form} file, for messages + * @param existingHandlers event handler methods the {@code .java} already has (see {@link #existingEventHandlers}) * @return what happened * @throws Exception if the form cannot be loaded or the code cannot be regenerated */ - static Outcome regenerateInFormEditor(Object editorSupport, String javaName, String formName) throws Exception { + static Outcome regenerateInFormEditor(Object editorSupport, String javaName, String formName, + Set existingHandlers) throws Exception { FormEditorApi api = FormEditorApi.resolve(editorSupport.getClass()); boolean open = (Boolean) invoke(api.isOpened(), editorSupport); Object formEditor; @@ -278,11 +321,13 @@ static Outcome regenerateInFormEditor(Object editorSupport, String javaName, Str } Document document = (Document) invoke(api.getDocument(), editorSupport); String before = documentText(document); + List addedHandlers = addMissingEventHandlers(api, formModel, existingHandlers); + // Also delivers the queued handler events, in the same batch. invoke(api.fireFormChanged(), formModel, true); invoke(api.regenerateCodeIfNeeded(), formEditor); String after = documentText(document); invoke(api.saveDocument(), editorSupport); - return new Outcome(open, !before.equals(after), after); + return new Outcome(open, !before.equals(after), after, addedHandlers); } finally { if (!open) { invoke(api.closeForm(), formEditor); @@ -290,6 +335,38 @@ static Outcome regenerateInFormEditor(Object editorSupport, String javaName, Str } } + /** + * Fires {@code FormModel.fireEventHandlerAdded(event, handler, null, null, true)} for + * each event handler of the form that the {@code .java} does not have yet - what the + * GUI Builder does when the user adds an event handler. On the EDT the form model + * queues these events; the code generator writes the handler methods (with the IDE's + * default body) when the next immediate event delivers the batch. Delivered in a batch, + * they also do not open the editor at the new handler, as a single added handler would. + * The form keeps its handlers in a hash map, so they are added sorted by name, which + * is also the order of the generated methods. + * + * @return the handlers that were added, sorted by name + */ + private static List addMissingEventHandlers(FormEditorApi api, Object formModel, + Set existingHandlers) throws Exception { + Object formEvents = invoke(api.getFormEvents(), formModel); + String[] handlers = (String[]) invoke(api.getAllEventHandlers(), formEvents); + Arrays.sort(handlers); + List added = new ArrayList<>(); + for (String handler : handlers) { + if (existingHandlers.contains(handler)) { + continue; + } + Object[] events = (Object[]) invoke(api.getEventsForHandler(), formEvents, handler); + if (events.length == 0) { + continue; + } + invoke(api.fireEventHandlerAdded(), formModel, events[0], handler, null, null, true); + added.add(handler); + } + return added; + } + private static IllegalStateException formLoadError(String formName) { return new IllegalStateException("NetBeans could not load " + formName + " without errors. Check the .form XML, or open the form in the GUI Builder (Design view) to see the errors."); @@ -299,18 +376,23 @@ private static IllegalStateException formLoadError(String formName) { * The form module methods this tool invokes. They are all resolved, with their * return types checked, before any of them is called, so that an incompatible * NetBeans version fails with "Unsupported NetBeans form editor version" before - * the form is touched. The {@code FormEditor} and {@code FormModel} classes are - * taken from the declared return types of {@code getFormEditor()} and - * {@code getFormModel()}. + * the form is touched. The {@code FormEditor}, {@code FormModel}, {@code FormEvents} + * and {@code Event} classes are taken from the declared return types of + * {@code getFormEditor()}, {@code getFormModel()}, {@code getFormEvents()} and + * {@code getEventsForHandler()}. * *

    Verified against NetBeans 31 ({@code org.netbeans.modules.nbform.FormEditorSupport}, - * {@code org.netbeans.modules.form.FormEditor}, {@code org.netbeans.modules.form.FormModel}). + * {@code org.netbeans.modules.form.FormEditor}, {@code org.netbeans.modules.form.FormModel}, + * {@code org.netbeans.modules.form.FormEvents}); exercised against the real form editor + * of the compile-time NetBeans version by {@code RegenerateFormCodeIT}. */ record FormEditorApi(Method isOpened, Method loadForm, Method reloadFormEditor, Method getFormEditor, Method getDocument, Method saveDocument, Method isFormLoaded, Method anyPersistenceError, Method getFormModel, Method regenerateCodeIfNeeded, Method closeForm, - Method isReadOnly, Method fireFormChanged) { + Method isReadOnly, Method fireFormChanged, + Method getFormEvents, Method getAllEventHandlers, Method getEventsForHandler, + Method fireEventHandlerAdded) { /** * Resolves the form module methods starting from the runtime class of the form editor support. @@ -324,6 +406,10 @@ static FormEditorApi resolve(Class supportClass) { Class editorClass = getFormEditor.getReturnType(); Method getFormModel = method(editorClass, "getFormModel", Object.class); Class modelClass = getFormModel.getReturnType(); + Method getFormEvents = method(modelClass, "getFormEvents", Object.class); + Class eventsClass = getFormEvents.getReturnType(); + Method getEventsForHandler = method(eventsClass, "getEventsForHandler", Object[].class, String.class); + Class eventClass = getEventsForHandler.getReturnType().getComponentType(); return new FormEditorApi( method(supportClass, "isOpened", boolean.class), method(supportClass, "loadForm", null), @@ -337,7 +423,12 @@ static FormEditorApi resolve(Class supportClass) { method(editorClass, "regenerateCodeIfNeeded", null), method(editorClass, "closeForm", null), method(modelClass, "isReadOnly", boolean.class), - method(modelClass, "fireFormChanged", null, boolean.class)); + method(modelClass, "fireFormChanged", null, boolean.class), + getFormEvents, + method(eventsClass, "getAllEventHandlers", String[].class), + getEventsForHandler, + method(modelClass, "fireEventHandlerAdded", null, + eventClass, String.class, String.class, String.class, boolean.class)); } /** diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java index 7457b3da..66c42181 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java @@ -1,5 +1,8 @@ package io.github.nbplugins.claudecodegui.mcp.tools; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; import javax.swing.text.BadLocationException; import javax.swing.text.Document; import javax.swing.text.PlainDocument; @@ -20,7 +23,10 @@ * it immediately, {@code fireFormChanged(false)} queues it via invokeLater, i.e. * after the caller's EDT task; *

  • a form change marks the form modified; {@code saveSourceOnly()} saves just the - * .java, {@code saveDocument()} saves .form and .java.
  • + * .java, {@code saveDocument()} saves .form and .java; + *
  • {@code fireEventHandlerAdded()} is a modifying event too, so on the EDT it is + * queued; the code generator writes the handler method when the next immediate + * event delivers the pending events.
  • *
*/ public class RegenerateFormCodeEditorTest { @@ -28,15 +34,47 @@ public class RegenerateFormCodeEditorTest { private static final String OLD_CODE = "class MyFrame { /* old initComponents */ }"; private static final String NEW_CODE = "class MyFrame { /* initComponents with jButton1 */ }"; + /** Fake {@code Event}. */ + public static class FakeEvent { + } + + /** Fake {@code FormEvents}: the event handlers declared in the .form. */ + public static class FakeFormEvents { + final List handlers = new ArrayList<>(); + + public String[] getAllEventHandlers() { return handlers.toArray(new String[0]); } + + public FakeEvent[] getEventsForHandler(String handler) { + return handlers.contains(handler) ? new FakeEvent[] {new FakeEvent()} : new FakeEvent[0]; + } + } + /** Fake {@code FormModel}. */ public static class FakeFormModel { FakeFormEditor editor; + final FakeFormEvents events = new FakeFormEvents(); boolean queuedEvent; + /** Handler-added events: queued on the EDT like other modifying events. */ + final List queuedHandlers = new ArrayList<>(); public boolean isReadOnly() { return false; } + public FakeFormEvents getFormEvents() { return events; } + + public Object fireEventHandlerAdded(FakeEvent event, String handler, String bodyText, + String annotationText, boolean createdNew) { + assertNotNull(event); + assertNull(bodyText, "the IDE's default handler body must be used"); + assertTrue(createdNew); + queuedHandlers.add(handler); + return null; + } + public Object fireFormChanged(boolean immediately) { if (immediately) { + // delivers the pending events in one batch + editor.generatedHandlers.addAll(queuedHandlers); + queuedHandlers.clear(); editor.codeUpToDate = false; editor.support.formModified = true; } else { @@ -55,6 +93,8 @@ public static class FakeFormEditor { boolean codeUpToDate = true; // JavaCodeGenerator's constructor default boolean failRegeneration; String generatedCode = NEW_CODE; + /** Handler methods written by the code generator when the handler-added events are delivered. */ + final List generatedHandlers = new ArrayList<>(); FakeFormEditor(FakeEditorSupport support) { this.support = support; @@ -74,6 +114,9 @@ public void regenerateCodeIfNeeded() throws BadLocationException { codeUpToDate = true; support.document.remove(0, support.document.getLength()); support.document.insertString(0, generatedCode, null); + for (String handler : generatedHandlers) { + support.document.insertString(support.document.getLength(), " handler:" + handler, null); + } } } } @@ -115,7 +158,7 @@ public void saveDocument() throws BadLocationException { public void designerClosedRegeneratesAndSavesJava() throws Exception { FakeEditorSupport support = new FakeEditorSupport(false); - RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form"); + RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of()); assertEquals(NEW_CODE, support.savedJava, "a freshly loaded form is considered up to date - the form must be marked changed " @@ -128,7 +171,7 @@ public void designerClosedRegeneratesAndSavesJava() throws Exception { public void designerOpenReloadsRegeneratesAndSavesJava() throws Exception { FakeEditorSupport support = new FakeEditorSupport(true); - RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form"); + RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of()); assertTrue(support.reloaded, "an open designer must be reloaded from the edited .form"); assertEquals(NEW_CODE, support.savedJava); @@ -136,13 +179,39 @@ public void designerOpenReloadsRegeneratesAndSavesJava() throws Exception { assertFalse(support.formModified, "the open designer must not be left with unsaved changes"); } + @Test + public void generatesOnlyEventHandlersMissingInJava() throws Exception { + FakeEditorSupport support = new FakeEditorSupport(false); + support.formEditor.model.events.handlers.addAll(List.of("otherNewHandler", "existingHandler", "newHandler")); + + RegenerateFormCode.Outcome outcome = RegenerateFormCode.regenerateInFormEditor( + support, "MyFrame.java", "MyFrame.form", Set.of("existingHandler")); + + assertEquals(List.of("newHandler", "otherNewHandler"), outcome.addedHandlers(), "added sorted by name"); + assertEquals(NEW_CODE + " handler:newHandler handler:otherNewHandler", support.savedJava, + "the queued handler events must be delivered before regenerating; an existing handler must not be re-added"); + assertTrue(support.formEditor.model.queuedHandlers.isEmpty(), "no handler event may be left queued"); + } + + @Test + public void addsNoEventHandlersWhenAllExist() throws Exception { + FakeEditorSupport support = new FakeEditorSupport(true); + support.formEditor.model.events.handlers.add("existingHandler"); + + RegenerateFormCode.Outcome outcome = RegenerateFormCode.regenerateInFormEditor( + support, "MyFrame.java", "MyFrame.form", Set.of("existingHandler")); + + assertEquals(List.of(), outcome.addedHandlers()); + assertEquals(NEW_CODE, support.savedJava); + } + @Test public void reportsUnchangedCodeInsteadOfSuccess() throws Exception { FakeEditorSupport support = new FakeEditorSupport(false); support.formEditor.generatedCode = OLD_CODE; RegenerateFormCode.Outcome outcome = - RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form"); + RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of()); assertFalse(outcome.codeChanged()); assertEquals(OLD_CODE, outcome.generatedText()); @@ -154,7 +223,7 @@ public void closesFormLoadedForRegenerationWhenRegenerationFails() throws Except support.formEditor.failRegeneration = true; IllegalStateException e = assertThrows(IllegalStateException.class, - () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form")); + () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of())); assertEquals("code generation failed", e.getMessage()); assertTrue(support.formEditor.closed, "a form loaded only for regeneration must be closed even on failure"); @@ -166,7 +235,7 @@ public void keepsOpenDesignerOpenWhenRegenerationFails() throws Exception { support.formEditor.failRegeneration = true; assertThrows(IllegalStateException.class, - () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form")); + () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of())); assertFalse(support.formEditor.closed, "the user's open designer must not be closed"); } @@ -192,7 +261,7 @@ public void incompatibleVersionFailsBeforeTouchingTheForm() throws Exception { IncompatibleEditorSupport support = new IncompatibleEditorSupport(); IllegalStateException e = assertThrows(IllegalStateException.class, - () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form")); + () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of())); assertTrue(e.getMessage().startsWith("Unsupported NetBeans form editor version"), e.getMessage()); assertTrue(e.getMessage().contains("saveDocument"), e.getMessage()); @@ -208,7 +277,7 @@ static class NonPublicEditorSupport { @Test public void nonPublicClassIsReportedAsUnsupportedVersion() { IllegalStateException e = assertThrows(IllegalStateException.class, - () -> RegenerateFormCode.regenerateInFormEditor(new NonPublicEditorSupport(), "MyFrame.java", "MyFrame.form")); + () -> RegenerateFormCode.regenerateInFormEditor(new NonPublicEditorSupport(), "MyFrame.java", "MyFrame.form", Set.of())); assertTrue(e.getMessage().startsWith("Unsupported NetBeans form editor version"), e.getMessage()); assertTrue(e.getMessage().contains("is not accessible"), e.getMessage()); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java index e94d24a4..a82753c7 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java @@ -88,6 +88,18 @@ public void staleDocumentDoesNotMatchDisk() { assertFalse(RegenerateFormCode.documentMatchesDisk(stale, disk)); } + @Test + public void findsExistingEventHandlersByTheirGuardedHeader() { + String javaText = " private void jButton1ActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_jButton1ActionPerformed\n" + + " }//GEN-LAST:event_jButton1ActionPerformed\n" + + " private void my$Handler(java.awt.event.MouseEvent evt) {//GEN-FIRST:event_my$Handler\n" + + " }//GEN-LAST:event_my$Handler\n" + + " private void notGuarded(java.awt.event.ActionEvent evt) {\n" + + " }\n"; + assertEquals(java.util.Set.of("jButton1ActionPerformed", "my$Handler"), + RegenerateFormCode.existingEventHandlers(javaText)); + } + @Test public void runChecksAccessToBothFilesBeforeRegenerating() throws Exception { createForm("MyFrame"); From 3c7c260a35f5894ee90ea91f2df13c234bd38917 Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 15:30:36 +0800 Subject: [PATCH 10/13] Added integration tests, covers many common cases Signed-off-by: Muhammad Rizky Rizaldi --- .../mcp/tools/RegenerateFormCodeIT.java | 244 ++++++++++++++++++ .../mcp/tools/forms/EventsPanel.form | 48 ++++ .../mcp/tools/forms/EventsPanel.java.expected | 66 +++++ .../mcp/tools/forms/EventsPanel.java.input | 18 ++ .../mcp/tools/forms/LocalDefaultsPanel.form | 33 +++ .../forms/LocalDefaultsPanel.java.expected | 24 ++ .../tools/forms/LocalDefaultsPanel.java.input | 14 + .../mcp/tools/forms/ModifiedPanel.form | 29 +++ .../tools/forms/ModifiedPanel.java.expected | 30 +++ .../mcp/tools/forms/ModifiedPanel.java.input | 28 ++ .../mcp/tools/forms/PlainFrame.form | 66 +++++ .../mcp/tools/forms/PlainFrame.java.expected | 54 ++++ .../mcp/tools/forms/PlainFrame.java.input | 14 + .../mcp/tools/forms/VariablesPanel.form | 74 ++++++ .../tools/forms/VariablesPanel.java.expected | 48 ++++ .../mcp/tools/forms/VariablesPanel.java.input | 14 + 16 files changed, 804 insertions(+) create mode 100644 src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeIT.java create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.form create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.java.expected create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.java.input create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.form create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.java.expected create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.java.input create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.form create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.java.expected create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.java.input create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.form create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.java.expected create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.java.input create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.form create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.java.expected create mode 100644 src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.java.input diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeIT.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeIT.java new file mode 100644 index 00000000..65c708f5 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeIT.java @@ -0,0 +1,244 @@ +package io.github.nbplugins.claudecodegui.mcp.tools; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import junit.framework.Test; +import org.netbeans.junit.NbModuleSuite; +import org.netbeans.junit.NbTestCase; + +/** + * Runs {@link RegenerateFormCode} against the real NetBeans form editor (form + form.nb + * modules) inside an {@link NbModuleSuite} runtime. + * + *

Each test copies a {@code .form} / {@code .java} fixture pair from + * {@code forms/} into the work directory, regenerates it, and compares the resulting + * {@code .java} with {@code forms/.java.expected} (line endings ignored). When the + * generated code differs, the actual file is left in the test's work directory - after + * reviewing it, it can be copied over the expected file (e.g. after a NetBeans upgrade). + * + *

The fixture files are not in an open project, so the open-projects access check is + * bypassed; it is covered by {@code RegenerateFormCodeTest}. + */ +public class RegenerateFormCodeIT extends NbTestCase { + + private static final String FIXTURES = "forms/"; + + /** + * @return the suite running this class inside a NetBeans module system with all + * modules from the test classpath enabled + */ + public static Test suite() { + return NbModuleSuite.createConfiguration(RegenerateFormCodeIT.class) + .clusters(".*") + .enableModules(".*") + .gui(false) + .suite(); + } + + /** + * @param name test method name passed by the JUnit runner + */ + public RegenerateFormCodeIT(String name) { + super(name); + } + + @Override + protected void setUp() throws Exception { + clearWorkDir(); + } + + /** A Free Design (GroupLayout) JFrame form is generated into empty guarded sections. */ + public void testPlainFormIsRegenerated() throws Exception { + File java = copyFixture("PlainFrame"); + + String result = regenerate(java); + + assertTrue(result, result.startsWith("Regenerated initComponents() and the variables declaration of PlainFrame.java")); + String code = assertMatchesExpected("PlainFrame", java); + String init = guardedSection(code, "initComponents"); + assertTrue(init, init.contains("jButton1.setText(\"OK\");")); + assertTrue(init, init.contains("javax.swing.GroupLayout layout = new javax.swing.GroupLayout(getContentPane());")); + assertTrue(init, init.contains("pack();")); + assertDeclarations(code, "private javax.swing.JButton jButton1;", + "private javax.swing.JLabel jLabel1;", + "private javax.swing.JTextField jTextField1;"); + assertTrue("the .form must still describe the components", + read(new File(getWorkDir(), "PlainFrame.form")).contains("name=\"jTextField1\"")); + } + + /** Regenerating a form whose code is already up to date changes nothing. */ + public void testUpToDateFormIsReportedUnchanged() throws Exception { + File java = copyFixture("PlainFrame"); + regenerate(java); + String regenerated = read(java); + + String result = regenerate(java); + + assertTrue(result, result.startsWith("The generated code in PlainFrame.java already matches PlainFrame.form")); + assertEquals(regenerated, read(java)); + } + + /** + * Hand edits inside the guarded sections are replaced by code generated from the + * .form; code outside the guarded sections is kept. + */ + public void testHandEditedGuardedSectionsAreReplaced() throws Exception { + File java = copyFixture("ModifiedPanel"); + + regenerate(java); + + String code = assertMatchesExpected("ModifiedPanel", java); + assertFalse(code, code.contains("Hand edited")); + assertFalse(code, code.contains("setEnabled(false)")); + assertFalse("a component not in the .form must be removed", code.contains("removedLabel")); + assertFalse("a changed variable type must be restored", code.contains("JToggleButton")); + assertTrue(code, guardedSection(code, "initComponents").contains("jButton1.setText(\"OK\");")); + assertDeclarations(code, "private javax.swing.JButton jButton1;", "private javax.swing.JLabel jLabel1;"); + assertTrue(code, code.contains("setToolTipText(\"Kept: code outside the guarded sections\");")); + assertTrue(code, code.contains("return \"Kept: a method outside the guarded sections\";")); + } + + /** + * Event handlers of the .form get listener registrations in initComponents() and + * guarded handler methods: missing ones are generated with the IDE's TODO body, + * an existing one keeps its body. + */ + public void testEventHandlerSectionsAreGenerated() throws Exception { + File java = copyFixture("EventsPanel"); + + String result = regenerate(java); + + assertTrue(result, result.contains( + "Added the missing event handler methods jLabel1MouseClicked, jTextField1ActionPerformed with a TODO body")); + String code = assertMatchesExpected("EventsPanel", java); + assertEquals("the new handlers get the default body", 2, count(code, "// TODO add your handling code here:")); + String init = guardedSection(code, "initComponents"); + assertTrue(init, init.contains("jLabel1.addMouseListener(")); + assertTrue(init, init.contains("jTextField1.addActionListener(")); + assertTrue(init, init.contains("jButton1.addActionListener(")); + assertTrue(init, init.contains("jButton2.addActionListener(")); + assertTrue(init, init.contains("jLabel1MouseClicked(evt);")); + assertTrue(init, init.contains("jTextField1ActionPerformed(evt);")); + assertEquals("jButton1 and jButton2 share one handler", 2, count(init, "jButton1ActionPerformed(evt);")); + + assertHandler(code, "jLabel1MouseClicked", "java.awt.event.MouseEvent"); + assertHandler(code, "jTextField1ActionPerformed", "java.awt.event.ActionEvent"); + assertHandler(code, "jButton1ActionPerformed", "java.awt.event.ActionEvent"); + assertTrue("the body of an existing handler must be kept", + code.contains("System.out.println(\"Kept: user code of an existing handler\");")); + assertEquals("a shared handler is generated once", 1, count(code, "private void jButton1ActionPerformed(")); + } + + /** Per-component variable modifiers and local variables. */ + public void testVariableModifiersAndLocalVariables() throws Exception { + File java = copyFixture("VariablesPanel"); + + regenerate(java); + + String code = assertMatchesExpected("VariablesPanel", java); + assertDeclarations(code, + "private javax.swing.JLabel defaultLabel;", + "public javax.swing.JButton publicButton;", + "protected javax.swing.JTextField protectedField;", + "javax.swing.JCheckBox packageCheckBox;", + "private static javax.swing.JLabel staticLabel;"); + String variables = guardedSection(code, "variables"); + assertFalse(variables, variables.contains("localLabel")); + assertFalse(variables, variables.contains("finalLocalLabel")); + String init = guardedSection(code, "initComponents"); + assertTrue(init, containsLine(init, "javax.swing.JLabel localLabel = new javax.swing.JLabel();")); + assertTrue(init, containsLine(init, "final javax.swing.JLabel finalLocalLabel = new javax.swing.JLabel();")); + assertTrue(init, containsLine(init, "publicButton = new javax.swing.JButton();")); + } + + /** Form-wide default of local variables, overridden by one component that is a public field. */ + public void testFormWideLocalVariablesDefault() throws Exception { + File java = copyFixture("LocalDefaultsPanel"); + + regenerate(java); + + String code = assertMatchesExpected("LocalDefaultsPanel", java); + String init = guardedSection(code, "initComponents"); + assertTrue(init, containsLine(init, "javax.swing.JLabel defaultLocalLabel = new javax.swing.JLabel();")); + assertTrue(init, containsLine(init, "publicFieldButton = new javax.swing.JButton();")); + assertDeclarations(code, "public javax.swing.JButton publicFieldButton;"); + assertFalse(code, guardedSection(code, "variables").contains("defaultLocalLabel")); + } + + private File copyFixture(String name) throws IOException { + File java = new File(getWorkDir(), name + ".java"); + Files.writeString(java.toPath(), resource(name + ".java.input"), StandardCharsets.UTF_8); + Files.writeString(new File(getWorkDir(), name + ".form").toPath(), resource(name + ".form"), StandardCharsets.UTF_8); + return java; + } + + private static String regenerate(File java) throws Exception { + RegenerateFormCode tool = new RegenerateFormCode() { + @Override + protected void checkAccess(File file) { + // fixtures are not in an open project + } + }; + return tool.run(new RegenerateFormCode.Params(java.getPath())); + } + + /** Compares the generated file with {@code .java.expected}, ignoring line endings. */ + private static String assertMatchesExpected(String name, File java) throws IOException { + String actual = read(java); + assertEquals("Generated code of " + name + " differs from " + FIXTURES + name + + ".java.expected; actual file: " + java, resource(name + ".java.expected"), actual); + return actual; + } + + private static void assertDeclarations(String code, String... declarations) { + String variables = guardedSection(code, "variables"); + for (String declaration : declarations) { + assertTrue("missing '" + declaration + "' in:\n" + variables, containsLine(variables, declaration)); + } + } + + private static void assertHandler(String code, String handler, String eventClass) { + String header = "private void " + handler + "(" + eventClass + " evt) {//GEN-FIRST:event_" + handler; + assertTrue("missing guarded header of " + handler + " in:\n" + code, code.contains(header)); + assertTrue("missing guarded footer of " + handler + " in:\n" + code, + code.contains("}//GEN-LAST:event_" + handler)); + } + + /** Returns the text between {@code //GEN-BEGIN:} and {@code //GEN-END:}. */ + private static String guardedSection(String code, String id) { + int begin = code.indexOf("//GEN-BEGIN:" + id); + int end = code.indexOf("//GEN-END:" + id); + assertTrue("guarded section " + id + " not found in:\n" + code, begin >= 0 && end > begin); + return code.substring(begin, end); + } + + private static boolean containsLine(String text, String line) { + return text.lines().anyMatch(l -> l.strip().equals(line)); + } + + private static int count(String text, String part) { + int count = 0; + for (int i = text.indexOf(part); i >= 0; i = text.indexOf(part, i + part.length())) { + count++; + } + return count; + } + + private static String read(File file) throws IOException { + return normalize(Files.readString(file.toPath(), StandardCharsets.UTF_8)); + } + + private static String resource(String name) throws IOException { + try (InputStream in = RegenerateFormCodeIT.class.getResourceAsStream(FIXTURES + name)) { + assertNotNull("missing test resource " + FIXTURES + name, in); + return normalize(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + private static String normalize(String text) { + return text.replace("\r\n", "\n"); + } +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.form b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.form new file mode 100644 index 00000000..1ae28424 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.form @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.java.expected b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.java.expected new file mode 100644 index 00000000..1293d61b --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.java.expected @@ -0,0 +1,66 @@ +public class EventsPanel extends javax.swing.JPanel { + + public EventsPanel() { + initComponents(); + } + + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + + jLabel1 = new javax.swing.JLabel(); + jTextField1 = new javax.swing.JTextField(); + jButton1 = new javax.swing.JButton(); + jButton2 = new javax.swing.JButton(); + + jLabel1.setText("Click me"); + jLabel1.addMouseListener(new java.awt.event.MouseAdapter() { + public void mouseClicked(java.awt.event.MouseEvent evt) { + jLabel1MouseClicked(evt); + } + }); + add(jLabel1); + + jTextField1.addActionListener(new java.awt.event.ActionListener() { + public void actionPerformed(java.awt.event.ActionEvent evt) { + jTextField1ActionPerformed(evt); + } + }); + add(jTextField1); + + jButton1.setText("OK"); + jButton1.addActionListener(new java.awt.event.ActionListener() { + public void actionPerformed(java.awt.event.ActionEvent evt) { + jButton1ActionPerformed(evt); + } + }); + add(jButton1); + + jButton2.setText("Also OK"); + jButton2.addActionListener(new java.awt.event.ActionListener() { + public void actionPerformed(java.awt.event.ActionEvent evt) { + jButton1ActionPerformed(evt); + } + }); + add(jButton2); + }// //GEN-END:initComponents + + private void jButton1ActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_jButton1ActionPerformed + System.out.println("Kept: user code of an existing handler"); + }//GEN-LAST:event_jButton1ActionPerformed + + private void jLabel1MouseClicked(java.awt.event.MouseEvent evt) {//GEN-FIRST:event_jLabel1MouseClicked + // TODO add your handling code here: + }//GEN-LAST:event_jLabel1MouseClicked + + private void jTextField1ActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_jTextField1ActionPerformed + // TODO add your handling code here: + }//GEN-LAST:event_jTextField1ActionPerformed + + // Variables declaration - do not modify//GEN-BEGIN:variables + private javax.swing.JButton jButton1; + private javax.swing.JButton jButton2; + private javax.swing.JLabel jLabel1; + private javax.swing.JTextField jTextField1; + // End of variables declaration//GEN-END:variables +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.java.input b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.java.input new file mode 100644 index 00000000..a4dc01bf --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/EventsPanel.java.input @@ -0,0 +1,18 @@ +public class EventsPanel extends javax.swing.JPanel { + + public EventsPanel() { + initComponents(); + } + + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + }// //GEN-END:initComponents + + private void jButton1ActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_jButton1ActionPerformed + System.out.println("Kept: user code of an existing handler"); + }//GEN-LAST:event_jButton1ActionPerformed + + // Variables declaration - do not modify//GEN-BEGIN:variables + // End of variables declaration//GEN-END:variables +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.form b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.form new file mode 100644 index 00000000..c5f21741 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.form @@ -0,0 +1,33 @@ + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.java.expected b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.java.expected new file mode 100644 index 00000000..b576a96a --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.java.expected @@ -0,0 +1,24 @@ +public class LocalDefaultsPanel extends javax.swing.JPanel { + + public LocalDefaultsPanel() { + initComponents(); + } + + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + + javax.swing.JLabel defaultLocalLabel = new javax.swing.JLabel(); + publicFieldButton = new javax.swing.JButton(); + + defaultLocalLabel.setText("form default: local variable"); + add(defaultLocalLabel); + + publicFieldButton.setText("public field"); + add(publicFieldButton); + }// //GEN-END:initComponents + + // Variables declaration - do not modify//GEN-BEGIN:variables + public javax.swing.JButton publicFieldButton; + // End of variables declaration//GEN-END:variables +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.java.input b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.java.input new file mode 100644 index 00000000..3167d8ed --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/LocalDefaultsPanel.java.input @@ -0,0 +1,14 @@ +public class LocalDefaultsPanel extends javax.swing.JPanel { + + public LocalDefaultsPanel() { + initComponents(); + } + + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + }// //GEN-END:initComponents + + // Variables declaration - do not modify//GEN-BEGIN:variables + // End of variables declaration//GEN-END:variables +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.form b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.form new file mode 100644 index 00000000..7736e902 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.form @@ -0,0 +1,29 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.java.expected b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.java.expected new file mode 100644 index 00000000..4a455a23 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.java.expected @@ -0,0 +1,30 @@ +public class ModifiedPanel extends javax.swing.JPanel { + + public ModifiedPanel() { + initComponents(); + setToolTipText("Kept: code outside the guarded sections"); + } + + public String greeting() { + return "Kept: a method outside the guarded sections"; + } + + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + + jLabel1 = new javax.swing.JLabel(); + jButton1 = new javax.swing.JButton(); + + jLabel1.setText("Name:"); + add(jLabel1); + + jButton1.setText("OK"); + add(jButton1); + }// //GEN-END:initComponents + + // Variables declaration - do not modify//GEN-BEGIN:variables + private javax.swing.JButton jButton1; + private javax.swing.JLabel jLabel1; + // End of variables declaration//GEN-END:variables +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.java.input b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.java.input new file mode 100644 index 00000000..b6922093 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/ModifiedPanel.java.input @@ -0,0 +1,28 @@ +public class ModifiedPanel extends javax.swing.JPanel { + + public ModifiedPanel() { + initComponents(); + setToolTipText("Kept: code outside the guarded sections"); + } + + public String greeting() { + return "Kept: a method outside the guarded sections"; + } + + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + jButton1 = new javax.swing.JToggleButton(); + removedLabel = new javax.swing.JLabel(); + + jButton1.setText("Hand edited"); + jButton1.setEnabled(false); + add(removedLabel); + add(jButton1); + }// //GEN-END:initComponents + + // Variables declaration - do not modify//GEN-BEGIN:variables + public javax.swing.JToggleButton jButton1; + private javax.swing.JLabel removedLabel; + // End of variables declaration//GEN-END:variables +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.form b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.form new file mode 100644 index 00000000..336ef8d3 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.form @@ -0,0 +1,66 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.java.expected b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.java.expected new file mode 100644 index 00000000..84cfbc9e --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.java.expected @@ -0,0 +1,54 @@ +public class PlainFrame extends javax.swing.JFrame { + + public PlainFrame() { + initComponents(); + } + + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + + jLabel1 = new javax.swing.JLabel(); + jTextField1 = new javax.swing.JTextField(); + jButton1 = new javax.swing.JButton(); + + setDefaultCloseOperation(javax.swing.WindowConstants.EXIT_ON_CLOSE); + setTitle("Plain Frame"); + + jLabel1.setText("Name:"); + + jButton1.setText("OK"); + + javax.swing.GroupLayout layout = new javax.swing.GroupLayout(getContentPane()); + getContentPane().setLayout(layout); + layout.setHorizontalGroup( + layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addComponent(jLabel1) + .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED) + .addComponent(jTextField1, javax.swing.GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE) + .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED) + .addComponent(jButton1) + .addContainerGap()) + ); + layout.setVerticalGroup( + layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.BASELINE) + .addComponent(jLabel1) + .addComponent(jTextField1, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE) + .addComponent(jButton1)) + .addContainerGap(javax.swing.GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)) + ); + + pack(); + }// //GEN-END:initComponents + + // Variables declaration - do not modify//GEN-BEGIN:variables + private javax.swing.JButton jButton1; + private javax.swing.JLabel jLabel1; + private javax.swing.JTextField jTextField1; + // End of variables declaration//GEN-END:variables +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.java.input b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.java.input new file mode 100644 index 00000000..474a4d29 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/PlainFrame.java.input @@ -0,0 +1,14 @@ +public class PlainFrame extends javax.swing.JFrame { + + public PlainFrame() { + initComponents(); + } + + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + }// //GEN-END:initComponents + + // Variables declaration - do not modify//GEN-BEGIN:variables + // End of variables declaration//GEN-END:variables +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.form b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.form new file mode 100644 index 00000000..69a78ea2 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.form @@ -0,0 +1,74 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.java.expected b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.java.expected new file mode 100644 index 00000000..734ba5c3 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.java.expected @@ -0,0 +1,48 @@ +public class VariablesPanel extends javax.swing.JPanel { + + public VariablesPanel() { + initComponents(); + } + + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + + defaultLabel = new javax.swing.JLabel(); + publicButton = new javax.swing.JButton(); + protectedField = new javax.swing.JTextField(); + packageCheckBox = new javax.swing.JCheckBox(); + staticLabel = new javax.swing.JLabel(); + javax.swing.JLabel localLabel = new javax.swing.JLabel(); + final javax.swing.JLabel finalLocalLabel = new javax.swing.JLabel(); + + defaultLabel.setText("form default: private field"); + add(defaultLabel); + + publicButton.setText("public field"); + add(publicButton); + + protectedField.setText("protected field"); + add(protectedField); + + packageCheckBox.setText("package-private field"); + add(packageCheckBox); + + staticLabel.setText("private static field"); + add(staticLabel); + + localLabel.setText("local variable"); + add(localLabel); + + finalLocalLabel.setText("final local variable"); + add(finalLocalLabel); + }// //GEN-END:initComponents + + // Variables declaration - do not modify//GEN-BEGIN:variables + private javax.swing.JLabel defaultLabel; + javax.swing.JCheckBox packageCheckBox; + protected javax.swing.JTextField protectedField; + public javax.swing.JButton publicButton; + private static javax.swing.JLabel staticLabel; + // End of variables declaration//GEN-END:variables +} diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.java.input b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.java.input new file mode 100644 index 00000000..be439a61 --- /dev/null +++ b/src/test/resources/io/github/nbplugins/claudecodegui/mcp/tools/forms/VariablesPanel.java.input @@ -0,0 +1,14 @@ +public class VariablesPanel extends javax.swing.JPanel { + + public VariablesPanel() { + initComponents(); + } + + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + }// //GEN-END:initComponents + + // Variables declaration - do not modify//GEN-BEGIN:variables + // End of variables declaration//GEN-END:variables +} From 0009280f21da7d1da5e8908be532d59436c5fd83 Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 15:31:08 +0800 Subject: [PATCH 11/13] Added back `NbBundle.Messages` annotation. This was needed duh Signed-off-by: Muhammad Rizky Rizaldi --- .../nbplugins/claudecodegui/actions/OpenWithClaudeAction.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/actions/OpenWithClaudeAction.java b/src/main/java/io/github/nbplugins/claudecodegui/actions/OpenWithClaudeAction.java index 92f25e23..a69115aa 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/actions/OpenWithClaudeAction.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/actions/OpenWithClaudeAction.java @@ -16,6 +16,7 @@ import org.openide.util.ContextAwareAction; import org.openide.util.ImageUtilities; import org.openide.util.Lookup; +import org.openide.util.NbBundle.Messages; import org.openide.util.actions.Presenter; /** @@ -36,6 +37,7 @@ @ActionReferences({ @ActionReference(path = "Projects/Actions", position = 100) }) +@Messages("CTL_OpenWithClaudeAction=Open with Claude Code") public final class OpenWithClaudeAction extends AbstractAction implements ContextAwareAction { From 765535310d69047b2ea658a5c6a63c216f229ef8 Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Mon, 26 Oct 2026 15:47:46 +0800 Subject: [PATCH 12/13] Ensure `formEditorSupport` exists on supported netbeans version Signed-off-by: Muhammad Rizky Rizaldi --- .../mcp/tools/RegenerateFormCode.java | 57 +++++++++++++------ .../tools/RegenerateFormCodeEditorTest.java | 25 +++++--- 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java index 149d4263..1250b8d1 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java @@ -69,6 +69,9 @@ public class RegenerateFormCode implements Tool existingHandlers = existingEventHandlers(javaText); - Outcome outcome = onEdt(() -> regenerateInFormEditor(editorSupport, javaName, formName, existingHandlers)); + Outcome outcome = onEdt(() -> regenerateInFormEditor(api, editorSupport, javaName, formName, existingHandlers)); String designerNote = outcome.designerOpen() ? " The open GUI Builder was reloaded from the edited .form." : ""; if (!outcome.codeChanged()) { @@ -284,10 +289,10 @@ protected String regenerate(FormFiles files) throws Exception { * file ({@code FormEditor.saveFormData()}) and may report saving errors in dialogs. * Validation and waiting for the editor are done by the caller, off the EDT. * - *

All form module methods are resolved before the first one is called (see - * {@link FormEditorApi}). A form loaded only for the regeneration (designer closed) - * is always closed again, also when the regeneration fails. + *

A form loaded only for the regeneration (designer closed) is always closed again, + * also when the regeneration fails. * + * @param api the form module methods, resolved by the caller before the form is touched * @param editorSupport the form module's {@code FormEditorSupport} * @param javaName name of the {@code .java} file, for messages * @param formName name of the {@code .form} file, for messages @@ -295,9 +300,8 @@ protected String regenerate(FormFiles files) throws Exception { * @return what happened * @throws Exception if the form cannot be loaded or the code cannot be regenerated */ - static Outcome regenerateInFormEditor(Object editorSupport, String javaName, String formName, + static Outcome regenerateInFormEditor(FormEditorApi api, Object editorSupport, String javaName, String formName, Set existingHandlers) throws Exception { - FormEditorApi api = FormEditorApi.resolve(editorSupport.getClass()); boolean open = (Boolean) invoke(api.isOpened(), editorSupport); Object formEditor; if (open) { @@ -373,18 +377,20 @@ private static IllegalStateException formLoadError(String formName) { } /** - * The form module methods this tool invokes. They are all resolved, with their - * return types checked, before any of them is called, so that an incompatible - * NetBeans version fails with "Unsupported NetBeans form editor version" before - * the form is touched. The {@code FormEditor}, {@code FormModel}, {@code FormEvents} - * and {@code Event} classes are taken from the declared return types of + * The form module methods this tool invokes on the form editor support. They are + * all resolved, with their return types checked, right after the editor support is + * obtained and before any of them is called, so that an incompatible NetBeans + * version fails with "Unsupported NetBeans form editor version" before the form is + * touched. The {@code FormEditor}, {@code FormModel}, {@code FormEvents} and + * {@code Event} classes are taken from the declared return types of * {@code getFormEditor()}, {@code getFormModel()}, {@code getFormEvents()} and * {@code getEventsForHandler()}. * - *

Verified against NetBeans 31 ({@code org.netbeans.modules.nbform.FormEditorSupport}, - * {@code org.netbeans.modules.form.FormEditor}, {@code org.netbeans.modules.form.FormModel}, - * {@code org.netbeans.modules.form.FormEvents}); exercised against the real form editor - * of the compile-time NetBeans version by {@code RegenerateFormCodeIT}. + *

Verified against NetBeans 23 and 31 ({@code org.netbeans.modules.form.FormDataObject}, + * {@code org.netbeans.modules.nbform.FormEditorSupport}, {@code org.netbeans.modules.form.FormEditor}, + * {@code FormModel}, {@code FormEvents}, {@code Event}): the method signatures and the + * form model's event queuing are the same in both. {@code RegenerateFormCodeIT} runs + * the tool against the real form editor of NetBeans 23, the compile-time version. */ record FormEditorApi(Method isOpened, Method loadForm, Method reloadFormEditor, Method getFormEditor, Method getDocument, Method saveDocument, @@ -458,17 +464,36 @@ private static IllegalStateException unsupported(Class owner, String methodNa + owner.getName() + "." + methodName + "() " + problem, cause); } - /** Returns the form module's {@code EditorSupport}, or {@code null} for a non-form data object. */ + /** + * Returns the form module's {@code EditorSupport}, or {@code null} for a non-form data + * object. {@code FormDataObject.getFormEditorSupport()} only creates the support + * object; it does not load the form. + * + * @throws IllegalStateException if the data object is the form module's + * {@code FormDataObject} but has no {@code getFormEditorSupport()} + */ private static Object formEditorSupport(DataObject dataObject) throws Exception { Method getFormEditorSupport; try { getFormEditorSupport = dataObject.getClass().getMethod("getFormEditorSupport"); } catch (NoSuchMethodException e) { + if (isFormDataObject(dataObject.getClass())) { + throw unsupported(dataObject.getClass(), "getFormEditorSupport", "not found", e); + } return null; } return invoke(getFormEditorSupport, dataObject); } + private static boolean isFormDataObject(Class dataObjectClass) { + for (Class c = dataObjectClass; c != null; c = c.getSuperclass()) { + if (c.getName().equals(FORM_DATA_OBJECT)) { + return true; + } + } + return false; + } + /** * An unmodified open editor reloads on its own after an external change, but * asynchronously. Waits until it shows the content on disk so the regenerated diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java index 66c42181..6f7d14df 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java @@ -154,11 +154,18 @@ public void saveDocument() throws BadLocationException { } } + /** Resolves the form editor API from the support's runtime class first, as the tool does. */ + private static RegenerateFormCode.Outcome regenerate(Object support, String javaName, String formName, + Set existingHandlers) throws Exception { + return RegenerateFormCode.regenerateInFormEditor(RegenerateFormCode.FormEditorApi.resolve(support.getClass()), + support, javaName, formName, existingHandlers); + } + @Test public void designerClosedRegeneratesAndSavesJava() throws Exception { FakeEditorSupport support = new FakeEditorSupport(false); - RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of()); + regenerate(support, "MyFrame.java", "MyFrame.form", Set.of()); assertEquals(NEW_CODE, support.savedJava, "a freshly loaded form is considered up to date - the form must be marked changed " @@ -171,7 +178,7 @@ public void designerClosedRegeneratesAndSavesJava() throws Exception { public void designerOpenReloadsRegeneratesAndSavesJava() throws Exception { FakeEditorSupport support = new FakeEditorSupport(true); - RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of()); + regenerate(support, "MyFrame.java", "MyFrame.form", Set.of()); assertTrue(support.reloaded, "an open designer must be reloaded from the edited .form"); assertEquals(NEW_CODE, support.savedJava); @@ -184,7 +191,7 @@ public void generatesOnlyEventHandlersMissingInJava() throws Exception { FakeEditorSupport support = new FakeEditorSupport(false); support.formEditor.model.events.handlers.addAll(List.of("otherNewHandler", "existingHandler", "newHandler")); - RegenerateFormCode.Outcome outcome = RegenerateFormCode.regenerateInFormEditor( + RegenerateFormCode.Outcome outcome = regenerate( support, "MyFrame.java", "MyFrame.form", Set.of("existingHandler")); assertEquals(List.of("newHandler", "otherNewHandler"), outcome.addedHandlers(), "added sorted by name"); @@ -198,7 +205,7 @@ public void addsNoEventHandlersWhenAllExist() throws Exception { FakeEditorSupport support = new FakeEditorSupport(true); support.formEditor.model.events.handlers.add("existingHandler"); - RegenerateFormCode.Outcome outcome = RegenerateFormCode.regenerateInFormEditor( + RegenerateFormCode.Outcome outcome = regenerate( support, "MyFrame.java", "MyFrame.form", Set.of("existingHandler")); assertEquals(List.of(), outcome.addedHandlers()); @@ -211,7 +218,7 @@ public void reportsUnchangedCodeInsteadOfSuccess() throws Exception { support.formEditor.generatedCode = OLD_CODE; RegenerateFormCode.Outcome outcome = - RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of()); + regenerate(support, "MyFrame.java", "MyFrame.form", Set.of()); assertFalse(outcome.codeChanged()); assertEquals(OLD_CODE, outcome.generatedText()); @@ -223,7 +230,7 @@ public void closesFormLoadedForRegenerationWhenRegenerationFails() throws Except support.formEditor.failRegeneration = true; IllegalStateException e = assertThrows(IllegalStateException.class, - () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of())); + () -> regenerate(support, "MyFrame.java", "MyFrame.form", Set.of())); assertEquals("code generation failed", e.getMessage()); assertTrue(support.formEditor.closed, "a form loaded only for regeneration must be closed even on failure"); @@ -235,7 +242,7 @@ public void keepsOpenDesignerOpenWhenRegenerationFails() throws Exception { support.formEditor.failRegeneration = true; assertThrows(IllegalStateException.class, - () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of())); + () -> regenerate(support, "MyFrame.java", "MyFrame.form", Set.of())); assertFalse(support.formEditor.closed, "the user's open designer must not be closed"); } @@ -261,7 +268,7 @@ public void incompatibleVersionFailsBeforeTouchingTheForm() throws Exception { IncompatibleEditorSupport support = new IncompatibleEditorSupport(); IllegalStateException e = assertThrows(IllegalStateException.class, - () -> RegenerateFormCode.regenerateInFormEditor(support, "MyFrame.java", "MyFrame.form", Set.of())); + () -> regenerate(support, "MyFrame.java", "MyFrame.form", Set.of())); assertTrue(e.getMessage().startsWith("Unsupported NetBeans form editor version"), e.getMessage()); assertTrue(e.getMessage().contains("saveDocument"), e.getMessage()); @@ -277,7 +284,7 @@ static class NonPublicEditorSupport { @Test public void nonPublicClassIsReportedAsUnsupportedVersion() { IllegalStateException e = assertThrows(IllegalStateException.class, - () -> RegenerateFormCode.regenerateInFormEditor(new NonPublicEditorSupport(), "MyFrame.java", "MyFrame.form", Set.of())); + () -> regenerate(new NonPublicEditorSupport(), "MyFrame.java", "MyFrame.form", Set.of())); assertTrue(e.getMessage().startsWith("Unsupported NetBeans form editor version"), e.getMessage()); assertTrue(e.getMessage().contains("is not accessible"), e.getMessage()); From f34cd537e83a8c98bd397ff11adfd0c4be665357 Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Wed, 11 Nov 2026 10:32:09 +0800 Subject: [PATCH 13/13] wip Signed-off-by: Muhammad Rizky Rizaldi --- CHANGELOG.md | 1 + pom.xml | 6 + .../claudecodegui/mcp/NetBeansMCPHandler.java | 7 +- .../mcp/tools/RegenerateFormCode.java | 261 ++++++++++++++++-- .../mcp/NetBeansMCPHandlerHookTest.java | 61 ++++ .../tools/RegenerateFormCodeEditorTest.java | 22 +- .../mcp/tools/RegenerateFormCodeIT.java | 80 ++++++ .../mcp/tools/RegenerateFormCodeTest.java | 42 +++ 8 files changed, 448 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a586fac..e332ec5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed file changes opening a diff tab for review while Claude Code is in auto, accept-edits or bypass-permissions mode, when the plugin did not recognize the mode shown in Claude's status line - Added the `regenerate_form_code` MCP tool: after Claude edits a Swing form (`.form`), it can regenerate the form's code in the IDE, including new event handler methods, instead of editing the generated code by hand # 1.3.42 (2026-10-22) - Fixed OpenAI-compatible models with strict chat templates (e.g. vLLM behind LiteLLM) failing every request with "System message must be at the beginning" diff --git a/pom.xml b/pom.xml index cdb01bc2..074f5f73 100644 --- a/pom.xml +++ b/pom.xml @@ -556,6 +556,12 @@ org-netbeans-modules-editor-lib ${netbeans.version} + + + org.netbeans.api + org-netbeans-modules-editor-guards + ${netbeans.version} + org.eclipse.jetty diff --git a/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java index 72daa3a3..e0e6b650 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java @@ -539,12 +539,15 @@ public CompletableFuture handlePreToolUse(String hookJson) { // cwd from hook JSON == Claude's current working directory (may be a subdirectory of session root) String cwd = hook.has("cwd") ? hook.get("cwd").asText() : null; - EditMode editMode = getEditModeForCwd(cwd); + // Claude Code reports its permission mode in every hook call. The mode read from the + // terminal screen is only the fallback for a missing or unknown value (e.g. "dontAsk"). + String permissionMode = hook.has("permission_mode") ? hook.get("permission_mode").asText() : null; + EditMode editMode = EditMode.fromKey(permissionMode).orElseGet(() -> getEditModeForCwd(cwd)); // Session root is the key that matched in the registry (may differ from hook cwd when Claude cd'd into a subdir) String sessionRoot = getSessionRootForCwd(cwd); String filePath = getFilePath(toolInput); - LOGGER.fine("handlePreToolUse: editMode=" + editMode + " file=" + filePath + LOGGER.fine("handlePreToolUse: editMode=" + editMode + " permission_mode=" + permissionMode + " file=" + filePath + " cwd=" + cwd + " sessionRoot=" + sessionRoot); // AUTO or higher (BYPASS_PERMISSIONS): auto-allow regardless of location diff --git a/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java index 1250b8d1..e623ced8 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java @@ -19,12 +19,16 @@ import java.util.regex.Pattern; import javax.swing.text.BadLocationException; import javax.swing.text.Document; +import javax.swing.text.StyledDocument; +import org.netbeans.api.editor.guards.GuardedSectionManager; +import org.netbeans.api.editor.guards.InteriorSection; import org.openbeans.claude.netbeans.NbUtils; import org.openbeans.claude.netbeans.tools.Tool; import org.openide.cookies.EditorCookie; import org.openide.filesystems.FileObject; import org.openide.filesystems.FileUtil; import org.openide.loaders.DataObject; +import org.openide.text.NbDocument; /** * MCP tool that regenerates the GUI Builder guarded code ({@code initComponents()} @@ -72,6 +76,9 @@ public class RegenerateFormCode implements Tool addedHandlers) {} + /** An error detected by this tool, with a message meant for Claude. */ + static final class FormToolException extends IllegalStateException { + FormToolException(String message) { super(message); } + + FormToolException(String message, Throwable cause) { super(message, cause); } + } + /** Creates a new instance of this tool. */ public RegenerateFormCode() {} @@ -240,26 +254,32 @@ protected String regenerate(FormFiles files) throws Exception { String javaText = javaFo.asText(); if (!javaText.contains("//GEN-BEGIN:initComponents")) { - throw new IllegalStateException(javaName + " has no generated initComponents() guarded block " + throw new FormToolException(javaName + " has no generated initComponents() guarded block " + "(//GEN-BEGIN:initComponents ... //GEN-END:initComponents); the form editor cannot generate code into it"); } DataObject dataObject = DataObject.find(javaFo); Object editorSupport = formEditorSupport(dataObject); if (editorSupport == null) { - throw new IllegalStateException(javaName + " is not recognized by NetBeans as a GUI Builder form " + throw new FormToolException(javaName + " is not recognized by NetBeans as a GUI Builder form " + "(is the Swing GUI Builder / form module enabled?)"); } // An unsupported NetBeans version fails here, before the form is touched. FormEditorApi api = FormEditorApi.resolve(editorSupport.getClass()); if (dataObject.isModified()) { - throw new IllegalStateException(javaName + " has unsaved changes in the IDE. " + throw new FormToolException(javaName + " has unsaved changes in the IDE. " + "Save them (saveDocument) or ask the user to revert them, then call regenerate_form_code again."); } waitForDocumentInSync(dataObject.getLookup().lookup(EditorCookie.class), javaFo, javaName); Set existingHandlers = existingEventHandlers(javaText); - Outcome outcome = onEdt(() -> regenerateInFormEditor(api, editorSupport, javaName, formName, existingHandlers)); + Outcome outcome; + try { + outcome = onEdt(() -> regenerateInFormEditor(api, editorSupport, javaName, formName, existingHandlers)); + } catch (Exception | LinkageError e) { + // The .java was unmodified before, so a modified one now holds partial changes. + throw regenerationFailed(javaName, formName, e, dataObject.isModified()); + } String designerNote = outcome.designerOpen() ? " The open GUI Builder was reloaded from the edited .form." : ""; if (!outcome.codeChanged()) { @@ -267,7 +287,7 @@ protected String regenerate(FormFiles files) throws Exception { + "; nothing was regenerated." + designerNote; } if (!documentMatchesDisk(outcome.generatedText(), javaFo.asText())) { - throw new IllegalStateException("The code was regenerated in the IDE but " + javaName + throw new FormToolException("The code was regenerated in the IDE but " + javaName + " on disk was not updated. Ask the user to save it in the IDE."); } String handlersNote = outcome.addedHandlers().isEmpty() ? "" @@ -311,34 +331,147 @@ static Outcome regenerateInFormEditor(FormEditorApi api, Object editorSupport, S formEditor = invoke(api.getFormEditor(), editorSupport); } if (formEditor == null) { - throw formLoadError(formName); + throw formLoadError(formName, null); } + boolean closed = false; try { - if (!(Boolean) invoke(api.isFormLoaded(), formEditor) - || (Boolean) invoke(api.anyPersistenceError(), formEditor)) { - throw formLoadError(formName); + if (!(Boolean) invoke(api.isFormLoaded(), formEditor)) { + // Not reportLoadingErrors(): for a form that failed to load it shows modal dialogs. + throw formLoadError(formName, null); + } + if ((Boolean) invoke(api.anyPersistenceError(), formEditor)) { + String details = api.reportLoadingErrors() == null ? null + : (String) invoke(api.reportLoadingErrors(), formEditor); + throw formLoadError(formName, details); } Object formModel = invoke(api.getFormModel(), formEditor); if (formModel == null || (Boolean) invoke(api.isReadOnly(), formModel)) { - throw new IllegalStateException(formName + " was loaded read-only (missing or damaged guarded " + throw new FormToolException(formName + " was loaded read-only (missing or damaged guarded " + "sections in " + javaName + ", or a read-only file); code was not regenerated."); } Document document = (Document) invoke(api.getDocument(), editorSupport); String before = documentText(document); - List addedHandlers = addMissingEventHandlers(api, formModel, existingHandlers); - // Also delivers the queued handler events, in the same batch. - invoke(api.fireFormChanged(), formModel, true); - invoke(api.regenerateCodeIfNeeded(), formEditor); + List addedHandlers = new ArrayList<>(); + try { + // One atomic edit: if the form editor fails halfway, its changes to the document are undone. + atomically(document, () -> { + try { + addedHandlers.addAll(addMissingEventHandlers(api, formModel, existingHandlers)); + // Also delivers the queued handler events, in the same batch. + invoke(api.fireFormChanged(), formModel, true); + invoke(api.regenerateCodeIfNeeded(), formEditor); + } catch (Throwable t) { + removeEventHandlerSections(document, addedHandlers); + throw t; + } + return null; + }); + } catch (Exception | Error e) { + if (!open) { + // Closing the form delivers form events the failed regeneration left queued, + // which mark the .java modified again - so close it before clearing the flag. + closed = true; + invoke(api.closeForm(), formEditor); + clearModifiedAfterRollback(api, editorSupport, document, before, e); + } + throw e; + } String after = documentText(document); invoke(api.saveDocument(), editorSupport); return new Outcome(open, !before.equals(after), after, addedHandlers); } finally { - if (!open) { + if (!open && !closed) { invoke(api.closeForm(), formEditor); } } } + /** + * After a rolled back regeneration the document text is unchanged, but the editor + * support still marks the {@code .java} modified: it clears the flag only when an undo + * reaches the saved state, not after an atomic edit that was undone. A modified + * {@code .java} would make the next call fail with "unsaved changes", so the unchanged + * text is saved, which writes back the content the file already has. Only called for a + * form this regeneration loaded and closed again (designer not open), so nothing else + * touched the form, and only when the document still has the exact text from before. A + * failure to save is attached to {@code failure} as suppressed. + */ + private static void clearModifiedAfterRollback(FormEditorApi api, Object editorSupport, Document document, + String before, Throwable failure) { + try { + if (documentText(document).equals(before)) { + invoke(api.saveSourceOnly(), editorSupport); + } + } catch (Exception e) { + failure.addSuppressed(e); + } + } + + /** + * Runs the task as one atomic edit of the document ({@link NbDocument#runAtomic}). The + * editor's document undoes all changes of an atomic edit that ends with a + * {@link RuntimeException} - but not with an {@link Error}, e.g. a {@link LinkageError} + * from loading a class of the project. So anything the task throws is passed through + * as a {@code RuntimeException} and rethrown unwrapped afterwards. A document without + * atomic edits is changed directly. + * + * @param document the document the task changes + * @param task the changes + * @return the task's result + * @throws Exception what the task threw ({@link Error}s are rethrown as such) + */ + static T atomically(Document document, Callable task) throws Exception { + if (!(document instanceof StyledDocument styledDocument)) { + return task.call(); + } + AtomicReference result = new AtomicReference<>(); + try { + NbDocument.runAtomic(styledDocument, () -> { + try { + result.set(task.call()); + } catch (Throwable t) { + throw new RollbackSignal(t); + } + }); + } catch (RollbackSignal signal) { + Throwable t = signal.getCause(); + if (t instanceof Exception e) { + throw e; + } + throw (Error) t; + } + return result.get(); + } + + /** Carries what the task of {@link #atomically} threw out of the atomic edit, so that it is rolled back. */ + private static final class RollbackSignal extends RuntimeException { + RollbackSignal(Throwable cause) { + super(null, cause, false, false); + } + } + + /** + * Unregisters the guarded sections of the given event handlers, if the form editor + * created them. Rolling back an atomic edit undoes the text of a section created in + * it, but the section stays registered in the {@link GuardedSectionManager}; when the + * document is saved later, its guard markers would be written at the collapsed position. + */ + private static void removeEventHandlerSections(Document document, List handlers) { + if (handlers.isEmpty() || !(document instanceof StyledDocument styledDocument)) { + return; + } + GuardedSectionManager guards = GuardedSectionManager.getInstance(styledDocument); + if (guards == null) { + return; + } + for (String handler : handlers) { + InteriorSection section = guards.findInteriorSection(EVENT_SECTION_PREFIX + handler); + if (section != null) { + section.removeSection(); + } + } + } + /** * Fires {@code FormModel.fireEventHandlerAdded(event, handler, null, null, true)} for * each event handler of the form that the {@code .java} does not have yet - what the @@ -371,9 +504,55 @@ private static List addMissingEventHandlers(FormEditorApi api, Object fo return added; } - private static IllegalStateException formLoadError(String formName) { - return new IllegalStateException("NetBeans could not load " + formName - + " without errors. Check the .form XML, or open the form in the GUI Builder (Design view) to see the errors."); + /** + * Turns a failed regeneration into an error whose message tells Claude what went + * wrong. An error of this tool keeps its message; any other one, e.g. thrown by the + * form module, is described by its type and message, since its message alone may be + * empty. A {@link LinkageError} means a class used by the form (e.g. a custom + * component of the project) could not be loaded from the project's compiled classes. + * + * @param javaName name of the {@code .java} file + * @param formName name of the {@code .form} file + * @param cause what was thrown + * @param leftChanges whether the failed regeneration left unsaved changes in the {@code .java} document + * @return the error to throw + */ + static FormToolException regenerationFailed(String javaName, String formName, Throwable cause, + boolean leftChanges) { + String message; + if (cause instanceof FormToolException) { + message = cause.getMessage(); + } else { + message = "The NetBeans form editor failed to regenerate the code of " + javaName + " from " + + formName + ": " + describe(cause) + "."; + if (cause instanceof LinkageError) { + message += " A class used by the form could not be loaded from the project's compiled classes;" + + " they are probably missing or out of date. Ask the user to clean and build the project," + + " then call regenerate_form_code again."; + } else { + message += " The stack trace is in the IDE log (messages.log)."; + } + } + if (leftChanges) { + message += " The failed regeneration left unsaved, partial changes in " + javaName + + " in the IDE; the file on disk was not changed by it. Ask the user to discard them" + + " (close " + javaName + " without saving) before calling regenerate_form_code again."; + } + return new FormToolException(message, cause); + } + + private static String describe(Throwable t) { + String message = t.getMessage(); + return message == null || message.isBlank() ? t.getClass().getName() : t.getClass().getName() + ": " + message; + } + + private static IllegalStateException formLoadError(String formName, String details) { + String message = "NetBeans could not load " + formName + " without errors; code was not regenerated."; + if (details != null && !details.isBlank()) { + message += " The form editor reported:\n" + details.strip() + "\n"; + } + return new FormToolException(message + + " Check the .form XML, or open the form in the GUI Builder (Design view) to see the errors."); } /** @@ -391,14 +570,18 @@ private static IllegalStateException formLoadError(String formName) { * {@code FormModel}, {@code FormEvents}, {@code Event}): the method signatures and the * form model's event queuing are the same in both. {@code RegenerateFormCodeIT} runs * the tool against the real form editor of NetBeans 23, the compile-time version. + * + *

{@code FormEditor.reportLoadingErrors()} is only used to describe load errors; + * the form module keeps it "temporarily for compatibility", so it is optional + * ({@code null} when missing) and its absence only drops those details. */ record FormEditorApi(Method isOpened, Method loadForm, Method reloadFormEditor, Method getFormEditor, - Method getDocument, Method saveDocument, + Method getDocument, Method saveDocument, Method saveSourceOnly, Method isFormLoaded, Method anyPersistenceError, Method getFormModel, Method regenerateCodeIfNeeded, Method closeForm, Method isReadOnly, Method fireFormChanged, Method getFormEvents, Method getAllEventHandlers, Method getEventsForHandler, - Method fireEventHandlerAdded) { + Method fireEventHandlerAdded, Method reportLoadingErrors) { /** * Resolves the form module methods starting from the runtime class of the form editor support. @@ -423,6 +606,7 @@ static FormEditorApi resolve(Class supportClass) { getFormEditor, method(supportClass, "getDocument", Document.class), method(supportClass, "saveDocument", null), + method(supportClass, "saveSourceOnly", null), method(editorClass, "isFormLoaded", boolean.class), method(editorClass, "anyPersistenceError", boolean.class), getFormModel, @@ -434,7 +618,17 @@ static FormEditorApi resolve(Class supportClass) { method(eventsClass, "getAllEventHandlers", String[].class), getEventsForHandler, method(modelClass, "fireEventHandlerAdded", null, - eventClass, String.class, String.class, String.class, boolean.class)); + eventClass, String.class, String.class, String.class, boolean.class), + optionalMethod(editorClass, "reportLoadingErrors", String.class)); + } + + /** Like {@link #method}, but returns {@code null} instead of failing. */ + private static Method optionalMethod(Class owner, String name, Class returnType, Class... paramTypes) { + try { + return method(owner, name, returnType, paramTypes); + } catch (IllegalStateException e) { + return null; + } } /** @@ -460,7 +654,7 @@ private static Method method(Class owner, String name, Class returnType, C } private static IllegalStateException unsupported(Class owner, String methodName, String problem, Exception cause) { - return new IllegalStateException("Unsupported NetBeans form editor version: " + return new FormToolException("Unsupported NetBeans form editor version: " + owner.getName() + "." + methodName + "() " + problem, cause); } @@ -514,7 +708,7 @@ private static void waitForDocumentInSync(EditorCookie editorCookie, FileObject long deadline = System.currentTimeMillis() + DOCUMENT_SYNC_TIMEOUT_MS; while (!documentMatchesDisk(documentText(document), diskText)) { if (System.currentTimeMillis() > deadline) { - throw new IllegalStateException("The open editor of " + javaName + " still differs from the file on disk. " + throw new FormToolException("The open editor of " + javaName + " still differs from the file on disk. " + "Ask the user to reload or close it in the IDE, then call regenerate_form_code again."); } Thread.sleep(100); @@ -557,21 +751,30 @@ private static Object invoke(Method method, Object target, Object... args) throw } } - private static T onEdt(Callable task) throws Exception { + /** + * Runs the task on the EDT and rethrows whatever it threw, {@link Error}s included - + * otherwise {@code invokeAndWait} wraps them in an {@code InvocationTargetException} + * without a message. + */ + static T onEdt(Callable task) throws Exception { if (EventQueue.isDispatchThread()) { return task.call(); } AtomicReference result = new AtomicReference<>(); - AtomicReference error = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); EventQueue.invokeAndWait(() -> { try { result.set(task.call()); - } catch (Exception e) { - error.set(e); + } catch (Throwable t) { + error.set(t); } }); - if (error.get() != null) { - throw error.get(); + Throwable t = error.get(); + if (t instanceof Exception e) { + throw e; + } + if (t instanceof Error err) { + throw err; } return result.get(); } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandlerHookTest.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandlerHookTest.java index 51267bfe..5df23601 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandlerHookTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandlerHookTest.java @@ -190,6 +190,60 @@ void nonFileEditToolAutoAllowed() throws Exception { assertTrue(future.get(1, TimeUnit.SECONDS).contains("\"allow\"")); } + // ------------------------------------------------------------------------- + // permission_mode of the hook call takes precedence over the screen-detected mode + // ------------------------------------------------------------------------- + + @Test + void hookPermissionModeAutoAutoAllowsWhenScreenDetectionMissedIt() throws Exception { + // No registry entry: the mode was not recognized on the terminal screen + CompletableFuture future = handler.handlePreToolUse(editJsonWithMode(FILE_OUTSIDE, "auto")); + + assertTrue(future.isDone(), "auto mode reported by the hook must auto-allow immediately"); + assertTrue(future.get(1, TimeUnit.SECONDS).contains("\"allow\"")); + } + + @Test + void hookPermissionModeBypassAutoAllows() throws Exception { + ClaudeSessionModel.EDIT_MODE_REGISTRY.put(CWD, EditMode.DEFAULT); + + CompletableFuture future = handler.handlePreToolUse(editJsonWithMode(FILE_OUTSIDE, "bypassPermissions")); + + assertTrue(future.isDone()); + assertTrue(future.get(1, TimeUnit.SECONDS).contains("\"allow\"")); + } + + @Test + void hookPermissionModeAcceptEditsAutoAllowsOnlyInsideProject() throws Exception { + CompletableFuture inside = handler.handlePreToolUse(editJsonWithMode(FILE_INSIDE, "acceptEdits")); + assertTrue(inside.isDone()); + assertTrue(inside.get(1, TimeUnit.SECONDS).contains("\"allow\"")); + + CompletableFuture outside = handler.handlePreToolUse(editJsonWithMode(FILE_OUTSIDE, "acceptEdits")); + assertFalse(outside.isDone() && outside.get(1, TimeUnit.SECONDS).contains("\"allow\""), + "acceptEdits must not auto-allow a file outside the project"); + } + + @Test + void hookPermissionModeDefaultOverridesStaleScreenDetection() throws Exception { + ClaudeSessionModel.EDIT_MODE_REGISTRY.put(CWD, EditMode.AUTO); + + CompletableFuture future = handler.handlePreToolUse(editJsonWithMode(FILE_INSIDE, "default")); + + assertFalse(future.isDone() && future.get(1, TimeUnit.SECONDS).contains("\"allow\""), + "default mode reported by the hook must not auto-allow"); + } + + @Test + void unknownHookPermissionModeFallsBackToScreenDetection() throws Exception { + ClaudeSessionModel.EDIT_MODE_REGISTRY.put(CWD, EditMode.BYPASS_PERMISSIONS); + + CompletableFuture future = handler.handlePreToolUse(editJsonWithMode(FILE_OUTSIDE, "dontAsk")); + + assertTrue(future.isDone()); + assertTrue(future.get(1, TimeUnit.SECONDS).contains("\"allow\"")); + } + // ------------------------------------------------------------------------- // helpers // ------------------------------------------------------------------------- @@ -198,6 +252,13 @@ private static String editJson(String filePath) { return editJsonWithCwd(filePath, CWD); } + private static String editJsonWithMode(String filePath, String permissionMode) { + return "{\"tool_name\":\"Edit\"," + + "\"tool_input\":{\"file_path\":\"" + filePath + "\"," + + "\"old_string\":\"x\",\"new_string\":\"y\"}," + + "\"cwd\":\"" + CWD + "\",\"permission_mode\":\"" + permissionMode + "\"}"; + } + private static String editJsonWithCwd(String filePath, String cwd) { return "{\"tool_name\":\"Edit\"," + "\"tool_input\":{\"file_path\":\"" + filePath + "\"," diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java index 6f7d14df..8bb24167 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java @@ -101,8 +101,12 @@ public static class FakeFormEditor { model.editor = this; } + /** Non-fatal load errors, as {@code reportLoadingErrors()} describes them; {@code null} for none. */ + String loadingErrors; + public boolean isFormLoaded() { return loaded; } - public boolean anyPersistenceError() { return false; } + public boolean anyPersistenceError() { return loadingErrors != null; } + public String reportLoadingErrors() { return loadingErrors; } public FakeFormModel getFormModel() { return model; } public void closeForm() { closed = true; loaded = false; } @@ -212,6 +216,22 @@ public void addsNoEventHandlersWhenAllExist() throws Exception { assertEquals(NEW_CODE, support.savedJava); } + @Test + public void loadErrorsOfTheFormEditorAreReported() throws Exception { + FakeEditorSupport support = new FakeEditorSupport(false); + support.formEditor.loadingErrors = "Error in loading component: [JFrame]->jPanel1->tblData\n" + + "Cannot load property: unknownProperty"; + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> regenerate(support, "MyFrame.java", "MyFrame.form", Set.of())); + + assertTrue(e.getMessage().startsWith("NetBeans could not load MyFrame.form without errors; code was not regenerated."), + e.getMessage()); + assertTrue(e.getMessage().contains("Cannot load property: unknownProperty"), e.getMessage()); + assertEquals(OLD_CODE, support.savedJava, "nothing may be regenerated or saved"); + assertTrue(support.formEditor.closed, "a form loaded only for regeneration must be closed again"); + } + @Test public void reportsUnchangedCodeInsteadOfSuccess() throws Exception { FakeEditorSupport support = new FakeEditorSupport(false); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeIT.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeIT.java index 65c708f5..c4e2b81b 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeIT.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeIT.java @@ -5,9 +5,20 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.Arrays; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; +import javax.swing.text.StyledDocument; import junit.framework.Test; +import org.netbeans.api.editor.guards.GuardedSectionManager; import org.netbeans.junit.NbModuleSuite; import org.netbeans.junit.NbTestCase; +import org.openide.cookies.EditorCookie; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.loaders.DataObject; +import org.openide.text.NbDocument; /** * Runs {@link RegenerateFormCode} against the real NetBeans form editor (form + form.nb @@ -48,6 +59,19 @@ public RegenerateFormCodeIT(String name) { @Override protected void setUp() throws Exception { clearWorkDir(); + useEditorDocumentsForJava(); + } + + /** + * Registers the NetBeans editor kit for {@code text/x-java}, as the Java editor module + * (not on the test classpath) does in the IDE. Without it, a {@code .java} file is + * loaded into a plain {@code FilterDocument}, which has no atomic edits to roll back. + */ + private static void useEditorDocumentsForJava() throws IOException { + FileObject kits = FileUtil.createFolder(FileUtil.getConfigRoot(), "Editors/text/x-java"); + if (kits.getFileObject("org-netbeans-modules-editor-NbEditorKit", "instance") == null) { + kits.createData("org-netbeans-modules-editor-NbEditorKit", "instance"); + } } /** A Free Design (GroupLayout) JFrame form is generated into empty guarded sections. */ @@ -132,6 +156,62 @@ public void testEventHandlerSectionsAreGenerated() throws Exception { assertEquals("a shared handler is generated once", 1, count(code, "private void jButton1ActionPerformed(")); } + /** + * A regeneration that fails halfway - here when inserting the second new event handler, + * with an Error like a LinkageError from a stale project class - leaves neither the + * editor document nor the files changed, and no stale guarded section behind: the next + * regeneration produces the same code as an undisturbed one. + */ + public void testFailedRegenerationIsRolledBack() throws Exception { + File java = copyFixture("EventsPanel"); + byte[] input = Files.readAllBytes(java.toPath()); + DataObject dataObject = DataObject.find(FileUtil.toFileObject(FileUtil.normalizeFile(java))); + StyledDocument document = dataObject.getLookup().lookup(EditorCookie.class).openDocument(); + assertTrue("the .java must be loaded into an editor document with atomic edits: " + document.getClass(), + document instanceof NbDocument.WriteLockable); + String documentBefore = document.getText(0, document.getLength()); + DocumentListener failing = new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + try { + if (e.getDocument().getText(e.getOffset(), e.getLength()).contains("void jTextField1ActionPerformed")) { + throw new IncompatibleClassChangeError("injected failure"); + } + } catch (BadLocationException ex) { + throw new IllegalStateException(ex); + } + } + + @Override + public void removeUpdate(DocumentEvent e) { + } + + @Override + public void changedUpdate(DocumentEvent e) { + } + }; + document.addDocumentListener(failing); + try { + regenerate(java); + fail("the injected failure must fail the regeneration"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage(), e.getMessage().contains("java.lang.IncompatibleClassChangeError: injected failure")); + assertFalse("the rollback must leave no partial changes: " + e.getMessage(), e.getMessage().contains("unsaved")); + } finally { + document.removeDocumentListener(failing); + } + + assertEquals("the document must be rolled back", documentBefore, document.getText(0, document.getLength())); + assertFalse("the rolled back document must not be modified", dataObject.isModified()); + assertNull("the rolled back handler section must not stay registered", + GuardedSectionManager.getInstance(document).findInteriorSection("event_jLabel1MouseClicked")); + assertTrue("the file content must not be changed", Arrays.equals(input, Files.readAllBytes(java.toPath()))); + + regenerate(java); + + assertMatchesExpected("EventsPanel", java); + } + /** Per-component variable modifiers and local variables. */ public void testVariableModifiersAndLocalVariables() throws Exception { File java = copyFixture("VariablesPanel"); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java index a82753c7..3bdb20de 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java @@ -100,6 +100,48 @@ public void findsExistingEventHandlersByTheirGuardedHeader() { RegenerateFormCode.existingEventHandlers(javaText)); } + @Test + public void errorThrownOnEdtIsRethrownAsIs() { + // invokeAndWait would wrap it in an InvocationTargetException without a message + IncompatibleClassChangeError error = assertThrows(IncompatibleClassChangeError.class, + () -> RegenerateFormCode.onEdt(() -> { + throw new IncompatibleClassChangeError("widget.PopupMenu and widget.PopupMenu$Separator disagree"); + })); + assertEquals("widget.PopupMenu and widget.PopupMenu$Separator disagree", error.getMessage()); + } + + @Test + public void linkageErrorOfFormModuleIsDescribedWithBuildHint() { + IllegalStateException e = RegenerateFormCode.regenerationFailed("Dlg.java", "Dlg.form", + new IncompatibleClassChangeError("widget.PopupMenu and widget.PopupMenu$Separator disagree"), false); + + assertTrue(e.getMessage().startsWith("The NetBeans form editor failed to regenerate the code of Dlg.java from Dlg.form: " + + "java.lang.IncompatibleClassChangeError: widget.PopupMenu and widget.PopupMenu$Separator disagree."), + e.getMessage()); + assertTrue(e.getMessage().contains("clean and build the project"), e.getMessage()); + assertFalse(e.getMessage().contains("unsaved"), e.getMessage()); + } + + @Test + public void exceptionWithoutMessageIsDescribedByItsType() { + IllegalStateException e = RegenerateFormCode.regenerationFailed("Dlg.java", "Dlg.form", + new NullPointerException(), false); + + assertTrue(e.getMessage().contains(": java.lang.NullPointerException."), e.getMessage()); + assertFalse(e.getMessage().contains("null"), e.getMessage()); + assertTrue(e.getMessage().contains("IDE log"), e.getMessage()); + } + + @Test + public void ownErrorKeepsItsMessageAndReportsLeftoverChanges() { + IllegalStateException e = RegenerateFormCode.regenerationFailed("Dlg.java", "Dlg.form", + new RegenerateFormCode.FormToolException("Dlg.form was loaded read-only."), true); + + assertTrue(e.getMessage().startsWith("Dlg.form was loaded read-only. The failed regeneration left unsaved, " + + "partial changes in Dlg.java"), e.getMessage()); + assertTrue(e.getMessage().contains("close Dlg.java without saving"), e.getMessage()); + } + @Test public void runChecksAccessToBothFilesBeforeRegenerating() throws Exception { createForm("MyFrame");