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..b34092da 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.23.10-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 d4f5a915..296ce04f 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,35 @@ 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_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"); + 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.searchAndFindNext(); + } + }); + pane.getActionMap().put("mddiff-find-prev", new AbstractAction() { + @Override public void actionPerformed(ActionEvent e) { + if (fb.isVisible()) fb.findPrev(); else fb.searchAndFindPrev(); + } + }); + } + 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..a7f2cbd6 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,43 @@ 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); + // 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"); + 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 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 if (findBar != null) findBar.searchAndFindPrev(); + } + }); + } + + void showFindBar() { + if (findBar != null) findBar.gainFocus(); + } + @Override public void componentClosed() { super.componentClosed(); @@ -483,6 +525,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 +554,8 @@ JPopupMenu buildContextMenu() { menu.add(forward); menu.add(refresh); menu.addSeparator(); + menu.add(find); + menu.addSeparator(); menu.add(selectAll); menu.add(copy); @@ -604,6 +652,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..68ccb1d6 --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBar.java @@ -0,0 +1,561 @@ +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.DefaultComboBoxModel; +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.MutableComboBoxModel; +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; +import org.openide.util.NbPreferences; + +/** + * Find bar for Markdown Preview, mirroring the NetBeans SearchBar UI layout and icons. + * 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); + + 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); + + private final List matchPositions = new ArrayList<>(); + private int currentMatchIndex = -1; + + 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:"); + + // 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); + + // 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", "Next"); + findNextButton.setToolTipText("Find Next (F3)"); + selectAllButton = createButton("select_all.png", "Select"); + 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"); + setToggleSelected(highlight, true); + wrapAround = createToggleButton("wrapAround.png"); + wrapAround.setToolTipText("Wrap Around"); + setToggleSelected(wrapAround, true); + + matches = new JLabel(); + closeButton = CloseButtonFactory.createBigCloseButton(); + closeButton.setToolTipText("Close (Escape)"); + + 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(); + loadHistory(); + setVisible(false); + } + + // ------------------------------------------------------------------------- + // Button factories + // ------------------------------------------------------------------------- + + private static JButton createButton(String iconFile, String text) { + Icon icon = ImageUtilities.loadImageIcon(ICON_BASE + iconFile, false); + JButton btn = icon != null ? new JButton(text, icon) : new JButton(text); + 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.setContentAreaFilled(false); + btn.setBorderPainted(false); + btn.setMargin(BTN_INSETS); + btn.setFocusable(false); + btn.addMouseListener(HOVER_LISTENER); + btn.addChangeListener(e -> { + if (!btn.getModel().isRollover()) { + btn.setContentAreaFilled(btn.isSelected()); + btn.setBorderPainted(btn.isSelected()); + } + }); + 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); + 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; + } + + // ------------------------------------------------------------------------- + // Wiring + // ------------------------------------------------------------------------- + + private void wireActions() { + 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(); } + }); + + matchCase.addActionListener(e -> search()); + wholeWords.addActionListener(e -> search()); + regexp.addActionListener(e -> search()); + highlight.addActionListener(e -> repaintHighlights()); + + findNextButton.addActionListener(e -> findNext()); + findPreviousButton.addActionListener(e -> findPrev()); + 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) { + 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(); } + }); + } + + 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); + SwingUtilities.invokeLater(() -> { + getTextField().requestFocusInWindow(); + getTextField().selectAll(); + }); + } + + /** + * 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(); + // Keep matchPositions and currentMatchIndex so F3 can resume + 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 = 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 { + 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) {} + } 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 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; + Highlighter h = targetPane.getHighlighter(); + if (highlight.isSelected()) { + for (int i = 0; i < matchPositions.size(); i++) { + if (i == currentMatchIndex) continue; + try { + allHighlightTags.add(h.addHighlight(matchPositions.get(i)[0], matchPositions.get(i)[1], allPainter)); + } 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); + targetPane.setCaretPosition(pos[1]); + targetPane.moveCaretPosition(pos[0]); + } + + private void updateMatchLabel() { + matches.setText(matchPositions.isEmpty() ? "No matches" + : (currentMatchIndex + 1) + " of " + matchPositions.size()); + } + + // ------------------------------------------------------------------------- + // Navigation + // ------------------------------------------------------------------------- + + public void findNext() { + 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()) { + currentMatchIndex = 0; + } else { + return; + } + repaintHighlights(); + scrollToCurrentMatch(); + updateMatchLabel(); + } + + public void findPrev() { + if (matchPositions.isEmpty()) { search(); return; } + String query = getTextField().getText(); + if (query != null && !query.isEmpty()) addHistory(query); + 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; } + setToggleSelected(highlight, true); + repaintHighlights(); + updateMatchLabel(); + } + + // ------------------------------------------------------------------------- + // Test accessors + // ------------------------------------------------------------------------- + + 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..9eb18ee7 --- /dev/null +++ b/src/test/java/io/github/nbclaudecodegui/ui/common/markdown/MarkdownFindBarTest.java @@ -0,0 +1,127 @@ +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 hideHidesBarPreservesPositionsForResumedSearch() { + bar.gainFocus(); + assertTrue(bar.isVisible()); + setQuery("hello"); + assertEquals(4, bar.getMatchPositions().size()); + bar.looseFocus(); + assertFalse(bar.isVisible()); + // matchPositions preserved so F3 can resume from correct position + assertEquals(4, bar.getMatchPositions().size()); + } + + @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()); + } +}