diff --git a/CHANGELOG.md b/CHANGELOG.md index 82edf5cf..d28938c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed anchor link clicks (`#section`) in markdown preview not enabling the Back button; Back now returns to the position before the anchor jump - Fixed markdown preview not rendering images (`![alt](path)` syntax) - Fixed session tab blocking resize below ~600px when docked as a side panel (https://github.com/nbclaudecodegui/NetbeansClaudeCodeGui/issues/19) - Fixed terminal appearing blank after dismissing the model selection menu diff --git a/docs/to-do.md b/docs/to-do.md index c23e8a7e..0419b4f0 100644 --- a/docs/to-do.md +++ b/docs/to-do.md @@ -7,6 +7,6 @@ - [x] Невозможно выйти из /usage по кнопке cancel. Отладить - [x] Не учитываются цвета Look and Feel для кнопок - [x] Previe markdown: не показываются изображения -- [ ] Previe markdown: не работает навигация по линкам внутри файла +- [x] Previe markdown: не работает навигация по линкам внутри файла # Features diff --git a/pom.xml b/pom.xml index 2797a16a..7a45e222 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.21.50-SNAPSHOT + 0.21.52-SNAPSHOT nbm Netbeans Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java index 777f8fb5..225fa90f 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java @@ -83,6 +83,7 @@ public class MarkdownPreviewTab extends TopComponent { // Navigation history final List historyPaths = new ArrayList<>(); final List historyFos = new ArrayList<>(); + final List historyAnchors = new ArrayList<>(); int historyIndex = -1; /** The href under the mouse at last ENTERED event; null when not hovering a link. */ @@ -155,6 +156,7 @@ public static void openLive(String filePath, String content, FileObject fo) { // Push initial entry into history tab.historyPaths.add(filePath); tab.historyFos.add(fo); + tab.historyAnchors.add(null); tab.historyIndex = 0; OPEN_TABS.put(filePath, tab); @@ -282,9 +284,11 @@ void navigateTo(String newPath, FileObject newFo, boolean addToHistory) { while (historyPaths.size() > historyIndex + 1) { historyPaths.remove(historyPaths.size() - 1); historyFos.remove(historyFos.size() - 1); + historyAnchors.remove(historyAnchors.size() - 1); } historyPaths.add(newPath); historyFos.add(newFo); + historyAnchors.add(null); historyIndex++; } @@ -317,17 +321,56 @@ boolean canGoForward() { return historyIndex < historyPaths.size() - 1; } + void navigateToAnchor(String anchor) { + // Truncate forward entries + while (historyPaths.size() > historyIndex + 1) { + historyPaths.remove(historyPaths.size() - 1); + historyFos.remove(historyFos.size() - 1); + historyAnchors.remove(historyAnchors.size() - 1); + } + historyPaths.add(filePath); + historyFos.add(fileObject); + historyAnchors.add(anchor); + historyIndex++; + pane.scrollToReference(anchor); + } + void navigateBack() { if (canGoBack()) { historyIndex--; - navigateTo(historyPaths.get(historyIndex), historyFos.get(historyIndex), false); + restoreHistoryEntry(historyIndex); } } void navigateForward() { if (canGoForward()) { historyIndex++; - navigateTo(historyPaths.get(historyIndex), historyFos.get(historyIndex), false); + restoreHistoryEntry(historyIndex); + } + } + + private void restoreHistoryEntry(int idx) { + String targetPath = historyPaths.get(idx); + FileObject targetFo = historyFos.get(idx); + String anchor = historyAnchors.size() > idx ? historyAnchors.get(idx) : null; + + if (targetPath.equals(filePath)) { + // Same file — just scroll + if (anchor != null) { + pane.scrollToReference(anchor); + } else { + // Scroll to top + SwingUtilities.invokeLater(() -> { + if (scrollPane != null) { + scrollPane.getViewport().setViewPosition(new Point(0, 0)); + } + }); + } + } else { + navigateTo(targetPath, targetFo, false); + if (anchor != null) { + SwingUtilities.invokeLater(() -> pane.scrollToReference(anchor)); + } } } @@ -350,6 +393,10 @@ File resolveLink(String href) { } void openLinkInSameTab(String href) { + if (href != null && href.startsWith("#")) { + navigateToAnchor(href.substring(1)); + return; + } File resolved = resolveLink(href); if (resolved != null) { FileObject fo = FileUtil.toFileObject(resolved); @@ -556,6 +603,7 @@ protected void componentOpened() { if (historyPaths.isEmpty()) { historyPaths.add(path); historyFos.add(fo); + historyAnchors.add(null); historyIndex = 0; } diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/MarkdownRenderer.java b/src/main/java/io/github/nbclaudecodegui/ui/common/MarkdownRenderer.java index 90d16086..1ec95775 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/common/MarkdownRenderer.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/MarkdownRenderer.java @@ -261,20 +261,23 @@ private static String convertSegment(String text) { // --- Headings --- if (line.startsWith("# ")) { - html.append("

").append(inlineToHtml(line.substring(2).trim())) - .append("

"); + String heading = line.substring(2).trim(); + html.append("

") + .append(inlineToHtml(heading)).append("

"); i++; continue; } if (line.startsWith("## ")) { - html.append("

").append(inlineToHtml(line.substring(3).trim())) - .append("

"); + String heading = line.substring(3).trim(); + html.append("

") + .append(inlineToHtml(heading)).append("

"); i++; continue; } if (line.startsWith("### ")) { - html.append("

").append(inlineToHtml(line.substring(4).trim())) - .append("

"); + String heading = line.substring(4).trim(); + html.append("

") + .append(inlineToHtml(heading)).append("

"); i++; continue; } @@ -477,6 +480,19 @@ private static boolean isBlockStart(String line) { || isTableRow(line); } + /** + * Converts heading text to a URL-friendly slug for use as a named anchor. + * Lowercases the text, strips non-alphanumeric characters (except spaces and hyphens), + * trims, replaces spaces with hyphens, and collapses consecutive hyphens. + */ + static String slugify(String text) { + return text.toLowerCase() + .replaceAll("[^a-z0-9\\s-]", "") + .trim() + .replaceAll("\\s+", "-") + .replaceAll("-+", "-"); + } + /** * Escapes HTML special characters in plain text. * diff --git a/src/test/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTabTest.java b/src/test/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTabTest.java index 604ba5e1..d69bc9a9 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTabTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTabTest.java @@ -323,6 +323,60 @@ void writeExternalRoundTripsFilePath() throws Exception { "savedScrollRatio must be 0.0 when no scrollPane is present"); } + // --- Anchor history tests --- + + @Test + void canGoBackTrueAfterNavigateToAnchor() { + MarkdownPreviewTab tab = new MarkdownPreviewTab(); + tab.historyPaths.add("/file.md"); + tab.historyFos.add(null); + tab.historyAnchors.add(null); + tab.historyIndex = 0; + tab.setFilePathForTest("/file.md"); + tab.setPaneForTest(new JEditorPane()); + + tab.navigateToAnchor("section"); + + assertTrue(tab.canGoBack(), "canGoBack() should be true after navigateToAnchor()"); + } + + @Test + void canGoBackFalseAfterNavigateToAnchorThenBack() { + MarkdownPreviewTab tab = new MarkdownPreviewTab(); + tab.historyPaths.add("/file.md"); + tab.historyFos.add(null); + tab.historyAnchors.add(null); + tab.historyIndex = 0; + tab.setFilePathForTest("/file.md"); + tab.setPaneForTest(new JEditorPane()); + + tab.navigateToAnchor("section"); + tab.navigateBack(); + + assertFalse(tab.canGoBack(), "canGoBack() should be false after navigating back to initial entry"); + } + + @Test + void forwardTruncationClearsAnchorHistory() { + MarkdownPreviewTab tab = new MarkdownPreviewTab(); + tab.historyPaths.add("/file.md"); + tab.historyFos.add(null); + tab.historyAnchors.add(null); + tab.historyPaths.add("/file.md"); + tab.historyFos.add(null); + tab.historyAnchors.add("old-anchor"); + tab.historyIndex = 0; + tab.setFilePathForTest("/file.md"); + tab.setPaneForTest(new JEditorPane()); + + // Navigate to new anchor (should truncate forward entry) + tab.navigateToAnchor("new-anchor"); + + assertEquals(2, tab.historyAnchors.size(), "History should have 2 entries after truncation and new push"); + assertNull(tab.historyAnchors.get(0), "First entry anchor should be null"); + assertEquals("new-anchor", tab.historyAnchors.get(1), "Second entry should be new anchor"); + } + private static List menuItemLabels(JPopupMenu menu) { List labels = new ArrayList<>(); for (int i = 0; i < menu.getComponentCount(); i++) { diff --git a/src/test/java/io/github/nbclaudecodegui/ui/common/MarkdownRendererTest.java b/src/test/java/io/github/nbclaudecodegui/ui/common/MarkdownRendererTest.java index fe383eb4..287a6d36 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/common/MarkdownRendererTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/common/MarkdownRendererTest.java @@ -71,9 +71,9 @@ void testPlainTextEscaped() { @Test void testHeadings() { String html = MarkdownRenderer.toHtml("# H1\n## H2\n### H3"); - assertTrue(html.contains("

H1

"), "h1"); - assertTrue(html.contains("

H2

"), "h2"); - assertTrue(html.contains("

H3

"), "h3"); + assertTrue(html.contains("

") && html.contains("H1") && html.contains("

"), "h1"); + assertTrue(html.contains("

") && html.contains("H2") && html.contains("

"), "h2"); + assertTrue(html.contains("

") && html.contains("H3") && html.contains("

"), "h3"); } @Test @@ -237,4 +237,32 @@ void testInlineImageEmptyAlt() { assertTrue(result.contains("alt=\"\""), "empty alt"); } + // ------------------------------------------------------------------------- + // Bug 3: Named anchors in headings for anchor navigation + // ------------------------------------------------------------------------- + + @Test + void testHeadingH1HasNamedAnchor() { + String html = MarkdownRenderer.toHtml("# My Heading"); + assertTrue(html.contains(""), "h1 named anchor"); + assertTrue(html.contains("

"), "h1 tag"); + } + + @Test + void testHeadingH2HasNamedAnchor() { + String html = MarkdownRenderer.toHtml("## Section Two"); + assertTrue(html.contains(""), "h2 named anchor"); + } + + @Test + void testHeadingH3HasNamedAnchor() { + String html = MarkdownRenderer.toHtml("### Sub Section"); + assertTrue(html.contains(""), "h3 named anchor"); + } + + @Test + void testHeadingSlugStripNonAlphanumeric() { + String html = MarkdownRenderer.toHtml("# Hello, World! (2024)"); + assertTrue(html.contains(""), "slug strips punctuation"); + } }