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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Fixed 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)
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.nbclaudecodegui</groupId>
<artifactId>netbeans-claude-code-gui</artifactId>
<version>0.21.60-SNAPSHOT</version>
<version>0.21.61-SNAPSHOT</version>
<packaging>nbm</packaging>
<name>Netbeans Claude Code GUI</name>
<description>NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE.</description>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,6 @@ public CompletableFuture<String> handlePreToolUse(String hookJson) {
String tabName = resolveUniqueHookTabName("Diff: " + new File(filePath).getName());
CompletableFuture<String> 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)),
Expand Down
56 changes: 31 additions & 25 deletions src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,31 +69,53 @@ 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)
* @param onClose called when the tab is closed via the × button without a decision
*/
public static void open(
String filePath, String before, String after, String tabName,
String confirmedDir,
String hookCwd,
Runnable onAccept,
Consumer<String> 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;
Expand Down Expand Up @@ -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;
Expand Down
71 changes: 71 additions & 0 deletions src/test/java/io/github/nbclaudecodegui/ui/FileDiffOpenerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading