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:
parent
e13a368c50
commit
ff0c8cc978
20 changed files with 754 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -286,6 +286,7 @@ export function useSettingsHandlers({
|
|||
showDockIcon: true,
|
||||
theme: 'dark',
|
||||
defaultTab: 'dashboard',
|
||||
claudeRootPath: null,
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue