diff --git a/CHANGELOG.md b/CHANGELOG.md index f27d061..9725126 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 bf06839..36fa6eb 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 15246f8..84569b2 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 ca0c1a1..305ca47 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 6fc78ea..72daa3a 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 0000000..370513a --- /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 137ef3c..918afef 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 0000000..e7feb28 --- /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 0000000..b14853e --- /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 0000000..e94d24a --- /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(" "))); + } +}