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 (`` 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("");
i++;
continue;
}
if (line.startsWith("## ")) {
- html.append("").append(inlineToHtml(line.substring(3).trim()))
- .append("
");
+ String heading = line.substring(3).trim();
+ html.append("");
i++;
continue;
}
if (line.startsWith("### ")) {
- html.append("").append(inlineToHtml(line.substring(4).trim()))
- .append("
");
+ String heading = line.substring(4).trim();
+ html.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("