From 018cee0a081a105a0915bc8851d5ad3602c179a0 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 18 May 2026 00:58:26 +0300 Subject: [PATCH 1/4] Intermediate commit --- CHANGELOG.md | 1 + pom.xml | 2 +- .../nbclaudecodegui/ui/ClaudeSessionTab.java | 81 ++++++++++++++++++- .../ui/MarkdownPreviewTab.java | 54 ++++++++++++- .../ui/NetBeansSettingsProvider.java | 8 +- .../ui/common/ZoomSupport.java | 70 ++++++++++++++++ .../nbclaudecodegui/ui/common/Zoomable.java | 11 +++ .../nbclaudecodegui/ui/MarkdownZoomTest.java | 52 ++++++++++++ .../ui/NetBeansSettingsProviderZoomTest.java | 25 ++++++ .../ui/common/ZoomSupportTest.java | 75 +++++++++++++++++ 10 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java create mode 100644 src/main/java/io/github/nbclaudecodegui/ui/common/Zoomable.java create mode 100644 src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java create mode 100644 src/test/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProviderZoomTest.java create mode 100644 src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index e6171003..13d6e5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Added font zoom (Alt+Scroll) for Terminal and Markdown Preview; Ctrl+0 resets; right-click "Zoom" submenu - Added Find bar (Ctrl+F / F3 / Shift+F3) to Markdown Preview - Added automatic Markdown Preview tab when Claude writes a plan file (configurable in Settings → General) - Added `show_markdown` MCP tool: displays markdown text in a NetBeans Markdown Preview tab diff --git a/pom.xml b/pom.xml index b34092da..44dde7ef 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.23.12-SNAPSHOT + 0.23.13-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java index cc407472..e8a26bdc 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java @@ -4,6 +4,8 @@ import com.jediterm.terminal.ui.JediTermWidget; import io.github.nbclaudecodegui.controller.ClaudeSessionController; import io.github.nbclaudecodegui.ui.common.BasicTextContextMenu; +import io.github.nbclaudecodegui.ui.common.Zoomable; +import io.github.nbclaudecodegui.ui.common.ZoomSupport; import io.github.nbclaudecodegui.model.ChoiceMenuModel; import io.github.nbclaudecodegui.model.ClaudeSessionModel; import io.github.nbclaudecodegui.model.EditMode; @@ -19,8 +21,13 @@ import java.awt.FlowLayout; import java.awt.Color; import java.awt.Dimension; +import java.awt.KeyEventDispatcher; +import java.awt.KeyboardFocusManager; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import javax.swing.JPopupMenu; import java.io.File; import java.io.IOException; import java.nio.file.Path; @@ -103,7 +110,7 @@ openAtStartup = false ) public class ClaudeSessionTab extends TopComponent - implements ClaudeSessionModel.ClaudeSessionModelListener { + implements ClaudeSessionModel.ClaudeSessionModelListener, Zoomable { private static final Logger LOG = Logger.getLogger(ClaudeSessionTab.class.getName()); @@ -174,7 +181,21 @@ public class ClaudeSessionTab extends TopComponent // UI — session (created once a session starts, nulled on stop) // ------------------------------------------------------------------------- - private JediTermWidget terminalWidget; + private JediTermWidget terminalWidget; + private NetBeansSettingsProvider settingsProvider; + private int termZoomDelta = + org.openide.util.NbPreferences.forModule(ClaudeSessionTab.class) + .getInt("terminalZoomDelta", 0); + private final KeyEventDispatcher zoomKeyInterceptor = e -> { + if (e.getID() != KeyEvent.KEY_PRESSED || terminalWidget == null) return false; + if (!SwingUtilities.isDescendingFrom(e.getComponent(), terminalWidget)) return false; + if (e.getKeyCode() == KeyEvent.VK_0 + && (e.getModifiersEx() & InputEvent.CTRL_DOWN_MASK) != 0) { + SwingUtilities.invokeLater(this::resetZoom); + return true; + } + return false; + }; private JSplitPane splitPane; private JPanel errorPanel; JPanel southCard; @@ -438,6 +459,51 @@ protected void componentActivated() { requestFocusOnInput(); } + @Override + protected void componentShowing() { + super.componentShowing(); + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .addKeyEventDispatcher(zoomKeyInterceptor); + } + + @Override + protected void componentHidden() { + super.componentHidden(); + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .removeKeyEventDispatcher(zoomKeyInterceptor); + } + + // ------------------------------------------------------------------------- + // Zoomable + // ------------------------------------------------------------------------- + + @Override public void zoomIn() { applyTermZoom(termZoomDelta + 1); } + @Override public void zoomOut() { applyTermZoom(termZoomDelta - 1); } + @Override public void resetZoom() { applyTermZoom(0); } + @Override public int getZoomDelta() { return termZoomDelta; } + @Override public int getMinDelta() { return 6 - (int) ClaudeCodePreferences.getTerminalFontSize(); } + @Override public int getMaxDelta() { return 72 - (int) ClaudeCodePreferences.getTerminalFontSize(); } + + private void applyTermZoom(int d) { + int clamped = Math.max(getMinDelta(), Math.min(getMaxDelta(), d)); + termZoomDelta = clamped; + org.openide.util.NbPreferences.forModule(ClaudeSessionTab.class) + .putInt("terminalZoomDelta", clamped); + if (settingsProvider != null) { + settingsProvider.setZoomDelta(clamped); + } + if (terminalWidget != null) { + try { + java.lang.reflect.Method m = + terminalWidget.getTerminalPanel().getClass() + .getMethod("reinitFontAndResize"); + m.invoke(terminalWidget.getTerminalPanel()); + } catch (ReflectiveOperationException ex) { + LOG.fine("reinitFontAndResize not available: " + ex.getMessage()); + } + } + } + @Override public boolean canClose() { return true; @@ -754,7 +820,9 @@ private void startSession(File dir, String profileName, String extraCliArgs, (cmd, err) -> showStartError(cmd, err, model.getWorkingDirectory(), capturedConfigDir)); updateDisplayName(dir); sessionTag = "[" + dir.getName() + "] "; - JediTermWidget widget = new JediTermWidget(new NetBeansSettingsProvider()); + settingsProvider = new NetBeansSettingsProvider(); + settingsProvider.setZoomDelta(termZoomDelta); + JediTermWidget widget = new JediTermWidget(settingsProvider); Color termBg = UIManager.getColor("EditorPane.background"); if (termBg == null) termBg = UIManager.getColor("Panel.background"); if (termBg != null) { @@ -842,6 +910,13 @@ public void componentResized(ComponentEvent e) { } }); + widget.getTerminalPanel().addMouseWheelListener( + ZoomSupport.createWheelListener(this)); + JPopupMenu termMenu = new JPopupMenu(); + termMenu.add(ZoomSupport.buildZoomMenu(this)); + widget.getTerminalPanel().setComponentPopupMenu(termMenu); + ZoomSupport.bindResetKey(this, this); + add(splitPane, BorderLayout.CENTER); revalidate(); repaint(); diff --git a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java index a7f2cbd6..2d40cf08 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java @@ -3,7 +3,12 @@ import io.github.nbclaudecodegui.settings.ClaudeCodePreferences; import io.github.nbclaudecodegui.ui.common.MarkdownRenderer; import io.github.nbclaudecodegui.ui.common.UiUtils; +import io.github.nbclaudecodegui.ui.common.Zoomable; +import io.github.nbclaudecodegui.ui.common.ZoomSupport; import io.github.nbclaudecodegui.ui.common.markdown.MarkdownFindBar; +import javax.swing.text.html.HTMLEditorKit; +import javax.swing.text.html.StyleSheet; +import org.openide.util.NbPreferences; import java.awt.BorderLayout; import java.awt.Toolkit; import java.awt.datatransfer.StringSelection; @@ -62,7 +67,7 @@ preferredID = "MarkdownPreviewTopComponent", persistenceType = TopComponent.PERSISTENCE_ALWAYS ) -public class MarkdownPreviewTab extends TopComponent { +public class MarkdownPreviewTab extends TopComponent implements Zoomable { /** * Open live-preview tabs keyed by absolute file path. @@ -113,6 +118,9 @@ public class MarkdownPreviewTab extends TopComponent { /** Scroll ratio saved by writeExternal; applied in componentOpened() after IDE restart. */ double savedScrollRatio = 0.0; + int mdZoomDelta = + NbPreferences.forModule(MarkdownPreviewTab.class).getInt("markdownZoomDelta", 0); + /** Creates a new empty tab instance; content and title are configured by factory methods. */ MarkdownPreviewTab() { } @@ -160,11 +168,14 @@ public static void openLive(String filePath, String content, FileObject fo) { tab.scrollPane = new JScrollPane(tab.pane); tab.add(tab.scrollPane, BorderLayout.CENTER); - // Attach hyperlink listener, context menu, and find bar + // Attach hyperlink listener, context menu, find bar, and zoom tab.attachHyperlinkListener(); tab.bindRefreshKey(tab.pane); tab.pane.setComponentPopupMenu(tab.buildContextMenu()); tab.initFindBar(tab.pane); + tab.pane.addMouseWheelListener(ZoomSupport.createWheelListener(tab)); + ZoomSupport.bindResetKey(tab.pane, tab); + tab.applyMdZoom(tab.mdZoomDelta); if (fo != null) { tab.fileListener = makeFileListener(tab, fo); @@ -507,6 +518,40 @@ private void attachHyperlinkListener() { }); } + // ------------------------------------------------------------------------- + // Zoomable + // ------------------------------------------------------------------------- + + @Override public void zoomIn() { applyMdZoom(mdZoomDelta + 1); } + @Override public void zoomOut() { applyMdZoom(mdZoomDelta - 1); } + @Override public void resetZoom() { applyMdZoom(0); } + @Override public int getZoomDelta() { return mdZoomDelta; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + + private void applyMdZoom(int d) { + int clamped = Math.max(getMinDelta(), Math.min(getMaxDelta(), d)); + mdZoomDelta = clamped; + NbPreferences.forModule(MarkdownPreviewTab.class).putInt("markdownZoomDelta", clamped); + updateZoomCss(); + } + + private void updateZoomCss() { + if (pane == null) return; + HTMLEditorKit kit = (HTMLEditorKit) pane.getEditorKit(); + StyleSheet ss = kit.getStyleSheet(); + int d = mdZoomDelta; + ss.addRule("body { font-size: " + (13 + d) + "pt; }"); + ss.addRule("pre { font-size: " + (12 + d) + "pt; }"); + ss.addRule("code { font-size: " + (12 + d) + "pt; }"); + ss.addRule("h1 { font-size: " + (16 + d) + "pt; }"); + ss.addRule("h2 { font-size: " + (14 + d) + "pt; }"); + ss.addRule("h3 { font-size: " + (13 + d) + "pt; }"); + ss.addRule(".info { font-size: " + (11 + d) + "pt; }"); + String text = pane.getText(); + pane.setText(text); + } + JPopupMenu buildContextMenu() { JPopupMenu menu = new JPopupMenu(); @@ -554,6 +599,8 @@ JPopupMenu buildContextMenu() { menu.add(forward); menu.add(refresh); menu.addSeparator(); + menu.add(ZoomSupport.buildZoomMenu(this)); + menu.addSeparator(); menu.add(find); menu.addSeparator(); menu.add(selectAll); @@ -653,6 +700,9 @@ protected void componentOpened() { bindRefreshKey(pane); pane.setComponentPopupMenu(buildContextMenu()); initFindBar(pane); + pane.addMouseWheelListener(ZoomSupport.createWheelListener(this)); + ZoomSupport.bindResetKey(pane, this); + applyMdZoom(mdZoomDelta); } filePath = path; diff --git a/src/main/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProvider.java b/src/main/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProvider.java index 32dd05f0..a4b87d0b 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProvider.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProvider.java @@ -23,9 +23,15 @@ */ final class NetBeansSettingsProvider extends DefaultSettingsProvider { + private int zoomDelta = 0; + + void setZoomDelta(int d) { + this.zoomDelta = d; + } + @Override public float getTerminalFontSize() { - return ClaudeCodePreferences.getTerminalFontSize(); + return ClaudeCodePreferences.getTerminalFontSize() + zoomDelta; } @Override diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java b/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java new file mode 100644 index 00000000..c3c1bf6b --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java @@ -0,0 +1,70 @@ +package io.github.nbclaudecodegui.ui.common; + +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; +import javax.swing.AbstractAction; +import javax.swing.JComponent; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.KeyStroke; +import java.awt.event.KeyEvent; + +/** Shared zoom utilities for terminal and markdown preview surfaces. */ +public final class ZoomSupport { + + private ZoomSupport() {} + + /** + * Returns the modifier mask used for mouse-wheel zoom. + * NetBeans standard binding is Alt+Wheel; we use that unconditionally. + */ + public static int discoverZoomWheelModifiers() { + return InputEvent.ALT_DOWN_MASK; + } + + /** Creates a MouseWheelListener that calls zoomIn/zoomOut when Alt is held. */ + public static MouseWheelListener createWheelListener(Zoomable zoomable) { + int mask = discoverZoomWheelModifiers(); + return (MouseWheelEvent e) -> { + if ((e.getModifiersEx() & mask) != 0) { + e.consume(); + if (e.getWheelRotation() < 0) { + zoomable.zoomIn(); + } else { + zoomable.zoomOut(); + } + } + }; + } + + /** Builds a "Zoom" JMenu with Increase, Decrease, and Reset items. */ + public static JMenu buildZoomMenu(Zoomable zoomable) { + JMenu menu = new JMenu("Zoom"); + + JMenuItem increase = new JMenuItem("Increase (Alt+Scroll Up)"); + increase.addActionListener(e -> zoomable.zoomIn()); + + JMenuItem decrease = new JMenuItem("Decrease (Alt+Scroll Down)"); + decrease.addActionListener(e -> zoomable.zoomOut()); + + JMenuItem reset = new JMenuItem("Reset (Ctrl+0)"); + reset.addActionListener(e -> zoomable.resetZoom()); + + menu.add(increase); + menu.add(decrease); + menu.addSeparator(); + menu.add(reset); + return menu; + } + + /** Binds Ctrl+0 → resetZoom() on the given component via InputMap/ActionMap. */ + public static void bindResetKey(JComponent comp, Zoomable zoomable) { + KeyStroke ks = KeyStroke.getKeyStroke(KeyEvent.VK_0, InputEvent.CTRL_DOWN_MASK); + comp.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, "zoom-reset"); + comp.getActionMap().put("zoom-reset", new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { zoomable.resetZoom(); } + }); + } +} diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/Zoomable.java b/src/main/java/io/github/nbclaudecodegui/ui/common/Zoomable.java new file mode 100644 index 00000000..31d07aa4 --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/Zoomable.java @@ -0,0 +1,11 @@ +package io.github.nbclaudecodegui.ui.common; + +/** Implemented by UI surfaces that support font-size zoom. */ +public interface Zoomable { + void zoomIn(); + void zoomOut(); + void resetZoom(); + int getZoomDelta(); + int getMinDelta(); + int getMaxDelta(); +} diff --git a/src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java b/src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java new file mode 100644 index 00000000..610503a2 --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java @@ -0,0 +1,52 @@ +package io.github.nbclaudecodegui.ui; + +import io.github.nbclaudecodegui.ui.common.MarkdownRenderer; +import javax.swing.JEditorPane; +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MarkdownZoomTest { + + private MarkdownPreviewTab tab; + + @BeforeEach + void setup() { + MarkdownPreviewTab.clearOpenTabsForTest(); + tab = new MarkdownPreviewTab(); + tab.pane = MarkdownRenderer.createOutputPane("test", null); + tab.mdZoomDelta = 0; + } + + @Test + void zoomIn_incrementsDelta() { + tab.zoomIn(); + assertEquals(1, tab.getZoomDelta()); + } + + @Test + void zoomOut_decrementsDelta() { + tab.zoomOut(); + assertEquals(-1, tab.getZoomDelta()); + } + + @Test + void zoomIn_clampedAtMax() { + for (int i = 0; i < 30; i++) tab.zoomIn(); + assertEquals(tab.getMaxDelta(), tab.getZoomDelta()); + } + + @Test + void zoomOut_clampedAtMin() { + for (int i = 0; i < 30; i++) tab.zoomOut(); + assertEquals(tab.getMinDelta(), tab.getZoomDelta()); + } + + @Test + void resetZoom_setsZero() { + tab.zoomIn(); + tab.zoomIn(); + tab.resetZoom(); + assertEquals(0, tab.getZoomDelta()); + } +} diff --git a/src/test/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProviderZoomTest.java b/src/test/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProviderZoomTest.java new file mode 100644 index 00000000..fe42e1fc --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/ui/NetBeansSettingsProviderZoomTest.java @@ -0,0 +1,25 @@ +package io.github.nbclaudecodegui.ui; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class NetBeansSettingsProviderZoomTest { + + @Test + void setZoomDelta_affectsGetTerminalFontSize() { + NetBeansSettingsProvider p = new NetBeansSettingsProvider(); + float base = p.getTerminalFontSize(); + p.setZoomDelta(3); + assertEquals(base + 3, p.getTerminalFontSize(), 0.01f); + } + + @Test + void defaultDeltaIsZero() { + NetBeansSettingsProvider p = new NetBeansSettingsProvider(); + assertEquals(0, p.getTerminalFontSize() - p.getTerminalFontSize(), 0.01f); + // verify delta 0 does not shift size + float before = p.getTerminalFontSize(); + p.setZoomDelta(0); + assertEquals(before, p.getTerminalFontSize(), 0.01f); + } +} diff --git a/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java b/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java new file mode 100644 index 00000000..356c4a06 --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java @@ -0,0 +1,75 @@ +package io.github.nbclaudecodegui.ui.common; + +import java.awt.event.InputEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; +import javax.swing.JLabel; +import javax.swing.JMenu; +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class ZoomSupportTest { + + @Test + void discoverZoomWheelModifiers_returnsAltMask() { + assertEquals(InputEvent.ALT_DOWN_MASK, ZoomSupport.discoverZoomWheelModifiers()); + } + + @Test + void createWheelListener_callsZoomInOnUpWithAltModifier() { + int[] calls = {0, 0}; + Zoomable z = new Zoomable() { + @Override public void zoomIn() { calls[0]++; } + @Override public void zoomOut() { calls[1]++; } + @Override public void resetZoom() {} + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + MouseWheelListener listener = ZoomSupport.createWheelListener(z); + JLabel src = new JLabel(); + MouseWheelEvent up = new MouseWheelEvent(src, MouseWheelEvent.MOUSE_WHEEL, + 0L, InputEvent.ALT_DOWN_MASK, 0, 0, 1, false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -1); + listener.mouseWheelMoved(up); + assertEquals(1, calls[0]); + assertEquals(0, calls[1]); + } + + @Test + void createWheelListener_ignoresWheelWithoutModifier() { + int[] calls = {0, 0}; + Zoomable z = new Zoomable() { + @Override public void zoomIn() { calls[0]++; } + @Override public void zoomOut() { calls[1]++; } + @Override public void resetZoom() {} + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + MouseWheelListener listener = ZoomSupport.createWheelListener(z); + JLabel src = new JLabel(); + MouseWheelEvent noMod = new MouseWheelEvent(src, MouseWheelEvent.MOUSE_WHEEL, + 0L, 0, 0, 0, 1, false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -1); + listener.mouseWheelMoved(noMod); + assertEquals(0, calls[0]); + assertEquals(0, calls[1]); + } + + @Test + void buildZoomMenu_menuHasExpectedItems() { + Zoomable z = new Zoomable() { + @Override public void zoomIn() {} + @Override public void zoomOut() {} + @Override public void resetZoom() {} + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + JMenu menu = ZoomSupport.buildZoomMenu(z); + assertEquals("Zoom", menu.getText()); + // 3 items + 1 separator = 4 components + assertEquals(4, menu.getMenuComponentCount()); + } +} From ce13f81db45744d7c71052da98357957027e1551 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 18 May 2026 09:49:55 +0300 Subject: [PATCH 2/4] Integrated terminal Zoom submenu into JediTerm's native context menu The Zoom submenu was previously registered via JComponent.setComponentPopupMenu(). JediTerm's TerminalPanel builds its right-click menu internally (its own mouse listener calls the protected createPopupMenu() and fills it from the TerminalActionProvider chain) and never consults Swing's component popup, so the Zoom item appeared as a separate single-item menu instead of extending the native Copy/Paste/Clear menu. Added ZoomableJediTermWidget, a JediTermWidget subclass whose overridden createTerminalPanel() returns a TerminalPanel that overrides createPopupMenu(): it builds the native menu via super, then appends a separator and the Zoom submenu. The append logic is extracted into the unit-tested ZoomSupport.appendZoomMenu(JPopupMenu, Zoomable). The same subclass exposes refreshFont(), replacing the reflective TerminalPanel.reinitFontAndResize() call in ClaudeSessionTab.applyTermZoom(). Documented in CLAUDE.md why JediTerm is used instead of NetBeans' built-in terminal (no alt-screen buffer / 256-color, no screen-buffer introspection, non-extensible context menu, no per-instance zoom, friend-only API). Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 25 +++++ pom.xml | 2 +- .../nbclaudecodegui/ui/ClaudeSessionTab.java | 19 +--- .../ui/ZoomableJediTermWidget.java | 91 +++++++++++++++++++ .../ui/common/ZoomSupport.java | 23 +++++ .../ui/common/ZoomSupportTest.java | 55 +++++++++++ 6 files changed, 200 insertions(+), 15 deletions(-) create mode 100644 src/main/java/io/github/nbclaudecodegui/ui/ZoomableJediTermWidget.java diff --git a/CLAUDE.md b/CLAUDE.md index 3e5913fa..a1983623 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,31 @@ When finishing a release by adding `# MAJOR.MINOR` to CHANGELOG.md, the commit m A NetBeans IDE plugin that embeds Claude Code CLI as a PTY-based terminal session connected to the IDE via three channels: PTY, MCP SSE, and an HTTP hook. +### Why JediTerm and not NetBeans' built-in terminal + +The terminal is JetBrains **JediTerm** (`jediterm-core`/`jediterm-ui`), not NetBeans' built-in +terminal (`org.netbeans.lib.terminalemulator.Term` / the "Open in Terminal" action). The built-in +terminal was evaluated and rejected for the following reasons: + +- **Broken TUI rendering (decisive).** `lib.terminalemulator` does not support the alternate + screen buffer (DECSET 1049) nor 256-color SGR codes (`ESC[38;5;Nm` / `ESC[48;5;Nm`). Claude Code + is a full-screen `xterm-256color` TUI that relies on both — it would render corrupted. +- **No screen-buffer introspection.** Only whole-row text (`Term.getRowText(int)`) is exposed; no + per-cell/attribute access. `ScreenContentDetector` polls the rendered screen to detect + interactive choice menus, which would be unreliable without cell/attribute data. +- **Context menu not extensible.** `Term`'s right-click menu is hardcoded in + `org.netbeans.modules.terminal.ioprovider.Terminal` with no public hook. JediTerm, by contrast, + exposes a `createPopupMenu` / `TerminalActionProvider` extension point (see `ZoomableJediTermWidget`). +- **No per-instance font/zoom API.** Term font size is a global setting only — incompatible with + the per-surface zoom feature. +- **Friend-only API.** `lib.terminalemulator` and `terminal` declare no public packages + (friend-package restricted), so they are not a supported third-party API and may break between + NetBeans releases. + +Only PTY hosting is equivalent (`StreamTerm.connect(streams)` vs. JediTerm's `TtyConnector`). +Replacing JediTerm would mean rewriting `process/`, `controller/`, and screen detection for a +net loss of capability — do not pursue it. + ### Process startup `ClaudeProcess.start(workingDir, profile, extraCliArgs, mode, resumeSessionId)`: diff --git a/pom.xml b/pom.xml index 44dde7ef..472b17e3 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.23.13-SNAPSHOT + 0.23.15-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java index e8a26bdc..f73b519a 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java @@ -27,7 +27,6 @@ import java.awt.event.ComponentEvent; import java.awt.event.InputEvent; import java.awt.event.KeyEvent; -import javax.swing.JPopupMenu; import java.io.File; import java.io.IOException; import java.nio.file.Path; @@ -492,15 +491,8 @@ private void applyTermZoom(int d) { if (settingsProvider != null) { settingsProvider.setZoomDelta(clamped); } - if (terminalWidget != null) { - try { - java.lang.reflect.Method m = - terminalWidget.getTerminalPanel().getClass() - .getMethod("reinitFontAndResize"); - m.invoke(terminalWidget.getTerminalPanel()); - } catch (ReflectiveOperationException ex) { - LOG.fine("reinitFontAndResize not available: " + ex.getMessage()); - } + if (terminalWidget instanceof ZoomableJediTermWidget zw) { + zw.refreshFont(); } } @@ -822,7 +814,7 @@ private void startSession(File dir, String profileName, String extraCliArgs, sessionTag = "[" + dir.getName() + "] "; settingsProvider = new NetBeansSettingsProvider(); settingsProvider.setZoomDelta(termZoomDelta); - JediTermWidget widget = new JediTermWidget(settingsProvider); + ZoomableJediTermWidget widget = new ZoomableJediTermWidget(settingsProvider, this); Color termBg = UIManager.getColor("EditorPane.background"); if (termBg == null) termBg = UIManager.getColor("Panel.background"); if (termBg != null) { @@ -912,9 +904,8 @@ public void componentResized(ComponentEvent e) { widget.getTerminalPanel().addMouseWheelListener( ZoomSupport.createWheelListener(this)); - JPopupMenu termMenu = new JPopupMenu(); - termMenu.add(ZoomSupport.buildZoomMenu(this)); - widget.getTerminalPanel().setComponentPopupMenu(termMenu); + // The Zoom submenu is spliced into JediTerm's native context menu by + // ZoomableJediTermWidget (JediTerm ignores setComponentPopupMenu). ZoomSupport.bindResetKey(this, this); add(splitPane, BorderLayout.CENTER); diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ZoomableJediTermWidget.java b/src/main/java/io/github/nbclaudecodegui/ui/ZoomableJediTermWidget.java new file mode 100644 index 00000000..52e6cbb4 --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/ui/ZoomableJediTermWidget.java @@ -0,0 +1,91 @@ +package io.github.nbclaudecodegui.ui; + +import com.jediterm.terminal.model.StyleState; +import com.jediterm.terminal.model.TerminalTextBuffer; +import com.jediterm.terminal.ui.JediTermWidget; +import com.jediterm.terminal.ui.TerminalActionProvider; +import com.jediterm.terminal.ui.TerminalPanel; +import com.jediterm.terminal.ui.settings.SettingsProvider; +import io.github.nbclaudecodegui.ui.common.Zoomable; +import io.github.nbclaudecodegui.ui.common.ZoomSupport; +import javax.swing.JPopupMenu; + +/** + * A {@link JediTermWidget} that splices a "Zoom" submenu into the terminal's + * native right-click context menu. + * + *

JediTerm builds its context menu internally: {@code TerminalPanel} has its + * own mouse listener that calls the protected + * {@code TerminalPanel.createPopupMenu(TerminalActionProvider)} and fills it from + * the {@code TerminalActionProvider} chain. It never consults Swing's + * {@code JComponent.setComponentPopupMenu()}. Adding a Zoom item via the Swing + * popup mechanism therefore produces a separate single-item popup + * instead of extending the native Copy/Paste/Clear menu. + * + *

The only clean integration point is to subclass {@code TerminalPanel} and + * override {@code createPopupMenu}: build the native menu via {@code super}, then + * append the Zoom submenu. This widget overrides the protected + * {@link #createTerminalPanel} factory so JediTerm uses that customized panel, + * and exposes {@link #refreshFont()} so callers can re-apply the font after a + * zoom change without reflection. + */ +public class ZoomableJediTermWidget extends JediTermWidget { + + private final Zoomable zoomable; + private ZoomTerminalPanel zoomPanel; + + /** + * @param settingsProvider terminal settings (font, colors); also supplies the + * zoom-adjusted font size + * @param zoomable the surface whose font the context-menu Zoom submenu + * and {@link #refreshFont()} operate on + */ + public ZoomableJediTermWidget(SettingsProvider settingsProvider, Zoomable zoomable) { + super(settingsProvider); + this.zoomable = zoomable; + } + + /** + * Re-applies the (possibly zoom-adjusted) font to the terminal and resizes + * the grid. Delegates to {@code TerminalPanel.reinitFontAndResize()}, which + * is {@code protected} and reachable only from within a panel subclass. + */ + public void refreshFont() { + if (zoomPanel != null) { + zoomPanel.refreshFont(); + } + } + + @Override + protected TerminalPanel createTerminalPanel(SettingsProvider settingsProvider, + StyleState styleState, + TerminalTextBuffer textBuffer) { + // Invoked from the JediTermWidget super-constructor (before this subclass's + // constructor body runs). 'zoomable' is read only later, lazily, from + // createPopupMenu()/refreshFont() — never here — so its not-yet-assigned + // state at this point is harmless. + zoomPanel = new ZoomTerminalPanel(settingsProvider, textBuffer, styleState); + return zoomPanel; + } + + /** Terminal panel that appends the Zoom submenu to the native context menu. */ + private final class ZoomTerminalPanel extends TerminalPanel { + + ZoomTerminalPanel(SettingsProvider settingsProvider, + TerminalTextBuffer textBuffer, + StyleState styleState) { + super(settingsProvider, textBuffer, styleState); + } + + @Override + protected JPopupMenu createPopupMenu(TerminalActionProvider provider) { + JPopupMenu menu = super.createPopupMenu(provider); + ZoomSupport.appendZoomMenu(menu, zoomable); + return menu; + } + + void refreshFont() { + reinitFontAndResize(); + } + } +} diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java b/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java index c3c1bf6b..1d45706f 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java @@ -8,6 +8,7 @@ import javax.swing.JComponent; import javax.swing.JMenu; import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; import javax.swing.KeyStroke; import java.awt.event.KeyEvent; @@ -59,6 +60,28 @@ public static JMenu buildZoomMenu(Zoomable zoomable) { return menu; } + /** + * Appends a separator (only when {@code menu} already has items) followed by + * the "Zoom" submenu produced by {@link #buildZoomMenu(Zoomable)} to the given + * popup. Used to splice the Zoom controls into JediTerm's native terminal + * context menu, which JediTerm builds internally and does not expose via + * Swing's {@code setComponentPopupMenu}. + * + * @param menu the popup to extend (typically JediTerm's native context menu); + * must not be {@code null} + * @param zoomable the surface whose font is zoomed by the submenu items; a + * {@code null} value makes this a no-op + */ + public static void appendZoomMenu(JPopupMenu menu, Zoomable zoomable) { + if (zoomable == null) { + return; + } + if (menu.getComponentCount() > 0) { + menu.addSeparator(); + } + menu.add(buildZoomMenu(zoomable)); + } + /** Binds Ctrl+0 → resetZoom() on the given component via InputMap/ActionMap. */ public static void bindResetKey(JComponent comp, Zoomable zoomable) { KeyStroke ks = KeyStroke.getKeyStroke(KeyEvent.VK_0, InputEvent.CTRL_DOWN_MASK); diff --git a/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java b/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java index 356c4a06..52e70fdc 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java @@ -5,6 +5,9 @@ import java.awt.event.MouseWheelListener; import javax.swing.JLabel; import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JSeparator; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; @@ -72,4 +75,56 @@ void buildZoomMenu_menuHasExpectedItems() { // 3 items + 1 separator = 4 components assertEquals(4, menu.getMenuComponentCount()); } + + private static Zoomable stubZoomable() { + return new Zoomable() { + @Override public void zoomIn() {} + @Override public void zoomOut() {} + @Override public void resetZoom() {} + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + } + + @Test + void appendZoomMenu_addsSeparatorAndZoomSubmenuAfterExistingItems() { + JPopupMenu menu = new JPopupMenu(); + JMenuItem first = new JMenuItem("Copy"); + JMenuItem second = new JMenuItem("Paste"); + menu.add(first); + menu.add(second); + + ZoomSupport.appendZoomMenu(menu, stubZoomable()); + + assertEquals(4, menu.getComponentCount()); + assertSame(first, menu.getComponent(0)); + assertSame(second, menu.getComponent(1)); + assertTrue(menu.getComponent(2) instanceof JSeparator); + assertTrue(menu.getComponent(3) instanceof JMenu); + JMenu zoom = (JMenu) menu.getComponent(3); + assertEquals("Zoom", zoom.getText()); + assertEquals(4, zoom.getMenuComponentCount()); + } + + @Test + void appendZoomMenu_noLeadingSeparatorOnEmptyMenu() { + JPopupMenu menu = new JPopupMenu(); + + ZoomSupport.appendZoomMenu(menu, stubZoomable()); + + assertEquals(1, menu.getComponentCount()); + assertTrue(menu.getComponent(0) instanceof JMenu); + assertEquals("Zoom", ((JMenu) menu.getComponent(0)).getText()); + } + + @Test + void appendZoomMenu_nullZoomableIsNoOp() { + JPopupMenu menu = new JPopupMenu(); + menu.add(new JMenuItem("Copy")); + + ZoomSupport.appendZoomMenu(menu, null); + + assertEquals(1, menu.getComponentCount()); + } } From c5f458729935bbf72566e436931f5658b50148ce Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 18 May 2026 10:24:00 +0300 Subject: [PATCH 3/4] Fixed Markdown Preview mouse-wheel scrolling broken by the zoom listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding any MouseWheelListener to the JEditorPane disabled Swing's native dispatchMouseWheelToAncestor fallback (gated by eventTypeEnabled(MOUSE_WHEEL)), so plain wheel events no longer reached the wrapping JScrollPane. The zoom listener only handled Alt+wheel and dropped the rest. Added a createWheelListener(Zoomable, boolean forwardToScrollAncestor) overload that re-dispatches non-zoom wheel events to the nearest ancestor JScrollPane, replicating the JDK's behavior. Markdown Preview opts in (both openLive and componentOpened paths); the legacy single-arg method delegates with false so JediTerm's terminal panel — which scrolls via its own wheel listener — is unchanged. Co-Authored-By: Claude Opus 4.7 --- pom.xml | 2 +- .../ui/MarkdownPreviewTab.java | 4 +- .../ui/common/ZoomSupport.java | 48 +++++++++++ .../ui/common/ZoomSupportTest.java | 81 +++++++++++++++++++ 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 472b17e3..02a9a660 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.23.15-SNAPSHOT + 0.23.16-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java index 2d40cf08..69d085bb 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java @@ -173,7 +173,7 @@ public static void openLive(String filePath, String content, FileObject fo) { tab.bindRefreshKey(tab.pane); tab.pane.setComponentPopupMenu(tab.buildContextMenu()); tab.initFindBar(tab.pane); - tab.pane.addMouseWheelListener(ZoomSupport.createWheelListener(tab)); + tab.pane.addMouseWheelListener(ZoomSupport.createWheelListener(tab, true)); ZoomSupport.bindResetKey(tab.pane, tab); tab.applyMdZoom(tab.mdZoomDelta); @@ -700,7 +700,7 @@ protected void componentOpened() { bindRefreshKey(pane); pane.setComponentPopupMenu(buildContextMenu()); initFindBar(pane); - pane.addMouseWheelListener(ZoomSupport.createWheelListener(this)); + pane.addMouseWheelListener(ZoomSupport.createWheelListener(this, true)); ZoomSupport.bindResetKey(pane, this); applyMdZoom(mdZoomDelta); } diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java b/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java index 1d45706f..1dc959c1 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java @@ -1,5 +1,7 @@ package io.github.nbclaudecodegui.ui.common; +import java.awt.Component; +import java.awt.Point; import java.awt.event.ActionEvent; import java.awt.event.InputEvent; import java.awt.event.MouseWheelEvent; @@ -9,7 +11,9 @@ import javax.swing.JMenu; import javax.swing.JMenuItem; import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; import java.awt.event.KeyEvent; /** Shared zoom utilities for terminal and markdown preview surfaces. */ @@ -27,6 +31,24 @@ public static int discoverZoomWheelModifiers() { /** Creates a MouseWheelListener that calls zoomIn/zoomOut when Alt is held. */ public static MouseWheelListener createWheelListener(Zoomable zoomable) { + return createWheelListener(zoomable, false); + } + + /** + * Creates a MouseWheelListener that calls zoomIn/zoomOut when Alt is held. + *

+ * When {@code forwardToScrollAncestor} is {@code true}, non-zoom wheel events + * (Alt not held) are re-dispatched to the nearest ancestor {@link JScrollPane}. + * This is required when the listener is attached to a component (e.g. a + * {@code JEditorPane} inside a {@code JScrollPane}) whose plain scrolling + * relied on Swing's automatic forwarding to the scroll pane — registering any + * wheel listener on that component disables the JDK's + * {@code dispatchMouseWheelToAncestor} fallback, so we replicate it here. + * Surfaces that scroll via their own wheel listener (e.g. JediTerm's + * terminal panel) pass {@code false} and keep their original behavior. + */ + public static MouseWheelListener createWheelListener(Zoomable zoomable, + boolean forwardToScrollAncestor) { int mask = discoverZoomWheelModifiers(); return (MouseWheelEvent e) -> { if ((e.getModifiersEx() & mask) != 0) { @@ -36,10 +58,36 @@ public static MouseWheelListener createWheelListener(Zoomable zoomable) { } else { zoomable.zoomOut(); } + } else if (forwardToScrollAncestor) { + forwardToScrollAncestor(e); } }; } + /** + * Re-dispatches a non-zoom wheel event to the nearest ancestor + * {@link JScrollPane}, mirroring the JDK's native + * {@code Component.dispatchMouseWheelToAncestor} so plain scrolling matches + * the pre-zoom default. + */ + private static void forwardToScrollAncestor(MouseWheelEvent e) { + Component src = e.getComponent(); + if (src == null) { + return; + } + JScrollPane sp = (JScrollPane) + SwingUtilities.getAncestorOfClass(JScrollPane.class, src); + if (sp == null) { + return; + } + Point p = SwingUtilities.convertPoint(src, e.getPoint(), sp); + sp.dispatchEvent(new MouseWheelEvent(sp, e.getID(), e.getWhen(), + e.getModifiersEx(), p.x, p.y, e.getXOnScreen(), e.getYOnScreen(), + e.getClickCount(), e.isPopupTrigger(), e.getScrollType(), + e.getScrollAmount(), e.getWheelRotation(), + e.getPreciseWheelRotation())); + } + /** Builds a "Zoom" JMenu with Increase, Decrease, and Reset items. */ public static JMenu buildZoomMenu(Zoomable zoomable) { JMenu menu = new JMenu("Zoom"); diff --git a/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java b/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java index 52e70fdc..e1d0e3a4 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java @@ -3,10 +3,12 @@ import java.awt.event.InputEvent; import java.awt.event.MouseWheelEvent; import java.awt.event.MouseWheelListener; +import javax.swing.JEditorPane; import javax.swing.JLabel; import javax.swing.JMenu; import javax.swing.JMenuItem; import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; import javax.swing.JSeparator; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; @@ -127,4 +129,83 @@ void appendZoomMenu_nullZoomableIsNoOp() { assertEquals(1, menu.getComponentCount()); } + + // --- Scroll-forwarding overload (Markdown Preview regression) ------------- + + private static Zoomable countingZoomable(int[] calls) { + return new Zoomable() { + @Override public void zoomIn() { calls[0]++; } + @Override public void zoomOut() { calls[1]++; } + @Override public void resetZoom() {} + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + } + + /** pane inside a JScrollPane; returns the scroll pane with a recording wheel listener. */ + private static JScrollPane scrolledPane(JEditorPane pane, int[] forwarded) { + JScrollPane sp = new JScrollPane(pane); + sp.addMouseWheelListener((MouseWheelEvent e) -> forwarded[0]++); + return sp; + } + + private static MouseWheelEvent wheel(java.awt.Component src, int modifiers) { + return new MouseWheelEvent(src, MouseWheelEvent.MOUSE_WHEEL, + 0L, modifiers, 0, 0, 1, false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -1); + } + + @Test + void createWheelListener_forwardTrue_redispatchesPlainWheelToScrollAncestor() { + int[] calls = {0, 0}; + int[] forwarded = {0}; + JEditorPane pane = new JEditorPane(); + scrolledPane(pane, forwarded); + + MouseWheelListener listener = ZoomSupport.createWheelListener(countingZoomable(calls), true); + MouseWheelEvent plain = wheel(pane, 0); + listener.mouseWheelMoved(plain); + + assertEquals(1, forwarded[0], "plain wheel must be forwarded to the JScrollPane"); + assertEquals(0, calls[0]); + assertEquals(0, calls[1]); + assertFalse(plain.isConsumed(), "forwarded event must not be consumed"); + } + + @Test + void createWheelListener_forwardFalse_doesNotForward_terminalBehaviorUnchanged() { + int[] calls = {0, 0}; + int[] forwarded = {0}; + JEditorPane pane = new JEditorPane(); + scrolledPane(pane, forwarded); + + // Explicit false overload + ZoomSupport.createWheelListener(countingZoomable(calls), false) + .mouseWheelMoved(wheel(pane, 0)); + // Legacy single-arg method must keep the same (non-forwarding) behavior + ZoomSupport.createWheelListener(countingZoomable(calls)) + .mouseWheelMoved(wheel(pane, 0)); + + assertEquals(0, forwarded[0], "no forwarding when forwardToScrollAncestor is false"); + assertEquals(0, calls[0]); + assertEquals(0, calls[1]); + } + + @Test + void createWheelListener_forwardTrue_altWheelStillZoomsAndIsNotForwarded() { + int[] calls = {0, 0}; + int[] forwarded = {0}; + JEditorPane pane = new JEditorPane(); + scrolledPane(pane, forwarded); + + MouseWheelListener listener = ZoomSupport.createWheelListener(countingZoomable(calls), true); + MouseWheelEvent alt = wheel(pane, InputEvent.ALT_DOWN_MASK); + listener.mouseWheelMoved(alt); + + assertEquals(1, calls[0], "Alt+wheel up must zoom in"); + assertEquals(0, calls[1]); + assertEquals(0, forwarded[0], "zoom event must not be forwarded to the scroll pane"); + assertTrue(alt.isConsumed(), "zoom event must be consumed"); + } } From b918dba82e5ec94ca3643d951d6daddd9958e25b Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 18 May 2026 11:22:44 +0300 Subject: [PATCH 4/4] Fixed Ctrl+0 and F5 not working in Markdown Preview Ctrl+0 (reset zoom) and F5 (refresh from disk) were registered only in the JEditorPane's WHEN_IN_FOCUSED_WINDOW input map, which the NetBeans global keymap pre-empts. Mirroring the earlier Ctrl+F fix, both keystrokes are now also bound in WHEN_FOCUSED so they win while the preview pane is focused. ZoomSupport.bindResetKey is shared with the terminal, but the change is inert there (the TopComponent is never the focus owner and Ctrl+0 is handled via a KeyEventDispatcher). Added unit tests for both bindings and a CHANGELOG entry for the F5 regression (Ctrl+0 is part of the still-unreleased zoom feature). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + pom.xml | 2 +- .../ui/MarkdownPreviewTab.java | 5 ++- .../ui/common/ZoomSupport.java | 2 ++ .../nbclaudecodegui/ui/MarkdownZoomTest.java | 16 +++++++++ .../ui/common/ZoomSupportTest.java | 34 +++++++++++++++++++ 6 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d6e5b7..92544b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed F5 not refreshing the Markdown Preview from disk - Added font zoom (Alt+Scroll) for Terminal and Markdown Preview; Ctrl+0 resets; right-click "Zoom" submenu - Added Find bar (Ctrl+F / F3 / Shift+F3) to Markdown Preview - Added automatic Markdown Preview tab when Claude writes a plan file (configurable in Settings → General) diff --git a/pom.xml b/pom.xml index 02a9a660..aab214c0 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.23.16-SNAPSHOT + 0.23.17-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java index 69d085bb..ea4dbdd7 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java @@ -640,8 +640,11 @@ public void popupMenuWillBecomeVisible(PopupMenuEvent e) { return menu; } - private void bindRefreshKey(JEditorPane p) { + void bindRefreshKey(JEditorPane p) { KeyStroke f5 = KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0); + // WHEN_FOCUSED overrides the NetBeans global F5 action while the pane is focused + p.getInputMap(JComponent.WHEN_FOCUSED).put(f5, "md-refresh"); + // WHEN_IN_FOCUSED_WINDOW still covers F5 from elsewhere in the tab (e.g. find bar) p.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(f5, "md-refresh"); p.getActionMap().put("md-refresh", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { forceReload(); } diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java b/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java index 1dc959c1..397cef9d 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/ZoomSupport.java @@ -133,6 +133,8 @@ public static void appendZoomMenu(JPopupMenu menu, Zoomable zoomable) { /** Binds Ctrl+0 → resetZoom() on the given component via InputMap/ActionMap. */ public static void bindResetKey(JComponent comp, Zoomable zoomable) { KeyStroke ks = KeyStroke.getKeyStroke(KeyEvent.VK_0, InputEvent.CTRL_DOWN_MASK); + // WHEN_FOCUSED overrides the NetBeans global Ctrl+0 action while comp is focused + comp.getInputMap(JComponent.WHEN_FOCUSED).put(ks, "zoom-reset"); comp.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, "zoom-reset"); comp.getActionMap().put("zoom-reset", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { zoomable.resetZoom(); } diff --git a/src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java b/src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java index 610503a2..c0eb3065 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/MarkdownZoomTest.java @@ -1,7 +1,10 @@ package io.github.nbclaudecodegui.ui; import io.github.nbclaudecodegui.ui.common.MarkdownRenderer; +import java.awt.event.KeyEvent; +import javax.swing.JComponent; import javax.swing.JEditorPane; +import javax.swing.KeyStroke; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -49,4 +52,17 @@ void resetZoom_setsZero() { tab.resetZoom(); assertEquals(0, tab.getZoomDelta()); } + + @Test + void bindRefreshKey_registersF5InFocusedAndWindowMaps() { + tab.bindRefreshKey(tab.pane); + + KeyStroke f5 = KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0); + assertEquals("md-refresh", + tab.pane.getInputMap(JComponent.WHEN_FOCUSED).get(f5), + "F5 must be bound in WHEN_FOCUSED to beat the NetBeans global action"); + assertEquals("md-refresh", + tab.pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).get(f5)); + assertNotNull(tab.pane.getActionMap().get("md-refresh")); + } } diff --git a/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java b/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java index e1d0e3a4..730cccf6 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/common/ZoomSupportTest.java @@ -1,10 +1,14 @@ package io.github.nbclaudecodegui.ui.common; import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; import java.awt.event.MouseWheelEvent; import java.awt.event.MouseWheelListener; +import javax.swing.Action; +import javax.swing.JComponent; import javax.swing.JEditorPane; import javax.swing.JLabel; +import javax.swing.KeyStroke; import javax.swing.JMenu; import javax.swing.JMenuItem; import javax.swing.JPopupMenu; @@ -208,4 +212,34 @@ void createWheelListener_forwardTrue_altWheelStillZoomsAndIsNotForwarded() { assertEquals(0, forwarded[0], "zoom event must not be forwarded to the scroll pane"); assertTrue(alt.isConsumed(), "zoom event must be consumed"); } + + // --- bindResetKey: Ctrl+0 must reach the focused surface (NetBeans steals + // WHEN_IN_FOCUSED_WINDOW), so it must also be in WHEN_FOCUSED ----------- + + @Test + void bindResetKey_registersCtrl0InFocusedAndWindowMaps() { + int[] resets = {0}; + Zoomable z = new Zoomable() { + @Override public void zoomIn() {} + @Override public void zoomOut() {} + @Override public void resetZoom() { resets[0]++; } + @Override public int getZoomDelta() { return 0; } + @Override public int getMinDelta() { return -8; } + @Override public int getMaxDelta() { return 20; } + }; + JLabel comp = new JLabel(); + + ZoomSupport.bindResetKey(comp, z); + + KeyStroke ctrl0 = KeyStroke.getKeyStroke(KeyEvent.VK_0, InputEvent.CTRL_DOWN_MASK); + assertEquals("zoom-reset", + comp.getInputMap(JComponent.WHEN_FOCUSED).get(ctrl0), + "Ctrl+0 must be bound in WHEN_FOCUSED to beat the NetBeans global action"); + assertEquals("zoom-reset", + comp.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).get(ctrl0)); + Action action = comp.getActionMap().get("zoom-reset"); + assertNotNull(action); + action.actionPerformed(null); + assertEquals(1, resets[0], "the bound action must invoke resetZoom()"); + } }