From c3b2897b3fe2b0abd7969c41554574c74ac6eb65 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sun, 17 May 2026 16:56:09 +0300 Subject: [PATCH 1/2] Added Find bar (Ctrl+F / F3 / Shift+F3) to Markdown Preview Find bar mirrors NetBeans SearchBar layout and uses its icons loaded from the editor.search module JAR via ImageUtilities. Supports incremental search, match case, whole words, regexp, highlight all, wrap around. Added to MarkdownPreviewTab (single-pane preview) and MarkdownDiffPanel (per-pane, independent bars). Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + pom.xml | 2 +- .../nbclaudecodegui/ui/MarkdownDiffPanel.java | 53 ++- .../ui/MarkdownPreviewTab.java | 48 +- .../ui/common/markdown/MarkdownFindBar.java | 432 ++++++++++++++++++ .../common/markdown/MarkdownFindBarTest.java | 126 +++++ 6 files changed, 657 insertions(+), 5 deletions(-) create mode 100644 src/main/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBar.java create mode 100644 src/test/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBarTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a99f1a..e6171003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- 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 - Added `show_markdown_file` MCP tool: opens a live-updating Markdown Preview tab for a .md file on disk diff --git a/pom.xml b/pom.xml index fd149405..7c0bf56a 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.23.10-SNAPSHOT + 0.23.11-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/MarkdownDiffPanel.java b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownDiffPanel.java index d4f5a915..7b9e2279 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownDiffPanel.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownDiffPanel.java @@ -1,15 +1,22 @@ package io.github.nbclaudecodegui.ui; import io.github.nbclaudecodegui.ui.common.MarkdownRenderer; +import io.github.nbclaudecodegui.ui.common.markdown.MarkdownFindBar; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Container; +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import javax.swing.AbstractAction; +import javax.swing.JComponent; import javax.swing.JEditorPane; import javax.swing.JMenuItem; import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.JSplitPane; +import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import org.openide.util.NbPreferences; @@ -66,7 +73,18 @@ public MarkdownDiffPanel(String before, String after) { beforeScroll = new JScrollPane(mdBeforePane); afterScroll = new JScrollPane(mdAfterPane); - splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, beforeScroll, afterScroll); + MarkdownFindBar beforeFindBar = new MarkdownFindBar(mdBeforePane); + MarkdownFindBar afterFindBar = new MarkdownFindBar(mdAfterPane); + + JPanel beforePanel = new JPanel(new BorderLayout()); + beforePanel.add(beforeScroll, BorderLayout.CENTER); + beforePanel.add(beforeFindBar, BorderLayout.SOUTH); + + JPanel afterPanel = new JPanel(new BorderLayout()); + afterPanel.add(afterScroll, BorderLayout.CENTER); + afterPanel.add(afterFindBar, BorderLayout.SOUTH); + + splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, beforePanel, afterPanel); splitPane.setResizeWeight(0.5); // restore divider @@ -86,10 +104,16 @@ public MarkdownDiffPanel(String before, String after) { add(splitPane, BorderLayout.CENTER); wireScrollSync(); - // Add "Hide" and "Pin Preview" to the existing popup menus on both editor panes - for (JEditorPane pane : new JEditorPane[]{mdBeforePane, mdAfterPane}) { + // Add "Find…", "Hide", and "Pin Preview" to the existing popup menus on both panes + for (int i = 0; i < 2; i++) { + JEditorPane pane = i == 0 ? mdBeforePane : mdAfterPane; + MarkdownFindBar fb = i == 0 ? beforeFindBar : afterFindBar; JPopupMenu menu = pane.getComponentPopupMenu(); if (menu == null) { menu = new JPopupMenu(); pane.setComponentPopupMenu(menu); } + JMenuItem findItem = new JMenuItem("Find…"); + findItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK)); + findItem.addActionListener(e -> fb.gainFocus()); + menu.add(findItem); menu.addSeparator(); JMenuItem hideItem = new JMenuItem("Hide"); hideItem.addActionListener(e -> { if (onHide != null) onHide.run(); }); @@ -97,11 +121,34 @@ public MarkdownDiffPanel(String before, String after) { pinItem.addActionListener(e -> { if (onPinPreview != null) onPinPreview.run(); }); menu.add(hideItem); menu.add(pinItem); + bindFindBarKeys(pane, fb); } // Let right-click on the divider also show the menu splitPane.setComponentPopupMenu(mdBeforePane.getComponentPopupMenu()); } + private static void bindFindBarKeys(JEditorPane pane, MarkdownFindBar fb) { + KeyStroke ctrlF = KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK); + KeyStroke f3 = KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0); + KeyStroke shiftF3 = KeyStroke.getKeyStroke(KeyEvent.VK_F3, InputEvent.SHIFT_DOWN_MASK); + pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ctrlF, "mddiff-find-show"); + pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(f3, "mddiff-find-next"); + pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(shiftF3, "mddiff-find-prev"); + pane.getActionMap().put("mddiff-find-show", new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { fb.gainFocus(); } + }); + pane.getActionMap().put("mddiff-find-next", new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { + if (fb.isVisible()) fb.findNext(); else fb.gainFocus(); + } + }); + pane.getActionMap().put("mddiff-find-prev", new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { + if (fb.isVisible()) fb.findPrev(); else fb.gainFocus(); + } + }); + } + private void wireScrollSync() { beforeScroll.getViewport().addChangeListener(e -> { if (syncActive) return; diff --git a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java index e26ba7b5..88d34971 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java @@ -3,6 +3,7 @@ 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.markdown.MarkdownFindBar; import java.awt.BorderLayout; import java.awt.Toolkit; import java.awt.datatransfer.StringSelection; @@ -103,6 +104,9 @@ public class MarkdownPreviewTab extends TopComponent { /** Snapshot of the href captured when the context menu opens; read by action listeners. */ String menuHref = null; + /** Find bar shown at the bottom of the preview. */ + private MarkdownFindBar findBar; + /** File path saved by writeExternal; applied in componentOpened() after IDE restart. */ String savedFilePath = null; @@ -156,10 +160,11 @@ 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 and context menu + // Attach hyperlink listener, context menu, and find bar tab.attachHyperlinkListener(); tab.bindRefreshKey(tab.pane); tab.pane.setComponentPopupMenu(tab.buildContextMenu()); + tab.initFindBar(tab.pane); if (fo != null) { tab.fileListener = makeFileListener(tab, fo); @@ -207,6 +212,40 @@ public void updateContent(String markdown) { }); } + private void initFindBar(JEditorPane p) { + findBar = new MarkdownFindBar(p); + add(findBar, BorderLayout.SOUTH); + bindFindBarKeys(p); + } + + private void bindFindBarKeys(JEditorPane p) { + KeyStroke ctrlF = KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK); + KeyStroke f3 = KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0); + KeyStroke shiftF3 = KeyStroke.getKeyStroke(KeyEvent.VK_F3, InputEvent.SHIFT_DOWN_MASK); + p.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ctrlF, "md-find-show"); + p.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(f3, "md-find-next"); + p.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(shiftF3, "md-find-prev"); + p.getActionMap().put("md-find-show", new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { showFindBar(); } + }); + p.getActionMap().put("md-find-next", new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { + if (findBar != null && findBar.isVisible()) findBar.findNext(); + else showFindBar(); + } + }); + p.getActionMap().put("md-find-prev", new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { + if (findBar != null && findBar.isVisible()) findBar.findPrev(); + else showFindBar(); + } + }); + } + + void showFindBar() { + if (findBar != null) findBar.gainFocus(); + } + @Override public void componentClosed() { super.componentClosed(); @@ -483,6 +522,10 @@ JPopupMenu buildContextMenu() { refresh.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0)); refresh.addActionListener(e -> forceReload()); + JMenuItem find = new JMenuItem("Find…"); + find.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK)); + find.addActionListener(e -> showFindBar()); + JMenuItem selectAll = new JMenuItem("Select All"); selectAll.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_A, InputEvent.CTRL_DOWN_MASK)); selectAll.addActionListener(e -> { if (pane != null) pane.selectAll(); }); @@ -508,6 +551,8 @@ JPopupMenu buildContextMenu() { menu.add(forward); menu.add(refresh); menu.addSeparator(); + menu.add(find); + menu.addSeparator(); menu.add(selectAll); menu.add(copy); @@ -604,6 +649,7 @@ protected void componentOpened() { attachHyperlinkListener(); bindRefreshKey(pane); pane.setComponentPopupMenu(buildContextMenu()); + initFindBar(pane); } filePath = path; diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBar.java b/src/main/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBar.java new file mode 100644 index 00000000..722e0dc9 --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBar.java @@ -0,0 +1,432 @@ +package io.github.nbclaudecodegui.ui.common.markdown; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Insets; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import javax.swing.AbstractAction; +import javax.swing.AbstractButton; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.Icon; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSeparator; +import javax.swing.JToggleButton; +import javax.swing.KeyStroke; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; +import javax.swing.text.DefaultHighlighter; +import javax.swing.text.Highlighter; +import javax.swing.text.JTextComponent; +import org.openide.awt.CloseButtonFactory; +import org.openide.util.ImageUtilities; + +/** + * Find bar for Markdown Preview, mirroring the NetBeans SearchBar UI layout and icons. + * Attach to a {@link JTextComponent} (typically a JEditorPane with HTMLEditorKit). + * Icons are loaded from the {@code org-netbeans-modules-editor-search} module JAR at runtime. + */ +public class MarkdownFindBar extends JPanel { + + private static final String ICON_BASE = "org/netbeans/modules/editor/search/resources/"; + private static final Insets BTN_INSETS = new Insets(2, 1, 0, 1); + + private static final Color HIGHLIGHT_ALL_COLOR = new Color(255, 255, 160); + private static final Color HIGHLIGHT_CURRENT_COLOR = new Color(255, 165, 0); + + private final JTextComponent targetPane; + + final JLabel findLabel; + final JComboBox incSearchComboBox; + final JButton findPreviousButton; + final JButton findNextButton; + final JButton selectAllButton; + final JToggleButton matchCase; + final JToggleButton wholeWords; + final JToggleButton regexp; + final JToggleButton highlight; + final JToggleButton wrapAround; + final JLabel matches; + final JButton closeButton; + + private final Highlighter.HighlightPainter allPainter = + new DefaultHighlighter.DefaultHighlightPainter(HIGHLIGHT_ALL_COLOR); + private final Highlighter.HighlightPainter currentPainter = + new DefaultHighlighter.DefaultHighlightPainter(HIGHLIGHT_CURRENT_COLOR); + + /** Positions of all current matches as [start, end] pairs. */ + private final List matchPositions = new ArrayList<>(); + private int currentMatchIndex = -1; + + /** Highlight tags for all-match highlights (to remove them later). */ + private final List allHighlightTags = new ArrayList<>(); + private Object currentHighlightTag = null; + + public MarkdownFindBar(JTextComponent targetPane) { + this.targetPane = targetPane; + + setLayout(new BoxLayout(this, BoxLayout.LINE_AXIS)); + + findLabel = new JLabel("Find:"); + incSearchComboBox = new JComboBox<>(); + incSearchComboBox.setEditable(true); + incSearchComboBox.setMaximumSize(new Dimension(300, incSearchComboBox.getPreferredSize().height)); + + findPreviousButton = createButton("find_previous.png"); + findPreviousButton.setToolTipText("Find Previous (Shift+F3)"); + findNextButton = createButton("find_next.png"); + findNextButton.setToolTipText("Find Next (F3)"); + selectAllButton = createButton("select_all.png"); + selectAllButton.setToolTipText("Select All (Alt+Enter)"); + + matchCase = createToggleButton("matchCase.png"); + matchCase.setToolTipText("Match Case"); + wholeWords = createToggleButton("wholeWord.png"); + wholeWords.setToolTipText("Whole Words"); + regexp = createToggleButton("regexp.png"); + regexp.setToolTipText("Regular Expression"); + highlight = createToggleButton("highlight.png"); + highlight.setToolTipText("Highlight All"); + highlight.setSelected(true); + highlight.setContentAreaFilled(true); + highlight.setBorderPainted(true); + wrapAround = createToggleButton("wrapAround.png"); + wrapAround.setToolTipText("Wrap Around"); + wrapAround.setSelected(true); + wrapAround.setContentAreaFilled(true); + wrapAround.setBorderPainted(true); + + matches = new JLabel(); + closeButton = CloseButtonFactory.createBigCloseButton(); + closeButton.setToolTipText("Close (Escape)"); + + // Layout: mirrors NetBeans SearchBar order + add(Box.createHorizontalStrut(8)); + add(findLabel); + add(Box.createHorizontalStrut(4)); + add(incSearchComboBox); + add(Box.createHorizontalStrut(4)); + add(createVerticalSeparator()); + add(findPreviousButton); + add(findNextButton); + add(selectAllButton); + add(createVerticalSeparator()); + add(matchCase); + add(wholeWords); + add(regexp); + add(highlight); + add(wrapAround); + add(Box.createHorizontalGlue()); + add(matches); + add(Box.createHorizontalStrut(8)); + add(closeButton); + + wireActions(); + setVisible(false); + } + + private static JButton createButton(String iconFile) { + Icon icon = ImageUtilities.loadImageIcon(ICON_BASE + iconFile, false); + JButton btn = icon != null ? new JButton(icon) : new JButton(iconFile); + styleButton(btn); + return btn; + } + + private static JToggleButton createToggleButton(String iconFile) { + Icon icon = ImageUtilities.loadImageIcon(ICON_BASE + iconFile, false); + JToggleButton btn = icon != null ? new JToggleButton(icon) : new JToggleButton(iconFile); + btn.addMouseListener(HOVER_LISTENER); + btn.setContentAreaFilled(false); + btn.setBorderPainted(false); + btn.setMargin(BTN_INSETS); + btn.setFocusable(false); + // When selected, keep border/fill shown + btn.addChangeListener(e -> { + if (!btn.getModel().isRollover()) { + btn.setContentAreaFilled(btn.isSelected()); + btn.setBorderPainted(btn.isSelected()); + } + }); + return btn; + } + + private static void styleButton(AbstractButton btn) { + btn.setContentAreaFilled(false); + btn.setBorderPainted(false); + btn.setMargin(BTN_INSETS); + btn.setFocusable(false); + btn.addMouseListener(HOVER_LISTENER); + } + + private static final MouseAdapter HOVER_LISTENER = new MouseAdapter() { + @Override public void mouseEntered(MouseEvent e) { + AbstractButton btn = (AbstractButton) e.getSource(); + if (btn.isEnabled()) { btn.setContentAreaFilled(true); btn.setBorderPainted(true); } + } + @Override public void mouseExited(MouseEvent e) { + AbstractButton btn = (AbstractButton) e.getSource(); + if (!(btn instanceof JToggleButton) || !btn.isSelected()) { + btn.setContentAreaFilled(false); + btn.setBorderPainted(false); + } + } + }; + + private static JSeparator createVerticalSeparator() { + JSeparator sep = new JSeparator(SwingConstants.VERTICAL); + sep.setMaximumSize(new Dimension(2, 20)); + return sep; + } + + private void wireActions() { + // Text field document listener → incremental search + getTextField().getDocument().addDocumentListener(new DocumentListener() { + @Override public void insertUpdate(DocumentEvent e) { search(); } + @Override public void removeUpdate(DocumentEvent e) { search(); } + @Override public void changedUpdate(DocumentEvent e) { search(); } + }); + + // Toggle buttons → re-search + matchCase.addActionListener(e -> search()); + wholeWords.addActionListener(e -> search()); + regexp.addActionListener(e -> search()); + highlight.addActionListener(e -> repaintHighlights()); + + // Buttons + findNextButton.addActionListener(e -> findNext()); + findPreviousButton.addActionListener(e -> findPrev()); + selectAllButton.addActionListener(e -> selectAll()); + closeButton.addActionListener(e -> looseFocus()); + + // Text field keyboard shortcuts + addTextFieldKeystroke(KeyEvent.VK_ENTER, 0, () -> findNext()); + addTextFieldKeystroke(KeyEvent.VK_ENTER, KeyEvent.SHIFT_DOWN_MASK, () -> findPrev()); + addTextFieldKeystroke(KeyEvent.VK_ENTER, KeyEvent.ALT_DOWN_MASK, () -> selectAll()); + addTextFieldKeystroke(KeyEvent.VK_ESCAPE, 0, () -> looseFocus()); + } + + private void addTextFieldKeystroke(int key, int modifiers, Runnable action) { + KeyStroke ks = KeyStroke.getKeyStroke(key, modifiers); + String name = "mdfindbar-" + key + "-" + modifiers; + getTextField().getInputMap(JComponent.WHEN_FOCUSED).put(ks, name); + getTextField().getActionMap().put(name, new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { action.run(); } + }); + } + + /** Returns the editable text field inside the combo box. */ + public javax.swing.text.JTextComponent getTextField() { + return (javax.swing.text.JTextComponent) incSearchComboBox.getEditor().getEditorComponent(); + } + + /** Shows the bar and focuses the search text field. */ + public void gainFocus() { + setVisible(true); + SwingUtilities.invokeLater(() -> { + getTextField().requestFocusInWindow(); + getTextField().selectAll(); + }); + } + + /** Hides the bar, clears all highlights, and returns focus to the target pane. */ + public void looseFocus() { + setVisible(false); + clearHighlights(); + matchPositions.clear(); + currentMatchIndex = -1; + matches.setText(""); + targetPane.requestFocusInWindow(); + } + + // ------------------------------------------------------------------------- + // Search + // ------------------------------------------------------------------------- + + void search() { + String query = getTextField().getText(); + clearHighlights(); + matchPositions.clear(); + currentMatchIndex = -1; + + if (query == null || query.isEmpty()) { + matches.setText(""); + return; + } + + String docText; + try { + docText = targetPane.getDocument().getText(0, targetPane.getDocument().getLength()); + } catch (BadLocationException e) { + matches.setText(""); + return; + } + + collectMatches(query, docText); + + if (matchPositions.isEmpty()) { + matches.setText("No matches"); + return; + } + + currentMatchIndex = 0; + repaintHighlights(); + scrollToCurrentMatch(); + updateMatchLabel(); + } + + private void collectMatches(String query, String docText) { + if (regexp.isSelected()) { + try { + int flags = matchCase.isSelected() ? 0 : Pattern.CASE_INSENSITIVE; + Pattern p = Pattern.compile(query, flags); + Matcher m = p.matcher(docText); + while (m.find()) { + if (wholeWords.isSelected()) { + boolean startOk = m.start() == 0 || !Character.isLetterOrDigit(docText.charAt(m.start() - 1)); + boolean endOk = m.end() == docText.length() || !Character.isLetterOrDigit(docText.charAt(m.end())); + if (!startOk || !endOk) continue; + } + matchPositions.add(new int[]{m.start(), m.end()}); + } + } catch (PatternSyntaxException ignored) { + // Bad regex — show no matches + } + } else { + String searchText = matchCase.isSelected() ? docText : docText.toLowerCase(); + String searchQuery = matchCase.isSelected() ? query : query.toLowerCase(); + int idx = 0; + while ((idx = searchText.indexOf(searchQuery, idx)) >= 0) { + int end = idx + searchQuery.length(); + if (wholeWords.isSelected()) { + boolean startOk = idx == 0 || !Character.isLetterOrDigit(docText.charAt(idx - 1)); + boolean endOk = end == docText.length() || !Character.isLetterOrDigit(docText.charAt(end)); + if (!startOk || !endOk) { idx++; continue; } + } + matchPositions.add(new int[]{idx, end}); + idx++; + } + } + } + + private void repaintHighlights() { + clearHighlights(); + if (matchPositions.isEmpty()) return; + Highlighter h = targetPane.getHighlighter(); + if (highlight.isSelected()) { + for (int i = 0; i < matchPositions.size(); i++) { + if (i == currentMatchIndex) continue; + try { + Object tag = h.addHighlight(matchPositions.get(i)[0], matchPositions.get(i)[1], allPainter); + allHighlightTags.add(tag); + } catch (BadLocationException ignored) {} + } + } + if (currentMatchIndex >= 0 && currentMatchIndex < matchPositions.size()) { + try { + currentHighlightTag = h.addHighlight( + matchPositions.get(currentMatchIndex)[0], + matchPositions.get(currentMatchIndex)[1], + currentPainter); + } catch (BadLocationException ignored) {} + } + } + + private void clearHighlights() { + Highlighter h = targetPane.getHighlighter(); + for (Object tag : allHighlightTags) { + h.removeHighlight(tag); + } + allHighlightTags.clear(); + if (currentHighlightTag != null) { + h.removeHighlight(currentHighlightTag); + currentHighlightTag = null; + } + } + + private void scrollToCurrentMatch() { + if (currentMatchIndex < 0 || currentMatchIndex >= matchPositions.size()) return; + int[] pos = matchPositions.get(currentMatchIndex); + // Select the match so the pane scrolls to it + targetPane.setCaretPosition(pos[1]); + targetPane.moveCaretPosition(pos[0]); + } + + private void updateMatchLabel() { + if (matchPositions.isEmpty()) { + matches.setText("No matches"); + } else { + matches.setText((currentMatchIndex + 1) + " of " + matchPositions.size()); + } + } + + // ------------------------------------------------------------------------- + // Navigation + // ------------------------------------------------------------------------- + + public void findNext() { + if (matchPositions.isEmpty()) { + search(); + return; + } + if (currentMatchIndex < matchPositions.size() - 1) { + currentMatchIndex++; + } else if (wrapAround.isSelected()) { + currentMatchIndex = 0; + } else { + return; + } + repaintHighlights(); + scrollToCurrentMatch(); + updateMatchLabel(); + } + + public void findPrev() { + if (matchPositions.isEmpty()) { + search(); + return; + } + if (currentMatchIndex > 0) { + currentMatchIndex--; + } else if (wrapAround.isSelected()) { + currentMatchIndex = matchPositions.size() - 1; + } else { + return; + } + repaintHighlights(); + scrollToCurrentMatch(); + updateMatchLabel(); + } + + private void selectAll() { + if (matchPositions.isEmpty()) { + search(); + return; + } + // Highlight all and update count without changing current index + highlight.setSelected(true); + repaintHighlights(); + updateMatchLabel(); + } + + // ------------------------------------------------------------------------- + // Package-private accessors for tests + // ------------------------------------------------------------------------- + + List getMatchPositions() { return matchPositions; } + int getCurrentMatchIndex() { return currentMatchIndex; } +} diff --git a/src/test/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBarTest.java b/src/test/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBarTest.java new file mode 100644 index 00000000..b7492232 --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBarTest.java @@ -0,0 +1,126 @@ +package io.github.nbclaudecodegui.ui.common.markdown; + +import java.util.List; +import javax.swing.JEditorPane; +import javax.swing.text.html.HTMLEditorKit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class MarkdownFindBarTest { + + private JEditorPane pane; + private MarkdownFindBar bar; + + @BeforeEach + void setUp() { + pane = new JEditorPane(); + pane.setEditorKit(new HTMLEditorKit()); + pane.setText("hello world Hello HELLO foo hello"); + bar = new MarkdownFindBar(pane); + } + + private void setQuery(String query) { + bar.getTextField().setText(query); + } + + @Test + void findsMatchesInDocument() { + setQuery("hello"); + List matches = bar.getMatchPositions(); + // "hello world Hello HELLO foo hello" contains hello (case-insensitive) 4 times + assertEquals(4, matches.size()); + } + + @Test + void matchesLabelShowsCount() { + setQuery("hello"); + assertEquals("1 of 4", bar.matches.getText()); + } + + @Test + void noMatchesLabel() { + setQuery("zzznomatch"); + assertEquals("No matches", bar.matches.getText()); + } + + @Test + void findNextWrapsAround() { + bar.wrapAround.setSelected(true); + setQuery("hello"); + assertEquals(4, bar.getMatchPositions().size()); + assertEquals(0, bar.getCurrentMatchIndex()); + bar.findNext(); + assertEquals(1, bar.getCurrentMatchIndex()); + bar.findNext(); + bar.findNext(); + assertEquals(3, bar.getCurrentMatchIndex()); + bar.findNext(); // wraps + assertEquals(0, bar.getCurrentMatchIndex()); + } + + @Test + void findPrevWrapsAround() { + bar.wrapAround.setSelected(true); + setQuery("hello"); + assertEquals(0, bar.getCurrentMatchIndex()); + bar.findPrev(); // wraps to last + assertEquals(3, bar.getCurrentMatchIndex()); + } + + @Test + void caseInsensitiveByDefault() { + bar.matchCase.setSelected(false); + setQuery("hello"); + // matches "hello", "Hello", "HELLO", "hello" = 4 + assertEquals(4, bar.getMatchPositions().size()); + } + + @Test + void matchCaseToggle() { + bar.matchCase.setSelected(true); + setQuery("hello"); + // only lowercase "hello" matches (appears 2 times: "hello world" and "foo hello") + assertEquals(2, bar.getMatchPositions().size()); + } + + @Test + void emptyQueryClearsMatches() { + setQuery("hello"); + assertEquals(4, bar.getMatchPositions().size()); + setQuery(""); + assertEquals(0, bar.getMatchPositions().size()); + assertEquals("", bar.matches.getText()); + } + + @Test + void hideHidesBarAndClearsHighlights() { + bar.gainFocus(); + assertTrue(bar.isVisible()); + setQuery("hello"); + bar.looseFocus(); + assertFalse(bar.isVisible()); + assertEquals(0, bar.getMatchPositions().size()); + assertEquals(-1, bar.getCurrentMatchIndex()); + } + + @Test + void regexpSearch() { + bar.regexp.setSelected(true); + setQuery("hel+o"); + // matches hello/Hello/HELLO case-insensitively with regexp + assertEquals(4, bar.getMatchPositions().size()); + } + + @Test + void wholeWordsFilter() { + bar.wholeWords.setSelected(true); + bar.matchCase.setSelected(false); + setQuery("hello"); + // "hello", "Hello", "HELLO", "hello" are all whole words here + assertEquals(4, bar.getMatchPositions().size()); + setQuery("hell"); + // "hell" is NOT a whole word in any of the tokens + assertEquals(0, bar.getMatchPositions().size()); + } +} From eef931bae1316f5eb26e8e20cb0533d18237a66f Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sun, 17 May 2026 19:37:56 +0300 Subject: [PATCH 2/2] Fixed Find bar: combo width, button labels, history persistence, F3 navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Combo sized dynamically 200–350 px (mirrors SearchComboBox) - Previous/Next/Select buttons show icon + text label (matches NB SearchBar) - Search history persisted to NbPreferences and restored on IDE restart - Ctrl+F bound on WHEN_FOCUSED to override built-in JEditorPane action - F3/Shift+F3 bound directly on find bar text field (WHEN_FOCUSED) - F3 when bar is closed: searches and navigates in one keypress (no double-press) - Repeated F3 advances past current match using selectionEnd as boundary - looseFocus() preserves matchPositions so F3 resumes from correct position Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 2 +- .../nbclaudecodegui/ui/MarkdownDiffPanel.java | 5 +- .../ui/MarkdownPreviewTab.java | 7 +- .../ui/common/markdown/MarkdownFindBar.java | 281 +++++++++++++----- .../common/markdown/MarkdownFindBarTest.java | 7 +- 5 files changed, 218 insertions(+), 84 deletions(-) diff --git a/pom.xml b/pom.xml index 7c0bf56a..b34092da 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.23.11-SNAPSHOT + 0.23.12-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/MarkdownDiffPanel.java b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownDiffPanel.java index 7b9e2279..296ce04f 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownDiffPanel.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownDiffPanel.java @@ -131,6 +131,7 @@ private static void bindFindBarKeys(JEditorPane pane, MarkdownFindBar fb) { KeyStroke ctrlF = KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK); KeyStroke f3 = KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0); KeyStroke shiftF3 = KeyStroke.getKeyStroke(KeyEvent.VK_F3, InputEvent.SHIFT_DOWN_MASK); + pane.getInputMap(JComponent.WHEN_FOCUSED).put(ctrlF, "mddiff-find-show"); pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ctrlF, "mddiff-find-show"); pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(f3, "mddiff-find-next"); pane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(shiftF3, "mddiff-find-prev"); @@ -139,12 +140,12 @@ private static void bindFindBarKeys(JEditorPane pane, MarkdownFindBar fb) { }); pane.getActionMap().put("mddiff-find-next", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { - if (fb.isVisible()) fb.findNext(); else fb.gainFocus(); + if (fb.isVisible()) fb.findNext(); else fb.searchAndFindNext(); } }); pane.getActionMap().put("mddiff-find-prev", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { - if (fb.isVisible()) fb.findPrev(); else fb.gainFocus(); + if (fb.isVisible()) fb.findPrev(); else fb.searchAndFindPrev(); } }); } diff --git a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java index 88d34971..a7f2cbd6 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java @@ -222,6 +222,9 @@ private void bindFindBarKeys(JEditorPane p) { KeyStroke ctrlF = KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK); KeyStroke f3 = KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0); KeyStroke shiftF3 = KeyStroke.getKeyStroke(KeyEvent.VK_F3, InputEvent.SHIFT_DOWN_MASK); + // WHEN_FOCUSED overrides built-in JEditorPane Ctrl+F before NetBeans global action + p.getInputMap(JComponent.WHEN_FOCUSED).put(ctrlF, "md-find-show"); + // WHEN_IN_FOCUSED_WINDOW covers F3/Shift+F3 from anywhere in the tab p.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ctrlF, "md-find-show"); p.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(f3, "md-find-next"); p.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(shiftF3, "md-find-prev"); @@ -231,13 +234,13 @@ private void bindFindBarKeys(JEditorPane p) { p.getActionMap().put("md-find-next", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { if (findBar != null && findBar.isVisible()) findBar.findNext(); - else showFindBar(); + else if (findBar != null) findBar.searchAndFindNext(); } }); p.getActionMap().put("md-find-prev", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { if (findBar != null && findBar.isVisible()) findBar.findPrev(); - else showFindBar(); + else if (findBar != null) findBar.searchAndFindPrev(); } }); } diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBar.java b/src/main/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBar.java index 722e0dc9..68ccb1d6 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBar.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBar.java @@ -16,6 +16,7 @@ import javax.swing.AbstractButton; import javax.swing.Box; import javax.swing.BoxLayout; +import javax.swing.DefaultComboBoxModel; import javax.swing.Icon; import javax.swing.JButton; import javax.swing.JComboBox; @@ -25,6 +26,7 @@ import javax.swing.JSeparator; import javax.swing.JToggleButton; import javax.swing.KeyStroke; +import javax.swing.MutableComboBoxModel; import javax.swing.SwingConstants; import javax.swing.SwingUtilities; import javax.swing.event.DocumentEvent; @@ -35,16 +37,18 @@ import javax.swing.text.JTextComponent; import org.openide.awt.CloseButtonFactory; import org.openide.util.ImageUtilities; +import org.openide.util.NbPreferences; /** * Find bar for Markdown Preview, mirroring the NetBeans SearchBar UI layout and icons. - * Attach to a {@link JTextComponent} (typically a JEditorPane with HTMLEditorKit). * Icons are loaded from the {@code org-netbeans-modules-editor-search} module JAR at runtime. */ public class MarkdownFindBar extends JPanel { private static final String ICON_BASE = "org/netbeans/modules/editor/search/resources/"; private static final Insets BTN_INSETS = new Insets(2, 1, 0, 1); + private static final int COMBO_MIN_WIDTH = 200; + private static final int COMBO_MAX_WIDTH = 350; private static final Color HIGHLIGHT_ALL_COLOR = new Color(255, 255, 160); private static final Color HIGHLIGHT_CURRENT_COLOR = new Color(255, 165, 0); @@ -69,11 +73,9 @@ public class MarkdownFindBar extends JPanel { private final Highlighter.HighlightPainter currentPainter = new DefaultHighlighter.DefaultHighlightPainter(HIGHLIGHT_CURRENT_COLOR); - /** Positions of all current matches as [start, end] pairs. */ private final List matchPositions = new ArrayList<>(); private int currentMatchIndex = -1; - /** Highlight tags for all-match highlights (to remove them later). */ private final List allHighlightTags = new ArrayList<>(); private Object currentHighlightTag = null; @@ -83,15 +85,27 @@ public MarkdownFindBar(JTextComponent targetPane) { setLayout(new BoxLayout(this, BoxLayout.LINE_AXIS)); findLabel = new JLabel("Find:"); - incSearchComboBox = new JComboBox<>(); + + // Combo sized like SearchComboBox: min 200, max 350, dynamic + incSearchComboBox = new JComboBox(new DefaultComboBoxModel<>()) { + @Override public Dimension getPreferredSize() { + int editW = getEditor().getEditorComponent().getPreferredSize().width + 10; + int w = editW > COMBO_MIN_WIDTH + ? Math.min(editW, COMBO_MAX_WIDTH) + : COMBO_MIN_WIDTH; + return new Dimension(w, super.getPreferredSize().height); + } + @Override public Dimension getMinimumSize() { return getPreferredSize(); } + @Override public Dimension getMaximumSize() { return getPreferredSize(); } + }; incSearchComboBox.setEditable(true); - incSearchComboBox.setMaximumSize(new Dimension(300, incSearchComboBox.getPreferredSize().height)); - findPreviousButton = createButton("find_previous.png"); + // Buttons with icon + text label, same as NetBeans SearchBar + findPreviousButton = createButton("find_previous.png", "Previous"); findPreviousButton.setToolTipText("Find Previous (Shift+F3)"); - findNextButton = createButton("find_next.png"); + findNextButton = createButton("find_next.png", "Next"); findNextButton.setToolTipText("Find Next (F3)"); - selectAllButton = createButton("select_all.png"); + selectAllButton = createButton("select_all.png", "Select"); selectAllButton.setToolTipText("Select All (Alt+Enter)"); matchCase = createToggleButton("matchCase.png"); @@ -102,20 +116,15 @@ public MarkdownFindBar(JTextComponent targetPane) { regexp.setToolTipText("Regular Expression"); highlight = createToggleButton("highlight.png"); highlight.setToolTipText("Highlight All"); - highlight.setSelected(true); - highlight.setContentAreaFilled(true); - highlight.setBorderPainted(true); + setToggleSelected(highlight, true); wrapAround = createToggleButton("wrapAround.png"); wrapAround.setToolTipText("Wrap Around"); - wrapAround.setSelected(true); - wrapAround.setContentAreaFilled(true); - wrapAround.setBorderPainted(true); + setToggleSelected(wrapAround, true); matches = new JLabel(); closeButton = CloseButtonFactory.createBigCloseButton(); closeButton.setToolTipText("Close (Escape)"); - // Layout: mirrors NetBeans SearchBar order add(Box.createHorizontalStrut(8)); add(findLabel); add(Box.createHorizontalStrut(4)); @@ -137,12 +146,17 @@ public MarkdownFindBar(JTextComponent targetPane) { add(closeButton); wireActions(); + loadHistory(); setVisible(false); } - private static JButton createButton(String iconFile) { + // ------------------------------------------------------------------------- + // Button factories + // ------------------------------------------------------------------------- + + private static JButton createButton(String iconFile, String text) { Icon icon = ImageUtilities.loadImageIcon(ICON_BASE + iconFile, false); - JButton btn = icon != null ? new JButton(icon) : new JButton(iconFile); + JButton btn = icon != null ? new JButton(text, icon) : new JButton(text); styleButton(btn); return btn; } @@ -150,12 +164,11 @@ private static JButton createButton(String iconFile) { private static JToggleButton createToggleButton(String iconFile) { Icon icon = ImageUtilities.loadImageIcon(ICON_BASE + iconFile, false); JToggleButton btn = icon != null ? new JToggleButton(icon) : new JToggleButton(iconFile); - btn.addMouseListener(HOVER_LISTENER); btn.setContentAreaFilled(false); btn.setBorderPainted(false); btn.setMargin(BTN_INSETS); btn.setFocusable(false); - // When selected, keep border/fill shown + btn.addMouseListener(HOVER_LISTENER); btn.addChangeListener(e -> { if (!btn.getModel().isRollover()) { btn.setContentAreaFilled(btn.isSelected()); @@ -165,6 +178,12 @@ private static JToggleButton createToggleButton(String iconFile) { return btn; } + private static void setToggleSelected(JToggleButton btn, boolean selected) { + btn.setSelected(selected); + btn.setContentAreaFilled(selected); + btn.setBorderPainted(selected); + } + private static void styleButton(AbstractButton btn) { btn.setContentAreaFilled(false); btn.setBorderPainted(false); @@ -193,31 +212,35 @@ private static JSeparator createVerticalSeparator() { return sep; } + // ------------------------------------------------------------------------- + // Wiring + // ------------------------------------------------------------------------- + private void wireActions() { - // Text field document listener → incremental search getTextField().getDocument().addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent e) { search(); } @Override public void removeUpdate(DocumentEvent e) { search(); } @Override public void changedUpdate(DocumentEvent e) { search(); } }); - // Toggle buttons → re-search matchCase.addActionListener(e -> search()); wholeWords.addActionListener(e -> search()); regexp.addActionListener(e -> search()); highlight.addActionListener(e -> repaintHighlights()); - // Buttons - findNextButton.addActionListener(e -> findNext()); + findNextButton.addActionListener(e -> findNext()); findPreviousButton.addActionListener(e -> findPrev()); - selectAllButton.addActionListener(e -> selectAll()); - closeButton.addActionListener(e -> looseFocus()); - - // Text field keyboard shortcuts - addTextFieldKeystroke(KeyEvent.VK_ENTER, 0, () -> findNext()); - addTextFieldKeystroke(KeyEvent.VK_ENTER, KeyEvent.SHIFT_DOWN_MASK, () -> findPrev()); - addTextFieldKeystroke(KeyEvent.VK_ENTER, KeyEvent.ALT_DOWN_MASK, () -> selectAll()); - addTextFieldKeystroke(KeyEvent.VK_ESCAPE, 0, () -> looseFocus()); + selectAllButton.addActionListener(e -> selectAll()); + closeButton.addActionListener(e -> looseFocus()); + + // Text-field keyboard shortcuts (WHEN_FOCUSED covers find bar text field) + addTextFieldKeystroke(KeyEvent.VK_ENTER, 0, () -> findNext()); + addTextFieldKeystroke(KeyEvent.VK_ENTER, KeyEvent.SHIFT_DOWN_MASK, () -> findPrev()); + addTextFieldKeystroke(KeyEvent.VK_ENTER, KeyEvent.ALT_DOWN_MASK, () -> selectAll()); + addTextFieldKeystroke(KeyEvent.VK_ESCAPE, 0, () -> looseFocus()); + // F3/Shift+F3 also work while text field has focus + addTextFieldKeystroke(KeyEvent.VK_F3, 0, () -> findNext()); + addTextFieldKeystroke(KeyEvent.VK_F3, KeyEvent.SHIFT_DOWN_MASK, () -> findPrev()); } private void addTextFieldKeystroke(int key, int modifiers, Runnable action) { @@ -229,11 +252,14 @@ private void addTextFieldKeystroke(int key, int modifiers, Runnable action) { }); } - /** Returns the editable text field inside the combo box. */ public javax.swing.text.JTextComponent getTextField() { return (javax.swing.text.JTextComponent) incSearchComboBox.getEditor().getEditorComponent(); } + // ------------------------------------------------------------------------- + // Visibility + // ------------------------------------------------------------------------- + /** Shows the bar and focuses the search text field. */ public void gainFocus() { setVisible(true); @@ -243,12 +269,15 @@ public void gainFocus() { }); } - /** Hides the bar, clears all highlights, and returns focus to the target pane. */ + /** + * Hides the bar and returns focus to the target pane. + * Highlights are cleared but the query and match positions are preserved so + * that F3 can resume navigation from the correct position after re-opening. + */ public void looseFocus() { setVisible(false); clearHighlights(); - matchPositions.clear(); - currentMatchIndex = -1; + // Keep matchPositions and currentMatchIndex so F3 can resume matches.setText(""); targetPane.requestFocusInWindow(); } @@ -268,27 +297,110 @@ void search() { return; } - String docText; - try { - docText = targetPane.getDocument().getText(0, targetPane.getDocument().getLength()); - } catch (BadLocationException e) { - matches.setText(""); + String docText = getDocText(); + if (docText == null) { matches.setText(""); return; } + + collectMatches(query, docText); + + if (matchPositions.isEmpty()) { + matches.setText("No matches"); + return; + } + + // Start at match nearest to caret + currentMatchIndex = indexNearestToCaret(); + repaintHighlights(); + scrollToCurrentMatch(); + updateMatchLabel(); + } + + /** + * Searches using the current query (re-collects matches) and navigates to + * the first match at or after the caret position. Used when F3 is pressed + * while the bar is hidden — so the user doesn't need to press F3 twice. + */ + public void searchAndFindNext() { + String query = getTextField().getText(); + if (query == null || query.isEmpty()) { + gainFocus(); return; } + String docText = getDocText(); + if (docText == null) return; + clearHighlights(); + matchPositions.clear(); collectMatches(query, docText); if (matchPositions.isEmpty()) { + gainFocus(); matches.setText("No matches"); return; } + addHistory(query); + // Use selectionEnd so repeated F3 advances past the already-highlighted match + int after = Math.max(targetPane.getSelectionEnd(), targetPane.getCaretPosition()); currentMatchIndex = 0; + boolean foundNext = false; + for (int i = 0; i < matchPositions.size(); i++) { + if (matchPositions.get(i)[0] >= after) { currentMatchIndex = i; foundNext = true; break; } + } + if (!foundNext) currentMatchIndex = wrapAround.isSelected() ? 0 : matchPositions.size() - 1; + repaintHighlights(); + scrollToCurrentMatch(); + updateMatchLabel(); + } + + public void searchAndFindPrev() { + String query = getTextField().getText(); + if (query == null || query.isEmpty()) { + gainFocus(); + return; + } + String docText = getDocText(); + if (docText == null) return; + + clearHighlights(); + matchPositions.clear(); + collectMatches(query, docText); + + if (matchPositions.isEmpty()) { + gainFocus(); + matches.setText("No matches"); + return; + } + + addHistory(query); + // Use selectionStart so repeated Shift+F3 goes back past the current match + int before = Math.min(targetPane.getSelectionStart(), targetPane.getCaretPosition()); + currentMatchIndex = matchPositions.size() - 1; + boolean foundPrev = false; + for (int i = matchPositions.size() - 1; i >= 0; i--) { + if (matchPositions.get(i)[1] <= before) { currentMatchIndex = i; foundPrev = true; break; } + } + if (!foundPrev) currentMatchIndex = wrapAround.isSelected() ? matchPositions.size() - 1 : 0; repaintHighlights(); scrollToCurrentMatch(); updateMatchLabel(); } + private String getDocText() { + try { + return targetPane.getDocument().getText(0, targetPane.getDocument().getLength()); + } catch (BadLocationException e) { + return null; + } + } + + private int indexNearestToCaret() { + int caret = targetPane.getCaretPosition(); + for (int i = 0; i < matchPositions.size(); i++) { + if (matchPositions.get(i)[0] >= caret) return i; + } + return 0; // wrap + } + private void collectMatches(String query, String docText) { if (regexp.isSelected()) { try { @@ -303,12 +415,10 @@ private void collectMatches(String query, String docText) { } matchPositions.add(new int[]{m.start(), m.end()}); } - } catch (PatternSyntaxException ignored) { - // Bad regex — show no matches - } + } catch (PatternSyntaxException ignored) {} } else { - String searchText = matchCase.isSelected() ? docText : docText.toLowerCase(); - String searchQuery = matchCase.isSelected() ? query : query.toLowerCase(); + String searchText = matchCase.isSelected() ? docText : docText.toLowerCase(); + String searchQuery = matchCase.isSelected() ? query : query.toLowerCase(); int idx = 0; while ((idx = searchText.indexOf(searchQuery, idx)) >= 0) { int end = idx + searchQuery.length(); @@ -323,6 +433,41 @@ private void collectMatches(String query, String docText) { } } + private static final String PREF_HISTORY_PREFIX = "markdownFindHistory."; + private static final int HISTORY_MAX = 20; + + /** Adds a non-empty query to the combo history (deduplicates, most recent first) and persists it. */ + private void addHistory(String query) { + if (query == null || query.isEmpty()) return; + MutableComboBoxModel model = (MutableComboBoxModel) incSearchComboBox.getModel(); + for (int i = model.getSize() - 1; i >= 0; i--) { + if (query.equals(model.getElementAt(i))) model.removeElementAt(i); + } + model.insertElementAt(query, 0); + while (model.getSize() > HISTORY_MAX) model.removeElementAt(model.getSize() - 1); + incSearchComboBox.setSelectedIndex(0); + saveHistory(); + } + + private void saveHistory() { + MutableComboBoxModel model = (MutableComboBoxModel) incSearchComboBox.getModel(); + var prefs = NbPreferences.forModule(MarkdownFindBar.class); + prefs.putInt(PREF_HISTORY_PREFIX + "size", model.getSize()); + for (int i = 0; i < model.getSize(); i++) { + prefs.put(PREF_HISTORY_PREFIX + i, model.getElementAt(i)); + } + } + + private void loadHistory() { + var prefs = NbPreferences.forModule(MarkdownFindBar.class); + int size = prefs.getInt(PREF_HISTORY_PREFIX + "size", 0); + MutableComboBoxModel model = (MutableComboBoxModel) incSearchComboBox.getModel(); + for (int i = 0; i < size; i++) { + String val = prefs.get(PREF_HISTORY_PREFIX + i, null); + if (val != null && !val.isEmpty()) model.addElement(val); + } + } + private void repaintHighlights() { clearHighlights(); if (matchPositions.isEmpty()) return; @@ -331,8 +476,7 @@ private void repaintHighlights() { for (int i = 0; i < matchPositions.size(); i++) { if (i == currentMatchIndex) continue; try { - Object tag = h.addHighlight(matchPositions.get(i)[0], matchPositions.get(i)[1], allPainter); - allHighlightTags.add(tag); + allHighlightTags.add(h.addHighlight(matchPositions.get(i)[0], matchPositions.get(i)[1], allPainter)); } catch (BadLocationException ignored) {} } } @@ -348,30 +492,21 @@ private void repaintHighlights() { private void clearHighlights() { Highlighter h = targetPane.getHighlighter(); - for (Object tag : allHighlightTags) { - h.removeHighlight(tag); - } + for (Object tag : allHighlightTags) h.removeHighlight(tag); allHighlightTags.clear(); - if (currentHighlightTag != null) { - h.removeHighlight(currentHighlightTag); - currentHighlightTag = null; - } + if (currentHighlightTag != null) { h.removeHighlight(currentHighlightTag); currentHighlightTag = null; } } private void scrollToCurrentMatch() { if (currentMatchIndex < 0 || currentMatchIndex >= matchPositions.size()) return; int[] pos = matchPositions.get(currentMatchIndex); - // Select the match so the pane scrolls to it targetPane.setCaretPosition(pos[1]); targetPane.moveCaretPosition(pos[0]); } private void updateMatchLabel() { - if (matchPositions.isEmpty()) { - matches.setText("No matches"); - } else { - matches.setText((currentMatchIndex + 1) + " of " + matchPositions.size()); - } + matches.setText(matchPositions.isEmpty() ? "No matches" + : (currentMatchIndex + 1) + " of " + matchPositions.size()); } // ------------------------------------------------------------------------- @@ -379,10 +514,9 @@ private void updateMatchLabel() { // ------------------------------------------------------------------------- public void findNext() { - if (matchPositions.isEmpty()) { - search(); - return; - } + if (matchPositions.isEmpty()) { search(); return; } + String query = getTextField().getText(); + if (query != null && !query.isEmpty()) addHistory(query); if (currentMatchIndex < matchPositions.size() - 1) { currentMatchIndex++; } else if (wrapAround.isSelected()) { @@ -396,10 +530,9 @@ public void findNext() { } public void findPrev() { - if (matchPositions.isEmpty()) { - search(); - return; - } + if (matchPositions.isEmpty()) { search(); return; } + String query = getTextField().getText(); + if (query != null && !query.isEmpty()) addHistory(query); if (currentMatchIndex > 0) { currentMatchIndex--; } else if (wrapAround.isSelected()) { @@ -413,20 +546,16 @@ public void findPrev() { } private void selectAll() { - if (matchPositions.isEmpty()) { - search(); - return; - } - // Highlight all and update count without changing current index - highlight.setSelected(true); + if (matchPositions.isEmpty()) { search(); return; } + setToggleSelected(highlight, true); repaintHighlights(); updateMatchLabel(); } // ------------------------------------------------------------------------- - // Package-private accessors for tests + // Test accessors // ------------------------------------------------------------------------- List getMatchPositions() { return matchPositions; } - int getCurrentMatchIndex() { return currentMatchIndex; } + int getCurrentMatchIndex() { return currentMatchIndex; } } diff --git a/src/test/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBarTest.java b/src/test/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBarTest.java index b7492232..9eb18ee7 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBarTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBarTest.java @@ -94,14 +94,15 @@ void emptyQueryClearsMatches() { } @Test - void hideHidesBarAndClearsHighlights() { + void hideHidesBarPreservesPositionsForResumedSearch() { bar.gainFocus(); assertTrue(bar.isVisible()); setQuery("hello"); + assertEquals(4, bar.getMatchPositions().size()); bar.looseFocus(); assertFalse(bar.isVisible()); - assertEquals(0, bar.getMatchPositions().size()); - assertEquals(-1, bar.getCurrentMatchIndex()); + // matchPositions preserved so F3 can resume from correct position + assertEquals(4, bar.getMatchPositions().size()); } @Test