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
+}