Implement Claude root path configuration and management

- Introduced functionality to select and manage the local Claude root folder, allowing users to specify a custom path.
- Added IPC handlers for selecting the Claude root folder and retrieving its information.
- Enhanced configuration validation to ensure the specified Claude root path is an absolute path.
- Updated the ServiceContext to reconfigure based on changes to the Claude root path, improving context management.
- Refactored related components to support the new Claude root path features, including updates to the UI and state management.
This commit is contained in:
matt 2026-02-14 13:43:56 +09:00
parent e13a368c50
commit ff0c8cc978
20 changed files with 754 additions and 29 deletions

View file

@ -22,6 +22,7 @@ import { existsSync } from 'fs';
import { join } from 'path';
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
// Window icon path for non-mac platforms.
const getWindowIconPath = (): string | undefined => {
@ -173,6 +174,55 @@ function onContextSwitched(context: ServiceContext): void {
}
}
/**
* Rebuilds the local ServiceContext using the current configured Claude root paths.
* Called when general.claudeRootPath changes.
*/
function reconfigureLocalContextForClaudeRoot(): void {
try {
const currentLocal = contextRegistry.get('local');
if (!currentLocal) {
logger.error('Cannot reconfigure local context: local context not found');
return;
}
const wasLocalActive = contextRegistry.getActiveContextId() === 'local';
const projectsDir = getProjectsBasePath();
const todosDir = getTodosBasePath();
logger.info(`Reconfiguring local context: projectsDir=${projectsDir}, todosDir=${todosDir}`);
if (wasLocalActive) {
currentLocal.stopFileWatcher();
}
const replacementLocal = new ServiceContext({
id: 'local',
type: 'local',
fsProvider: new LocalFileSystemProvider(),
projectsDir,
todosDir,
});
if (notificationManager) {
replacementLocal.fileWatcher.setNotificationManager(notificationManager);
}
replacementLocal.start();
if (!wasLocalActive) {
replacementLocal.stopFileWatcher();
}
contextRegistry.replaceContext('local', replacementLocal);
if (wasLocalActive) {
wireFileWatcherEvents(replacementLocal);
}
} catch (error) {
logger.error('Failed to reconfigure local context for Claude root change:', error);
}
}
/**
* Initializes all services.
*/
@ -185,11 +235,16 @@ function initializeServices(): void {
// Create ServiceContextRegistry
contextRegistry = new ServiceContextRegistry();
const localProjectsDir = getProjectsBasePath();
const localTodosDir = getTodosBasePath();
// Create local context
const localContext = new ServiceContext({
id: 'local',
type: 'local',
fsProvider: new LocalFileSystemProvider(),
projectsDir: localProjectsDir,
todosDir: localTodosDir,
});
// Register and start local context
@ -215,6 +270,9 @@ function initializeServices(): void {
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, {
rewire: rewireContextEvents,
full: onContextSwitched,
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
reconfigureLocalContextForClaudeRoot();
},
});
// HTTP Server control IPC handlers

View file

@ -17,10 +17,16 @@
* - config:testTrigger: Test a trigger against historical session data
*/
import {
getAutoDetectedClaudeBasePath,
getClaudeBasePath,
} from '@main/utils/pathDecoder';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { execFile } from 'child_process';
import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import {
type AppConfig,
@ -36,11 +42,15 @@ import { validateConfigUpdatePayload } from './configValidation';
import { validateTriggerId } from './guards';
import type { TriggerColor } from '@shared/constants/triggerColors';
import type { ClaudeRootFolderSelection, ClaudeRootInfo } from '@shared/types';
const logger = createLogger('IPC:config');
// Get singleton instance
const configManager = ConfigManager.getInstance();
let onClaudeRootPathUpdated:
| ((claudeRootPath: string | null) => Promise<void> | void)
| null = null;
/**
* Response type for config operations
@ -51,6 +61,17 @@ interface ConfigResult<T = void> {
error?: string;
}
/**
* Initializes config handlers with callbacks that require app-level services.
*/
export function initializeConfigHandlers(
options: {
onClaudeRootPathUpdated?: (claudeRootPath: string | null) => Promise<void> | void;
} = {}
): void {
onClaudeRootPathUpdated = options.onClaudeRootPathUpdated ?? null;
}
/**
* Registers all config-related IPC handlers.
*/
@ -86,6 +107,8 @@ export function registerConfigHandlers(ipcMain: IpcMain): void {
// Dialog handlers
ipcMain.handle('config:selectFolders', handleSelectFolders);
ipcMain.handle('config:selectClaudeRootFolder', handleSelectClaudeRootFolder);
ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo);
// Editor handlers
ipcMain.handle('config:openInEditor', handleOpenInEditor);
@ -127,7 +150,22 @@ async function handleUpdateConfig(
return { success: false, error: validation.error };
}
const isClaudeRootUpdate =
validation.section === 'general' &&
Object.prototype.hasOwnProperty.call(validation.data, 'claudeRootPath');
configManager.updateConfig(validation.section, validation.data);
if (isClaudeRootUpdate && onClaudeRootPathUpdated) {
const nextClaudeRootPath = (validation.data as { claudeRootPath?: string | null })
.claudeRootPath;
try {
await onClaudeRootPathUpdated(nextClaudeRootPath ?? null);
} catch (callbackError) {
logger.error('Failed to apply updated Claude root path at runtime:', callbackError);
}
}
const updatedConfig = configManager.getConfig();
return { success: true, data: updatedConfig };
} catch (error) {
@ -598,6 +636,94 @@ async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise<ConfigRe
}
}
/**
* Handler for 'config:selectClaudeRootFolder' - Opens native folder picker for Claude root.
*/
async function handleSelectClaudeRootFolder(
_event: IpcMainInvokeEvent
): Promise<ConfigResult<ClaudeRootFolderSelection | null>> {
try {
const focusedWindow = BrowserWindow.getFocusedWindow();
const currentRootPath = getClaudeBasePath();
const dialogOptions: Electron.OpenDialogOptions = {
properties: ['openDirectory'],
title: 'Select Claude Root Folder',
buttonLabel: 'Select Folder',
defaultPath: currentRootPath,
};
const result = focusedWindow
? await dialog.showOpenDialog(focusedWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (result.canceled || result.filePaths.length === 0) {
return { success: true, data: null };
}
const selectedPath = path.resolve(path.normalize(result.filePaths[0]));
const folderName = path.basename(selectedPath);
const projectsDir = path.join(selectedPath, 'projects');
const hasProjectsDir = (() => {
try {
return fs.existsSync(projectsDir) && fs.statSync(projectsDir).isDirectory();
} catch {
return false;
}
})();
return {
success: true,
data: {
path: selectedPath,
isClaudeDirName: folderName === '.claude',
hasProjectsDir,
},
};
} catch (error) {
logger.error('Error in config:selectClaudeRootFolder:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to open Claude root folder dialog',
};
}
}
/**
* Handler for 'config:getClaudeRootInfo' - Returns default/custom/effective local Claude root paths.
*/
async function handleGetClaudeRootInfo(
_event: IpcMainInvokeEvent
): Promise<ConfigResult<ClaudeRootInfo>> {
try {
const customPath = configManager.getConfig().general.claudeRootPath;
const defaultPath = getAutoDetectedClaudeBasePath();
const resolvedPath = getClaudeBasePath();
return {
success: true,
data: {
defaultPath,
resolvedPath,
customPath,
},
};
} catch (error) {
logger.error('Error in config:getClaudeRootInfo:', error);
// Last-resort fallback to a best-effort auto-detected value.
const fallbackDefault = getAutoDetectedClaudeBasePath();
return {
success: true,
data: {
defaultPath: fallbackDefault,
resolvedPath: fallbackDefault,
customPath: null,
},
};
}
}
// =============================================================================
// Cleanup
// =============================================================================
@ -623,6 +749,8 @@ export function removeConfigHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('config:pinSession');
ipcMain.removeHandler('config:unpinSession');
ipcMain.removeHandler('config:selectFolders');
ipcMain.removeHandler('config:selectClaudeRootFolder');
ipcMain.removeHandler('config:getClaudeRootInfo');
ipcMain.removeHandler('config:openInEditor');
logger.info('Config handlers removed');
}

View file

@ -3,6 +3,8 @@
* Prevents invalid/unknown data from mutating persisted config.
*/
import * as path from 'path';
import type {
AppConfig,
DisplayConfig,
@ -200,6 +202,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
'showDockIcon',
'theme',
'defaultTab',
'claudeRootPath',
];
const result: Partial<GeneralConfig> = {};
@ -237,6 +240,33 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
}
result.defaultTab = value;
break;
case 'claudeRootPath':
if (value === null) {
result.claudeRootPath = null;
break;
}
if (typeof value !== 'string') {
return {
valid: false,
error: 'general.claudeRootPath must be an absolute path string or null',
};
}
{
const trimmed = value.trim();
if (!trimmed) {
result.claudeRootPath = null;
break;
}
const normalized = path.normalize(trimmed);
if (!path.isAbsolute(normalized)) {
return {
valid: false,
error: 'general.claudeRootPath must be an absolute path',
};
}
result.claudeRootPath = path.resolve(normalized);
}
break;
default:
return { valid: false, error: `Unsupported general key: ${key}` };
}

View file

@ -16,7 +16,7 @@
import { createLogger } from '@shared/utils/logger';
import { ipcMain } from 'electron';
import { registerConfigHandlers, removeConfigHandlers } from './config';
import { initializeConfigHandlers, registerConfigHandlers, removeConfigHandlers } from './config';
import {
initializeContextHandlers,
registerContextHandlers,
@ -68,6 +68,7 @@ export function initializeIpcHandlers(
contextCallbacks: {
rewire: (context: ServiceContext) => void;
full: (context: ServiceContext) => void;
onClaudeRootPathUpdated: (claudeRootPath: string | null) => Promise<void> | void;
}
): void {
// Initialize domain handlers with registry
@ -78,6 +79,9 @@ export function initializeIpcHandlers(
initializeUpdaterHandlers(updater);
initializeSshHandlers(sshManager, registry, contextCallbacks.rewire);
initializeContextHandlers(registry, contextCallbacks.rewire);
initializeConfigHandlers({
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
});
// Register all handlers
registerProjectHandlers(ipcMain);

View file

@ -9,6 +9,7 @@
* - Handle JSON parse errors gracefully
*/
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { validateRegexPattern } from '@main/utils/regexValidation';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
@ -179,6 +180,7 @@ export interface GeneralConfig {
showDockIcon: boolean;
theme: 'dark' | 'light' | 'system';
defaultTab: 'dashboard' | 'last-session';
claudeRootPath: string | null;
}
export interface DisplayConfig {
@ -244,6 +246,7 @@ const DEFAULT_CONFIG: AppConfig = {
showDockIcon: true,
theme: 'dark',
defaultTab: 'dashboard',
claudeRootPath: null,
},
display: {
showTimestamps: true,
@ -265,6 +268,38 @@ const DEFAULT_CONFIG: AppConfig = {
},
};
function normalizeConfiguredClaudeRootPath(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const normalized = path.normalize(trimmed);
if (!path.isAbsolute(normalized)) {
return null;
}
const resolved = path.resolve(normalized);
const root = path.parse(resolved).root;
if (resolved === root) {
return resolved;
}
let end = resolved.length;
while (end > root.length) {
const char = resolved[end - 1];
if (char !== '/' && char !== '\\') {
break;
}
end--;
}
return resolved.slice(0, end);
}
// ===========================================================================
// ConfigManager Class
// ===========================================================================
@ -278,6 +313,7 @@ export class ConfigManager {
constructor(configPath?: string) {
this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
this.config = this.loadConfig();
setClaudeBasePathOverride(this.config.general.claudeRootPath);
this.triggerManager = new TriggerManager(this.config.notifications.triggers, () =>
this.saveConfig()
);
@ -361,6 +397,11 @@ export class ConfigManager {
private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {
const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);
const loadedTriggers = loadedNotifications.triggers ?? [];
const mergedGeneral: GeneralConfig = {
...DEFAULT_CONFIG.general,
...(loaded.general ?? {}),
};
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
// Merge triggers: preserve existing triggers, add missing builtin ones
const mergedTriggers = TriggerManager.mergeTriggers(loadedTriggers, DEFAULT_TRIGGERS);
@ -371,10 +412,7 @@ export class ConfigManager {
...loadedNotifications,
triggers: mergedTriggers,
},
general: {
...DEFAULT_CONFIG.general,
...(loaded.general ?? {}),
},
general: mergedGeneral,
display: {
...DEFAULT_CONFIG.display,
...(loaded.display ?? {}),
@ -429,14 +467,39 @@ export class ConfigManager {
* @param data - Partial data to merge into the section
*/
updateConfig<K extends ConfigSection>(section: K, data: Partial<AppConfig[K]>): AppConfig {
const normalizedData = this.normalizeSectionUpdate(section, data);
this.config[section] = {
...this.config[section],
...data,
...normalizedData,
};
if (section === 'general') {
setClaudeBasePathOverride(this.config.general.claudeRootPath);
}
this.saveConfig();
return this.getConfig();
}
private normalizeSectionUpdate<K extends ConfigSection>(
section: K,
data: Partial<AppConfig[K]>
): Partial<AppConfig[K]> {
if (section !== 'general') {
return data;
}
if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) {
return data;
}
const generalUpdate = data as Partial<GeneralConfig>;
return {
...generalUpdate,
claudeRootPath: normalizeConfiguredClaudeRootPath(generalUpdate.claudeRootPath),
} as unknown as Partial<AppConfig[K]>;
}
// ===========================================================================
// Notification Ignore Regex Management
// ===========================================================================
@ -764,6 +827,7 @@ export class ConfigManager {
*/
resetToDefaults(): AppConfig {
this.config = this.deepClone(DEFAULT_CONFIG);
setClaudeBasePathOverride(this.config.general.claudeRootPath);
this.triggerManager.setTriggers(this.config.notifications.triggers);
this.saveConfig();
logger.info('Config reset to defaults');
@ -777,6 +841,7 @@ export class ConfigManager {
*/
reload(): AppConfig {
this.config = this.loadConfig();
setClaudeBasePathOverride(this.config.general.claudeRootPath);
this.triggerManager.setTriggers(this.config.notifications.triggers);
logger.info('Config reloaded from disk');
return this.getConfig();

View file

@ -54,6 +54,33 @@ export class ServiceContextRegistry {
logger.info(`Context registered: ${context.id} (${context.type})`);
}
/**
* Replaces an existing context instance in-place (same context ID).
* Used for local-context reconfiguration without changing activeContextId semantics.
*
* @throws Error if context does not exist or replacement ID mismatches
*/
replaceContext(contextId: string, replacement: ServiceContext): void {
const existing = this.contexts.get(contextId);
if (!existing) {
throw new Error(`Context not found: ${contextId}`);
}
if (replacement.id !== contextId) {
throw new Error(
`Replacement context ID mismatch: expected "${contextId}", got "${replacement.id}"`
);
}
if (existing === replacement) {
return;
}
this.contexts.set(contextId, replacement);
existing.dispose();
logger.info(`Context replaced: ${contextId} (${replacement.type})`);
}
/**
* Gets the active ServiceContext.
* @throws Error if active context not found (should never happen)

View file

@ -8,7 +8,7 @@
* - Support tilde (~) expansion to home directory
*/
import { encodePath } from '@main/utils/pathDecoder';
import { encodePath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { countTokens } from '@main/utils/tokenizer';
import { createLogger } from '@shared/utils/logger';
import { app } from 'electron';
@ -232,8 +232,7 @@ async function readAutoMemoryFile(
): Promise<ClaudeMdFileInfo> {
const expandedRoot = expandTilde(projectRoot);
const encoded = encodePath(expandedRoot);
const homeDir = app.getPath('home');
const memoryPath = path.join(homeDir, '.claude', 'projects', encoded, 'memory', 'MEMORY.md');
const memoryPath = path.join(getClaudeBasePath(), 'projects', encoded, 'memory', 'MEMORY.md');
try {
if (!(await fsProvider.exists(memoryPath))) {
@ -271,8 +270,8 @@ export async function readAllClaudeMdFiles(
const enterprisePath = getEnterprisePath();
files.set('enterprise', await readClaudeMdFile(enterprisePath, fsProvider));
// 2. User memory: ~/.claude/CLAUDE.md
const userMemoryPath = '~/.claude/CLAUDE.md';
// 2. User memory: <Claude root>/CLAUDE.md
const userMemoryPath = path.join(getClaudeBasePath(), 'CLAUDE.md');
files.set('user', await readClaudeMdFile(userMemoryPath, fsProvider));
// 3. Project memory: ${projectRoot}/CLAUDE.md
@ -291,9 +290,8 @@ export async function readAllClaudeMdFiles(
const projectLocalPath = path.join(expandedProjectRoot, 'CLAUDE.local.md');
files.set('project-local', await readClaudeMdFile(projectLocalPath, fsProvider));
// 7. User rules: ~/.claude/rules/**/*.md
const homeDir = app.getPath('home');
const userRulesPath = path.join(homeDir, '.claude', 'rules');
// 7. User rules: <Claude root>/rules/**/*.md
const userRulesPath = path.join(getClaudeBasePath(), 'rules');
files.set('user-rules', await readDirectoryMdFiles(userRulesPath, fsProvider));
// 8. Auto memory: ~/.claude/projects/<encoded>/memory/MEMORY.md

View file

@ -1,3 +1,4 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@ -226,14 +227,140 @@ export function buildTodoPath(claudeBasePath: string, sessionId: string): string
* Get the user's home directory.
*/
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || os.homedir() || '/';
const windowsHome =
process.env.HOMEDRIVE && process.env.HOMEPATH
? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
: null;
return process.env.HOME || process.env.USERPROFILE || windowsHome || os.homedir() || '/';
}
let claudeBasePathOverride: string | null = null;
function isWslEnvironment(): boolean {
if (process.platform !== 'linux') {
return false;
}
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
return true;
}
// Fallback for environments where WSL vars are not exported.
return os.release().toLowerCase().includes('microsoft');
}
function toWslPathFromWindowsPath(windowsPath: string): string | null {
const normalized = windowsPath.trim().replace(/\\/g, '/');
if (!normalized) {
return null;
}
if (normalized.startsWith('/mnt/')) {
return normalized;
}
const match = /^([a-zA-Z]):\/(.+)$/.exec(normalized);
if (!match) {
return null;
}
const drive = match[1].toLowerCase();
const rest = match[2];
return `/mnt/${drive}/${rest}`;
}
function getWslClaudeBaseCandidates(): string[] {
const candidates = new Set<string>();
const addCandidate = (baseHome: string | null | undefined): void => {
if (!baseHome) return;
const withClaude = path.posix.join(baseHome, '.claude');
candidates.add(path.posix.normalize(withClaude));
};
// WSL-native home (e.g. /home/<user>) should be preferred when present.
addCandidate(getHomeDir());
addCandidate(toWslPathFromWindowsPath(process.env.USERPROFILE ?? ''));
const homeDrivePath =
process.env.HOMEDRIVE && process.env.HOMEPATH
? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
: '';
addCandidate(toWslPathFromWindowsPath(homeDrivePath));
if (process.env.USER) {
addCandidate(`/mnt/c/Users/${process.env.USER}`);
}
return Array.from(candidates);
}
function getDefaultClaudeBasePath(): string {
if (isWslEnvironment()) {
const candidates = getWslClaudeBaseCandidates();
const existing = candidates.find((candidate) => fs.existsSync(candidate));
if (existing) {
return existing;
}
}
return path.join(getHomeDir(), '.claude');
}
/**
* Get the auto-detected Claude config base path (~/.claude) without considering overrides.
*/
export function getAutoDetectedClaudeBasePath(): string {
return getDefaultClaudeBasePath();
}
function normalizeOverridePath(claudeBasePath: string): string | null {
const trimmed = claudeBasePath.trim();
if (!trimmed) {
return null;
}
const normalized = path.normalize(trimmed);
if (!path.isAbsolute(normalized)) {
return null;
}
const resolved = path.resolve(normalized);
const root = path.parse(resolved).root;
if (resolved === root) {
return resolved;
}
let end = resolved.length;
while (end > root.length) {
const char = resolved[end - 1];
if (char !== '/' && char !== '\\') {
break;
}
end--;
}
return resolved.slice(0, end);
}
/**
* Override the Claude config base path (~/.claude).
* Pass null to return to auto-detection.
*/
export function setClaudeBasePathOverride(claudeBasePath: string | null | undefined): void {
if (claudeBasePath == null) {
claudeBasePathOverride = null;
return;
}
claudeBasePathOverride = normalizeOverridePath(claudeBasePath);
}
/**
* Get the Claude config base path (~/.claude).
*/
function getClaudeBasePath(): string {
return path.join(getHomeDir(), '.claude');
export function getClaudeBasePath(): string {
return claudeBasePathOverride ?? getDefaultClaudeBasePath();
}
/**

View file

@ -9,6 +9,8 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getClaudeBasePath } from './pathDecoder';
/**
* Sensitive file patterns that should never be accessible.
* These are checked against the normalized absolute path.
@ -104,8 +106,7 @@ export function isPathWithinAllowedDirectories(
): boolean {
const isWindows = process.platform === 'win32';
const normalizedTarget = normalizeForCompare(normalizedPath, isWindows);
const homeDir = os.homedir();
const claudeDir = path.join(homeDir, '.claude');
const claudeDir = getClaudeBasePath();
const normalizedClaudeDir = normalizeForCompare(claudeDir, isWindows);
// Always allow access to ~/.claude for session data
@ -169,7 +170,7 @@ export function validateFilePath(
if (!isPathWithinAllowedDirectories(normalizedPath, projectPath)) {
return {
valid: false,
error: 'Path is outside allowed directories (project or ~/.claude)',
error: 'Path is outside allowed directories (project or Claude root)',
};
}
@ -189,7 +190,7 @@ export function validateFilePath(
if (!isPathWithinAllowedDirectories(normalizedRealTarget, realProjectPath)) {
return {
valid: false,
error: 'Path is outside allowed directories (project or ~/.claude)',
error: 'Path is outside allowed directories (project or Claude root)',
};
}
}

View file

@ -50,6 +50,12 @@ export const CONFIG_TEST_TRIGGER = 'config:testTrigger';
/** Select folders dialog */
export const CONFIG_SELECT_FOLDERS = 'config:selectFolders';
/** Select local Claude root folder */
export const CONFIG_SELECT_CLAUDE_ROOT_FOLDER = 'config:selectClaudeRootFolder';
/** Get effective/default Claude root folder info */
export const CONFIG_GET_CLAUDE_ROOT_INFO = 'config:getClaudeRootInfo';
/** Open config file in external editor */
export const CONFIG_OPEN_IN_EDITOR = 'config:openInEditor';

View file

@ -33,12 +33,14 @@ import {
CONFIG_ADD_TRIGGER,
CONFIG_CLEAR_SNOOZE,
CONFIG_GET,
CONFIG_GET_CLAUDE_ROOT_INFO,
CONFIG_GET_TRIGGERS,
CONFIG_OPEN_IN_EDITOR,
CONFIG_PIN_SESSION,
CONFIG_REMOVE_IGNORE_REGEX,
CONFIG_REMOVE_IGNORE_REPOSITORY,
CONFIG_REMOVE_TRIGGER,
CONFIG_SELECT_CLAUDE_ROOT_FOLDER,
CONFIG_SELECT_FOLDERS,
CONFIG_SNOOZE,
CONFIG_TEST_TRIGGER,
@ -49,6 +51,8 @@ import {
import type {
AppConfig,
ClaudeRootFolderSelection,
ClaudeRootInfo,
ContextInfo,
ElectronAPI,
HttpServerStatus,
@ -266,6 +270,14 @@ const electronAPI: ElectronAPI = {
selectFolders: async (): Promise<string[]> => {
return invokeIpcWithResult<string[]>(CONFIG_SELECT_FOLDERS);
},
selectClaudeRootFolder: async (): Promise<ClaudeRootFolderSelection | null> => {
return invokeIpcWithResult<ClaudeRootFolderSelection | null>(
CONFIG_SELECT_CLAUDE_ROOT_FOLDER
);
},
getClaudeRootInfo: async (): Promise<ClaudeRootInfo> => {
return invokeIpcWithResult<ClaudeRootInfo>(CONFIG_GET_CLAUDE_ROOT_INFO);
},
openInEditor: async (): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_OPEN_IN_EDITOR);
},

View file

@ -9,6 +9,8 @@
import type {
AppConfig,
ClaudeMdFileInfo,
ClaudeRootFolderSelection,
ClaudeRootInfo,
ConfigAPI,
ContextInfo,
ConversationGroup,
@ -409,6 +411,19 @@ export class HttpAPIClient implements ElectronAPI {
console.warn('[HttpAPIClient] selectFolders is not available in browser mode');
return [];
},
selectClaudeRootFolder: async (): Promise<ClaudeRootFolderSelection | null> => {
console.warn('[HttpAPIClient] selectClaudeRootFolder is not available in browser mode');
return null;
},
getClaudeRootInfo: async (): Promise<ClaudeRootInfo> => {
const config = await this.config.get();
const fallbackPath = config.general.claudeRootPath ?? '~/.claude';
return {
defaultPath: fallbackPath,
resolvedPath: fallbackPath,
customPath: config.general.claudeRootPath,
};
},
openInEditor: async (): Promise<void> => {
console.warn('[HttpAPIClient] openInEditor is not available in browser mode');
},

View file

@ -29,6 +29,7 @@ export interface SafeConfig {
showDockIcon: boolean;
theme: 'dark' | 'light' | 'system';
defaultTab: 'dashboard' | 'last-session';
claudeRootPath: string | null;
};
notifications: {
enabled: boolean;
@ -152,6 +153,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
showDockIcon: displayConfig?.general?.showDockIcon ?? true,
theme: displayConfig?.general?.theme ?? 'dark',
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
},
notifications: {
enabled: displayConfig?.notifications?.enabled ?? true,

View file

@ -286,6 +286,7 @@ export function useSettingsHandlers({
showDockIcon: true,
theme: 'dark',
defaultTab: 'dashboard',
claudeRootPath: null,
},
display: {
showTimestamps: true,

View file

@ -11,14 +11,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { useStore } from '@renderer/store';
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
import { FolderOpen, Loader2, Monitor, RotateCcw, Server, Wifi, WifiOff } from 'lucide-react';
import { SettingRow } from '../components/SettingRow';
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
import { SettingsSelect } from '../components/SettingsSelect';
import type {
ClaudeRootInfo,
SshAuthMethod,
SshConfigHostEntry,
SshConnectionConfig,
@ -33,6 +36,7 @@ const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
];
export const ConnectionSection = (): React.JSX.Element => {
const connectionMode = useStore((s) => s.connectionMode);
const connectionState = useStore((s) => s.connectionState);
const connectedHost = useStore((s) => s.connectedHost);
const connectionError = useStore((s) => s.connectionError);
@ -43,6 +47,8 @@ export const ConnectionSection = (): React.JSX.Element => {
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
const lastSshConfig = useStore((s) => s.lastSshConfig);
const loadLastConnection = useStore((s) => s.loadLastConnection);
const fetchProjects = useStore((s) => s.fetchProjects);
const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups);
// Form state
const [host, setHost] = useState('');
@ -62,6 +68,9 @@ export const ConnectionSection = (): React.JSX.Element => {
// Saved profiles
const [savedProfiles, setSavedProfiles] = useState<SshConnectionProfile[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null);
const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false);
const [claudeRootError, setClaudeRootError] = useState<string | null>(null);
const loadProfiles = useCallback(async () => {
try {
@ -73,12 +82,24 @@ export const ConnectionSection = (): React.JSX.Element => {
}
}, []);
const loadClaudeRootInfo = useCallback(async () => {
try {
const info = await api.config.getClaudeRootInfo();
setClaudeRootInfo(info);
} catch (error) {
setClaudeRootError(
error instanceof Error ? error.message : 'Failed to load local Claude root settings'
);
}
}, []);
// Fetch SSH config hosts, saved profiles, and load last connection on mount
useEffect(() => {
void fetchSshConfigHosts();
void loadLastConnection();
void loadProfiles();
}, [fetchSshConfigHosts, loadLastConnection, loadProfiles]);
void loadClaudeRootInfo();
}, [fetchSshConfigHosts, loadLastConnection, loadProfiles, loadClaudeRootInfo]);
// Pre-fill form from saved connection config when it arrives (one-time on mount).
// setState in effect is intentional: lastSshConfig loads async from IPC, so we can't
@ -172,8 +193,99 @@ export const ConnectionSection = (): React.JSX.Element => {
await disconnectSsh();
};
const resetWorkspaceForRootChange = useCallback((): void => {
useStore.setState({
projects: [],
repositoryGroups: [],
openTabs: [],
activeTabId: null,
selectedTabIds: [],
paneLayout: {
panes: [
{
id: 'pane-default',
tabs: [],
activeTabId: null,
selectedTabIds: [],
widthFraction: 1,
},
],
focusedPaneId: 'pane-default',
},
...getFullResetState(),
});
}, []);
const applyClaudeRootPath = useCallback(
async (claudeRootPath: string | null): Promise<void> => {
try {
setUpdatingClaudeRoot(true);
setClaudeRootError(null);
await api.config.update('general', { claudeRootPath });
await loadClaudeRootInfo();
if (connectionMode === 'local') {
resetWorkspaceForRootChange();
await Promise.all([fetchProjects(), fetchRepositoryGroups()]);
}
} catch (error) {
setClaudeRootError(error instanceof Error ? error.message : 'Failed to update Claude root');
} finally {
setUpdatingClaudeRoot(false);
}
},
[
connectionMode,
fetchProjects,
fetchRepositoryGroups,
loadClaudeRootInfo,
resetWorkspaceForRootChange,
]
);
const handleSelectClaudeRootFolder = useCallback(async (): Promise<void> => {
setClaudeRootError(null);
const selection = await api.config.selectClaudeRootFolder();
if (!selection) {
return;
}
if (!selection.isClaudeDirName) {
const proceed = await confirm({
title: 'Selected folder is not .claude',
message: `This folder is named "${selection.path.split(/[\\/]/).pop() ?? selection.path}", not ".claude". Continue anyway?`,
confirmLabel: 'Use Folder',
});
if (!proceed) {
return;
}
}
if (!selection.hasProjectsDir) {
const proceed = await confirm({
title: 'No projects directory found',
message: 'This folder does not contain a "projects" directory. Continue anyway?',
confirmLabel: 'Use Folder',
});
if (!proceed) {
return;
}
}
await applyClaudeRootPath(selection.path);
}, [applyClaudeRootPath]);
const handleResetClaudeRoot = useCallback(async (): Promise<void> => {
await applyClaudeRootPath(null);
}, [applyClaudeRootPath]);
const isConnecting = connectionState === 'connecting';
const isConnected = connectionState === 'connected';
const isCustomClaudeRoot = Boolean(claudeRootInfo?.customPath);
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude';
const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1';
const inputStyle = {
@ -184,6 +296,67 @@ export const ConnectionSection = (): React.JSX.Element => {
return (
<div className="space-y-6">
<SettingsSectionHeader title="Local Claude Root" />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Choose which local folder is treated as your Claude data root
</p>
<SettingRow
label="Current Local Root"
description={isCustomClaudeRoot ? 'Using custom path' : 'Using auto-detected path'}
>
<div className="max-w-96 text-right">
<div className="truncate font-mono text-xs" style={{ color: 'var(--color-text)' }}>
{resolvedClaudeRootPath}
</div>
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
Auto-detected: {defaultClaudeRootPath}
</div>
</div>
</SettingRow>
<div className="flex items-center gap-3">
<button
onClick={() => void handleSelectClaudeRootFolder()}
disabled={updatingClaudeRoot}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text)',
}}
>
<span className="flex items-center gap-2">
{updatingClaudeRoot ? (
<Loader2 className="size-3 animate-spin" />
) : (
<FolderOpen className="size-3" />
)}
Select Folder
</span>
</button>
<button
onClick={() => void handleResetClaudeRoot()}
disabled={updatingClaudeRoot || !isCustomClaudeRoot}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-secondary)',
}}
>
<span className="flex items-center gap-2">
<RotateCcw className="size-3" />
Use Auto-Detect
</span>
</button>
</div>
{claudeRootError && (
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
<p className="text-sm text-red-400">{claudeRootError}</p>
</div>
)}
<SettingsSectionHeader title="Remote Connection" />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Connect to a remote machine to view Claude Code sessions running there
@ -234,7 +407,7 @@ export const ConnectionSection = (): React.JSX.Element => {
style={{ color: 'var(--color-text-secondary)' }}
>
<Monitor className="size-4" />
<span>Local (~/.claude/)</span>
<span>Local ({resolvedClaudeRootPath})</span>
</div>
</SettingRow>
)}

View file

@ -87,6 +87,10 @@ export interface ConfigAPI {
testTrigger: (trigger: NotificationTrigger) => Promise<TriggerTestResult>;
/** Opens native folder selection dialog and returns selected paths */
selectFolders: () => Promise<string[]>;
/** Open native dialog to select local Claude root folder */
selectClaudeRootFolder: () => Promise<ClaudeRootFolderSelection | null>;
/** Get resolved Claude root path info for local mode */
getClaudeRootInfo: () => Promise<ClaudeRootInfo>;
/** Opens the config JSON file in an external editor */
openInEditor: () => Promise<void>;
/** Pin a session for a project */
@ -95,6 +99,24 @@ export interface ConfigAPI {
unpinSession: (projectId: string, sessionId: string) => Promise<void>;
}
export interface ClaudeRootInfo {
/** Auto-detected default Claude root path for this machine */
defaultPath: string;
/** Effective path currently used by local context */
resolvedPath: string;
/** Custom override path from settings (null means auto-detect) */
customPath: string | null;
}
export interface ClaudeRootFolderSelection {
/** Selected directory absolute path */
path: string;
/** Whether the selected folder name is exactly ".claude" */
isClaudeDirName: boolean;
/** Whether selected folder contains a "projects" directory */
hasProjectsDir: boolean;
}
// =============================================================================
// Session API
// =============================================================================

View file

@ -260,6 +260,8 @@ export interface AppConfig {
theme: 'dark' | 'light' | 'system';
/** Default tab to show on app launch */
defaultTab: 'dashboard' | 'last-session';
/** Optional custom Claude root folder (auto-detected when null) */
claudeRootPath: string | null;
};
/** Display and UI settings */
display: {

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import * as path from 'path';
import { validateConfigUpdatePayload } from '../../../src/main/ipc/configValidation';
@ -19,6 +20,31 @@ describe('configValidation', () => {
}
});
it('accepts absolute general.claudeRootPath updates', () => {
const result = validateConfigUpdatePayload('general', {
claudeRootPath: '/Users/test/.claude',
});
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.section).toBe('general');
expect(result.data).toEqual({
claudeRootPath: path.resolve('/Users/test/.claude'),
});
}
});
it('rejects relative general.claudeRootPath updates', () => {
const result = validateConfigUpdatePayload('general', {
claudeRootPath: '.claude',
});
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain('absolute path');
}
});
it('rejects invalid section names', () => {
const result = validateConfigUpdatePayload('invalid-section', { theme: 'dark' });
expect(result.valid).toBe(false);

View file

@ -5,7 +5,9 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder';
import {
isPathWithinAllowedDirectories,
@ -18,6 +20,14 @@ describe('pathValidation', () => {
const claudeDir = path.join(homeDir, '.claude');
const testProjectPath = path.resolve('/home/user/my-project');
beforeEach(() => {
setClaudeBasePathOverride(claudeDir);
});
afterEach(() => {
setClaudeBasePathOverride(null);
});
describe('isPathWithinAllowedDirectories', () => {
it('should allow paths within ~/.claude', () => {
expect(

View file

@ -68,6 +68,11 @@ export interface MockElectronAPI {
getTriggers: ReturnType<typeof vi.fn>;
testTrigger: ReturnType<typeof vi.fn>;
selectFolders: ReturnType<typeof vi.fn>;
selectClaudeRootFolder: ReturnType<typeof vi.fn>;
getClaudeRootInfo: ReturnType<typeof vi.fn>;
openInEditor: ReturnType<typeof vi.fn>;
pinSession: ReturnType<typeof vi.fn>;
unpinSession: ReturnType<typeof vi.fn>;
};
}
@ -106,8 +111,8 @@ export function createMockElectronAPI(): MockElectronAPI {
openPath: vi.fn().mockResolvedValue({ success: true }),
openExternal: vi.fn().mockResolvedValue({ success: true }),
notifications: {
onNew: vi.fn().mockReturnValue(() => {}),
onUpdated: vi.fn().mockReturnValue(() => {}),
onNew: vi.fn().mockReturnValue(() => undefined),
onUpdated: vi.fn().mockReturnValue(() => undefined),
getUnread: vi.fn().mockResolvedValue([]),
markAsRead: vi.fn().mockResolvedValue(undefined),
markAllAsRead: vi.fn().mockResolvedValue(undefined),
@ -118,8 +123,8 @@ export function createMockElectronAPI(): MockElectronAPI {
delete: vi.fn().mockResolvedValue(true),
clear: vi.fn().mockResolvedValue(true),
},
onFileChange: vi.fn().mockReturnValue(() => {}),
onTodoChange: vi.fn().mockReturnValue(() => {}),
onFileChange: vi.fn().mockReturnValue(() => undefined),
onTodoChange: vi.fn().mockReturnValue(() => undefined),
config: {
get: vi.fn().mockResolvedValue({
notifications: {
@ -136,12 +141,16 @@ export function createMockElectronAPI(): MockElectronAPI {
showDockIcon: true,
theme: 'dark',
defaultTab: 'dashboard',
claudeRootPath: null,
},
display: {
showTimestamps: true,
compactMode: false,
syntaxHighlighting: true,
},
sessions: {
pinnedSessions: {},
},
}),
update: vi.fn(),
addIgnoreRegex: vi.fn(),
@ -156,6 +165,15 @@ export function createMockElectronAPI(): MockElectronAPI {
getTriggers: vi.fn().mockResolvedValue([]),
testTrigger: vi.fn(),
selectFolders: vi.fn().mockResolvedValue([]),
selectClaudeRootFolder: vi.fn().mockResolvedValue(null),
getClaudeRootInfo: vi.fn().mockResolvedValue({
defaultPath: '~/.claude',
resolvedPath: '~/.claude',
customPath: null,
}),
openInEditor: vi.fn(),
pinSession: vi.fn(),
unpinSession: vi.fn(),
},
};
}