diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c68808..9abe8585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +- 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.45 (2026-10-28) - Fixed edit-mode detection and switching while background agents or shells are listed under the status line # 1.3.42 (2026-10-22) 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..243f3059 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -782,6 +782,10 @@ 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 | + +> [!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 diff --git a/pom.xml b/pom.xml index 9683e912..6132ca33 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 @@ -387,6 +556,12 @@ org-netbeans-modules-editor-lib ${netbeans.version} + + + org.netbeans.api + org-netbeans-modules-editor-guards + ${netbeans.version} + org.eclipse.jetty @@ -491,6 +666,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/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java index 6fc78ea3..e0e6b650 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); } @@ -528,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 new file mode 100644 index 00000000..e623ced8 --- /dev/null +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCode.java @@ -0,0 +1,781 @@ +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.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; +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()} + * 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: + *

    + *
  • designer closed: {@code FormEditorSupport.loadForm()} → mark changed → + * {@code FormEditor.regenerateCodeIfNeeded()} → {@code saveDocument()} → + * {@code FormEditor.closeForm()}
  • + *
  • designer open: {@code FormEditorSupport.reloadFormEditor()} (re-reads the + * edited {@code .form} from disk) → mark changed → {@code regenerateCodeIfNeeded()} → + * {@code saveDocument()}
  • + *
+ * "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. + * + *

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 { + + 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+"); + + /** The form module's data object of a {@code .java} / {@code .form} pair. */ + private static final String FORM_DATA_OBJECT = "org.netbeans.modules.form.FormDataObject"; + + /** Name prefix of the guarded section of an event handler method (as {@code JavaCodeGenerator} names it). */ + private static final String EVENT_SECTION_PREFIX = "event_"; + + /** Guarded header of an event handler method, capturing the handler name. */ + private static final Pattern EVENT_HANDLER_HEADER = Pattern.compile("//GEN-FIRST:event_([\\w$]+)"); + + /** 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 + * @param addedHandlers event handler methods that were missing in the {@code .java} and were generated + */ + record Outcome(boolean designerOpen, boolean codeChanged, String generatedText, List 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() {} + + @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. " + + "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."; + } + + @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(""); + } + + /** + * 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. + * + * @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(); + + String javaText = javaFo.asText(); + if (!javaText.contains("//GEN-BEGIN:initComponents")) { + 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 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 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; + 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()) { + return "The generated code in " + javaName + " already matches " + formName + + "; nothing was regenerated." + designerNote; + } + if (!documentMatchesDisk(outcome.generatedText(), javaFo.asText())) { + 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() ? "" + : " 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." + handlersNote + designerNote + + " Re-read " + javaName + " and " + formName + " before editing them again."; + } + + /** + * Loads (or reloads) the form, regenerates its guarded code and saves it. + * 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. + * + *

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 + * @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(FormEditorApi api, Object editorSupport, String javaName, String formName, + Set existingHandlers) throws Exception { + boolean open = (Boolean) invoke(api.isOpened(), editorSupport); + Object formEditor; + if (open) { + formEditor = invoke(api.reloadFormEditor(), editorSupport); + } else { + invoke(api.loadForm(), editorSupport); + formEditor = invoke(api.getFormEditor(), editorSupport); + } + if (formEditor == null) { + throw formLoadError(formName, null); + } + boolean closed = false; + try { + 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 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 = 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 && !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 + * 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; + } + + /** + * 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."); + } + + /** + * 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 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. + * + *

{@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 saveSourceOnly, + Method isFormLoaded, Method anyPersistenceError, Method getFormModel, + Method regenerateCodeIfNeeded, Method closeForm, + Method isReadOnly, Method fireFormChanged, + Method getFormEvents, Method getAllEventHandlers, Method getEventsForHandler, + Method fireEventHandlerAdded, Method reportLoadingErrors) { + + /** + * 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(); + 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), + method(supportClass, "reloadFormEditor", editorClass), + 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, + method(editorClass, "regenerateCodeIfNeeded", null), + method(editorClass, "closeForm", null), + method(modelClass, "isReadOnly", 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), + 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; + } + } + + /** + * 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; + } + } + + private static IllegalStateException unsupported(Class owner, String methodName, String problem, Exception cause) { + return new FormToolException("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. {@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 + * 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 { + 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 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); + } + // 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 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 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) { + throw ex; + } + if (cause instanceof Error err) { + throw err; + } + throw e; + } + } + + /** + * 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<>(); + EventQueue.invokeAndWait(() -> { + try { + result.set(task.call()); + } catch (Throwable t) { + error.set(t); + } + }); + 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/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/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 new file mode 100644 index 00000000..8bb24167 --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeEditorTest.java @@ -0,0 +1,312 @@ +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; +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 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;
  • + *
  • 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;
  • + *
  • {@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 { + + 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 { + 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 + 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; + 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 loadingErrors != null; } + public String reportLoadingErrors() { return loadingErrors; } + 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()); + support.document.insertString(0, generatedCode, null); + for (String handler : generatedHandlers) { + support.document.insertString(support.document.getLength(), " handler:" + handler, 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 FakeFormEditor getFormEditor() { return formEditor; } + public FakeFormEditor 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; + } + } + + /** 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); + + 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 " + + "(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); + + 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); + 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 generatesOnlyEventHandlersMissingInJava() throws Exception { + FakeEditorSupport support = new FakeEditorSupport(false); + support.formEditor.model.events.handlers.addAll(List.of("otherNewHandler", "existingHandler", "newHandler")); + + RegenerateFormCode.Outcome outcome = regenerate( + 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 = regenerate( + support, "MyFrame.java", "MyFrame.form", Set.of("existingHandler")); + + assertEquals(List.of(), outcome.addedHandlers()); + 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); + support.formEditor.generatedCode = OLD_CODE; + + RegenerateFormCode.Outcome outcome = + regenerate(support, "MyFrame.java", "MyFrame.form", Set.of()); + + 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, + () -> 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"); + } + + @Test + public void keepsOpenDesignerOpenWhenRegenerationFails() throws Exception { + FakeEditorSupport support = new FakeEditorSupport(true); + support.formEditor.failRegeneration = true; + + assertThrows(IllegalStateException.class, + () -> regenerate(support, "MyFrame.java", "MyFrame.form", Set.of())); + + 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, + () -> 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()); + 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, + () -> 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()); + } +} 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..c4e2b81b --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeIT.java @@ -0,0 +1,324 @@ +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 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 + * 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(); + 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. */ + 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(")); + } + + /** + * 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"); + + 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/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..3bdb20de --- /dev/null +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/tools/RegenerateFormCodeTest.java @@ -0,0 +1,192 @@ +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 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 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"); + 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(" "))); + } +} 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(); } 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 +}