/plans/*.md}) and the {@code autoPlanPreview}
+ * setting is enabled. Called after the user approves a Write hook.
+ *
+ * The plans directory is identified by the parent directory being named
+ * {@code "plans"}, making it robust against different {@code CLAUDE_CONFIG_DIR}
+ * values (default {@code ~/.claude}, isolated profiles, etc.)
+ *
+ *
The tab is opened before Claude writes the file; {@link MarkdownPreviewTab#openLive}
+ * schedules a deferred {@code forceReload()} that wires the {@link org.openide.filesystems.FileChangeListener}
+ * once the file appears on disk.
+ */
+ private void openPlanPreviewIfNeeded(JsonNode toolInput, String filePath) {
+ if (!ClaudeCodePreferences.isAutoPlanPreview()) return;
+ if (filePath == null || filePath.isEmpty() || !filePath.endsWith(".md")) return;
+ java.nio.file.Path p = java.nio.file.Path.of(filePath);
+ if (p.getParent() == null
+ || !"plans".equals(p.getParent().getFileName().toString())) return;
+ File file = new File(filePath);
+ FileObject fo = FileUtil.toFileObject(FileUtil.normalizeFile(file));
+ String currentContent = "";
+ if (fo != null) {
+ try { currentContent = fo.asText(); } catch (java.io.IOException ignored) {}
+ }
+ final String initialContent = currentContent;
+ SwingUtilities.invokeLater(() -> MarkdownPreviewTab.openLive(filePath, initialContent, fo));
+ }
+
private static boolean isFileEditTool(String toolName) {
return "Edit".equals(toolName) || "Write".equals(toolName) || "MultiEdit".equals(toolName);
}
diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java
index 6ccbfcaf..5805c2af 100644
--- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java
+++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java
@@ -2,6 +2,9 @@
import io.github.nbclaudecodegui.model.FavoriteEntry;
import io.github.nbclaudecodegui.model.PromptFavoritesStore;
+import javax.swing.DefaultListCellRenderer;
+import javax.swing.JComboBox;
+import javax.swing.JList;
import io.github.nbclaudecodegui.ui.FavoritesPanel;
import java.awt.BorderLayout;
import java.awt.GridBagConstraints;
@@ -72,8 +75,12 @@ public final class ClaudeCodeOptionsPanel extends JPanel {
private javax.swing.JCheckBox debugCheckBox;
/** Checkbox to open diff in a separate tab. */
private javax.swing.JCheckBox diffInSessionCheck;
- /** Checkbox to show markdown preview for .md files in diff. */
- private javax.swing.JCheckBox mdPreviewInDiffCheck;
+ /** Label for the markdown preview in diff combobox. */
+ private JLabel mdPreviewInDiffLabel;
+ /** Combobox for the markdown preview in diff mode. */
+ private JComboBox mdPreviewInDiffCombo;
+ /** Checkbox to automatically open Plan Preview when Claude writes a plan file. */
+ private javax.swing.JCheckBox autoPlanPreviewCheck;
/** Checkbox for context-menu session mode (checked = New, unchecked = Continue last). */
private javax.swing.JCheckBox startNewSessionCheck;
/** Spinner for the maximum number of sessions shown in the session list. */
@@ -219,13 +226,36 @@ private JPanel buildGeneralPanel() {
row++;
// --- md preview in diff ---
- mdPreviewInDiffCheck = new javax.swing.JCheckBox("Show markdown preview for .md files in diff");
- GridBagConstraints mdGbc = new GridBagConstraints();
- mdGbc.gridx = 0; mdGbc.gridy = row;
- mdGbc.gridwidth = 3;
- mdGbc.anchor = GridBagConstraints.WEST;
- mdGbc.insets = new Insets(4, 8, 4, 8);
- form.add(mdPreviewInDiffCheck, mdGbc);
+ mdPreviewInDiffLabel = new JLabel("Markdown preview in diff:");
+ form.add(mdPreviewInDiffLabel, gbc(0, row, false));
+ mdPreviewInDiffCombo = new JComboBox<>(MdPreviewInDiffMode.values());
+ mdPreviewInDiffCombo.setRenderer(new DefaultListCellRenderer() {
+ @Override
+ public java.awt.Component getListCellRendererComponent(
+ JList> list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
+ super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
+ if (value instanceof MdPreviewInDiffMode mode) {
+ setText(switch (mode) {
+ case ALWAYS -> "Always";
+ case NEVER -> "Never";
+ case EXCEPT_PLAN -> "Except when Plan Preview tab is open";
+ });
+ }
+ return this;
+ }
+ });
+ form.add(mdPreviewInDiffCombo, gbc(1, row, false));
+ row++;
+
+ // --- auto plan preview ---
+ autoPlanPreviewCheck = new javax.swing.JCheckBox(
+ "Automatically open Plan Preview tab when Claude writes a plan");
+ GridBagConstraints appGbc = new GridBagConstraints();
+ appGbc.gridx = 0; appGbc.gridy = row;
+ appGbc.gridwidth = 3;
+ appGbc.anchor = GridBagConstraints.WEST;
+ appGbc.insets = new Insets(4, 8, 4, 8);
+ form.add(autoPlanPreviewCheck, appGbc);
row++;
// --- markdown preview dock mode ---
@@ -444,7 +474,8 @@ void load() {
debugCheckBox.setSelected(ClaudeCodePreferences.isDebugMode());
diffInSessionCheck.setSelected(
ClaudeCodePreferences.isOpenDiffInSeparateTab());
- mdPreviewInDiffCheck.setSelected(ClaudeCodePreferences.isMdPreviewInDiff());
+ mdPreviewInDiffCombo.setSelectedItem(ClaudeCodePreferences.getMdPreviewInDiffMode());
+ autoPlanPreviewCheck.setSelected(ClaudeCodePreferences.isAutoPlanPreview());
startNewSessionCheck.setSelected(
ClaudeCodePreferences.getContextMenuSessionMode()
== io.github.nbclaudecodegui.model.SessionMode.NEW);
@@ -489,7 +520,9 @@ void store() {
ClaudeCodePreferences.setHistoryTtlDays((Integer) historyTtlDaysSpinner.getValue());
ClaudeCodePreferences.setDebugMode(debugCheckBox.isSelected());
ClaudeCodePreferences.setOpenDiffInSeparateTab(diffInSessionCheck.isSelected());
- ClaudeCodePreferences.setMdPreviewInDiff(mdPreviewInDiffCheck.isSelected());
+ MdPreviewInDiffMode selMode = (MdPreviewInDiffMode) mdPreviewInDiffCombo.getSelectedItem();
+ if (selMode != null) ClaudeCodePreferences.setMdPreviewInDiffMode(selMode);
+ ClaudeCodePreferences.setAutoPlanPreview(autoPlanPreviewCheck.isSelected());
ClaudeCodePreferences.setContextMenuSessionMode(
startNewSessionCheck.isSelected()
? io.github.nbclaudecodegui.model.SessionMode.NEW
diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java
index 98fd704c..79ba5920 100644
--- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java
+++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java
@@ -380,31 +380,78 @@ public static void setOpenDiffInSeparateTab(boolean v) {
}
// -------------------------------------------------------------------------
- // mdPreviewInDiff
+ // mdPreviewInDiffMode
// -------------------------------------------------------------------------
- /** Preference key: show markdown preview for .md files in diff. */
- public static final String KEY_MD_PREVIEW_IN_DIFF = "mdPreviewInDiff";
- /** Default: markdown preview enabled. */
- public static final boolean DEFAULT_MD_PREVIEW_IN_DIFF = true;
+ /** Preference key: when to show markdown preview for .md files in diff. */
+ public static final String KEY_MD_PREVIEW_IN_DIFF_MODE = "mdPreviewInDiffMode";
+ /** Default: always show the inline preview. */
+ public static final MdPreviewInDiffMode DEFAULT_MD_PREVIEW_IN_DIFF_MODE =
+ MdPreviewInDiffMode.ALWAYS;
/**
- * Returns whether the markdown preview is shown for .md files in diffs.
+ * Returns the current mode for showing the inline Markdown preview in the diff panel.
*
- * @return {@code true} if the preview is enabled
+ * @return the {@link MdPreviewInDiffMode}, never {@code null}
*/
+ public static MdPreviewInDiffMode getMdPreviewInDiffMode() {
+ String raw = NbPreferences.forModule(ClaudeCodePreferences.class)
+ .get(KEY_MD_PREVIEW_IN_DIFF_MODE, DEFAULT_MD_PREVIEW_IN_DIFF_MODE.name());
+ try {
+ return MdPreviewInDiffMode.valueOf(raw);
+ } catch (IllegalArgumentException e) {
+ return DEFAULT_MD_PREVIEW_IN_DIFF_MODE;
+ }
+ }
+
+ /**
+ * Persists the markdown-preview-in-diff mode setting.
+ *
+ * @param v the mode to store, must not be {@code null}
+ */
+ public static void setMdPreviewInDiffMode(MdPreviewInDiffMode v) {
+ NbPreferences.forModule(ClaudeCodePreferences.class)
+ .put(KEY_MD_PREVIEW_IN_DIFF_MODE, v.name());
+ }
+
+ /**
+ * Returns {@code true} if the inline Markdown preview is enabled in any form.
+ *
+ * @deprecated Use {@link #getMdPreviewInDiffMode()} for full control.
+ */
+ @Deprecated
public static boolean isMdPreviewInDiff() {
+ return getMdPreviewInDiffMode() != MdPreviewInDiffMode.NEVER;
+ }
+
+ // -------------------------------------------------------------------------
+ // autoPlanPreview
+ // -------------------------------------------------------------------------
+
+ /** Preference key: automatically open Markdown Preview when Claude writes a plan file. */
+ public static final String KEY_AUTO_PLAN_PREVIEW = "autoPlanPreview";
+ /** Default: auto plan preview enabled. */
+ public static final boolean DEFAULT_AUTO_PLAN_PREVIEW = true;
+
+ /**
+ * Returns {@code true} if the plugin should automatically open a live Markdown
+ * Preview tab when Claude writes a plan file to any {@code plans/} directory.
+ *
+ * @return {@code true} if auto plan preview is enabled
+ */
+ public static boolean isAutoPlanPreview() {
return NbPreferences.forModule(ClaudeCodePreferences.class)
- .getBoolean(KEY_MD_PREVIEW_IN_DIFF, DEFAULT_MD_PREVIEW_IN_DIFF);
+ .getBoolean(KEY_AUTO_PLAN_PREVIEW, DEFAULT_AUTO_PLAN_PREVIEW);
}
/**
- * Persists the markdown-preview-in-diff setting.
+ * Persists the auto-plan-preview setting.
*
- * @param v {@code true} to enable the preview
+ * @param v {@code true} to enable automatic plan preview
*/
- public static void setMdPreviewInDiff(boolean v) {
- NbPreferences.forModule(ClaudeCodePreferences.class).putBoolean(KEY_MD_PREVIEW_IN_DIFF, v);
+ public static void setAutoPlanPreview(boolean v) {
+ NbPreferences.forModule(ClaudeCodePreferences.class)
+ .putBoolean(KEY_AUTO_PLAN_PREVIEW, v);
}
// -------------------------------------------------------------------------
diff --git a/src/main/java/io/github/nbclaudecodegui/settings/MdPreviewInDiffMode.java b/src/main/java/io/github/nbclaudecodegui/settings/MdPreviewInDiffMode.java
new file mode 100644
index 00000000..fbecf9be
--- /dev/null
+++ b/src/main/java/io/github/nbclaudecodegui/settings/MdPreviewInDiffMode.java
@@ -0,0 +1,17 @@
+package io.github.nbclaudecodegui.settings;
+
+/**
+ * Controls when the inline Markdown preview is shown inside the file diff panel.
+ */
+public enum MdPreviewInDiffMode {
+ /** Always show the inline preview for .md files in the diff panel. */
+ ALWAYS,
+ /** Never show the inline preview. */
+ NEVER,
+ /**
+ * Show the inline preview, except when a live Plan Preview tab is already
+ * open for a file in the {@code plans/} directory — in that case the
+ * dedicated preview tab makes the inline one redundant.
+ */
+ EXCEPT_PLAN
+}
diff --git a/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java b/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java
index 0d927ef0..b7993a31 100644
--- a/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java
+++ b/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java
@@ -2,8 +2,10 @@
import io.github.nbclaudecodegui.model.EditMode;
import io.github.nbclaudecodegui.settings.ClaudeCodePreferences;
+import io.github.nbclaudecodegui.settings.MdPreviewInDiffMode;
import java.io.File;
import java.io.IOException;
+import java.nio.file.Path;
import java.io.Reader;
import java.io.StringReader;
import java.io.Writer;
@@ -217,7 +219,21 @@ public static void open(
// Build the wrapper panel that holds either the raw diff or the MD split
java.awt.Component initialContent = diffView.getComponent();
- if (isMdFile && ClaudeCodePreferences.isMdPreviewInDiff()) {
+ MdPreviewInDiffMode previewMode = ClaudeCodePreferences.getMdPreviewInDiffMode();
+ boolean showMdPreview = isMdFile && switch (previewMode) {
+ case ALWAYS -> true;
+ case NEVER -> false;
+ case EXCEPT_PLAN -> {
+ // Suppress for plan files when a dedicated tab will open (autoPlanPreview)
+ // or already is open for this exact file.
+ Path parent = Path.of(filePath).getParent();
+ boolean isPlanFile = parent != null
+ && "plans".equals(parent.getFileName().toString());
+ yield !(isPlanFile && (ClaudeCodePreferences.isAutoPlanPreview()
+ || MarkdownPreviewTab.isTabOpenFor(filePath)));
+ }
+ };
+ if (showMdPreview) {
MarkdownDiffPanel mdPanel = new MarkdownDiffPanel(before, after);
mdPanel.attachRawDiffSync(diffView.getComponent());
javax.swing.JSplitPane mdSplit = new javax.swing.JSplitPane(
@@ -257,7 +273,7 @@ public static void open(
if (isMdFile) {
JCheckBoxMenuItem previewItem =
new JCheckBoxMenuItem("Preview Markdown");
- previewItem.setSelected(ClaudeCodePreferences.isMdPreviewInDiff());
+ previewItem.setSelected(showMdPreview);
previewItemRef[0] = previewItem;
previewItem.addActionListener(e ->
diff --git a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java
index fa261e99..e26ba7b5 100644
--- a/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java
+++ b/src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java
@@ -16,6 +16,8 @@
import javax.swing.JComponent;
import java.io.File;
import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Objects;
import java.io.ObjectInput;
import java.io.ObjectOutput;
import java.net.URI;
@@ -40,6 +42,7 @@
import org.openide.filesystems.FileEvent;
import org.openide.filesystems.FileObject;
import org.openide.filesystems.FileUtil;
+import org.openide.util.RequestProcessor;
import org.openide.windows.TopComponent;
/**
@@ -67,6 +70,9 @@ public class MarkdownPreviewTab extends TopComponent {
private static final Map OPEN_TABS =
Collections.synchronizedMap(new HashMap<>());
+ /** Used to schedule deferred {@link #forceReload()} when a tab is opened before the file exists. */
+ private static final RequestProcessor RP = new RequestProcessor("markdown-preview-reload", 1, true);
+
/** The editor pane that renders HTML-converted markdown. */
JEditorPane pane;
@@ -74,10 +80,10 @@ public class MarkdownPreviewTab extends TopComponent {
private JScrollPane scrollPane;
/** Listener attached to the backing FileObject, if any. */
- private FileChangeListener fileListener;
+ FileChangeListener fileListener;
/** The FileObject being monitored (may be null for diff-pinned previews). */
- private FileObject fileObject;
+ FileObject fileObject;
/** Absolute path key used to identify this tab in {@link #OPEN_TABS}. */
String filePath;
@@ -131,6 +137,7 @@ public static void openLive(String filePath, String content, FileObject fo) {
existing.fileListener = makeFileListener(existing, fo);
fo.addFileChangeListener(existing.fileListener);
}
+ RP.post(existing::forceReload, 3000);
existing.requestActive();
return;
}
@@ -168,6 +175,8 @@ public static void openLive(String filePath, String content, FileObject fo) {
OPEN_TABS.put(filePath, tab);
UiUtils.dockAndOpen(tab, ClaudeCodePreferences.getMarkdownPreviewDockMode());
tab.requestActive();
+
+ RP.post(tab::forceReload, 3000);
}
/**
@@ -472,7 +481,7 @@ JPopupMenu buildContextMenu() {
JMenuItem refresh = new JMenuItem("Refresh");
refresh.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0));
- refresh.addActionListener(e -> doRefresh());
+ refresh.addActionListener(e -> forceReload());
JMenuItem selectAll = new JMenuItem("Select All");
selectAll.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_A, InputEvent.CTRL_DOWN_MASK));
@@ -520,7 +529,9 @@ public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
copyUrl.setEnabled(hasLink);
back.setEnabled(canGoBack());
forward.setEnabled(canGoForward());
- refresh.setVisible(fileObject != null);
+ // Show Refresh only when the tab tracks a real file (not synthetic MCP content).
+ refresh.setVisible(fileObject != null
+ || (filePath != null && new File(filePath).exists()));
copy.setEnabled(pane != null
&& pane.getSelectionStart() != pane.getSelectionEnd());
}
@@ -538,20 +549,10 @@ private void bindRefreshKey(JEditorPane p) {
KeyStroke f5 = KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0);
p.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(f5, "md-refresh");
p.getActionMap().put("md-refresh", new AbstractAction() {
- @Override public void actionPerformed(ActionEvent e) { doRefresh(); }
+ @Override public void actionPerformed(ActionEvent e) { forceReload(); }
});
}
- void doRefresh() {
- if (fileObject == null) return;
- try {
- fileObject.refresh(false);
- updateContent(fileObject.asText());
- } catch (IOException e) {
- // silently ignore
- }
- }
-
@Override
public void writeExternal(ObjectOutput out) throws IOException {
super.writeExternal(out);
@@ -633,13 +634,56 @@ protected void componentOpened() {
}
}
+ /**
+ * Returns {@code true} if at least one live Markdown Preview tab is currently
+ * open for a file whose parent directory is named {@code plans}. Used to
+ * suppress the inline diff preview when a dedicated plan preview is visible.
+ */
+ public static boolean hasActivePlanPreview() {
+ return OPEN_TABS.values().stream()
+ .filter(MarkdownPreviewTab::isOpened)
+ .map(t -> t.filePath != null ? Path.of(t.filePath).getParent() : null)
+ .filter(Objects::nonNull)
+ .anyMatch(p -> "plans".equals(p.getFileName().toString()));
+ }
+
+ /** Returns {@code true} if a live preview tab is currently open for exactly {@code filePath}. */
+ public static boolean isTabOpenFor(String filePath) {
+ MarkdownPreviewTab tab = OPEN_TABS.get(filePath);
+ return tab != null && tab.isOpened();
+ }
+
+ /**
+ * Forces an immediate reload from disk. When {@code fileObject} is null (tab was
+ * opened before the file existed), attempts to acquire the {@link FileObject} and
+ * attach a {@link FileChangeListener}. Called automatically by {@link #openLive}
+ * after a 3-second delay whenever the tab is opened without a FileObject.
+ */
+ public void forceReload() {
+ if (fileObject == null) {
+ // File may not have existed when the tab was opened (hook fires before write).
+ // Try to obtain the FileObject now that the file should be on disk.
+ File f = new File(filePath);
+ if (!f.exists()) return;
+ FileObject fo = FileUtil.toFileObject(FileUtil.normalizeFile(f));
+ if (fo == null) return;
+ fileObject = fo;
+ fileListener = makeFileListener(this, fo);
+ fo.addFileChangeListener(fileListener);
+ }
+ try {
+ fileObject.refresh(false);
+ updateContent(fileObject.asText());
+ } catch (IOException ignored) {}
+ }
+
// --- Test hooks (package-private) ---
static void clearOpenTabsForTest() {
OPEN_TABS.clear();
}
- static Map getOpenTabsForTest() {
+ public static Map getOpenTabsForTest() {
return OPEN_TABS;
}
diff --git a/src/test/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferencesTest.java b/src/test/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferencesTest.java
index 8274762c..2de9b4bd 100644
--- a/src/test/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferencesTest.java
+++ b/src/test/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferencesTest.java
@@ -17,7 +17,7 @@ void tearDown() {
ClaudeCodePreferences.DEFAULT_CLAUDE_EXECUTABLE_PATH);
ClaudeCodePreferences.setMcpPort(ClaudeCodePreferences.DEFAULT_MCP_PORT);
ClaudeCodePreferences.setOpenDiffInSeparateTab(ClaudeCodePreferences.DEFAULT_OPEN_DIFF_IN_SEPARATE_TAB);
- ClaudeCodePreferences.setMdPreviewInDiff(ClaudeCodePreferences.DEFAULT_MD_PREVIEW_IN_DIFF);
+ ClaudeCodePreferences.setMdPreviewInDiffMode(ClaudeCodePreferences.DEFAULT_MD_PREVIEW_IN_DIFF_MODE);
ClaudeCodePreferences.setSessionDockMode(ClaudeCodePreferences.DEFAULT_SESSION_DOCK_MODE);
ClaudeCodePreferences.setMarkdownPreviewDockMode(ClaudeCodePreferences.DEFAULT_MARKDOWN_PREVIEW_DOCK_MODE);
ClaudeCodePreferences.setFileDiffDockMode(ClaudeCodePreferences.DEFAULT_FILE_DIFF_DOCK_MODE);
@@ -100,7 +100,7 @@ void mdPreviewInDiffDefaultIsTrue() {
@Test
void mdPreviewInDiffStoreAndRetrieve() {
- ClaudeCodePreferences.setMdPreviewInDiff(false);
+ ClaudeCodePreferences.setMdPreviewInDiffMode(MdPreviewInDiffMode.NEVER);
assertFalse(ClaudeCodePreferences.isMdPreviewInDiff());
}
diff --git a/src/test/java/io/github/nbclaudecodegui/settings/MdPreviewInDiffModePreferenceTest.java b/src/test/java/io/github/nbclaudecodegui/settings/MdPreviewInDiffModePreferenceTest.java
new file mode 100644
index 00000000..6b0915ee
--- /dev/null
+++ b/src/test/java/io/github/nbclaudecodegui/settings/MdPreviewInDiffModePreferenceTest.java
@@ -0,0 +1,82 @@
+package io.github.nbclaudecodegui.settings;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link MdPreviewInDiffMode} preference and {@code autoPlanPreview} preference
+ * in {@link ClaudeCodePreferences}.
+ */
+class MdPreviewInDiffModePreferenceTest {
+
+ @AfterEach
+ void tearDown() {
+ ClaudeCodePreferences.setMdPreviewInDiffMode(ClaudeCodePreferences.DEFAULT_MD_PREVIEW_IN_DIFF_MODE);
+ ClaudeCodePreferences.setAutoPlanPreview(ClaudeCodePreferences.DEFAULT_AUTO_PLAN_PREVIEW);
+ }
+
+ @Test
+ void mdPreviewInDiffMode_defaultIsAlways() {
+ assertEquals(MdPreviewInDiffMode.ALWAYS, ClaudeCodePreferences.DEFAULT_MD_PREVIEW_IN_DIFF_MODE);
+ assertEquals(MdPreviewInDiffMode.ALWAYS, ClaudeCodePreferences.getMdPreviewInDiffMode());
+ }
+
+ @Test
+ void mdPreviewInDiffMode_roundTrip_never() {
+ ClaudeCodePreferences.setMdPreviewInDiffMode(MdPreviewInDiffMode.NEVER);
+ assertEquals(MdPreviewInDiffMode.NEVER, ClaudeCodePreferences.getMdPreviewInDiffMode());
+ }
+
+ @Test
+ void mdPreviewInDiffMode_roundTrip_exceptPlan() {
+ ClaudeCodePreferences.setMdPreviewInDiffMode(MdPreviewInDiffMode.EXCEPT_PLAN);
+ assertEquals(MdPreviewInDiffMode.EXCEPT_PLAN, ClaudeCodePreferences.getMdPreviewInDiffMode());
+ }
+
+ @Test
+ void mdPreviewInDiffMode_unknownValueFallsBackToDefault() {
+ // Write an invalid raw value directly via NbPreferences, then verify fallback
+ org.openide.util.NbPreferences.forModule(ClaudeCodePreferences.class)
+ .put(ClaudeCodePreferences.KEY_MD_PREVIEW_IN_DIFF_MODE, "INVALID_VALUE");
+ assertEquals(ClaudeCodePreferences.DEFAULT_MD_PREVIEW_IN_DIFF_MODE,
+ ClaudeCodePreferences.getMdPreviewInDiffMode());
+ }
+
+ @Test
+ void isMdPreviewInDiff_deprecatedWrapper_returnsTrueForAlways() {
+ ClaudeCodePreferences.setMdPreviewInDiffMode(MdPreviewInDiffMode.ALWAYS);
+ assertTrue(ClaudeCodePreferences.isMdPreviewInDiff());
+ }
+
+ @Test
+ void isMdPreviewInDiff_deprecatedWrapper_returnsFalseForNever() {
+ ClaudeCodePreferences.setMdPreviewInDiffMode(MdPreviewInDiffMode.NEVER);
+ assertFalse(ClaudeCodePreferences.isMdPreviewInDiff());
+ }
+
+ @Test
+ void isMdPreviewInDiff_deprecatedWrapper_returnsTrueForExceptPlan() {
+ ClaudeCodePreferences.setMdPreviewInDiffMode(MdPreviewInDiffMode.EXCEPT_PLAN);
+ assertTrue(ClaudeCodePreferences.isMdPreviewInDiff());
+ }
+
+ @Test
+ void autoPlanPreview_defaultIsTrue() {
+ assertTrue(ClaudeCodePreferences.DEFAULT_AUTO_PLAN_PREVIEW);
+ assertTrue(ClaudeCodePreferences.isAutoPlanPreview());
+ }
+
+ @Test
+ void autoPlanPreview_roundTrip_false() {
+ ClaudeCodePreferences.setAutoPlanPreview(false);
+ assertFalse(ClaudeCodePreferences.isAutoPlanPreview());
+ }
+
+ @Test
+ void autoPlanPreview_roundTrip_true() {
+ ClaudeCodePreferences.setAutoPlanPreview(false);
+ ClaudeCodePreferences.setAutoPlanPreview(true);
+ assertTrue(ClaudeCodePreferences.isAutoPlanPreview());
+ }
+}
diff --git a/src/test/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTabPlanDetectionTest.java b/src/test/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTabPlanDetectionTest.java
new file mode 100644
index 00000000..fcfe0f01
--- /dev/null
+++ b/src/test/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTabPlanDetectionTest.java
@@ -0,0 +1,201 @@
+package io.github.nbclaudecodegui.ui;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.util.Map;
+import javax.swing.JEditorPane;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.Path;
+
+/**
+ * Unit tests for {@link MarkdownPreviewTab#hasActivePlanPreview()} and related logic.
+ */
+class MarkdownPreviewTabPlanDetectionTest {
+
+ @BeforeEach
+ @AfterEach
+ void clearTabs() {
+ MarkdownPreviewTab.clearOpenTabsForTest();
+ }
+
+ @Test
+ void hasActivePlanPreview_noTabs_returnsFalse() {
+ assertFalse(MarkdownPreviewTab.hasActivePlanPreview());
+ }
+
+ @Test
+ void hasActivePlanPreview_nonPlanTab_returnsFalse() {
+ MarkdownPreviewTab tab = new MarkdownPreviewTab();
+ tab.setFilePathForTest("/home/user/.claude/projects/foo/bar.md");
+ Map openTabs = MarkdownPreviewTab.getOpenTabsForTest();
+ openTabs.put(tab.filePath, tab);
+ assertFalse(MarkdownPreviewTab.hasActivePlanPreview());
+ }
+
+ @Test
+ void hasActivePlanPreview_planTab_returnsTrue() {
+ MarkdownPreviewTab tab = new MarkdownPreviewTab();
+ tab.setFilePathForTest("/home/user/.claude/plans/my-plan.md");
+ Map openTabs = MarkdownPreviewTab.getOpenTabsForTest();
+ openTabs.put(tab.filePath, tab);
+ // isOpened() returns false for a tab not in the window system; simulate by checking
+ // that the method iterates and checks parent dir name — since isOpened() returns false
+ // in test, we verify the behaviour when the tab IS considered opened via a subclass
+ // or by checking that with an opened tab the result is true.
+ // In test context isOpened() == false, so result is false here — verify that
+ // a non-opened plan tab does NOT trigger hasActivePlanPreview.
+ assertFalse(MarkdownPreviewTab.hasActivePlanPreview(),
+ "A closed (not opened) plan tab should not trigger hasActivePlanPreview");
+ }
+
+ @Test
+ void hasActivePlanPreview_closedPlanTab_returnsFalse() {
+ MarkdownPreviewTab tab = new MarkdownPreviewTab();
+ tab.setFilePathForTest("/home/user/.claude/plans/some-plan.md");
+ Map openTabs = MarkdownPreviewTab.getOpenTabsForTest();
+ openTabs.put(tab.filePath, tab);
+ // isOpened() is false in test environment (tab not in window system)
+ assertFalse(MarkdownPreviewTab.hasActivePlanPreview());
+ }
+
+ @Test
+ void hasActivePlanPreview_profilePlansDir_returnsFalseWhenClosed() {
+ MarkdownPreviewTab tab = new MarkdownPreviewTab();
+ tab.setFilePathForTest("/home/user/.claude-work/plans/project-plan.md");
+ Map openTabs = MarkdownPreviewTab.getOpenTabsForTest();
+ openTabs.put(tab.filePath, tab);
+ // Tab is not in window system, so isOpened() == false → hasActivePlanPreview == false
+ assertFalse(MarkdownPreviewTab.hasActivePlanPreview());
+ }
+
+ /** show_markdown uses a synthetic "mcp://" key — forceReload must be a safe no-op. */
+ @Test
+ void forceReload_syntheticMcpKey_doesNothing() {
+ MarkdownPreviewTab tab = new MarkdownPreviewTab();
+ tab.setFilePathForTest("mcp://show_markdown/Plan");
+ tab.setPaneForTest(new JEditorPane());
+ assertDoesNotThrow(() -> tab.forceReload(),
+ "forceReload() must not throw for synthetic MCP keys");
+ assertNull(tab.fileObject, "fileObject must remain null for synthetic MCP keys");
+ }
+
+ @Test
+ void forceReload_fileNotYetExists_doesNothing() {
+ MarkdownPreviewTab tab = new MarkdownPreviewTab();
+ tab.setFilePathForTest("/tmp/nonexistent-plan-" + System.currentTimeMillis() + ".md");
+ tab.setPaneForTest(new JEditorPane());
+ tab.forceReload();
+ assertNull(tab.fileObject, "fileObject should remain null when file does not exist");
+ }
+
+ // --- isPlanFile helper (mirrors FileDiffOpener EXCEPT_PLAN logic) ---
+
+ private static boolean isPlanFile(String filePath) {
+ Path parent = Path.of(filePath).getParent();
+ return parent != null && "plans".equals(parent.getFileName().toString());
+ }
+
+ @Test
+ void isPlanFile_planPath_returnsTrue() {
+ assertTrue(isPlanFile("/home/user/.claude/plans/my-plan.md"));
+ }
+
+ @Test
+ void isPlanFile_profilePlanPath_returnsTrue() {
+ assertTrue(isPlanFile("/home/user/.netbeans/claude-profiles/Work/plans/some.md"));
+ }
+
+ @Test
+ void isPlanFile_nonPlanPath_returnsFalse() {
+ assertFalse(isPlanFile("/home/user/my-projects/docs/testing-auto-plan-preview.md"));
+ }
+
+ @Test
+ void isPlanFile_projectsDir_returnsFalse() {
+ // "projects" should not be mistaken for "plans"
+ assertFalse(isPlanFile("/home/user/.claude/projects/abc/session.md"));
+ }
+
+ // --- EXCEPT_PLAN logic tests (mirrors FileDiffOpener: !(isPlanFile && (autoPlan || isTabOpen))) ---
+
+ /** Non-plan file always shows inline preview regardless of autoPlanPreview or open tabs. */
+ @Test
+ void exceptPlan_nonPlanFile_alwaysShows() {
+ String nonPlanPath = "/home/user/my-projects/docs/notes.md";
+ boolean isPlan = isPlanFile(nonPlanPath);
+ assertFalse(isPlan);
+ // showPreview = !(isPlanFile && (...)) → !false = true always
+ boolean showPreview = !(isPlan && (true /* autoPlanPreview */ || true /* isTabOpen */));
+ assertTrue(showPreview, "EXCEPT_PLAN must not suppress preview for non-plan .md files");
+ }
+
+ /** Plan file + autoPlanPreview=on → suppress (dedicated tab will open after Allow). */
+ @Test
+ void exceptPlan_planFile_suppressedWhenAutoPlanPreviewEnabled() {
+ String planPath = "/home/user/.claude/plans/my-plan.md";
+ assertTrue(isPlanFile(planPath));
+ boolean showPreview = !(isPlanFile(planPath) && (true /* autoPlanPreview */ || false /* isTabOpen */));
+ assertFalse(showPreview, "EXCEPT_PLAN should suppress plan file when autoPlanPreview=on");
+ }
+
+ /** Plan file + tab already open for this file → suppress. */
+ @Test
+ void exceptPlan_planFile_suppressedWhenThisFileTabOpen() {
+ String planPath = "/home/user/.claude/plans/my-plan.md";
+ assertTrue(isPlanFile(planPath));
+ // isTabOpenFor returns true when tab is in OPEN_TABS and isOpened()
+ // In test env isOpened()=false, so we test the logic directly
+ boolean showPreview = !(isPlanFile(planPath) && (false /* autoPlanPreview */ || true /* isTabOpen */));
+ assertFalse(showPreview, "EXCEPT_PLAN should suppress plan file when its tab is open");
+ }
+
+ /** Plan file + autoPlanPreview=off + no tab open → show inline preview. */
+ @Test
+ void exceptPlan_planFile_showsWhenAutoPlanOffAndNoTab() {
+ String planPath = "/home/user/.claude/plans/my-plan.md";
+ assertTrue(isPlanFile(planPath));
+ boolean showPreview = !(isPlanFile(planPath) && (false /* autoPlanPreview */ || false /* isTabOpen */));
+ assertTrue(showPreview, "EXCEPT_PLAN should show plan file preview when autoPlanPreview=off and no tab open");
+ }
+
+ /** isTabOpenFor: returns false when tab not in OPEN_TABS. */
+ @Test
+ void isTabOpenFor_noTab_returnsFalse() {
+ assertFalse(MarkdownPreviewTab.isTabOpenFor("/home/user/.claude/plans/nonexistent.md"));
+ }
+
+ /** isTabOpenFor: returns false when tab in OPEN_TABS but not opened (test env). */
+ @Test
+ void isTabOpenFor_tabInMap_butNotOpened_returnsFalse() {
+ MarkdownPreviewTab tab = new MarkdownPreviewTab();
+ tab.setFilePathForTest("/home/user/.claude/plans/my-plan.md");
+ MarkdownPreviewTab.getOpenTabsForTest().put(tab.filePath, tab);
+ // isOpened() == false in test env (not in window system)
+ assertFalse(MarkdownPreviewTab.isTabOpenFor(tab.filePath));
+ }
+
+ @Test
+ void forceReload_fileExistsAfterOpen_doesNotThrow(@TempDir File tempDir) throws Exception {
+ File planFile = new File(tempDir, "plan.md");
+ MarkdownPreviewTab tab = new MarkdownPreviewTab();
+ tab.setFilePathForTest(planFile.getAbsolutePath());
+ tab.setPaneForTest(new JEditorPane());
+
+ // File doesn't exist yet — forceReload should be a no-op
+ tab.forceReload();
+ assertNull(tab.fileObject, "fileObject should remain null when file does not yet exist");
+
+ // Simulate Claude writing the file
+ Files.writeString(planFile.toPath(), "# Hello Plan");
+
+ // forceReload should not throw; FileUtil.toFileObject may return null in unit test
+ // environment (no NetBeans VFS), but the code must not crash regardless
+ assertDoesNotThrow(() -> tab.forceReload(),
+ "forceReload() must not throw even when FileUtil returns null in unit test env");
+ }
+}