diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bba4971..95024e77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+- Fixed diff dialog appearing as a detached tab with disabled Cancel/Accept-All buttons when Claude edits a file outside its current working directory (e.g. after cd into a subdirectory)
- Fixed choice menu not recognizing the Ultraplan "Tell Claude what to change" option as a text input field
- Added "Bypass Permissions" edit mode: automatically detected when Claude is launched with `--dangerously-skip-permissions`; auto-allows all file edits without showing a diff dialog
- Fixed PreToolUse and Stop hook HTTP 502 errors (regression in claude-code 2.1.118)
diff --git a/pom.xml b/pom.xml
index 33a66742..ffe475e7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.nbclaudecodegui
netbeans-claude-code-gui
- 0.22.23-SNAPSHOT
+ 0.22.24-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/mcp/NetBeansMCPHandler.java b/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java
index 683e1afe..7acf4688 100644
--- a/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java
+++ b/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java
@@ -523,7 +523,6 @@ public CompletableFuture handlePreToolUse(String hookJson) {
String tabName = resolveUniqueHookTabName("Diff: " + new File(filePath).getName());
CompletableFuture future = DiffTabTracker.registerHookFuture(tabName);
- // Pass cwd as confirmedDir so FileDiffTab can warn if file is outside this project
FileDiffOpener.open(filePath, before, after, tabName, cwd,
() -> DiffTabTracker.resolveHook(tabName, hookAllowJson()),
reason -> DiffTabTracker.resolveHook(tabName, hookDenyJson(reason)),
diff --git a/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java b/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java
index e065f2bb..0d927ef0 100644
--- a/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java
+++ b/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java
@@ -69,10 +69,11 @@ public static boolean isFileUnderDirectory(String filePath, String dirPath) {
* @param filePath path of the file being changed (used for tab title and session lookup)
* @param before file content before the change
* @param after file content after the change
- * @param tabName display name of the diff tab
- * @param confirmedDir if non-null, the session's working directory (hook case); the warning
- * is shown when the file is outside this directory. If null (MCP case),
- * the warning is shown when the file is outside all open sessions.
+ * @param tabName display name of the diff tab
+ * @param hookCwd if non-null, Claude's current working directory at hook time (hook case).
+ * Used to locate the session tab (whose startup dir may be a parent of hookCwd)
+ * and to warn when the file lies outside the session's project root.
+ * If null (MCP case), the warning is shown when the file is outside all open sessions.
* @param onAccept called when the user clicks Accept
* @param onDecline called with the (possibly empty) reason when the user clicks Decline
* @param onCancel called when the user clicks Cancel (caller should also send Ctrl+C)
@@ -80,20 +81,41 @@ public static boolean isFileUnderDirectory(String filePath, String dirPath) {
*/
public static void open(
String filePath, String before, String after, String tabName,
- String confirmedDir,
+ String hookCwd,
Runnable onAccept,
Consumer onDecline,
Runnable onCancel,
Runnable onClose) {
SwingUtilities.invokeLater(() -> {
+ // Look up the session tab first so its startup dir can be used as the project root.
+ // hookCwd != null (hook case): match tab whose startup dir is an ancestor of hookCwd.
+ // Claude may have cd'd into a subdirectory; the tab was opened at the project root.
+ // hookCwd == null (MCP case): match tab whose startup dir contains filePath.
+ final ClaudeSessionTab sessionTab = WindowManager.getDefault().getRegistry().getOpened().stream()
+ .map(tc -> {
+ if (!(tc instanceof ClaudeSessionTab s)) return null;
+ File dir = s.getWorkingDirectory();
+ if (dir == null) return null;
+ String abs = dir.getAbsolutePath();
+ return (hookCwd != null ? isFileUnderDirectory(hookCwd, abs)
+ : isFileUnderDirectory(filePath, abs)) ? s : null;
+ })
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(null);
+
// Determine if the file is outside the relevant project directory.
- // confirmedDir != null (hook): check against that specific directory.
- // confirmedDir == null (MCP): check against all open sessions.
+ // hookCwd != null (hook): use the session tab's startup dir as the project root
+ // (falls back to hookCwd if no tab found).
+ // hookCwd == null (MCP): check against all open sessions.
final boolean outsideProject;
final String outsideWarning;
- if (confirmedDir != null) {
- outsideProject = !isFileUnderDirectory(filePath, confirmedDir);
+ if (hookCwd != null) {
+ String projectRoot = (sessionTab != null && sessionTab.getWorkingDirectory() != null)
+ ? sessionTab.getWorkingDirectory().getAbsolutePath()
+ : hookCwd;
+ outsideProject = !isFileUnderDirectory(filePath, projectRoot);
outsideWarning = "⚠ This file is outside the current project";
} else {
boolean insideAny = false;
@@ -143,22 +165,6 @@ public static void open(
// firing again when the diff is closed.
AtomicBoolean decided = new AtomicBoolean(false);
- // Look up the session whose working directory matches the file being edited.
- // confirmedDir != null (hook case): use exact directory match.
- // confirmedDir == null (MCP case): fall back to isFileUnderDirectory.
- final ClaudeSessionTab sessionTab = WindowManager.getDefault().getRegistry().getOpened().stream()
- .map(tc -> {
- if (!(tc instanceof ClaudeSessionTab s)) return null;
- File dir = s.getWorkingDirectory();
- if (dir == null) return null;
- String abs = dir.getAbsolutePath();
- return (confirmedDir != null ? abs.equals(confirmedDir)
- : isFileUnderDirectory(filePath, abs)) ? s : null;
- })
- .filter(Objects::nonNull)
- .findFirst()
- .orElse(null);
-
String workingDir = sessionTab != null && sessionTab.getWorkingDirectory() != null
? sessionTab.getWorkingDirectory().getAbsolutePath()
: null;
diff --git a/src/test/java/io/github/nbclaudecodegui/ui/FileDiffOpenerTest.java b/src/test/java/io/github/nbclaudecodegui/ui/FileDiffOpenerTest.java
index 473b3643..7b080ea5 100644
--- a/src/test/java/io/github/nbclaudecodegui/ui/FileDiffOpenerTest.java
+++ b/src/test/java/io/github/nbclaudecodegui/ui/FileDiffOpenerTest.java
@@ -182,6 +182,77 @@ void restoreDividerFromArray() {
assertEquals(200, split.getDividerLocation());
}
+ // --- session tab lookup for hook case ---
+ // The lookup condition is: isFileUnderDirectory(hookCwd, tabWorkingDir)
+ // i.e. hookCwd starts with tabWorkingDir — tab's dir is ancestor of hookCwd.
+
+ @Test
+ void sessionTabMatchWhenHookCwdEqualsTabDir() {
+ // hookCwd == tabWorkingDir: should match (equals is ancestor-or-equal)
+ String tabDir = "/home/user/project";
+ String hookCwd = "/home/user/project";
+ assertTrue(FileDiffOpener.isFileUnderDirectory(hookCwd, tabDir),
+ "Tab dir equals hookCwd — must match");
+ }
+
+ @Test
+ void sessionTabMatchWhenHookCwdIsSubdirOfTabDir() {
+ // Bug case: Claude cd'd into a subdir; tab was opened at project root
+ String tabDir = "/home/user/project";
+ String hookCwd = "/home/user/project/claude-launch-tests";
+ assertTrue(FileDiffOpener.isFileUnderDirectory(hookCwd, tabDir),
+ "hookCwd is a subdir of tabDir — must match");
+ }
+
+ @Test
+ void sessionTabNoMatchWhenHookCwdUnrelated() {
+ String tabDir = "/home/user/project";
+ String hookCwd = "/home/user/other";
+ assertFalse(FileDiffOpener.isFileUnderDirectory(hookCwd, tabDir),
+ "hookCwd is unrelated to tabDir — must not match");
+ }
+
+ @Test
+ void sessionTabNoMatchWhenHookCwdIsParentOfTabDir() {
+ // tabDir must be an ancestor of hookCwd, not the other way around
+ String tabDir = "/home/user/project/subdir";
+ String hookCwd = "/home/user/project";
+ assertFalse(FileDiffOpener.isFileUnderDirectory(hookCwd, tabDir),
+ "tabDir is a child of hookCwd — must not match");
+ }
+
+ // --- outsideProject calculation after fix ---
+ // After fix: outsideProject uses tabWorkingDir (not hookCwd) as project root.
+
+ @Test
+ void fileInsideTabDirNotOutsideProject() {
+ // file is under tabWorkingDir → not outside project
+ String tabDir = "/home/user/project";
+ String filePath = "/home/user/project/src/Foo.java";
+ assertFalse(!FileDiffOpener.isFileUnderDirectory(filePath, tabDir));
+ }
+
+ @Test
+ void fileInsideTabDirButOutsideHookCwd() {
+ // Bug case: file under tabDir but not under hookCwd (subdir).
+ // After fix we check against tabDir, so no warning.
+ String tabDir = "/home/user/project";
+ String hookCwd = "/home/user/project/claude-launch-tests";
+ String filePath = "/home/user/project/src/Foo.java";
+ boolean outsideHookCwd = !FileDiffOpener.isFileUnderDirectory(filePath, hookCwd);
+ boolean outsideTabDir = !FileDiffOpener.isFileUnderDirectory(filePath, tabDir);
+ assertTrue(outsideHookCwd, "file IS outside hookCwd (subdir) — old logic would warn");
+ assertFalse(outsideTabDir, "file is NOT outside tabDir (project root) — new logic: no warn");
+ }
+
+ @Test
+ void fileTrulyOutsideProject() {
+ String tabDir = "/home/user/project";
+ String filePath = "/home/other/file.java";
+ assertTrue(!FileDiffOpener.isFileUnderDirectory(filePath, tabDir),
+ "file outside project root — should warn");
+ }
+
// --- helpers ---
private static JPopupMenu menuWithCopy() {