diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f773e98..1cb8f932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Added capability of changing the profile storage directory and sharing a single directory with several profiles - Fixed drag-and-drop into text fields: files are now inserted at the text caret position instead of where the mouse was dropped - Fixed Accept/Decline and Yes/No button colors to respect the IDE Look and Feel theme (dark themes no longer show clashing hardcoded colors) - Added session management when opening a session from the toolbar and when closing a tab via the Close button diff --git a/docs/screenshots/profiles-tab.png b/docs/screenshots/profiles-tab.png index c196579f..b80de9f5 100644 Binary files a/docs/screenshots/profiles-tab.png and b/docs/screenshots/profiles-tab.png differ diff --git a/docs/user-manual.md b/docs/user-manual.md index e510032b..843978c5 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -383,7 +383,21 @@ Named profiles are stored under a common base directory (default: `~/.netbeans/c The base directory only affects newly created profiles. To move existing profiles to a new location, manually move their subdirectories to the new location first, then update the directory via the **New Profiles Directory** field (click **Change…**). -The read-only **Profile Storage Directory** field shows the storage path for the selected profile. For the Default profile it shows `~/.claude (not overridden)`. +The **Profile Storage Directory** field shows where Claude Code stores its configuration +for the selected profile. For the **Default** profile it shows `~/.claude (not overridden)` +and the buttons are disabled. For named profiles: + +- **Computed path** (default) — `/`. The field shows the path + with no suffix. +- **Custom path** — any directory you choose. The field shows the path with a `(custom)` suffix. + +| Button | Effect | +|--------|--------| +| **Change…** | Opens a directory chooser. The chosen path is stored with the profile and used as `CLAUDE_CONFIG_DIR` when a session starts. | +| **Reset** | Clears the custom path and reverts to the computed default. Enabled only when a custom path is set. | + +> **Tip:** Use a custom path to share a single Claude Code configuration directory across +> multiple profiles, or to point a profile at an existing `~/.claude`-style directory. ### Managing profiles @@ -393,7 +407,6 @@ The read-only **Profile Storage Directory** field shows the storage path for the | **Copy** | Duplicate the currently selected profile | | **Rename** | Rename the currently selected profile | | **Delete** | Delete the currently selected profile | -| **Change…** | Change the profile storage directory | ### Authentication diff --git a/pom.xml b/pom.xml index ab4e7cad..add1ea3f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.21.22-SNAPSHOT + 0.21.26-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/process/ClaudeProcess.java b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java index f0b3731e..8ffabfa9 100644 --- a/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java @@ -577,13 +577,18 @@ static Map buildEnv(ClaudeProfile profile, java.nio.file.Path pr env.put("TERM", "xterm-256color"); if (profile != null && !profile.isDefault()) { - // Set isolated CLAUDE_CONFIG_DIR for this profile - java.nio.file.Path configDir = ClaudeProfileStore.resolveConfigDir(profile, profilesDir); - env.put("CLAUDE_CONFIG_DIR", configDir.toAbsolutePath().toString()); - try { - java.nio.file.Files.createDirectories(configDir); - } catch (java.io.IOException e) { - LOG.warning("Could not create profile config dir " + configDir + ": " + e.getMessage()); + // Set isolated CLAUDE_CONFIG_DIR for this profile, unless the resolved + // directory is the same as the default (~/.claude) — in that case Claude + // already uses that directory without any override. + java.nio.file.Path configDir = ClaudeProfileStore.resolveStorageDir(profile, profilesDir); + java.nio.file.Path defaultClaudeDir = java.nio.file.Path.of(System.getProperty("user.home"), ".claude"); + if (!configDir.toAbsolutePath().equals(defaultClaudeDir.toAbsolutePath())) { + env.put("CLAUDE_CONFIG_DIR", configDir.toAbsolutePath().toString()); + try { + java.nio.file.Files.createDirectories(configDir); + } catch (java.io.IOException e) { + LOG.warning("Could not create profile config dir " + configDir + ": " + e.getMessage()); + } } } diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfile.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfile.java index bc73ac5c..7d8effa9 100644 --- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfile.java +++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfile.java @@ -150,6 +150,13 @@ public enum ProxyMode { */ private String extraCliArgs; + /** + * Explicit storage directory for {@code CLAUDE_CONFIG_DIR}. + * When blank, the computed path {@code /} is used. + * When non-blank, this exact path is used as-is. + */ + private String storageDir; + // ------------------------------------------------------------------------- // No-arg constructor (Jackson) // ------------------------------------------------------------------------- @@ -169,6 +176,7 @@ public ClaudeProfile() { this.modelAliases = new HashMap<>(); this.customModels = new ArrayList<>(); this.extraCliArgs = ""; + this.storageDir = ""; } // ------------------------------------------------------------------------- @@ -547,6 +555,35 @@ public void setExtraCliArgs(String extraCliArgs) { this.extraCliArgs = extraCliArgs != null ? extraCliArgs : ""; } + /** + * Returns the explicit storage directory for {@code CLAUDE_CONFIG_DIR}. + * A blank value means the computed path {@code /} is used. + * + * @return explicit storage dir, or {@code ""} if not set + */ + public String getStorageDir() { return storageDir != null ? storageDir : ""; } + + /** + * Sets the explicit storage directory for {@code CLAUDE_CONFIG_DIR}. + * Pass blank or {@code null} to fall back to the computed path. + * + * @param storageDir explicit path; {@code null} is treated as empty + */ + public void setStorageDir(String storageDir) { + this.storageDir = storageDir != null ? storageDir : ""; + } + + /** + * Fluent setter for {@link #setStorageDir(String)}. + * + * @param storageDir explicit path; {@code null} clears the override + * @return this profile instance + */ + public ClaudeProfile withStorageDir(String storageDir) { + setStorageDir(storageDir); + return this; + } + // ------------------------------------------------------------------------- // Object // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfileStore.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfileStore.java index 9a514dc9..6b559969 100644 --- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfileStore.java +++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfileStore.java @@ -206,23 +206,39 @@ private static String profileNames(List profiles) { } /** - * Returns the {@code CLAUDE_CONFIG_DIR} path for a non-Default profile. + * Returns the storage directory (used as {@code CLAUDE_CONFIG_DIR}) for a + * non-Default profile. * - *

The path is {@code //}. + *

If the profile has an explicit {@link ClaudeProfile#getStorageDir()} set, + * that path is returned as-is. Otherwise the computed path + * {@code /} is used. * - * @param profile the profile (must not be Default) - * @param profilesDir base directory for profile config dirs + * @param profile the profile (must not be Default) + * @param profilesDir base directory for per-profile config dirs; used only + * when no explicit path is set * @return absolute path to this profile's config directory * @throws IllegalArgumentException if {@code profile.isDefault()} is {@code true} */ - public static Path resolveConfigDir(ClaudeProfile profile, Path profilesDir) { + public static Path resolveStorageDir(ClaudeProfile profile, Path profilesDir) { if (profile.isDefault()) { throw new IllegalArgumentException( "Default profile has no isolated config dir; caller must not set CLAUDE_CONFIG_DIR."); } + String explicit = profile.getStorageDir(); + if (explicit != null && !explicit.isBlank()) { + return Path.of(explicit); + } return profilesDir.resolve(profile.getId()); } + /** + * @deprecated Use {@link #resolveStorageDir(ClaudeProfile, Path)} instead. + */ + @Deprecated + public static Path resolveConfigDir(ClaudeProfile profile, Path profilesDir) { + return resolveStorageDir(profile, profilesDir); + } + /** * Returns the settings directory for any profile, including Default. * Default profile maps to {@code ~/.claude/}; named profiles map to diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfilesPanel.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfilesPanel.java index 068b207e..cab22635 100644 --- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfilesPanel.java +++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfilesPanel.java @@ -88,8 +88,12 @@ public final class ClaudeProfilesPanel extends JPanel { private JButton renameButton; /** Button to delete the current profile. */ private JButton deleteButton; - /** Field showing the configuration directory for the selected profile. */ - private JTextField configDirField; + /** Field showing the storage directory for the selected profile. */ + private JTextField storageDirField; + /** Button to set a custom storage directory for the selected profile. */ + private JButton changeStorageDirButton; + /** Button to reset the storage directory to the computed default. */ + private JButton resetStorageDirButton; /** Field for extra CLI arguments passed to the claude process. */ private JTextField extraCliArgsField; @@ -224,13 +228,19 @@ private void initComponents() { mainPanel.add(profileRow, rowGbc); row++; - // --- Config directory (read-only) --- + // --- Storage directory row --- mainPanel.add(new JLabel("Profile Storage Directory:"), gbc(0, row, false)); - configDirField = new JTextField(40); - configDirField.setEditable(false); - GridBagConstraints cfgGbc = gbcFill(1, row); - cfgGbc.gridwidth = 2; - mainPanel.add(configDirField, cfgGbc); + storageDirField = new JTextField(40); + storageDirField.setEditable(false); + mainPanel.add(storageDirField, gbcFill(1, row)); + JPanel storageDirButtons = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + changeStorageDirButton = new JButton("Change\u2026"); + changeStorageDirButton.addActionListener(e -> onChangeStorageDir()); + resetStorageDirButton = new JButton("Reset"); + resetStorageDirButton.addActionListener(e -> onResetStorageDir()); + storageDirButtons.add(changeStorageDirButton); + storageDirButtons.add(resetStorageDirButton); + mainPanel.add(storageDirButtons, gbc(2, row, false)); row++; // --- Connection type section --- @@ -626,13 +636,8 @@ private void loadProfileIntoForm(ClaudeProfile p) { renameButton.setEnabled(!def); deleteButton.setEnabled(!def); - // Config directory - if (def) { - configDirField.setText("~/.claude (not overridden)"); - } else { - Path profilesDir = ClaudeCodePreferences.getProfilesDir(); - configDirField.setText(ClaudeProfileStore.resolveConfigDir(p, profilesDir).toAbsolutePath().toString()); - } + // Storage directory + refreshStorageDirField(p); // Connection type ClaudeProfile.ConnectionType ct = p.computeConnectionType(); @@ -813,6 +818,46 @@ private void onChangeProfilesDir() { } } + private void onChangeStorageDir() { + ClaudeProfile p = currentFormProfile; + if (p == null || p.isDefault()) return; + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + String current = p.getStorageDir(); + if (!current.isBlank()) { + chooser.setCurrentDirectory(new File(current)); + } else { + Path profilesDir = ClaudeCodePreferences.getProfilesDir(); + chooser.setCurrentDirectory(ClaudeProfileStore.resolveStorageDir(p, profilesDir).toFile()); + } + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + p.withStorageDir(chooser.getSelectedFile().getAbsolutePath()); + refreshStorageDirField(p); + } + } + + private void onResetStorageDir() { + ClaudeProfile p = currentFormProfile; + if (p == null || p.isDefault()) return; + p.withStorageDir(""); + refreshStorageDirField(p); + } + + private void refreshStorageDirField(ClaudeProfile p) { + if (p.isDefault()) { + storageDirField.setText("~/.claude (not overridden)"); + changeStorageDirButton.setEnabled(false); + resetStorageDirButton.setEnabled(false); + } else { + Path profilesDir = ClaudeCodePreferences.getProfilesDir(); + String resolved = ClaudeProfileStore.resolveStorageDir(p, profilesDir).toAbsolutePath().toString(); + boolean isCustom = !p.getStorageDir().isBlank(); + storageDirField.setText(isCustom ? resolved + " (custom)" : resolved); + changeStorageDirButton.setEnabled(true); + resetStorageDirButton.setEnabled(isCustom); + } + } + // ------------------------------------------------------------------------- // Profile name dialog // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanel.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanel.java index bf13069b..2653cc81 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanel.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionSelectorPanel.java @@ -417,7 +417,7 @@ private Path resolveClaudeConfigDir() { ClaudeProfile p = ClaudeProfileStore.findByName(profileName); if (p == null) return null; return io.github.nbclaudecodegui.settings.ClaudeProfileStore - .resolveConfigDir(p, ClaudeCodePreferences.getProfilesDir()); + .resolveStorageDir(p, ClaudeCodePreferences.getProfilesDir()); } private void onBrowse() { diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java index f53516de..dfb8a688 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java @@ -900,7 +900,7 @@ public void openSwitchDialog(SessionMode initialMode) { io.github.nbclaudecodegui.settings.ClaudeProfile profile = io.github.nbclaudecodegui.settings.ClaudeProfileStore.findByName(profileName); if (profile != null) { - claudeConfigDir = io.github.nbclaudecodegui.settings.ClaudeProfileStore.resolveConfigDir( + claudeConfigDir = io.github.nbclaudecodegui.settings.ClaudeProfileStore.resolveStorageDir( profile, ClaudeCodePreferences.getProfilesDir()); } } diff --git a/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusLineElement.java b/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusLineElement.java index b41bfe8c..a00a6e62 100644 --- a/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusLineElement.java +++ b/src/main/java/org/openbeans/claude/netbeans/ClaudeCodeStatusLineElement.java @@ -119,7 +119,7 @@ private void onStatusLabelClicked() { if (profileName != null && !ClaudeProfile.DEFAULT_NAME.equals(profileName)) { ClaudeProfile profile = ClaudeProfileStore.findByName(profileName); if (profile != null) { - claudeConfigDir = ClaudeProfileStore.resolveConfigDir( + claudeConfigDir = ClaudeProfileStore.resolveStorageDir( profile, ClaudeCodePreferences.getProfilesDir()); } } diff --git a/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessProfileTest.java b/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessProfileTest.java index a8b1dfd2..3fc4c631 100644 --- a/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessProfileTest.java +++ b/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessProfileTest.java @@ -51,6 +51,18 @@ void buildEnv_namedProfile_setsClaudeConfigDir() { assertEquals(expected, env.get("CLAUDE_CONFIG_DIR")); } + @Test + void buildEnv_namedProfile_storageDirMatchesDefault_noClaudeConfigDir() { + // If a non-default profile's storage dir is explicitly set to ~/.claude + // (same as the default), CLAUDE_CONFIG_DIR must NOT be set. + Path defaultClaudeDir = Path.of(System.getProperty("user.home"), ".claude"); + ClaudeProfile p = ClaudeProfile.createNamed("SameAsDefault"); + p.setStorageDir(defaultClaudeDir.toString()); + Map env = ClaudeProcess.buildEnv(p, PROFILES_DIR); + assertFalse(env.containsKey("CLAUDE_CONFIG_DIR"), + "CLAUDE_CONFIG_DIR must not be set when storage dir equals ~/.claude"); + } + // ------------------------------------------------------------------------- // Auth injection // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileStoreTest.java b/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileStoreTest.java index b7368968..35d1bba1 100644 --- a/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileStoreTest.java +++ b/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileStoreTest.java @@ -202,4 +202,33 @@ void extraCliArgs_jacksonRoundTrip() throws Exception { assertEquals(1, loaded.size()); assertEquals("--verbose --model foo", loaded.get(0).getExtraCliArgs()); } + + // ------------------------------------------------------------------------- + // resolveStorageDir + // ------------------------------------------------------------------------- + + @Test + void resolveStorageDir_computed() { + ClaudeProfile p = ClaudeProfile.createNamed("MyProfile"); + Path profilesDir = Path.of("/base/profiles"); + Path result = ClaudeProfileStore.resolveStorageDir(p, profilesDir); + assertEquals(Path.of("/base/profiles/MyProfile"), result); + } + + @Test + void resolveStorageDir_explicit() { + ClaudeProfile p = ClaudeProfile.createNamed("MyProfile"); + p.withStorageDir("/my/custom/dir"); + Path profilesDir = Path.of("/base/profiles"); + Path result = ClaudeProfileStore.resolveStorageDir(p, profilesDir); + assertEquals(Path.of("/my/custom/dir"), result); + } + + @Test + void resolveStorageDir_default_throws() { + ClaudeProfile def = ClaudeProfile.createDefault(); + Path profilesDir = Path.of("/base/profiles"); + assertThrows(IllegalArgumentException.class, + () -> ClaudeProfileStore.resolveStorageDir(def, profilesDir)); + } } diff --git a/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileTest.java b/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileTest.java index c57c0315..86574f31 100644 --- a/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileTest.java +++ b/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileTest.java @@ -346,4 +346,43 @@ void extraCliArgs_nullTreatedAsEmpty() { p.setExtraCliArgs(null); assertEquals("", p.getExtraCliArgs()); } + + // ------------------------------------------------------------------------- + // storageDir + // ------------------------------------------------------------------------- + + @Test + void storageDir_defaultIsEmpty() { + assertEquals("", ClaudeProfile.createNamed("P").getStorageDir()); + } + + @Test + void storageDir_setAndGet() { + ClaudeProfile p = ClaudeProfile.createNamed("P"); + p.setStorageDir("/my/custom/dir"); + assertEquals("/my/custom/dir", p.getStorageDir()); + } + + @Test + void storageDir_nullTreatedAsEmpty() { + ClaudeProfile p = ClaudeProfile.createNamed("P"); + p.setStorageDir(null); + assertEquals("", p.getStorageDir()); + } + + @Test + void storageDir_fluentSetter() { + ClaudeProfile p = ClaudeProfile.createNamed("P").withStorageDir("/x/y"); + assertEquals("/x/y", p.getStorageDir()); + } + + @Test + void storageDir_jacksonRoundTrip() throws Exception { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + ClaudeProfile p = ClaudeProfile.createNamed("P"); + p.setStorageDir("/custom/path"); + String json = mapper.writeValueAsString(p); + ClaudeProfile loaded = mapper.readValue(json, ClaudeProfile.class); + assertEquals("/custom/path", loaded.getStorageDir()); + } }