Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/to-do.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
- [x] Невозможно выйти из /usage по кнопке cancel. Отладить
- [x] Не учитываются цвета Look and Feel для кнопок
- [x] Previe markdown: не показываются изображения
- [ ] Previe markdown: не работает навигация по линкам внутри файла
- [x] Previe markdown: не работает навигация по линкам внутри файла

# Features
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.nbclaudecodegui</groupId>
<artifactId>netbeans-claude-code-gui</artifactId>
<version>0.21.50-SNAPSHOT</version>
<version>0.21.52-SNAPSHOT</version>
<packaging>nbm</packaging>
<name>Netbeans Claude Code GUI</name>
<description>NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE.</description>
Expand Down
52 changes: 50 additions & 2 deletions src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public class MarkdownPreviewTab extends TopComponent {
// Navigation history
final List<String> historyPaths = new ArrayList<>();
final List<FileObject> historyFos = new ArrayList<>();
final List<String> historyAnchors = new ArrayList<>();
int historyIndex = -1;

/** The href under the mouse at last ENTERED event; null when not hovering a link. */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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++;
}

Expand Down Expand Up @@ -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));
}
}
}

Expand All @@ -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);
Expand Down Expand Up @@ -556,6 +603,7 @@ protected void componentOpened() {
if (historyPaths.isEmpty()) {
historyPaths.add(path);
historyFos.add(fo);
historyAnchors.add(null);
historyIndex = 0;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,20 +261,23 @@ private static String convertSegment(String text) {

// --- Headings ---
if (line.startsWith("# ")) {
html.append("<h1>").append(inlineToHtml(line.substring(2).trim()))
.append("</h1>");
String heading = line.substring(2).trim();
html.append("<h1><a name=\"").append(slugify(heading)).append("\">")
.append(inlineToHtml(heading)).append("</a></h1>");
i++;
continue;
}
if (line.startsWith("## ")) {
html.append("<h2>").append(inlineToHtml(line.substring(3).trim()))
.append("</h2>");
String heading = line.substring(3).trim();
html.append("<h2><a name=\"").append(slugify(heading)).append("\">")
.append(inlineToHtml(heading)).append("</a></h2>");
i++;
continue;
}
if (line.startsWith("### ")) {
html.append("<h3>").append(inlineToHtml(line.substring(4).trim()))
.append("</h3>");
String heading = line.substring(4).trim();
html.append("<h3><a name=\"").append(slugify(heading)).append("\">")
.append(inlineToHtml(heading)).append("</a></h3>");
i++;
continue;
}
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> menuItemLabels(JPopupMenu menu) {
List<String> labels = new ArrayList<>();
for (int i = 0; i < menu.getComponentCount(); i++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ void testPlainTextEscaped() {
@Test
void testHeadings() {
String html = MarkdownRenderer.toHtml("# H1\n## H2\n### H3");
assertTrue(html.contains("<h1>H1</h1>"), "h1");
assertTrue(html.contains("<h2>H2</h2>"), "h2");
assertTrue(html.contains("<h3>H3</h3>"), "h3");
assertTrue(html.contains("<h1>") && html.contains("H1") && html.contains("</h1>"), "h1");
assertTrue(html.contains("<h2>") && html.contains("H2") && html.contains("</h2>"), "h2");
assertTrue(html.contains("<h3>") && html.contains("H3") && html.contains("</h3>"), "h3");
}

@Test
Expand Down Expand Up @@ -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("<a name=\"my-heading\">"), "h1 named anchor");
assertTrue(html.contains("<h1>"), "h1 tag");
}

@Test
void testHeadingH2HasNamedAnchor() {
String html = MarkdownRenderer.toHtml("## Section Two");
assertTrue(html.contains("<a name=\"section-two\">"), "h2 named anchor");
}

@Test
void testHeadingH3HasNamedAnchor() {
String html = MarkdownRenderer.toHtml("### Sub Section");
assertTrue(html.contains("<a name=\"sub-section\">"), "h3 named anchor");
}

@Test
void testHeadingSlugStripNonAlphanumeric() {
String html = MarkdownRenderer.toHtml("# Hello, World! (2024)");
assertTrue(html.contains("<a name=\"hello-world-2024\">"), "slug strips punctuation");
}
}
Loading