diff --git a/CHANGELOG.md b/CHANGELOG.md index e29f06ed..48a99f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- 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/docs/screenshots/settings-general.png b/docs/screenshots/settings-general.png index e76786d1..3e47a682 100644 Binary files a/docs/screenshots/settings-general.png and b/docs/screenshots/settings-general.png differ diff --git a/docs/user-manual.md b/docs/user-manual.md index 87e0efd4..687cf93a 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -317,12 +317,30 @@ Press Escape to click Cancel. ### Markdown preview -For `.md` files, a rendered markdown preview is shown alongside the raw diff. Toggle it via **right-click on the diff → Preview Markdown** (checkbox). The default state is configured in **Tools → Options → Claude Code → General → Show markdown preview for .md files in diff**. Toggling in the context menu does not change the global setting. +For `.md` files, a rendered markdown preview is shown alongside the raw diff. Toggle it via **right-click on the diff → Preview Markdown** (checkbox). The default state is configured in **Tools → Options → Claude Code → General → Markdown preview in diff**, which offers three options: + +| Option | Behaviour | +|--------|-----------| +| **Always** | Always show the inline preview for `.md` files in the diff panel (default) | +| **Never** | Never show the inline preview | +| **Except when Plan Preview tab is open** | Show the inline preview, but suppress it when a dedicated live Plan Preview tab is already visible for a file in the `plans/` directory | + +Toggling in the context menu does not change the global setting. **Pin Preview** — opens the rendered markdown (proposed content) in a separate IDE tab that remains open after the diff is closed. Both features are especially useful in Claude's plan mode: they let you read the formatted plan before deciding whether to accept it. +### Auto Plan Preview + +When Claude writes a plan file to a `plans/` directory (e.g. `~/.claude/plans/my-plan.md`), the plugin can automatically open a live **Markdown Preview** tab the moment you click **Accept** in the diff panel. + +- The preview opens immediately when you click **Accept**; the file content appears within a second or two as Claude finishes writing it. +- If you click **Accept** again for the same plan file (e.g. after an update), the existing preview tab refreshes in-place instead of opening a new tab. +- The feature works with any `CLAUDE_CONFIG_DIR` location — it detects the `plans/` directory by name, so named profiles with custom config directories are supported automatically. + +**Enable / disable:** **Tools → Options → Claude Code → General → Automatically open Plan Preview tab when Claude writes a plan**. + If the file being edited is outside the current project directory, a warning ⚠ is shown in the diff panel. --- @@ -343,7 +361,8 @@ Open **Tools → Options → Claude Code** in NetBeans. | Insert newline key | Enter | Key combination that inserts a newline in the input area. The send and newline keys are configured independently but cannot be set to the same value. | | Open file diff in a separate tab | Off | Opens the diff panel in a new IDE tab instead of embedding it in the session tab. | | File diff tab dock position | Editor area | Where the File Diff tab is docked when opened as a separate tab. Enabled only when "Open file diff in a separate tab" is checked. | -| Show markdown preview for .md files in diff | On | Shows a rendered markdown preview alongside the raw diff for `.md` files. | +| Markdown preview in diff | Always | Controls when the inline markdown preview is shown for `.md` files in the diff panel. **Always** — always show it. **Never** — never show it. **Except when Plan Preview tab is open** — show it, but suppress it when the diffed file is a plan file and Auto Plan Preview is on (or its tab is already open). | +| Automatically open Plan Preview tab when Claude writes a plan | On | When enabled, a live Markdown Preview tab opens automatically as soon as the user clicks Allow in a diff panel for a file in a `plans/` directory. | | Markdown preview dock position | Right side | Where the Markdown Preview tab is docked when first opened. Choices: Editor area, Right side, Left side top, Left side bottom, Bottom dock. | | Choice menu focus | Grab focus | Controls whether the choice menu panel grabs keyboard focus when it appears. Choices: Grab focus, Show without grabbing focus, Hide menu. | | Terminal font | Auto | Font used in the embedded terminal. Click **Choose…** to open the font picker. The label shows the resolved font name (e.g. `Adwaita Mono, 14 (Auto)` when auto-detection is active, or `Monospaced, 14` when set explicitly). | @@ -657,6 +676,8 @@ The plugin exposes a set of IDE tools to Claude via an MCP (Model Context Protoc | `openDiff` | Shows a git diff for a file in the diff viewer | | `closeAllDiffTabs` | Closes all open diff viewer tabs | | `permission_prompt` | Shows proposed file changes in the diff panel and waits for your approval | +| `show_markdown` | Renders markdown text directly in a Markdown Preview tab; accepts the markdown content plus optional title and file path | +| `show_markdown_file` | Opens a live-updating Markdown Preview tab for an existing `.md` file on disk; the tab automatically refreshes when the file changes | ### Prompts that work well with IDE tools diff --git a/pom.xml b/pom.xml index 094b59f7..1b493418 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.23.1-SNAPSHOT + 0.23.6-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/mcp/NetBeansMCPHandler.java b/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java index 3cea166d..90c7a250 100644 --- a/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java +++ b/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java @@ -50,7 +50,9 @@ import javax.swing.SwingUtilities; import org.openide.windows.WindowManager; import io.github.nbclaudecodegui.ui.ClaudeSessionTab; +import io.github.nbclaudecodegui.settings.ClaudeCodePreferences; import io.github.nbclaudecodegui.ui.FileDiffOpener; +import io.github.nbclaudecodegui.ui.MarkdownPreviewTab; import io.github.nbclaudecodegui.mcp.tools.DiffTabTracker; import io.github.nbclaudecodegui.mcp.tools.GetDiagnostics; import io.github.nbclaudecodegui.mcp.tools.GetOpenEditors; @@ -548,7 +550,10 @@ public CompletableFuture handlePreToolUse(String hookJson) { CompletableFuture future = DiffTabTracker.registerHookFuture(tabName); FileDiffOpener.open(filePath, before, after, tabName, cwd, - () -> DiffTabTracker.resolveHook(tabName, hookAllowJson()), + () -> { + DiffTabTracker.resolveHook(tabName, hookAllowJson()); + openPlanPreviewIfNeeded(toolInput, filePath); + }, reason -> DiffTabTracker.resolveHook(tabName, hookDenyJson(reason)), () -> { DiffTabTracker.resolveHook(tabName, hookDenyJson("")); @@ -565,6 +570,35 @@ public CompletableFuture handlePreToolUse(String hookJson) { } } + /** + * Opens a live Markdown Preview tab for the written file when it is a plan + * file ({@code /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"); + } +}