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 { join } from 'path';
|
||||||
|
|
||||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||||
|
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||||
|
|
||||||
// Window icon path for non-mac platforms.
|
// Window icon path for non-mac platforms.
|
||||||
const getWindowIconPath = (): string | undefined => {
|
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.
|
* Initializes all services.
|
||||||
*/
|
*/
|
||||||
|
|
@ -185,11 +235,16 @@ function initializeServices(): void {
|
||||||
// Create ServiceContextRegistry
|
// Create ServiceContextRegistry
|
||||||
contextRegistry = new ServiceContextRegistry();
|
contextRegistry = new ServiceContextRegistry();
|
||||||
|
|
||||||
|
const localProjectsDir = getProjectsBasePath();
|
||||||
|
const localTodosDir = getTodosBasePath();
|
||||||
|
|
||||||
// Create local context
|
// Create local context
|
||||||
const localContext = new ServiceContext({
|
const localContext = new ServiceContext({
|
||||||
id: 'local',
|
id: 'local',
|
||||||
type: 'local',
|
type: 'local',
|
||||||
fsProvider: new LocalFileSystemProvider(),
|
fsProvider: new LocalFileSystemProvider(),
|
||||||
|
projectsDir: localProjectsDir,
|
||||||
|
todosDir: localTodosDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register and start local context
|
// Register and start local context
|
||||||
|
|
@ -215,6 +270,9 @@ function initializeServices(): void {
|
||||||
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, {
|
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, {
|
||||||
rewire: rewireContextEvents,
|
rewire: rewireContextEvents,
|
||||||
full: onContextSwitched,
|
full: onContextSwitched,
|
||||||
|
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
|
||||||
|
reconfigureLocalContextForClaudeRoot();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// HTTP Server control IPC handlers
|
// HTTP Server control IPC handlers
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,16 @@
|
||||||
* - config:testTrigger: Test a trigger against historical session data
|
* - config:testTrigger: Test a trigger against historical session data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAutoDetectedClaudeBasePath,
|
||||||
|
getClaudeBasePath,
|
||||||
|
} from '@main/utils/pathDecoder';
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AppConfig,
|
type AppConfig,
|
||||||
|
|
@ -36,11 +42,15 @@ import { validateConfigUpdatePayload } from './configValidation';
|
||||||
import { validateTriggerId } from './guards';
|
import { validateTriggerId } from './guards';
|
||||||
|
|
||||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||||
|
import type { ClaudeRootFolderSelection, ClaudeRootInfo } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('IPC:config');
|
const logger = createLogger('IPC:config');
|
||||||
|
|
||||||
// Get singleton instance
|
// Get singleton instance
|
||||||
const configManager = ConfigManager.getInstance();
|
const configManager = ConfigManager.getInstance();
|
||||||
|
let onClaudeRootPathUpdated:
|
||||||
|
| ((claudeRootPath: string | null) => Promise<void> | void)
|
||||||
|
| null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response type for config operations
|
* Response type for config operations
|
||||||
|
|
@ -51,6 +61,17 @@ interface ConfigResult<T = void> {
|
||||||
error?: string;
|
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.
|
* Registers all config-related IPC handlers.
|
||||||
*/
|
*/
|
||||||
|
|
@ -86,6 +107,8 @@ export function registerConfigHandlers(ipcMain: IpcMain): void {
|
||||||
|
|
||||||
// Dialog handlers
|
// Dialog handlers
|
||||||
ipcMain.handle('config:selectFolders', handleSelectFolders);
|
ipcMain.handle('config:selectFolders', handleSelectFolders);
|
||||||
|
ipcMain.handle('config:selectClaudeRootFolder', handleSelectClaudeRootFolder);
|
||||||
|
ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo);
|
||||||
|
|
||||||
// Editor handlers
|
// Editor handlers
|
||||||
ipcMain.handle('config:openInEditor', handleOpenInEditor);
|
ipcMain.handle('config:openInEditor', handleOpenInEditor);
|
||||||
|
|
@ -127,7 +150,22 @@ async function handleUpdateConfig(
|
||||||
return { success: false, error: validation.error };
|
return { success: false, error: validation.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isClaudeRootUpdate =
|
||||||
|
validation.section === 'general' &&
|
||||||
|
Object.prototype.hasOwnProperty.call(validation.data, 'claudeRootPath');
|
||||||
|
|
||||||
configManager.updateConfig(validation.section, validation.data);
|
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();
|
const updatedConfig = configManager.getConfig();
|
||||||
return { success: true, data: updatedConfig };
|
return { success: true, data: updatedConfig };
|
||||||
} catch (error) {
|
} 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
|
// Cleanup
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -623,6 +749,8 @@ export function removeConfigHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler('config:pinSession');
|
ipcMain.removeHandler('config:pinSession');
|
||||||
ipcMain.removeHandler('config:unpinSession');
|
ipcMain.removeHandler('config:unpinSession');
|
||||||
ipcMain.removeHandler('config:selectFolders');
|
ipcMain.removeHandler('config:selectFolders');
|
||||||
|
ipcMain.removeHandler('config:selectClaudeRootFolder');
|
||||||
|
ipcMain.removeHandler('config:getClaudeRootInfo');
|
||||||
ipcMain.removeHandler('config:openInEditor');
|
ipcMain.removeHandler('config:openInEditor');
|
||||||
logger.info('Config handlers removed');
|
logger.info('Config handlers removed');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
* Prevents invalid/unknown data from mutating persisted config.
|
* Prevents invalid/unknown data from mutating persisted config.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
DisplayConfig,
|
DisplayConfig,
|
||||||
|
|
@ -200,6 +202,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
||||||
'showDockIcon',
|
'showDockIcon',
|
||||||
'theme',
|
'theme',
|
||||||
'defaultTab',
|
'defaultTab',
|
||||||
|
'claudeRootPath',
|
||||||
];
|
];
|
||||||
|
|
||||||
const result: Partial<GeneralConfig> = {};
|
const result: Partial<GeneralConfig> = {};
|
||||||
|
|
@ -237,6 +240,33 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
||||||
}
|
}
|
||||||
result.defaultTab = value;
|
result.defaultTab = value;
|
||||||
break;
|
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:
|
default:
|
||||||
return { valid: false, error: `Unsupported general key: ${key}` };
|
return { valid: false, error: `Unsupported general key: ${key}` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
|
|
||||||
import { registerConfigHandlers, removeConfigHandlers } from './config';
|
import { initializeConfigHandlers, registerConfigHandlers, removeConfigHandlers } from './config';
|
||||||
import {
|
import {
|
||||||
initializeContextHandlers,
|
initializeContextHandlers,
|
||||||
registerContextHandlers,
|
registerContextHandlers,
|
||||||
|
|
@ -68,6 +68,7 @@ export function initializeIpcHandlers(
|
||||||
contextCallbacks: {
|
contextCallbacks: {
|
||||||
rewire: (context: ServiceContext) => void;
|
rewire: (context: ServiceContext) => void;
|
||||||
full: (context: ServiceContext) => void;
|
full: (context: ServiceContext) => void;
|
||||||
|
onClaudeRootPathUpdated: (claudeRootPath: string | null) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
// Initialize domain handlers with registry
|
// Initialize domain handlers with registry
|
||||||
|
|
@ -78,6 +79,9 @@ export function initializeIpcHandlers(
|
||||||
initializeUpdaterHandlers(updater);
|
initializeUpdaterHandlers(updater);
|
||||||
initializeSshHandlers(sshManager, registry, contextCallbacks.rewire);
|
initializeSshHandlers(sshManager, registry, contextCallbacks.rewire);
|
||||||
initializeContextHandlers(registry, contextCallbacks.rewire);
|
initializeContextHandlers(registry, contextCallbacks.rewire);
|
||||||
|
initializeConfigHandlers({
|
||||||
|
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||||
|
});
|
||||||
|
|
||||||
// Register all handlers
|
// Register all handlers
|
||||||
registerProjectHandlers(ipcMain);
|
registerProjectHandlers(ipcMain);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
* - Handle JSON parse errors gracefully
|
* - Handle JSON parse errors gracefully
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||||
import { validateRegexPattern } from '@main/utils/regexValidation';
|
import { validateRegexPattern } from '@main/utils/regexValidation';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
@ -179,6 +180,7 @@ export interface GeneralConfig {
|
||||||
showDockIcon: boolean;
|
showDockIcon: boolean;
|
||||||
theme: 'dark' | 'light' | 'system';
|
theme: 'dark' | 'light' | 'system';
|
||||||
defaultTab: 'dashboard' | 'last-session';
|
defaultTab: 'dashboard' | 'last-session';
|
||||||
|
claudeRootPath: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DisplayConfig {
|
export interface DisplayConfig {
|
||||||
|
|
@ -244,6 +246,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
showDockIcon: true,
|
showDockIcon: true,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
defaultTab: 'dashboard',
|
defaultTab: 'dashboard',
|
||||||
|
claudeRootPath: null,
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
showTimestamps: true,
|
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
|
// ConfigManager Class
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -278,6 +313,7 @@ export class ConfigManager {
|
||||||
constructor(configPath?: string) {
|
constructor(configPath?: string) {
|
||||||
this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
|
this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
|
||||||
this.config = this.loadConfig();
|
this.config = this.loadConfig();
|
||||||
|
setClaudeBasePathOverride(this.config.general.claudeRootPath);
|
||||||
this.triggerManager = new TriggerManager(this.config.notifications.triggers, () =>
|
this.triggerManager = new TriggerManager(this.config.notifications.triggers, () =>
|
||||||
this.saveConfig()
|
this.saveConfig()
|
||||||
);
|
);
|
||||||
|
|
@ -361,6 +397,11 @@ export class ConfigManager {
|
||||||
private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {
|
private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {
|
||||||
const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);
|
const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);
|
||||||
const loadedTriggers = loadedNotifications.triggers ?? [];
|
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
|
// Merge triggers: preserve existing triggers, add missing builtin ones
|
||||||
const mergedTriggers = TriggerManager.mergeTriggers(loadedTriggers, DEFAULT_TRIGGERS);
|
const mergedTriggers = TriggerManager.mergeTriggers(loadedTriggers, DEFAULT_TRIGGERS);
|
||||||
|
|
@ -371,10 +412,7 @@ export class ConfigManager {
|
||||||
...loadedNotifications,
|
...loadedNotifications,
|
||||||
triggers: mergedTriggers,
|
triggers: mergedTriggers,
|
||||||
},
|
},
|
||||||
general: {
|
general: mergedGeneral,
|
||||||
...DEFAULT_CONFIG.general,
|
|
||||||
...(loaded.general ?? {}),
|
|
||||||
},
|
|
||||||
display: {
|
display: {
|
||||||
...DEFAULT_CONFIG.display,
|
...DEFAULT_CONFIG.display,
|
||||||
...(loaded.display ?? {}),
|
...(loaded.display ?? {}),
|
||||||
|
|
@ -429,14 +467,39 @@ export class ConfigManager {
|
||||||
* @param data - Partial data to merge into the section
|
* @param data - Partial data to merge into the section
|
||||||
*/
|
*/
|
||||||
updateConfig<K extends ConfigSection>(section: K, data: Partial<AppConfig[K]>): AppConfig {
|
updateConfig<K extends ConfigSection>(section: K, data: Partial<AppConfig[K]>): AppConfig {
|
||||||
|
const normalizedData = this.normalizeSectionUpdate(section, data);
|
||||||
this.config[section] = {
|
this.config[section] = {
|
||||||
...this.config[section],
|
...this.config[section],
|
||||||
...data,
|
...normalizedData,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (section === 'general') {
|
||||||
|
setClaudeBasePathOverride(this.config.general.claudeRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
return this.getConfig();
|
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
|
// Notification Ignore Regex Management
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -764,6 +827,7 @@ export class ConfigManager {
|
||||||
*/
|
*/
|
||||||
resetToDefaults(): AppConfig {
|
resetToDefaults(): AppConfig {
|
||||||
this.config = this.deepClone(DEFAULT_CONFIG);
|
this.config = this.deepClone(DEFAULT_CONFIG);
|
||||||
|
setClaudeBasePathOverride(this.config.general.claudeRootPath);
|
||||||
this.triggerManager.setTriggers(this.config.notifications.triggers);
|
this.triggerManager.setTriggers(this.config.notifications.triggers);
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
logger.info('Config reset to defaults');
|
logger.info('Config reset to defaults');
|
||||||
|
|
@ -777,6 +841,7 @@ export class ConfigManager {
|
||||||
*/
|
*/
|
||||||
reload(): AppConfig {
|
reload(): AppConfig {
|
||||||
this.config = this.loadConfig();
|
this.config = this.loadConfig();
|
||||||
|
setClaudeBasePathOverride(this.config.general.claudeRootPath);
|
||||||
this.triggerManager.setTriggers(this.config.notifications.triggers);
|
this.triggerManager.setTriggers(this.config.notifications.triggers);
|
||||||
logger.info('Config reloaded from disk');
|
logger.info('Config reloaded from disk');
|
||||||
return this.getConfig();
|
return this.getConfig();
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,33 @@ export class ServiceContextRegistry {
|
||||||
logger.info(`Context registered: ${context.id} (${context.type})`);
|
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.
|
* Gets the active ServiceContext.
|
||||||
* @throws Error if active context not found (should never happen)
|
* @throws Error if active context not found (should never happen)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
* - Support tilde (~) expansion to home directory
|
* - 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 { countTokens } from '@main/utils/tokenizer';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
|
|
@ -232,8 +232,7 @@ async function readAutoMemoryFile(
|
||||||
): Promise<ClaudeMdFileInfo> {
|
): Promise<ClaudeMdFileInfo> {
|
||||||
const expandedRoot = expandTilde(projectRoot);
|
const expandedRoot = expandTilde(projectRoot);
|
||||||
const encoded = encodePath(expandedRoot);
|
const encoded = encodePath(expandedRoot);
|
||||||
const homeDir = app.getPath('home');
|
const memoryPath = path.join(getClaudeBasePath(), 'projects', encoded, 'memory', 'MEMORY.md');
|
||||||
const memoryPath = path.join(homeDir, '.claude', 'projects', encoded, 'memory', 'MEMORY.md');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!(await fsProvider.exists(memoryPath))) {
|
if (!(await fsProvider.exists(memoryPath))) {
|
||||||
|
|
@ -271,8 +270,8 @@ export async function readAllClaudeMdFiles(
|
||||||
const enterprisePath = getEnterprisePath();
|
const enterprisePath = getEnterprisePath();
|
||||||
files.set('enterprise', await readClaudeMdFile(enterprisePath, fsProvider));
|
files.set('enterprise', await readClaudeMdFile(enterprisePath, fsProvider));
|
||||||
|
|
||||||
// 2. User memory: ~/.claude/CLAUDE.md
|
// 2. User memory: <Claude root>/CLAUDE.md
|
||||||
const userMemoryPath = '~/.claude/CLAUDE.md';
|
const userMemoryPath = path.join(getClaudeBasePath(), 'CLAUDE.md');
|
||||||
files.set('user', await readClaudeMdFile(userMemoryPath, fsProvider));
|
files.set('user', await readClaudeMdFile(userMemoryPath, fsProvider));
|
||||||
|
|
||||||
// 3. Project memory: ${projectRoot}/CLAUDE.md
|
// 3. Project memory: ${projectRoot}/CLAUDE.md
|
||||||
|
|
@ -291,9 +290,8 @@ export async function readAllClaudeMdFiles(
|
||||||
const projectLocalPath = path.join(expandedProjectRoot, 'CLAUDE.local.md');
|
const projectLocalPath = path.join(expandedProjectRoot, 'CLAUDE.local.md');
|
||||||
files.set('project-local', await readClaudeMdFile(projectLocalPath, fsProvider));
|
files.set('project-local', await readClaudeMdFile(projectLocalPath, fsProvider));
|
||||||
|
|
||||||
// 7. User rules: ~/.claude/rules/**/*.md
|
// 7. User rules: <Claude root>/rules/**/*.md
|
||||||
const homeDir = app.getPath('home');
|
const userRulesPath = path.join(getClaudeBasePath(), 'rules');
|
||||||
const userRulesPath = path.join(homeDir, '.claude', 'rules');
|
|
||||||
files.set('user-rules', await readDirectoryMdFiles(userRulesPath, fsProvider));
|
files.set('user-rules', await readDirectoryMdFiles(userRulesPath, fsProvider));
|
||||||
|
|
||||||
// 8. Auto memory: ~/.claude/projects/<encoded>/memory/MEMORY.md
|
// 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 os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
|
@ -226,14 +227,140 @@ export function buildTodoPath(claudeBasePath: string, sessionId: string): string
|
||||||
* Get the user's home directory.
|
* Get the user's home directory.
|
||||||
*/
|
*/
|
||||||
function getHomeDir(): string {
|
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).
|
* Get the Claude config base path (~/.claude).
|
||||||
*/
|
*/
|
||||||
function getClaudeBasePath(): string {
|
export function getClaudeBasePath(): string {
|
||||||
return path.join(getHomeDir(), '.claude');
|
return claudeBasePathOverride ?? getDefaultClaudeBasePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { getClaudeBasePath } from './pathDecoder';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sensitive file patterns that should never be accessible.
|
* Sensitive file patterns that should never be accessible.
|
||||||
* These are checked against the normalized absolute path.
|
* These are checked against the normalized absolute path.
|
||||||
|
|
@ -104,8 +106,7 @@ export function isPathWithinAllowedDirectories(
|
||||||
): boolean {
|
): boolean {
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const normalizedTarget = normalizeForCompare(normalizedPath, isWindows);
|
const normalizedTarget = normalizeForCompare(normalizedPath, isWindows);
|
||||||
const homeDir = os.homedir();
|
const claudeDir = getClaudeBasePath();
|
||||||
const claudeDir = path.join(homeDir, '.claude');
|
|
||||||
const normalizedClaudeDir = normalizeForCompare(claudeDir, isWindows);
|
const normalizedClaudeDir = normalizeForCompare(claudeDir, isWindows);
|
||||||
|
|
||||||
// Always allow access to ~/.claude for session data
|
// Always allow access to ~/.claude for session data
|
||||||
|
|
@ -169,7 +170,7 @@ export function validateFilePath(
|
||||||
if (!isPathWithinAllowedDirectories(normalizedPath, projectPath)) {
|
if (!isPathWithinAllowedDirectories(normalizedPath, projectPath)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
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)) {
|
if (!isPathWithinAllowedDirectories(normalizedRealTarget, realProjectPath)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
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 */
|
/** Select folders dialog */
|
||||||
export const CONFIG_SELECT_FOLDERS = 'config:selectFolders';
|
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 */
|
/** Open config file in external editor */
|
||||||
export const CONFIG_OPEN_IN_EDITOR = 'config:openInEditor';
|
export const CONFIG_OPEN_IN_EDITOR = 'config:openInEditor';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,14 @@ import {
|
||||||
CONFIG_ADD_TRIGGER,
|
CONFIG_ADD_TRIGGER,
|
||||||
CONFIG_CLEAR_SNOOZE,
|
CONFIG_CLEAR_SNOOZE,
|
||||||
CONFIG_GET,
|
CONFIG_GET,
|
||||||
|
CONFIG_GET_CLAUDE_ROOT_INFO,
|
||||||
CONFIG_GET_TRIGGERS,
|
CONFIG_GET_TRIGGERS,
|
||||||
CONFIG_OPEN_IN_EDITOR,
|
CONFIG_OPEN_IN_EDITOR,
|
||||||
CONFIG_PIN_SESSION,
|
CONFIG_PIN_SESSION,
|
||||||
CONFIG_REMOVE_IGNORE_REGEX,
|
CONFIG_REMOVE_IGNORE_REGEX,
|
||||||
CONFIG_REMOVE_IGNORE_REPOSITORY,
|
CONFIG_REMOVE_IGNORE_REPOSITORY,
|
||||||
CONFIG_REMOVE_TRIGGER,
|
CONFIG_REMOVE_TRIGGER,
|
||||||
|
CONFIG_SELECT_CLAUDE_ROOT_FOLDER,
|
||||||
CONFIG_SELECT_FOLDERS,
|
CONFIG_SELECT_FOLDERS,
|
||||||
CONFIG_SNOOZE,
|
CONFIG_SNOOZE,
|
||||||
CONFIG_TEST_TRIGGER,
|
CONFIG_TEST_TRIGGER,
|
||||||
|
|
@ -49,6 +51,8 @@ import {
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
ClaudeRootFolderSelection,
|
||||||
|
ClaudeRootInfo,
|
||||||
ContextInfo,
|
ContextInfo,
|
||||||
ElectronAPI,
|
ElectronAPI,
|
||||||
HttpServerStatus,
|
HttpServerStatus,
|
||||||
|
|
@ -266,6 +270,14 @@ const electronAPI: ElectronAPI = {
|
||||||
selectFolders: async (): Promise<string[]> => {
|
selectFolders: async (): Promise<string[]> => {
|
||||||
return invokeIpcWithResult<string[]>(CONFIG_SELECT_FOLDERS);
|
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> => {
|
openInEditor: async (): Promise<void> => {
|
||||||
return invokeIpcWithResult<void>(CONFIG_OPEN_IN_EDITOR);
|
return invokeIpcWithResult<void>(CONFIG_OPEN_IN_EDITOR);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
ClaudeMdFileInfo,
|
ClaudeMdFileInfo,
|
||||||
|
ClaudeRootFolderSelection,
|
||||||
|
ClaudeRootInfo,
|
||||||
ConfigAPI,
|
ConfigAPI,
|
||||||
ContextInfo,
|
ContextInfo,
|
||||||
ConversationGroup,
|
ConversationGroup,
|
||||||
|
|
@ -409,6 +411,19 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
console.warn('[HttpAPIClient] selectFolders is not available in browser mode');
|
console.warn('[HttpAPIClient] selectFolders is not available in browser mode');
|
||||||
return [];
|
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> => {
|
openInEditor: async (): Promise<void> => {
|
||||||
console.warn('[HttpAPIClient] openInEditor is not available in browser mode');
|
console.warn('[HttpAPIClient] openInEditor is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export interface SafeConfig {
|
||||||
showDockIcon: boolean;
|
showDockIcon: boolean;
|
||||||
theme: 'dark' | 'light' | 'system';
|
theme: 'dark' | 'light' | 'system';
|
||||||
defaultTab: 'dashboard' | 'last-session';
|
defaultTab: 'dashboard' | 'last-session';
|
||||||
|
claudeRootPath: string | null;
|
||||||
};
|
};
|
||||||
notifications: {
|
notifications: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -152,6 +153,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
showDockIcon: displayConfig?.general?.showDockIcon ?? true,
|
showDockIcon: displayConfig?.general?.showDockIcon ?? true,
|
||||||
theme: displayConfig?.general?.theme ?? 'dark',
|
theme: displayConfig?.general?.theme ?? 'dark',
|
||||||
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
|
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
|
||||||
|
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
enabled: displayConfig?.notifications?.enabled ?? true,
|
enabled: displayConfig?.notifications?.enabled ?? true,
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,7 @@ export function useSettingsHandlers({
|
||||||
showDockIcon: true,
|
showDockIcon: true,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
defaultTab: 'dashboard',
|
defaultTab: 'dashboard',
|
||||||
|
claudeRootPath: null,
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,17 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { useStore } from '@renderer/store';
|
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 { SettingRow } from '../components/SettingRow';
|
||||||
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
||||||
import { SettingsSelect } from '../components/SettingsSelect';
|
import { SettingsSelect } from '../components/SettingsSelect';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ClaudeRootInfo,
|
||||||
SshAuthMethod,
|
SshAuthMethod,
|
||||||
SshConfigHostEntry,
|
SshConfigHostEntry,
|
||||||
SshConnectionConfig,
|
SshConnectionConfig,
|
||||||
|
|
@ -33,6 +36,7 @@ const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ConnectionSection = (): React.JSX.Element => {
|
export const ConnectionSection = (): React.JSX.Element => {
|
||||||
|
const connectionMode = useStore((s) => s.connectionMode);
|
||||||
const connectionState = useStore((s) => s.connectionState);
|
const connectionState = useStore((s) => s.connectionState);
|
||||||
const connectedHost = useStore((s) => s.connectedHost);
|
const connectedHost = useStore((s) => s.connectedHost);
|
||||||
const connectionError = useStore((s) => s.connectionError);
|
const connectionError = useStore((s) => s.connectionError);
|
||||||
|
|
@ -43,6 +47,8 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
|
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
|
||||||
const lastSshConfig = useStore((s) => s.lastSshConfig);
|
const lastSshConfig = useStore((s) => s.lastSshConfig);
|
||||||
const loadLastConnection = useStore((s) => s.loadLastConnection);
|
const loadLastConnection = useStore((s) => s.loadLastConnection);
|
||||||
|
const fetchProjects = useStore((s) => s.fetchProjects);
|
||||||
|
const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [host, setHost] = useState('');
|
const [host, setHost] = useState('');
|
||||||
|
|
@ -62,6 +68,9 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
// Saved profiles
|
// Saved profiles
|
||||||
const [savedProfiles, setSavedProfiles] = useState<SshConnectionProfile[]>([]);
|
const [savedProfiles, setSavedProfiles] = useState<SshConnectionProfile[]>([]);
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
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 () => {
|
const loadProfiles = useCallback(async () => {
|
||||||
try {
|
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
|
// Fetch SSH config hosts, saved profiles, and load last connection on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchSshConfigHosts();
|
void fetchSshConfigHosts();
|
||||||
void loadLastConnection();
|
void loadLastConnection();
|
||||||
void loadProfiles();
|
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).
|
// 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
|
// 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();
|
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 isConnecting = connectionState === 'connecting';
|
||||||
const isConnected = connectionState === 'connected';
|
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 inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1';
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
|
|
@ -184,6 +296,67 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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" />
|
<SettingsSectionHeader title="Remote Connection" />
|
||||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
Connect to a remote machine to view Claude Code sessions running there
|
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)' }}
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
>
|
>
|
||||||
<Monitor className="size-4" />
|
<Monitor className="size-4" />
|
||||||
<span>Local (~/.claude/)</span>
|
<span>Local ({resolvedClaudeRootPath})</span>
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,10 @@ export interface ConfigAPI {
|
||||||
testTrigger: (trigger: NotificationTrigger) => Promise<TriggerTestResult>;
|
testTrigger: (trigger: NotificationTrigger) => Promise<TriggerTestResult>;
|
||||||
/** Opens native folder selection dialog and returns selected paths */
|
/** Opens native folder selection dialog and returns selected paths */
|
||||||
selectFolders: () => Promise<string[]>;
|
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 */
|
/** Opens the config JSON file in an external editor */
|
||||||
openInEditor: () => Promise<void>;
|
openInEditor: () => Promise<void>;
|
||||||
/** Pin a session for a project */
|
/** Pin a session for a project */
|
||||||
|
|
@ -95,6 +99,24 @@ export interface ConfigAPI {
|
||||||
unpinSession: (projectId: string, sessionId: string) => Promise<void>;
|
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
|
// Session API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,8 @@ export interface AppConfig {
|
||||||
theme: 'dark' | 'light' | 'system';
|
theme: 'dark' | 'light' | 'system';
|
||||||
/** Default tab to show on app launch */
|
/** Default tab to show on app launch */
|
||||||
defaultTab: 'dashboard' | 'last-session';
|
defaultTab: 'dashboard' | 'last-session';
|
||||||
|
/** Optional custom Claude root folder (auto-detected when null) */
|
||||||
|
claudeRootPath: string | null;
|
||||||
};
|
};
|
||||||
/** Display and UI settings */
|
/** Display and UI settings */
|
||||||
display: {
|
display: {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
import { validateConfigUpdatePayload } from '../../../src/main/ipc/configValidation';
|
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', () => {
|
it('rejects invalid section names', () => {
|
||||||
const result = validateConfigUpdatePayload('invalid-section', { theme: 'dark' });
|
const result = validateConfigUpdatePayload('invalid-section', { theme: 'dark' });
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
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 {
|
import {
|
||||||
isPathWithinAllowedDirectories,
|
isPathWithinAllowedDirectories,
|
||||||
|
|
@ -18,6 +20,14 @@ describe('pathValidation', () => {
|
||||||
const claudeDir = path.join(homeDir, '.claude');
|
const claudeDir = path.join(homeDir, '.claude');
|
||||||
const testProjectPath = path.resolve('/home/user/my-project');
|
const testProjectPath = path.resolve('/home/user/my-project');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setClaudeBasePathOverride(claudeDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
});
|
||||||
|
|
||||||
describe('isPathWithinAllowedDirectories', () => {
|
describe('isPathWithinAllowedDirectories', () => {
|
||||||
it('should allow paths within ~/.claude', () => {
|
it('should allow paths within ~/.claude', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,11 @@ export interface MockElectronAPI {
|
||||||
getTriggers: ReturnType<typeof vi.fn>;
|
getTriggers: ReturnType<typeof vi.fn>;
|
||||||
testTrigger: ReturnType<typeof vi.fn>;
|
testTrigger: ReturnType<typeof vi.fn>;
|
||||||
selectFolders: 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 }),
|
openPath: vi.fn().mockResolvedValue({ success: true }),
|
||||||
openExternal: vi.fn().mockResolvedValue({ success: true }),
|
openExternal: vi.fn().mockResolvedValue({ success: true }),
|
||||||
notifications: {
|
notifications: {
|
||||||
onNew: vi.fn().mockReturnValue(() => {}),
|
onNew: vi.fn().mockReturnValue(() => undefined),
|
||||||
onUpdated: vi.fn().mockReturnValue(() => {}),
|
onUpdated: vi.fn().mockReturnValue(() => undefined),
|
||||||
getUnread: vi.fn().mockResolvedValue([]),
|
getUnread: vi.fn().mockResolvedValue([]),
|
||||||
markAsRead: vi.fn().mockResolvedValue(undefined),
|
markAsRead: vi.fn().mockResolvedValue(undefined),
|
||||||
markAllAsRead: vi.fn().mockResolvedValue(undefined),
|
markAllAsRead: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|
@ -118,8 +123,8 @@ export function createMockElectronAPI(): MockElectronAPI {
|
||||||
delete: vi.fn().mockResolvedValue(true),
|
delete: vi.fn().mockResolvedValue(true),
|
||||||
clear: vi.fn().mockResolvedValue(true),
|
clear: vi.fn().mockResolvedValue(true),
|
||||||
},
|
},
|
||||||
onFileChange: vi.fn().mockReturnValue(() => {}),
|
onFileChange: vi.fn().mockReturnValue(() => undefined),
|
||||||
onTodoChange: vi.fn().mockReturnValue(() => {}),
|
onTodoChange: vi.fn().mockReturnValue(() => undefined),
|
||||||
config: {
|
config: {
|
||||||
get: vi.fn().mockResolvedValue({
|
get: vi.fn().mockResolvedValue({
|
||||||
notifications: {
|
notifications: {
|
||||||
|
|
@ -136,12 +141,16 @@ export function createMockElectronAPI(): MockElectronAPI {
|
||||||
showDockIcon: true,
|
showDockIcon: true,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
defaultTab: 'dashboard',
|
defaultTab: 'dashboard',
|
||||||
|
claudeRootPath: null,
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
compactMode: false,
|
compactMode: false,
|
||||||
syntaxHighlighting: true,
|
syntaxHighlighting: true,
|
||||||
},
|
},
|
||||||
|
sessions: {
|
||||||
|
pinnedSessions: {},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
addIgnoreRegex: vi.fn(),
|
addIgnoreRegex: vi.fn(),
|
||||||
|
|
@ -156,6 +165,15 @@ export function createMockElectronAPI(): MockElectronAPI {
|
||||||
getTriggers: vi.fn().mockResolvedValue([]),
|
getTriggers: vi.fn().mockResolvedValue([]),
|
||||||
testTrigger: vi.fn(),
|
testTrigger: vi.fn(),
|
||||||
selectFolders: vi.fn().mockResolvedValue([]),
|
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