diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b6493eb..d74fb390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Improved error panel on process start failure: shows Working Directory and CLAUDE_CONFIG_DIR fields (before Command and Error) for easier diagnosis; all fields have a right-click Copy menu; fixed "Restart Advanced…" not dismissing the error panel - Added Advanced tab in Tools → Options → Claude Code with Debug mode, Hang timeout, and a new "Enable MCP integration" toggle (disabling it skips the --mcp-config flag while hooks remain active) - Fixed process start errors and hang timeouts now logged to the IDE log file at SEVERE level - Added hang detection: configurable timeout (default 60 s) kills the process and shows an error if no PTY output is received after launch; set to 0 to disable diff --git a/pom.xml b/pom.xml index 84b47bf7..d806dc45 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.21.36-SNAPSHOT + 0.21.38-SNAPSHOT nbm Netbeans Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java index 8938baa3..93886df9 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java @@ -15,6 +15,7 @@ import io.github.nbclaudecodegui.settings.ClaudeProfileStore; import java.awt.BorderLayout; import java.awt.CardLayout; +import java.awt.FlowLayout; import java.awt.Color; import java.awt.Dimension; import java.awt.event.ComponentAdapter; @@ -281,7 +282,6 @@ public ClaudeSessionTab(File dir) { setIcon(ImageUtilities.loadImage(ICON, true)); updateDisplayName(dir); model.addListener(this); - controller.setHangCallback(this::showStartError); } // ------------------------------------------------------------------------- @@ -653,6 +653,17 @@ private void startSession(File dir, String profileName, String extraCliArgs) { private void startSession(File dir, String profileName, String extraCliArgs, SessionMode mode, String resumeSessionId) { this.activeResumeSessionId = resumeSessionId; + Path configDir = null; + if (!ClaudeProfile.DEFAULT_NAME.equals(profileName)) { + ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); + if (profile != null && !profile.isDefault()) { + configDir = ClaudeProfileStore.resolveStorageDir( + profile, ClaudeCodePreferences.getProfilesDir()); + } + } + final Path capturedConfigDir = configDir; + controller.setHangCallback( + (cmd, err) -> showStartError(cmd, err, model.getWorkingDirectory(), capturedConfigDir)); updateDisplayName(dir); sessionTag = "[" + dir.getName() + "] "; JediTermWidget widget = new JediTermWidget(new NetBeansSettingsProvider()); @@ -669,7 +680,7 @@ private void startSession(File dir, String profileName, String extraCliArgs, LOG.log(Level.SEVERE, "Claude process failed to start in " + dir, ex); String command = controller.getLastAttemptedCommand(); String errorMsg = ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName(); - showStartError(command, errorMsg); + showStartError(command, errorMsg, dir, capturedConfigDir); } } @@ -764,12 +775,31 @@ private void showSelectorLayout() { repaint(); } - private void showStartError(String command, String error) { + /** + * Replaces the current layout with a diagnostics panel describing why the + * Claude Code process could not be started. + * + * @param command the command that was attempted (may be empty) + * @param error the error message + * @param workingDir the working directory that was used (may be {@code null}) + * @param claudeConfigDir the {@code CLAUDE_CONFIG_DIR} value in effect; + * {@code null} means the default profile (no override) + */ + private void showStartError(String command, String error, + File workingDir, Path claudeConfigDir) { if (splitPane != null) { remove(splitPane); splitPane = null; } + JTextField dirField = new JTextField(workingDir != null ? workingDir.getAbsolutePath() : ""); + dirField.setEditable(false); + BasicTextContextMenu.attach(dirField, BasicTextContextMenu.createReadOnly(dirField)); + + JTextField cfgDirField = new JTextField(claudeConfigDir != null ? claudeConfigDir.toString() : ""); + cfgDirField.setEditable(false); + BasicTextContextMenu.attach(cfgDirField, BasicTextContextMenu.createReadOnly(cfgDirField)); + JTextField cmdField = new JTextField(command); cmdField.setEditable(false); BasicTextContextMenu.attach(cmdField, BasicTextContextMenu.createReadOnly(cmdField)); @@ -784,10 +814,28 @@ private void showStartError(String command, String error) { JPanel fieldsPanel = new JPanel(new GridBagLayout()); GridBagConstraints gbc = new GridBagConstraints(); gbc.gridx = 0; gbc.gridy = 0; gbc.insets = new Insets(4, 0, 4, 8); gbc.anchor = GridBagConstraints.WEST; + fieldsPanel.add(new JLabel("Working Directory:"), gbc); + gbc.gridx = 1; gbc.fill = GridBagConstraints.HORIZONTAL; gbc.weightx = 1.0; + fieldsPanel.add(dirField, gbc); + gbc.gridx = 0; gbc.gridy = 1; gbc.fill = GridBagConstraints.NONE; gbc.weightx = 0; + JTextField cfgDirLabelField = new JTextField("CLAUDE_CONFIG_DIR"); + cfgDirLabelField.setEditable(false); + cfgDirLabelField.setBorder(null); + cfgDirLabelField.setBackground(null); + cfgDirLabelField.setOpaque(false); + BasicTextContextMenu.attach(cfgDirLabelField, BasicTextContextMenu.createReadOnly(cfgDirLabelField)); + JPanel cfgDirLabelPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + cfgDirLabelPanel.setOpaque(false); + cfgDirLabelPanel.add(cfgDirLabelField); + cfgDirLabelPanel.add(new JLabel(":")); + fieldsPanel.add(cfgDirLabelPanel, gbc); + gbc.gridx = 1; gbc.fill = GridBagConstraints.HORIZONTAL; gbc.weightx = 1.0; + fieldsPanel.add(cfgDirField, gbc); + gbc.gridx = 0; gbc.gridy = 2; gbc.fill = GridBagConstraints.NONE; gbc.weightx = 0; fieldsPanel.add(new JLabel("Command:"), gbc); gbc.gridx = 1; gbc.fill = GridBagConstraints.HORIZONTAL; gbc.weightx = 1.0; fieldsPanel.add(cmdField, gbc); - gbc.gridx = 0; gbc.gridy = 1; gbc.fill = GridBagConstraints.NONE; gbc.weightx = 0; + gbc.gridx = 0; gbc.gridy = 3; gbc.fill = GridBagConstraints.NONE; gbc.weightx = 0; fieldsPanel.add(new JLabel("Error:"), gbc); gbc.gridx = 1; gbc.fill = GridBagConstraints.HORIZONTAL; gbc.weightx = 1.0; fieldsPanel.add(errField, gbc); @@ -984,6 +1032,10 @@ private void cleanupChatLayout() { southCardLayout = null; activeCard = null; } + if (errorPanel != null) { + remove(errorPanel); + errorPanel = null; + } selectorPanel.setVisible(true); selectorPanel.unlock(); add(selectorPanel, BorderLayout.CENTER); diff --git a/src/test/java/io/github/nbclaudecodegui/ui/ClaudeSessionTabErrorPanelTest.java b/src/test/java/io/github/nbclaudecodegui/ui/ClaudeSessionTabErrorPanelTest.java new file mode 100644 index 00000000..fd95f90a --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/ui/ClaudeSessionTabErrorPanelTest.java @@ -0,0 +1,159 @@ +package io.github.nbclaudecodegui.ui; + +import io.github.nbclaudecodegui.model.SessionMode; +import java.awt.BorderLayout; +import java.awt.Component; +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Path; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the error panel in {@link ClaudeSessionTab}. + */ +class ClaudeSessionTabErrorPanelTest { + + /** Reflectively invoke {@code showStartError} with all four parameters. */ + private static void showStartError(ClaudeSessionTab tab, + String command, String error, + File workingDir, Path claudeConfigDir) + throws Exception { + Method m = ClaudeSessionTab.class.getDeclaredMethod( + "showStartError", String.class, String.class, File.class, Path.class); + m.setAccessible(true); + m.invoke(tab, command, error, workingDir, claudeConfigDir); + } + + /** Reflectively read the {@code errorPanel} field. */ + private static JPanel getErrorPanel(ClaudeSessionTab tab) throws Exception { + Field f = ClaudeSessionTab.class.getDeclaredField("errorPanel"); + f.setAccessible(true); + return (JPanel) f.get(tab); + } + + /** Collect all JTextFields recursively from a container. */ + private static java.util.List collectFields(Component c) { + java.util.List result = new java.util.ArrayList<>(); + collectFields(c, result); + return result; + } + + private static void collectFields(Component c, java.util.List result) { + if (c instanceof JTextField tf) { + result.add(tf); + } else if (c instanceof java.awt.Container container) { + for (Component child : container.getComponents()) { + collectFields(child, result); + } + } + } + + /** Collect all JLabels recursively from a container. */ + private static java.util.List collectLabels(Component c) { + java.util.List result = new java.util.ArrayList<>(); + collectLabels(c, result); + return result; + } + + private static void collectLabels(Component c, java.util.List result) { + if (c instanceof JLabel lbl) { + result.add(lbl); + } else if (c instanceof java.awt.Container container) { + for (Component child : container.getComponents()) { + collectLabels(child, result); + } + } + } + + @Test + void showStartError_errorPanelIsCenterComponent() throws Exception { + ClaudeSessionTab tab = new ClaudeSessionTab(); + File dir = new File("/tmp/test-dir"); + Path cfgDir = Path.of("/home/user/.config/claude/profiles/myprofile"); + + showStartError(tab, "claude --arg", "Connection refused", dir, cfgDir); + + BorderLayout layout = (BorderLayout) tab.getLayout(); + Component center = layout.getLayoutComponent(BorderLayout.CENTER); + JPanel errorPanel = getErrorPanel(tab); + + assertNotNull(errorPanel, "errorPanel field must be set"); + assertSame(errorPanel, center, "errorPanel must be the CENTER component"); + } + + @Test + void showStartError_fieldOrderAndValues() throws Exception { + ClaudeSessionTab tab = new ClaudeSessionTab(); + File dir = new File("/tmp/my-project"); + Path cfgDir = Path.of("/home/user/.config/claude/profiles/work"); + + showStartError(tab, "claude --verbose", "No such file", dir, cfgDir); + + JPanel errorPanel = getErrorPanel(tab); + assertNotNull(errorPanel); + + java.util.List labels = collectLabels(errorPanel); + // Filter out the title label (it has no colon) + java.util.List fieldLabels = labels.stream() + .map(JLabel::getText) + .filter(t -> t.contains(":")) + .toList(); + + // 3 colon-containing labels: "Working Directory:", ":", "Command:", "Error:" + // Note: CLAUDE_CONFIG_DIR row uses a JTextField for the env var name + a ":" JLabel + assertEquals(4, fieldLabels.size(), "Must have 4 colon-containing labels"); + assertEquals("Working Directory:", fieldLabels.get(0)); + assertEquals(":", fieldLabels.get(1)); + assertEquals("Command:", fieldLabels.get(2)); + assertEquals("Error:", fieldLabels.get(3)); + + // 5 JTextFields: dirField, cfgDirLabelField("CLAUDE_CONFIG_DIR"), cfgDirField, cmdField, errField + java.util.List fields = collectFields(errorPanel); + assertEquals(5, fields.size(), "Must have 5 text fields"); + assertEquals(dir.getAbsolutePath(), fields.get(0).getText()); + assertEquals("CLAUDE_CONFIG_DIR", fields.get(1).getText()); + assertEquals(cfgDir.toString(), fields.get(2).getText()); + assertEquals("claude --verbose", fields.get(3).getText()); + assertEquals("No such file", fields.get(4).getText()); + } + + @Test + void showStartError_defaultProfile_configDirIsBlank() throws Exception { + ClaudeSessionTab tab = new ClaudeSessionTab(); + File dir = new File("/tmp/proj"); + + // null claudeConfigDir → default profile + showStartError(tab, "claude", "error", dir, null); + + JPanel errorPanel = getErrorPanel(tab); + assertNotNull(errorPanel); + + java.util.List fields = collectFields(errorPanel); + assertEquals(5, fields.size()); + assertEquals("", fields.get(2).getText(), "CLAUDE_CONFIG_DIR value field must be empty for default profile"); + } + + @Test + void restartAdvanced_afterError_clearsErrorPanel() throws Exception { + ClaudeSessionTab tab = new ClaudeSessionTab(); + File dir = new File("/tmp/proj"); + + showStartError(tab, "claude", "fail", dir, null); + assertNotNull(getErrorPanel(tab), "errorPanel must be set after showStartError"); + + tab.onSaveAndSwitch("", SessionMode.RESTART_ADVANCED, null); + + assertNull(getErrorPanel(tab), "errorPanel must be null after RESTART_ADVANCED"); + + BorderLayout layout = (BorderLayout) tab.getLayout(); + Component center = layout.getLayoutComponent(BorderLayout.CENTER); + assertNotNull(center, "A CENTER component must be present after RESTART_ADVANCED"); + assertFalse(center instanceof JPanel ep && ep == getErrorPanel(tab), + "CENTER must not be the error panel"); + } +}