- Changed image format for agent comments screenshot in README. - Improved table of contents in README for better navigation. - Refactored imports in IPC config and discovery services for consistency. - Added shimmer effect for waiting members in CSS. - Enhanced dashboard view to display active teams with online indicators. - Updated team provisioning components to support message severity. - Improved task detail dialog layout for related tasks and dependencies. - Adjusted team model selector default value and refined member status handling. - Fixed minor styling issues in messages panel and tab bar. These changes aim to improve user experience and maintainability across the application.
1172 lines
36 KiB
TypeScript
1172 lines
36 KiB
TypeScript
/**
|
|
* IPC Handlers for App Configuration.
|
|
*
|
|
* Handlers:
|
|
* - config:get: Get full app configuration
|
|
* - config:update: Update a specific config section
|
|
* - config:addIgnoreRegex: Add an ignore pattern for notifications
|
|
* - config:removeIgnoreRegex: Remove an ignore pattern
|
|
* - config:addIgnoreRepository: Add a repository to ignore list
|
|
* - config:removeIgnoreRepository: Remove a repository from ignore list
|
|
* - config:snooze: Set snooze duration for notifications
|
|
* - config:clearSnooze: Clear the snooze timer
|
|
* - config:addTrigger: Add a new notification trigger
|
|
* - config:updateTrigger: Update an existing notification trigger
|
|
* - config:removeTrigger: Remove a notification trigger
|
|
* - config:getTriggers: Get all notification triggers
|
|
* - config:testTrigger: Test a trigger against historical session data
|
|
*/
|
|
|
|
import { syncTelemetryFlag } from '@main/sentry';
|
|
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 { promisify } from 'util';
|
|
|
|
import {
|
|
type AppConfig,
|
|
ConfigManager,
|
|
type NotificationTrigger,
|
|
type TriggerContentType,
|
|
type TriggerMatchField,
|
|
type TriggerMode,
|
|
type TriggerTokenType,
|
|
} from '../services';
|
|
|
|
import { validateConfigUpdatePayload } from './configValidation';
|
|
import { validateTriggerId } from './guards';
|
|
|
|
import type { TriggerColor } from '@shared/constants/triggerColors';
|
|
import type {
|
|
ClaudeRootFolderSelection,
|
|
ClaudeRootInfo,
|
|
IpcResult,
|
|
WslClaudeRootCandidate,
|
|
} from '@shared/types';
|
|
|
|
const logger = createLogger('IPC:config');
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// Get singleton instance
|
|
const configManager = ConfigManager.getInstance();
|
|
let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> | void) | null =
|
|
null;
|
|
let onAgentLanguageUpdated: ((newLangCode: string) => Promise<void> | void) | null = null;
|
|
|
|
/**
|
|
* Initializes config handlers with callbacks that require app-level services.
|
|
*/
|
|
export function initializeConfigHandlers(
|
|
options: {
|
|
onClaudeRootPathUpdated?: (claudeRootPath: string | null) => Promise<void> | void;
|
|
onAgentLanguageUpdated?: (newLangCode: string) => Promise<void> | void;
|
|
} = {}
|
|
): void {
|
|
onClaudeRootPathUpdated = options.onClaudeRootPathUpdated ?? null;
|
|
onAgentLanguageUpdated = options.onAgentLanguageUpdated ?? null;
|
|
}
|
|
|
|
/**
|
|
* Registers all config-related IPC handlers.
|
|
*/
|
|
export function registerConfigHandlers(ipcMain: IpcMain): void {
|
|
// Get full configuration
|
|
ipcMain.handle('config:get', handleGetConfig);
|
|
|
|
// Update configuration section
|
|
ipcMain.handle('config:update', handleUpdateConfig);
|
|
|
|
// Ignore regex pattern handlers
|
|
ipcMain.handle('config:addIgnoreRegex', handleAddIgnoreRegex);
|
|
ipcMain.handle('config:removeIgnoreRegex', handleRemoveIgnoreRegex);
|
|
|
|
// Ignore repository handlers
|
|
ipcMain.handle('config:addIgnoreRepository', handleAddIgnoreRepository);
|
|
ipcMain.handle('config:removeIgnoreRepository', handleRemoveIgnoreRepository);
|
|
|
|
// Snooze handlers
|
|
ipcMain.handle('config:snooze', handleSnooze);
|
|
ipcMain.handle('config:clearSnooze', handleClearSnooze);
|
|
|
|
// Trigger management handlers
|
|
ipcMain.handle('config:addTrigger', handleAddTrigger);
|
|
ipcMain.handle('config:updateTrigger', handleUpdateTrigger);
|
|
ipcMain.handle('config:removeTrigger', handleRemoveTrigger);
|
|
ipcMain.handle('config:getTriggers', handleGetTriggers);
|
|
ipcMain.handle('config:testTrigger', handleTestTrigger);
|
|
|
|
// Session pin handlers
|
|
ipcMain.handle('config:pinSession', handlePinSession);
|
|
ipcMain.handle('config:unpinSession', handleUnpinSession);
|
|
|
|
// Session hide handlers
|
|
ipcMain.handle('config:hideSession', handleHideSession);
|
|
ipcMain.handle('config:unhideSession', handleUnhideSession);
|
|
ipcMain.handle('config:hideSessions', handleHideSessions);
|
|
ipcMain.handle('config:unhideSessions', handleUnhideSessions);
|
|
|
|
// Dialog handlers
|
|
ipcMain.handle('config:selectFolders', handleSelectFolders);
|
|
ipcMain.handle('config:selectClaudeRootFolder', handleSelectClaudeRootFolder);
|
|
ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo);
|
|
ipcMain.handle('config:findWslClaudeRoots', handleFindWslClaudeRoots);
|
|
|
|
// Custom project path handlers
|
|
ipcMain.handle('config:addCustomProjectPath', handleAddCustomProjectPath);
|
|
ipcMain.handle('config:removeCustomProjectPath', handleRemoveCustomProjectPath);
|
|
|
|
// Editor handlers
|
|
ipcMain.handle('config:openInEditor', handleOpenInEditor);
|
|
|
|
logger.info('Config handlers registered (including trigger management)');
|
|
}
|
|
|
|
// =============================================================================
|
|
// Handler Functions
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Handler for 'config:get' IPC call.
|
|
* Returns the full app configuration.
|
|
*/
|
|
async function handleGetConfig(_event: IpcMainInvokeEvent): Promise<IpcResult<AppConfig>> {
|
|
try {
|
|
const config = configManager.getConfig();
|
|
return { success: true, data: config };
|
|
} catch (error) {
|
|
logger.error('Error in config:get:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:update' IPC call.
|
|
* Updates a specific section of the configuration.
|
|
* Returns the full updated config.
|
|
*/
|
|
async function handleUpdateConfig(
|
|
_event: IpcMainInvokeEvent,
|
|
section: unknown,
|
|
data: unknown
|
|
): Promise<IpcResult<AppConfig>> {
|
|
try {
|
|
const validation = validateConfigUpdatePayload(section, data);
|
|
if (!validation.valid) {
|
|
return { success: false, error: validation.error };
|
|
}
|
|
|
|
const isClaudeRootUpdate =
|
|
validation.section === 'general' &&
|
|
Object.prototype.hasOwnProperty.call(validation.data, 'claudeRootPath');
|
|
|
|
// Capture previous language BEFORE applying the update so we can detect real changes
|
|
const prevAgentLanguage =
|
|
validation.section === 'general' &&
|
|
Object.prototype.hasOwnProperty.call(validation.data, 'agentLanguage')
|
|
? configManager.getConfig().general.agentLanguage
|
|
: undefined;
|
|
|
|
configManager.updateConfig(validation.section, validation.data);
|
|
|
|
// Sync Sentry opt-in when general.telemetryEnabled changes
|
|
if (
|
|
validation.section === 'general' &&
|
|
Object.prototype.hasOwnProperty.call(validation.data, 'telemetryEnabled')
|
|
) {
|
|
syncTelemetryFlag(configManager.getConfig().general.telemetryEnabled);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (prevAgentLanguage !== undefined && onAgentLanguageUpdated) {
|
|
const newLangCode = (validation.data as { agentLanguage?: string }).agentLanguage;
|
|
if (newLangCode && newLangCode !== prevAgentLanguage) {
|
|
try {
|
|
await onAgentLanguageUpdated(newLangCode);
|
|
} catch (callbackError) {
|
|
logger.error('Failed to notify teams about language change:', callbackError);
|
|
}
|
|
}
|
|
}
|
|
|
|
const updatedConfig = configManager.getConfig();
|
|
return { success: true, data: updatedConfig };
|
|
} catch (error) {
|
|
logger.error('Error in config:update:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:addIgnoreRegex' IPC call.
|
|
* Adds a regex pattern to the notification ignore list.
|
|
*/
|
|
async function handleAddIgnoreRegex(
|
|
_event: IpcMainInvokeEvent,
|
|
pattern: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!pattern || typeof pattern !== 'string') {
|
|
return { success: false, error: 'Pattern is required and must be a string' };
|
|
}
|
|
|
|
// Validate that the pattern is a valid regex
|
|
try {
|
|
new RegExp(pattern);
|
|
} catch {
|
|
return { success: false, error: 'Invalid regex pattern' };
|
|
}
|
|
|
|
configManager.addIgnoreRegex(pattern);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:addIgnoreRegex:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:removeIgnoreRegex' IPC call.
|
|
* Removes a regex pattern from the notification ignore list.
|
|
*/
|
|
async function handleRemoveIgnoreRegex(
|
|
_event: IpcMainInvokeEvent,
|
|
pattern: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!pattern || typeof pattern !== 'string') {
|
|
return { success: false, error: 'Pattern is required and must be a string' };
|
|
}
|
|
|
|
configManager.removeIgnoreRegex(pattern);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:removeIgnoreRegex:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:addIgnoreRepository' IPC call.
|
|
* Adds a repository to the notification ignore list.
|
|
*/
|
|
async function handleAddIgnoreRepository(
|
|
_event: IpcMainInvokeEvent,
|
|
repositoryId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!repositoryId || typeof repositoryId !== 'string') {
|
|
return { success: false, error: 'Repository ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.addIgnoreRepository(repositoryId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:addIgnoreRepository:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:removeIgnoreRepository' IPC call.
|
|
* Removes a repository from the notification ignore list.
|
|
*/
|
|
async function handleRemoveIgnoreRepository(
|
|
_event: IpcMainInvokeEvent,
|
|
repositoryId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!repositoryId || typeof repositoryId !== 'string') {
|
|
return { success: false, error: 'Repository ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.removeIgnoreRepository(repositoryId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:removeIgnoreRepository:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:snooze' IPC call.
|
|
* Sets the snooze timer for notifications.
|
|
*/
|
|
async function handleSnooze(_event: IpcMainInvokeEvent, minutes: number): Promise<IpcResult> {
|
|
try {
|
|
if (typeof minutes !== 'number' || minutes <= 0 || minutes > 24 * 60) {
|
|
return { success: false, error: 'Minutes must be a positive number' };
|
|
}
|
|
|
|
configManager.setSnooze(minutes);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:snooze:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:clearSnooze' IPC call.
|
|
* Clears the snooze timer.
|
|
*/
|
|
async function handleClearSnooze(_event: IpcMainInvokeEvent): Promise<IpcResult> {
|
|
try {
|
|
configManager.clearSnooze();
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:clearSnooze:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:addTrigger' - Adds a new notification trigger.
|
|
*/
|
|
async function handleAddTrigger(
|
|
_event: IpcMainInvokeEvent,
|
|
trigger: {
|
|
id: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
contentType: string;
|
|
mode?: TriggerMode;
|
|
requireError?: boolean;
|
|
toolName?: string;
|
|
matchField?: string;
|
|
matchPattern?: string;
|
|
ignorePatterns?: string[];
|
|
tokenThreshold?: number;
|
|
tokenType?: TriggerTokenType;
|
|
repositoryIds?: string[];
|
|
color?: string;
|
|
}
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!trigger.id || !trigger.name || !trigger.contentType) {
|
|
return {
|
|
success: false,
|
|
error: 'Trigger must have id, name, and contentType',
|
|
};
|
|
}
|
|
|
|
configManager.addTrigger({
|
|
id: trigger.id,
|
|
name: trigger.name,
|
|
enabled: trigger.enabled,
|
|
contentType: trigger.contentType as TriggerContentType,
|
|
mode: trigger.mode ?? (trigger.requireError ? 'error_status' : 'content_match'),
|
|
requireError: trigger.requireError,
|
|
toolName: trigger.toolName,
|
|
matchField: trigger.matchField as TriggerMatchField | undefined,
|
|
matchPattern: trigger.matchPattern,
|
|
ignorePatterns: trigger.ignorePatterns,
|
|
tokenThreshold: trigger.tokenThreshold,
|
|
tokenType: trigger.tokenType,
|
|
repositoryIds: trigger.repositoryIds,
|
|
color: trigger.color as TriggerColor | undefined,
|
|
isBuiltin: false,
|
|
});
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:addTrigger:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to add trigger',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:updateTrigger' - Updates an existing notification trigger.
|
|
*/
|
|
async function handleUpdateTrigger(
|
|
_event: IpcMainInvokeEvent,
|
|
triggerId: string,
|
|
updates: Partial<{
|
|
name: string;
|
|
enabled: boolean;
|
|
contentType: string;
|
|
requireError: boolean;
|
|
toolName: string;
|
|
matchField: string;
|
|
matchPattern: string;
|
|
ignorePatterns: string[];
|
|
mode: TriggerMode;
|
|
tokenThreshold: number;
|
|
tokenType: TriggerTokenType;
|
|
repositoryIds: string[];
|
|
color: string;
|
|
}>
|
|
): Promise<IpcResult> {
|
|
try {
|
|
const validatedTriggerId = validateTriggerId(triggerId);
|
|
if (!validatedTriggerId.valid) {
|
|
return {
|
|
success: false,
|
|
error: validatedTriggerId.error ?? 'Trigger ID is required',
|
|
};
|
|
}
|
|
|
|
configManager.updateTrigger(validatedTriggerId.value!, updates as Partial<NotificationTrigger>);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:updateTrigger:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to update trigger',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:removeTrigger' - Removes a notification trigger.
|
|
*/
|
|
async function handleRemoveTrigger(
|
|
_event: IpcMainInvokeEvent,
|
|
triggerId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
const validatedTriggerId = validateTriggerId(triggerId);
|
|
if (!validatedTriggerId.valid) {
|
|
return {
|
|
success: false,
|
|
error: validatedTriggerId.error ?? 'Trigger ID is required',
|
|
};
|
|
}
|
|
|
|
configManager.removeTrigger(validatedTriggerId.value!);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:removeTrigger:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to remove trigger',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:getTriggers' - Gets all notification triggers.
|
|
*/
|
|
async function handleGetTriggers(
|
|
_event: IpcMainInvokeEvent
|
|
): Promise<IpcResult<NotificationTrigger[]>> {
|
|
try {
|
|
const triggers = configManager.getTriggers();
|
|
|
|
return { success: true, data: triggers };
|
|
} catch (error) {
|
|
logger.error('Error in config:getTriggers:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to get triggers',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:testTrigger' - Tests a trigger against historical session data.
|
|
* Returns errors that would have been detected by the trigger.
|
|
*
|
|
* Safety: Results are truncated if:
|
|
* - More than 10,000 total matches found
|
|
* - More than 100 sessions scanned
|
|
* - Test runs longer than 30 seconds
|
|
*/
|
|
async function handleTestTrigger(
|
|
_event: IpcMainInvokeEvent,
|
|
trigger: NotificationTrigger
|
|
): Promise<
|
|
IpcResult<{
|
|
totalCount: number;
|
|
errors: {
|
|
id: string;
|
|
sessionId: string;
|
|
projectId: string;
|
|
message: string;
|
|
timestamp: number;
|
|
source: string;
|
|
toolUseId?: string;
|
|
subagentId?: string;
|
|
lineNumber?: number;
|
|
context: { projectName: string };
|
|
}[];
|
|
/** True if results were truncated due to safety limits */
|
|
truncated?: boolean;
|
|
}>
|
|
> {
|
|
try {
|
|
const { errorDetector } = await import('../services');
|
|
const result = await errorDetector.testTrigger(trigger, 50);
|
|
|
|
// Map the DetectedError objects to the format expected by the renderer
|
|
// Include toolUseId, subagentId, and lineNumber for deep linking to exact error location
|
|
const errors = result.errors.map((error) => ({
|
|
id: error.id,
|
|
sessionId: error.sessionId,
|
|
projectId: error.projectId,
|
|
message: error.message,
|
|
timestamp: error.timestamp,
|
|
source: error.source,
|
|
toolUseId: error.toolUseId,
|
|
subagentId: error.subagentId,
|
|
lineNumber: error.lineNumber,
|
|
context: { projectName: error.context.projectName },
|
|
}));
|
|
|
|
return {
|
|
success: true,
|
|
data: { totalCount: result.totalCount, errors, truncated: result.truncated },
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error in config:testTrigger:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to test trigger',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:pinSession' - Pins a session for a project.
|
|
*/
|
|
async function handlePinSession(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return { success: false, error: 'Session ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.pinSession(projectId, sessionId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:pinSession:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:unpinSession' - Unpins a session for a project.
|
|
*/
|
|
async function handleUnpinSession(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return { success: false, error: 'Session ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.unpinSession(projectId, sessionId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:unpinSession:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:openInEditor' - Opens the config JSON file in an external editor.
|
|
* Tries editors in order: $VISUAL, $EDITOR, cursor, code, then falls back to system open.
|
|
*/
|
|
async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<IpcResult> {
|
|
try {
|
|
const configPath = configManager.getConfigPath();
|
|
|
|
// Try editors in priority order
|
|
const editors: string[] = [];
|
|
if (process.env.VISUAL) editors.push(process.env.VISUAL);
|
|
if (process.env.EDITOR) editors.push(process.env.EDITOR);
|
|
editors.push('cursor', 'code', 'subl', 'zed');
|
|
|
|
for (const editor of editors) {
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const child = execFile(editor, [configPath], { timeout: 5000 });
|
|
// If the process spawns successfully, resolve after a short delay
|
|
// (editors typically fork and the parent exits quickly)
|
|
const timer = setTimeout(() => resolve(), 500);
|
|
child.on('error', (err) => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
});
|
|
return { success: true };
|
|
} catch {
|
|
// Editor not found, try next
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Fallback: open with system default
|
|
const { shell } = await import('electron');
|
|
const errorMessage = await shell.openPath(configPath);
|
|
if (errorMessage) {
|
|
return { success: false, error: errorMessage };
|
|
}
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:openInEditor:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:addCustomProjectPath' - Persists a custom project path.
|
|
*/
|
|
async function handleAddCustomProjectPath(
|
|
_event: IpcMainInvokeEvent,
|
|
projectPath: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectPath || typeof projectPath !== 'string') {
|
|
return { success: false, error: 'Project path is required and must be a string' };
|
|
}
|
|
|
|
configManager.addCustomProjectPath(projectPath);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:addCustomProjectPath:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:removeCustomProjectPath' - Removes a custom project path.
|
|
*/
|
|
async function handleRemoveCustomProjectPath(
|
|
_event: IpcMainInvokeEvent,
|
|
projectPath: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectPath || typeof projectPath !== 'string') {
|
|
return { success: false, error: 'Project path is required and must be a string' };
|
|
}
|
|
|
|
configManager.removeCustomProjectPath(projectPath);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:removeCustomProjectPath:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
|
|
* Allows users to select one or more folders for trigger project scope.
|
|
*/
|
|
async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise<IpcResult<string[]>> {
|
|
try {
|
|
// Get the focused window for proper dialog parenting
|
|
const focusedWindow = BrowserWindow.getFocusedWindow();
|
|
|
|
// dialog.showOpenDialog accepts either (options) or (window, options)
|
|
const dialogOptions: Electron.OpenDialogOptions = {
|
|
properties: ['openDirectory', 'multiSelections'],
|
|
title: 'Select Project Folders',
|
|
buttonLabel: 'Select',
|
|
};
|
|
|
|
const result = focusedWindow
|
|
? await dialog.showOpenDialog(focusedWindow, dialogOptions)
|
|
: await dialog.showOpenDialog(dialogOptions);
|
|
|
|
if (result.canceled) {
|
|
return { success: true, data: [] };
|
|
}
|
|
|
|
return { success: true, data: result.filePaths };
|
|
} catch (error) {
|
|
logger.error('Error in config:selectFolders:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to open folder dialog',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:selectClaudeRootFolder' - Opens native folder picker for Claude root.
|
|
*/
|
|
async function handleSelectClaudeRootFolder(
|
|
_event: IpcMainInvokeEvent
|
|
): Promise<IpcResult<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<IpcResult<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,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
function normalizeWslHomePath(home: string): string | null {
|
|
const trimmed = home.trim();
|
|
if (!trimmed.startsWith('/')) {
|
|
return null;
|
|
}
|
|
|
|
let normalized = path.posix.normalize(trimmed);
|
|
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
normalized = normalized.slice(0, -1);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function toWslUncPath(distro: string, posixPath: string): string {
|
|
const uncSuffix = posixPath.replace(/\//g, '\\');
|
|
return `\\\\wsl.localhost\\${distro}${uncSuffix}`;
|
|
}
|
|
|
|
function getWslExecutableCandidates(): string[] {
|
|
const candidates = new Set<string>();
|
|
|
|
const windir = process.env.WINDIR;
|
|
if (windir) {
|
|
candidates.add(path.join(windir, 'System32', 'wsl.exe'));
|
|
candidates.add(path.join(windir, 'Sysnative', 'wsl.exe'));
|
|
}
|
|
|
|
candidates.add('wsl.exe');
|
|
return Array.from(candidates);
|
|
}
|
|
|
|
function looksLikeUtf16Le(buffer: Buffer): boolean {
|
|
const sampleSize = Math.min(buffer.length, 512);
|
|
if (sampleSize < 2) {
|
|
return false;
|
|
}
|
|
|
|
let pairs = 0;
|
|
let nullsAtOddIndex = 0;
|
|
for (let i = 0; i + 1 < sampleSize; i += 2) {
|
|
pairs += 1;
|
|
if (buffer[i + 1] === 0) {
|
|
nullsAtOddIndex += 1;
|
|
}
|
|
}
|
|
|
|
return pairs > 0 && nullsAtOddIndex / pairs >= 0.3;
|
|
}
|
|
|
|
function decodeWslOutput(output: string | Buffer | undefined): string {
|
|
if (typeof output === 'string') {
|
|
return output.replace(/\0/g, '');
|
|
}
|
|
if (!output || output.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe;
|
|
const decoded =
|
|
hasUtf16LeBom || looksLikeUtf16Le(output)
|
|
? output.toString('utf16le')
|
|
: output.toString('utf8');
|
|
return decoded.replace(/\0/g, '');
|
|
}
|
|
|
|
async function runWsl(args: string[], timeout = 5000): Promise<{ stdout: string; stderr: string }> {
|
|
const candidates = getWslExecutableCandidates();
|
|
let lastError: unknown = null;
|
|
|
|
for (const executable of candidates) {
|
|
try {
|
|
const result = await execFileAsync(executable, args, {
|
|
timeout,
|
|
windowsHide: true,
|
|
maxBuffer: 1024 * 1024,
|
|
encoding: 'buffer',
|
|
});
|
|
return {
|
|
stdout: decodeWslOutput(result.stdout),
|
|
stderr: decodeWslOutput(result.stderr),
|
|
};
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
|
|
throw lastError instanceof Error ? lastError : new Error('Unable to execute wsl.exe');
|
|
}
|
|
|
|
function parseWslDistros(stdout: string): string[] {
|
|
const distros: string[] = [];
|
|
const seen = new Set<string>();
|
|
const lines = stdout.split(/\r?\n/);
|
|
|
|
for (const rawLine of lines) {
|
|
let line = rawLine.replace(/\0/g, '').trim();
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
line = line.replace(/^\*\s*/, '').trim();
|
|
line = stripDefaultSuffix(line);
|
|
|
|
const lower = line.toLowerCase();
|
|
if (
|
|
lower.startsWith('windows subsystem for linux') ||
|
|
lower.includes('default version') ||
|
|
lower.startsWith('the following is a list')
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const key = line.toLowerCase();
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
distros.push(line);
|
|
}
|
|
}
|
|
|
|
return distros;
|
|
}
|
|
|
|
async function listWslDistros(): Promise<string[]> {
|
|
const commands: string[][] = [['--list', '--quiet'], ['-l', '-q'], ['-l']];
|
|
|
|
for (const command of commands) {
|
|
try {
|
|
const { stdout } = await runWsl(command, 4000);
|
|
const parsed = parseWslDistros(stdout);
|
|
if (parsed.length > 0) {
|
|
return parsed;
|
|
}
|
|
} catch {
|
|
// Try the next command variant.
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function stripDefaultSuffix(input: string): string {
|
|
const suffix = '(default)';
|
|
if (!input.toLowerCase().endsWith(suffix)) {
|
|
return input;
|
|
}
|
|
|
|
return input.slice(0, input.length - suffix.length).trimEnd();
|
|
}
|
|
|
|
async function resolveWslHome(distro: string): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await runWsl(['-d', distro, '--', 'sh', '-lc', 'printf %s "$HOME"'], 5000);
|
|
return normalizeWslHomePath(stdout);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function resolveWslDefaultUser(distro: string): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await runWsl(['-d', distro, '--', 'whoami'], 3000);
|
|
const user = stdout.trim();
|
|
return user && !user.includes('/') && !user.includes('\\') ? user : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:findWslClaudeRoots' - Find Windows UNC candidates for WSL Claude roots.
|
|
*/
|
|
async function handleFindWslClaudeRoots(
|
|
_event: IpcMainInvokeEvent
|
|
): Promise<IpcResult<WslClaudeRootCandidate[]>> {
|
|
try {
|
|
if (process.platform !== 'win32') {
|
|
return { success: true, data: [] };
|
|
}
|
|
|
|
const distros = await listWslDistros();
|
|
if (distros.length === 0) {
|
|
return { success: true, data: [] };
|
|
}
|
|
|
|
const candidates: WslClaudeRootCandidate[] = [];
|
|
const seen = new Set<string>();
|
|
for (const distro of distros) {
|
|
const resolvedHomePath = await resolveWslHome(distro);
|
|
// Fallback: query the default WSL user, then try Windows USERNAME
|
|
const wslUser = await resolveWslDefaultUser(distro);
|
|
const fallbackUser = wslUser || process.env.USERNAME;
|
|
const fallbackHomePath = fallbackUser
|
|
? fallbackUser === 'root'
|
|
? '/root'
|
|
: `/home/${fallbackUser}`
|
|
: null;
|
|
const normalizedHome =
|
|
normalizeWslHomePath(resolvedHomePath ?? '') ??
|
|
(fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null);
|
|
|
|
if (!normalizedHome) {
|
|
continue;
|
|
}
|
|
|
|
const claudePosixPath = path.posix.join(normalizedHome, '.claude');
|
|
const claudeUncPath = toWslUncPath(distro, claudePosixPath);
|
|
const key = claudeUncPath.toLowerCase();
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
|
|
const projectsPath = path.join(claudeUncPath, 'projects');
|
|
const hasProjectsDir = (() => {
|
|
try {
|
|
return fs.existsSync(projectsPath) && fs.statSync(projectsPath).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
candidates.push({
|
|
distro,
|
|
path: claudeUncPath,
|
|
hasProjectsDir,
|
|
});
|
|
}
|
|
|
|
return { success: true, data: candidates };
|
|
} catch (error) {
|
|
logger.error('Error in config:findWslClaudeRoots:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to detect WSL Claude paths',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:hideSession' - Hides a session for a project.
|
|
*/
|
|
async function handleHideSession(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return { success: false, error: 'Session ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.hideSession(projectId, sessionId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:hideSession:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:unhideSession' - Unhides a session for a project.
|
|
*/
|
|
async function handleUnhideSession(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionId: string
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return { success: false, error: 'Session ID is required and must be a string' };
|
|
}
|
|
|
|
configManager.unhideSession(projectId, sessionId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:unhideSession:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:hideSessions' - Bulk hide sessions for a project.
|
|
*/
|
|
async function handleHideSessions(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionIds: string[]
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) {
|
|
return { success: false, error: 'Session IDs must be an array of strings' };
|
|
}
|
|
|
|
configManager.hideSessions(projectId, sessionIds);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:hideSessions:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'config:unhideSessions' - Bulk unhide sessions for a project.
|
|
*/
|
|
async function handleUnhideSessions(
|
|
_event: IpcMainInvokeEvent,
|
|
projectId: string,
|
|
sessionIds: string[]
|
|
): Promise<IpcResult> {
|
|
try {
|
|
if (!projectId || typeof projectId !== 'string') {
|
|
return { success: false, error: 'Project ID is required and must be a string' };
|
|
}
|
|
if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) {
|
|
return { success: false, error: 'Session IDs must be an array of strings' };
|
|
}
|
|
|
|
configManager.unhideSessions(projectId, sessionIds);
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in config:unhideSessions:', error);
|
|
return { success: false, error: getErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Cleanup
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Removes all config-related IPC handlers.
|
|
* Should be called when shutting down.
|
|
*/
|
|
export function removeConfigHandlers(ipcMain: IpcMain): void {
|
|
ipcMain.removeHandler('config:get');
|
|
ipcMain.removeHandler('config:update');
|
|
ipcMain.removeHandler('config:addIgnoreRegex');
|
|
ipcMain.removeHandler('config:removeIgnoreRegex');
|
|
ipcMain.removeHandler('config:addIgnoreRepository');
|
|
ipcMain.removeHandler('config:removeIgnoreRepository');
|
|
ipcMain.removeHandler('config:snooze');
|
|
ipcMain.removeHandler('config:clearSnooze');
|
|
ipcMain.removeHandler('config:addTrigger');
|
|
ipcMain.removeHandler('config:updateTrigger');
|
|
ipcMain.removeHandler('config:removeTrigger');
|
|
ipcMain.removeHandler('config:getTriggers');
|
|
ipcMain.removeHandler('config:testTrigger');
|
|
ipcMain.removeHandler('config:pinSession');
|
|
ipcMain.removeHandler('config:unpinSession');
|
|
ipcMain.removeHandler('config:hideSession');
|
|
ipcMain.removeHandler('config:unhideSession');
|
|
ipcMain.removeHandler('config:hideSessions');
|
|
ipcMain.removeHandler('config:unhideSessions');
|
|
ipcMain.removeHandler('config:addCustomProjectPath');
|
|
ipcMain.removeHandler('config:removeCustomProjectPath');
|
|
ipcMain.removeHandler('config:selectFolders');
|
|
ipcMain.removeHandler('config:selectClaudeRootFolder');
|
|
ipcMain.removeHandler('config:getClaudeRootInfo');
|
|
ipcMain.removeHandler('config:findWslClaudeRoots');
|
|
ipcMain.removeHandler('config:openInEditor');
|
|
logger.info('Config handlers removed');
|
|
}
|