diff --git a/src/main/index.ts b/src/main/index.ts index 09844019..8bbc53ef 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index ecb2976c..569b305e 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -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) + | null = null; /** * Response type for config operations @@ -51,6 +61,17 @@ interface ConfigResult { error?: string; } +/** + * Initializes config handlers with callbacks that require app-level services. + */ +export function initializeConfigHandlers( + options: { + onClaudeRootPathUpdated?: (claudeRootPath: string | null) => Promise | 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> { + 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> { + 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'); } diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 7ca5277f..469d7c33 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -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 = {}; @@ -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}` }; } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index dcff5e79..13c70df9 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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 { // 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); diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index b2f57e46..e7984bf0 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -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 { const loadedNotifications = loaded.notifications ?? ({} as Partial); 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(section: K, data: Partial): 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( + section: K, + data: Partial + ): Partial { + if (section !== 'general') { + return data; + } + + if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) { + return data; + } + + const generalUpdate = data as Partial; + return { + ...generalUpdate, + claudeRootPath: normalizeConfiguredClaudeRootPath(generalUpdate.claudeRootPath), + } as unknown as Partial; + } + // =========================================================================== // 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(); diff --git a/src/main/services/infrastructure/ServiceContextRegistry.ts b/src/main/services/infrastructure/ServiceContextRegistry.ts index 1d18715d..18ec9fef 100644 --- a/src/main/services/infrastructure/ServiceContextRegistry.ts +++ b/src/main/services/infrastructure/ServiceContextRegistry.ts @@ -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) diff --git a/src/main/services/parsing/ClaudeMdReader.ts b/src/main/services/parsing/ClaudeMdReader.ts index 34871cc7..e78447c7 100644 --- a/src/main/services/parsing/ClaudeMdReader.ts +++ b/src/main/services/parsing/ClaudeMdReader.ts @@ -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 { 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.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: /rules/**/*.md + const userRulesPath = path.join(getClaudeBasePath(), 'rules'); files.set('user-rules', await readDirectoryMdFiles(userRulesPath, fsProvider)); // 8. Auto memory: ~/.claude/projects//memory/MEMORY.md diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 5257b748..c11afc6f 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -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(); + + 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/) 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(); } /** diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index 985fb326..2a92463c 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -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)', }; } } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 82c7336e..50e3b8ce 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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'; diff --git a/src/preload/index.ts b/src/preload/index.ts index e84df012..1a9fa4e4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => { return invokeIpcWithResult(CONFIG_SELECT_FOLDERS); }, + selectClaudeRootFolder: async (): Promise => { + return invokeIpcWithResult( + CONFIG_SELECT_CLAUDE_ROOT_FOLDER + ); + }, + getClaudeRootInfo: async (): Promise => { + return invokeIpcWithResult(CONFIG_GET_CLAUDE_ROOT_INFO); + }, openInEditor: async (): Promise => { return invokeIpcWithResult(CONFIG_OPEN_IN_EDITOR); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 186c5fa3..918e1431 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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 => { + console.warn('[HttpAPIClient] selectClaudeRootFolder is not available in browser mode'); + return null; + }, + getClaudeRootInfo: async (): Promise => { + const config = await this.config.get(); + const fallbackPath = config.general.claudeRootPath ?? '~/.claude'; + return { + defaultPath: fallbackPath, + resolvedPath: fallbackPath, + customPath: config.general.claudeRootPath, + }; + }, openInEditor: async (): Promise => { console.warn('[HttpAPIClient] openInEditor is not available in browser mode'); }, diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index af3ead86..6d3f9c6a 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -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, diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 4ae8d22a..e24add0c 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -286,6 +286,7 @@ export function useSettingsHandlers({ showDockIcon: true, theme: 'dark', defaultTab: 'dashboard', + claudeRootPath: null, }, display: { showTimestamps: true, diff --git a/src/renderer/components/settings/sections/ConnectionSection.tsx b/src/renderer/components/settings/sections/ConnectionSection.tsx index 95bf3d73..0610eacd 100644 --- a/src/renderer/components/settings/sections/ConnectionSection.tsx +++ b/src/renderer/components/settings/sections/ConnectionSection.tsx @@ -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([]); const [selectedProfileId, setSelectedProfileId] = useState(null); + const [claudeRootInfo, setClaudeRootInfo] = useState(null); + const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false); + const [claudeRootError, setClaudeRootError] = useState(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 => { + 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 => { + 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 => { + 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 (
+ +

+ Choose which local folder is treated as your Claude data root +

+ + +
+
+ {resolvedClaudeRootPath} +
+
+ Auto-detected: {defaultClaudeRootPath} +
+
+
+ +
+ + + +
+ + {claudeRootError && ( +
+

{claudeRootError}

+
+ )} +

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)' }} > - Local (~/.claude/) + Local ({resolvedClaudeRootPath})

)} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 55832127..a54b8113 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -87,6 +87,10 @@ export interface ConfigAPI { testTrigger: (trigger: NotificationTrigger) => Promise; /** Opens native folder selection dialog and returns selected paths */ selectFolders: () => Promise; + /** Open native dialog to select local Claude root folder */ + selectClaudeRootFolder: () => Promise; + /** Get resolved Claude root path info for local mode */ + getClaudeRootInfo: () => Promise; /** Opens the config JSON file in an external editor */ openInEditor: () => Promise; /** Pin a session for a project */ @@ -95,6 +99,24 @@ export interface ConfigAPI { unpinSession: (projectId: string, sessionId: string) => Promise; } +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 // ============================================================================= diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 7428c2b6..84a133dc 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -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: { diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index 75cf42f2..0dbd7707 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -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); diff --git a/test/main/utils/pathValidation.test.ts b/test/main/utils/pathValidation.test.ts index 0da3fdfd..d0de930c 100644 --- a/test/main/utils/pathValidation.test.ts +++ b/test/main/utils/pathValidation.test.ts @@ -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( diff --git a/test/mocks/electronAPI.ts b/test/mocks/electronAPI.ts index f16e9390..a5e2ee33 100644 --- a/test/mocks/electronAPI.ts +++ b/test/mocks/electronAPI.ts @@ -68,6 +68,11 @@ export interface MockElectronAPI { getTriggers: ReturnType; testTrigger: ReturnType; selectFolders: ReturnType; + selectClaudeRootFolder: ReturnType; + getClaudeRootInfo: ReturnType; + openInEditor: ReturnType; + pinSession: ReturnType; + unpinSession: ReturnType; }; } @@ -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(), }, }; }