From 854a10ae1034ee383a5792185e3fab327bf009bf Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sat, 25 Apr 2026 19:42:16 +0300 Subject: [PATCH] Fixed diff dialog appearing as orphan tab when Claude edits outside cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Claude cd's into a subdirectory mid-session, the PreToolUse hook fires with cwd = that subdirectory. The session tab lookup used abs.equals(hookCwd), which failed because the tab's startup dir is the project root — not the subdir Claude cd'd into. This left sessionTab = null, causing: - diff opening as a detached top-level tab instead of embedded in session - Cancel and Accept-All buttons disabled - false "outside project" warning for files in the project root Fix: change lookup condition to isFileUnderDirectory(hookCwd, abs) so a tab matches when its startup dir is an ancestor of hookCwd. Also compute outsideProject against the tab's startup dir (project root) rather than hookCwd, and rename the parameter confirmedDir → hookCwd. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + pom.xml | 2 +- .../mcp/NetBeansMCPHandler.java | 1 - .../nbclaudecodegui/ui/FileDiffOpener.java | 56 ++++++++------- .../ui/FileDiffOpenerTest.java | 71 +++++++++++++++++++ 5 files changed, 104 insertions(+), 27 deletions(-) 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() {