feat(http): implement HTTP server and route handlers for configuration, notifications, projects, sessions, and SSH management
- Introduced an HTTP server to facilitate communication with the application. - Added route handlers for managing application configuration, including getting and updating settings. - Implemented notification operations with routes for retrieving, marking, and deleting notifications. - Created project and session management routes to list projects, sessions, and their details. - Developed SSH connection management routes for connecting, disconnecting, and retrieving SSH state and configuration. - Enhanced the application architecture to support real-time event streaming via Server-Sent Events (SSE). This commit significantly expands the application's capabilities by integrating an HTTP server and various management routes, improving user interaction and functionality.
This commit is contained in:
parent
bd54e973ff
commit
7fa2f96ed4
45 changed files with 3144 additions and 108 deletions
|
|
@ -42,9 +42,12 @@
|
|||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@tanstack/react-virtual": "^3.10.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"electron-updater": "^6.7.3",
|
||||
"fastify": "^5.7.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
|
|
|
|||
483
pnpm-lock.yaml
483
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
394
src/main/http/config.ts
Normal file
394
src/main/http/config.ts
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
/**
|
||||
* HTTP route handlers for App Configuration.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /api/config - Get full config
|
||||
* - POST /api/config/update - Update config section
|
||||
* - POST /api/config/ignore-regex - Add ignore pattern
|
||||
* - DELETE /api/config/ignore-regex - Remove ignore pattern
|
||||
* - POST /api/config/ignore-repository - Add ignored repository
|
||||
* - DELETE /api/config/ignore-repository - Remove ignored repository
|
||||
* - POST /api/config/snooze - Set snooze
|
||||
* - POST /api/config/clear-snooze - Clear snooze
|
||||
* - POST /api/config/triggers - Add trigger
|
||||
* - PUT /api/config/triggers/:triggerId - Update trigger
|
||||
* - DELETE /api/config/triggers/:triggerId - Remove trigger
|
||||
* - GET /api/config/triggers - Get all triggers
|
||||
* - POST /api/config/triggers/:triggerId/test - Test trigger
|
||||
* - POST /api/config/pin-session - Pin session
|
||||
* - POST /api/config/unpin-session - Unpin session
|
||||
* - POST /api/config/select-folders - No-op in browser
|
||||
* - POST /api/config/open-in-editor - No-op in browser
|
||||
*/
|
||||
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { validateConfigUpdatePayload } from '../ipc/configValidation';
|
||||
import { validateTriggerId } from '../ipc/guards';
|
||||
import {
|
||||
ConfigManager,
|
||||
type NotificationTrigger,
|
||||
type TriggerContentType,
|
||||
type TriggerMatchField,
|
||||
type TriggerMode,
|
||||
type TriggerTokenType,
|
||||
} from '../services';
|
||||
|
||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:config');
|
||||
|
||||
interface ConfigResult<T = void> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function registerConfigRoutes(app: FastifyInstance): void {
|
||||
const configManager = ConfigManager.getInstance();
|
||||
|
||||
// Get full config
|
||||
app.get('/api/config', async () => {
|
||||
try {
|
||||
const config = configManager.getConfig();
|
||||
return { success: true, data: config };
|
||||
} catch (error) {
|
||||
logger.error('Error in GET /api/config:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Update config section
|
||||
app.post<{ Body: { section: unknown; data: unknown } }>('/api/config/update', async (request) => {
|
||||
try {
|
||||
const { section, data } = request.body;
|
||||
const validation = validateConfigUpdatePayload(section, data);
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
configManager.updateConfig(validation.section, validation.data);
|
||||
const updatedConfig = configManager.getConfig();
|
||||
return { success: true, data: updatedConfig };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/config/update:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Add ignore regex
|
||||
app.post<{ Body: { pattern: string } }>('/api/config/ignore-regex', async (request) => {
|
||||
try {
|
||||
const { pattern } = request.body;
|
||||
if (!pattern || typeof pattern !== 'string') {
|
||||
return { success: false, error: 'Pattern is required and must be a string' };
|
||||
}
|
||||
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid regex pattern' };
|
||||
}
|
||||
|
||||
configManager.addIgnoreRegex(pattern);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/config/ignore-regex:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Remove ignore regex
|
||||
app.delete<{ Body: { pattern: string } }>('/api/config/ignore-regex', async (request) => {
|
||||
try {
|
||||
const { pattern } = request.body;
|
||||
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 DELETE /api/config/ignore-regex:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Add ignore repository
|
||||
app.post<{ Body: { repositoryId: string } }>('/api/config/ignore-repository', async (request) => {
|
||||
try {
|
||||
const { repositoryId } = request.body;
|
||||
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 POST /api/config/ignore-repository:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Remove ignore repository
|
||||
app.delete<{ Body: { repositoryId: string } }>(
|
||||
'/api/config/ignore-repository',
|
||||
async (request) => {
|
||||
try {
|
||||
const { repositoryId } = request.body;
|
||||
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 DELETE /api/config/ignore-repository:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Set snooze
|
||||
app.post<{ Body: { minutes: number } }>('/api/config/snooze', async (request) => {
|
||||
try {
|
||||
const { minutes } = request.body;
|
||||
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 POST /api/config/snooze:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Clear snooze
|
||||
app.post('/api/config/clear-snooze', async () => {
|
||||
try {
|
||||
configManager.clearSnooze();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/config/clear-snooze:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Add trigger
|
||||
app.post<{
|
||||
Body: {
|
||||
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;
|
||||
};
|
||||
}>('/api/config/triggers', async (request) => {
|
||||
try {
|
||||
const trigger = request.body;
|
||||
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 POST /api/config/triggers:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to add trigger',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Update trigger
|
||||
app.put<{
|
||||
Params: { triggerId: string };
|
||||
Body: 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;
|
||||
}>;
|
||||
}>('/api/config/triggers/:triggerId', async (request) => {
|
||||
try {
|
||||
const validated = validateTriggerId(request.params.triggerId);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Trigger ID is required' };
|
||||
}
|
||||
|
||||
configManager.updateTrigger(validated.value!, request.body as Partial<NotificationTrigger>);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in PUT /api/config/triggers:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update trigger',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Remove trigger
|
||||
app.delete<{ Params: { triggerId: string } }>(
|
||||
'/api/config/triggers/:triggerId',
|
||||
async (request) => {
|
||||
try {
|
||||
const validated = validateTriggerId(request.params.triggerId);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Trigger ID is required' };
|
||||
}
|
||||
|
||||
configManager.removeTrigger(validated.value!);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in DELETE /api/config/triggers:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove trigger',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get triggers
|
||||
app.get('/api/config/triggers', async () => {
|
||||
try {
|
||||
const triggers = configManager.getTriggers();
|
||||
return { success: true, data: triggers };
|
||||
} catch (error) {
|
||||
logger.error('Error in GET /api/config/triggers:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get triggers',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Test trigger
|
||||
app.post<{ Params: { triggerId: string }; Body: NotificationTrigger }>(
|
||||
'/api/config/triggers/:triggerId/test',
|
||||
async (request) => {
|
||||
try {
|
||||
const { errorDetector } = await import('../services');
|
||||
const result = await errorDetector.testTrigger(request.body, 50);
|
||||
|
||||
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 POST /api/config/triggers/test:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to test trigger',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Pin session
|
||||
app.post<{ Body: { projectId: string; sessionId: string } }>(
|
||||
'/api/config/pin-session',
|
||||
async (request) => {
|
||||
try {
|
||||
const { projectId, sessionId } = request.body;
|
||||
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 POST /api/config/pin-session:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Unpin session
|
||||
app.post<{ Body: { projectId: string; sessionId: string } }>(
|
||||
'/api/config/unpin-session',
|
||||
async (request) => {
|
||||
try {
|
||||
const { projectId, sessionId } = request.body;
|
||||
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 POST /api/config/unpin-session:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Select folders - no-op in browser mode
|
||||
app.post('/api/config/select-folders', async (): Promise<ConfigResult<string[]>> => {
|
||||
return { success: true, data: [] };
|
||||
});
|
||||
|
||||
// Open in editor - no-op in browser mode
|
||||
app.post('/api/config/open-in-editor', async (): Promise<ConfigResult> => {
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
63
src/main/http/events.ts
Normal file
63
src/main/http/events.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* SSE (Server-Sent Events) route for real-time event streaming.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /api/events: SSE stream with keep-alive pings
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:events');
|
||||
|
||||
const KEEPALIVE_INTERVAL_MS = 30_000;
|
||||
|
||||
/** All connected SSE clients */
|
||||
const clients = new Set<FastifyReply>();
|
||||
|
||||
/**
|
||||
* Registers the SSE events endpoint.
|
||||
*/
|
||||
export function registerEventRoutes(app: FastifyInstance): void {
|
||||
app.get('/api/events', async (request, reply) => {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
clients.add(reply);
|
||||
logger.info(`SSE client connected (total: ${clients.size})`);
|
||||
|
||||
// Keep-alive ping
|
||||
const timer = setInterval(() => {
|
||||
reply.raw.write(':ping\n\n');
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
// Cleanup on disconnect
|
||||
request.raw.on('close', () => {
|
||||
clearInterval(timer);
|
||||
clients.delete(reply);
|
||||
logger.info(`SSE client disconnected (total: ${clients.size})`);
|
||||
});
|
||||
|
||||
// Prevent Fastify from ending the response
|
||||
await reply;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts an event to all connected SSE clients.
|
||||
*/
|
||||
export function broadcastEvent(channel: string, data: unknown): void {
|
||||
const payload = `event: ${channel}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
|
||||
for (const client of clients) {
|
||||
try {
|
||||
client.raw.write(payload);
|
||||
} catch {
|
||||
clients.delete(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/main/http/index.ts
Normal file
65
src/main/http/index.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* HTTP Route Registration Orchestrator.
|
||||
*
|
||||
* Registers all domain-specific route handlers on a Fastify instance.
|
||||
* Each route file mirrors the corresponding IPC handler.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { registerConfigRoutes } from './config';
|
||||
import { broadcastEvent, registerEventRoutes } from './events';
|
||||
import { registerNotificationRoutes } from './notifications';
|
||||
import { registerProjectRoutes } from './projects';
|
||||
import { registerSearchRoutes } from './search';
|
||||
import { registerSessionRoutes } from './sessions';
|
||||
import { registerSshRoutes } from './ssh';
|
||||
import { registerSubagentRoutes } from './subagents';
|
||||
import { registerUpdaterRoutes } from './updater';
|
||||
import { registerUtilityRoutes } from './utility';
|
||||
import { registerValidationRoutes } from './validation';
|
||||
|
||||
import type {
|
||||
ChunkBuilder,
|
||||
DataCache,
|
||||
ProjectScanner,
|
||||
SessionParser,
|
||||
SubagentResolver,
|
||||
UpdaterService,
|
||||
} from '../services';
|
||||
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:routes');
|
||||
|
||||
export interface HttpServices {
|
||||
projectScanner: ProjectScanner;
|
||||
sessionParser: SessionParser;
|
||||
subagentResolver: SubagentResolver;
|
||||
chunkBuilder: ChunkBuilder;
|
||||
dataCache: DataCache;
|
||||
updaterService: UpdaterService;
|
||||
sshConnectionManager: SshConnectionManager;
|
||||
}
|
||||
|
||||
export function registerHttpRoutes(
|
||||
app: FastifyInstance,
|
||||
services: HttpServices,
|
||||
sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise<void>
|
||||
): void {
|
||||
registerProjectRoutes(app, services);
|
||||
registerSessionRoutes(app, services);
|
||||
registerSearchRoutes(app, services);
|
||||
registerSubagentRoutes(app, services);
|
||||
registerNotificationRoutes(app);
|
||||
registerConfigRoutes(app);
|
||||
registerValidationRoutes(app);
|
||||
registerUtilityRoutes(app);
|
||||
registerSshRoutes(app, services.sshConnectionManager, sshModeSwitchCallback);
|
||||
registerUpdaterRoutes(app, services);
|
||||
registerEventRoutes(app);
|
||||
|
||||
logger.info('All HTTP routes registered');
|
||||
}
|
||||
|
||||
export { broadcastEvent };
|
||||
121
src/main/http/notifications.ts
Normal file
121
src/main/http/notifications.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* HTTP route handlers for Notification Operations.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /api/notifications - Get notifications (paginated)
|
||||
* - POST /api/notifications/:id/read - Mark as read
|
||||
* - POST /api/notifications/read-all - Mark all as read
|
||||
* - DELETE /api/notifications/:id - Delete notification
|
||||
* - DELETE /api/notifications - Clear all notifications
|
||||
* - GET /api/notifications/unread-count - Get unread count
|
||||
*/
|
||||
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { coercePageLimit, validateNotificationId } from '../ipc/guards';
|
||||
import { NotificationManager } from '../services';
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:notifications');
|
||||
|
||||
export function registerNotificationRoutes(app: FastifyInstance): void {
|
||||
// Get notifications
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||
'/api/notifications',
|
||||
async (request) => {
|
||||
try {
|
||||
const limit = coercePageLimit(
|
||||
request.query.limit ? Number(request.query.limit) : undefined,
|
||||
20
|
||||
);
|
||||
const rawOffset = request.query.offset ? Number(request.query.offset) : 0;
|
||||
const offset =
|
||||
typeof rawOffset === 'number' && Number.isFinite(rawOffset) && rawOffset >= 0
|
||||
? Math.floor(rawOffset)
|
||||
: 0;
|
||||
|
||||
const manager = NotificationManager.getInstance();
|
||||
const result = await manager.getNotifications({ limit, offset });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Error in GET /api/notifications:', getErrorMessage(error));
|
||||
return {
|
||||
notifications: [],
|
||||
total: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mark read
|
||||
app.post<{ Params: { id: string } }>('/api/notifications/:id/read', async (request) => {
|
||||
try {
|
||||
const validated = validateNotificationId(request.params.id);
|
||||
if (!validated.valid) {
|
||||
logger.error(`POST notifications/:id/read rejected: ${validated.error ?? 'unknown'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const manager = NotificationManager.getInstance();
|
||||
return await manager.markRead(validated.value!);
|
||||
} catch (error) {
|
||||
logger.error(`Error in POST notifications/${request.params.id}/read:`, error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Mark all read
|
||||
app.post('/api/notifications/read-all', async () => {
|
||||
try {
|
||||
const manager = NotificationManager.getInstance();
|
||||
return await manager.markAllRead();
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/notifications/read-all:', error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Delete notification
|
||||
app.delete<{ Params: { id: string } }>('/api/notifications/:id', async (request) => {
|
||||
try {
|
||||
const validated = validateNotificationId(request.params.id);
|
||||
if (!validated.valid) {
|
||||
logger.error(`DELETE notifications/:id rejected: ${validated.error ?? 'unknown'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const manager = NotificationManager.getInstance();
|
||||
return manager.deleteNotification(validated.value!);
|
||||
} catch (error) {
|
||||
logger.error(`Error in DELETE notifications/${request.params.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear all
|
||||
app.delete('/api/notifications', async () => {
|
||||
try {
|
||||
const manager = NotificationManager.getInstance();
|
||||
return await manager.clearAll();
|
||||
} catch (error) {
|
||||
logger.error('Error in DELETE /api/notifications:', error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Unread count
|
||||
app.get('/api/notifications/unread-count', async () => {
|
||||
try {
|
||||
const manager = NotificationManager.getInstance();
|
||||
return await manager.getUnreadCount();
|
||||
} catch (error) {
|
||||
logger.error('Error in GET /api/notifications/unread-count:', error);
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
55
src/main/http/projects.ts
Normal file
55
src/main/http/projects.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* HTTP route handlers for Project Operations.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /api/projects - List all projects
|
||||
* - GET /api/repository-groups - List projects grouped by git repository
|
||||
* - GET /api/worktrees/:id/sessions - List sessions for a worktree
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { validateProjectId } from '../ipc/guards';
|
||||
|
||||
import type { HttpServices } from './index';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:projects');
|
||||
|
||||
export function registerProjectRoutes(app: FastifyInstance, services: HttpServices): void {
|
||||
app.get('/api/projects', async () => {
|
||||
try {
|
||||
const projects = await services.projectScanner.scan();
|
||||
return projects;
|
||||
} catch (error) {
|
||||
logger.error('Error in GET /api/projects:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/repository-groups', async () => {
|
||||
try {
|
||||
const groups = await services.projectScanner.scanWithWorktreeGrouping();
|
||||
return groups;
|
||||
} catch (error) {
|
||||
logger.error('Error in GET /api/repository-groups:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/worktrees/:id/sessions', async (request) => {
|
||||
try {
|
||||
const validated = validateProjectId(request.params.id);
|
||||
if (!validated.valid) {
|
||||
logger.error(`GET /api/worktrees/:id/sessions rejected: ${validated.error ?? 'unknown'}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessions = await services.projectScanner.listWorktreeSessions(validated.value!);
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
logger.error(`Error in GET /api/worktrees/${request.params.id}/sessions:`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
50
src/main/http/search.ts
Normal file
50
src/main/http/search.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* HTTP route handlers for Search Operations.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /api/projects/:projectId/search - Search sessions in a project
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { coerceSearchMaxResults, validateProjectId, validateSearchQuery } from '../ipc/guards';
|
||||
|
||||
import type { HttpServices } from './index';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:search');
|
||||
|
||||
export function registerSearchRoutes(app: FastifyInstance, services: HttpServices): void {
|
||||
app.get<{
|
||||
Params: { projectId: string };
|
||||
Querystring: { q?: string; maxResults?: string };
|
||||
}>('/api/projects/:projectId/search', async (request) => {
|
||||
const query = request.query.q ?? '';
|
||||
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedQuery = validateSearchQuery(query);
|
||||
if (!validatedProject.valid || !validatedQuery.valid) {
|
||||
logger.error(
|
||||
`GET search rejected: ${validatedProject.error ?? validatedQuery.error ?? 'Invalid inputs'}`
|
||||
);
|
||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
|
||||
const maxResults = coerceSearchMaxResults(
|
||||
request.query.maxResults ? Number(request.query.maxResults) : undefined,
|
||||
50
|
||||
);
|
||||
|
||||
const result = await services.projectScanner.searchSessions(
|
||||
validatedProject.value!,
|
||||
validatedQuery.value!,
|
||||
maxResults
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Error in GET search for ${request.params.projectId}:`, error);
|
||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
||||
}
|
||||
});
|
||||
}
|
||||
279
src/main/http/sessions.ts
Normal file
279
src/main/http/sessions.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* HTTP route handlers for Session Operations.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /api/projects/:projectId/sessions - List sessions
|
||||
* - GET /api/projects/:projectId/sessions-paginated - Paginated sessions
|
||||
* - GET /api/projects/:projectId/sessions/:sessionId - Full session detail
|
||||
* - GET /api/projects/:projectId/sessions/:sessionId/groups - Conversation groups
|
||||
* - GET /api/projects/:projectId/sessions/:sessionId/metrics - Session metrics
|
||||
* - GET /api/projects/:projectId/sessions/:sessionId/waterfall - Waterfall data
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { coercePageLimit, validateProjectId, validateSessionId } from '../ipc/guards';
|
||||
import { DataCache } from '../services';
|
||||
|
||||
import type { SessionsPaginationOptions } from '../types';
|
||||
import type { HttpServices } from './index';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:sessions');
|
||||
|
||||
export function registerSessionRoutes(app: FastifyInstance, services: HttpServices): void {
|
||||
// List sessions
|
||||
app.get<{ Params: { projectId: string } }>(
|
||||
'/api/projects/:projectId/sessions',
|
||||
async (request) => {
|
||||
try {
|
||||
const validated = validateProjectId(request.params.projectId);
|
||||
if (!validated.valid) {
|
||||
logger.error(`GET sessions rejected: ${validated.error ?? 'unknown'}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessions = await services.projectScanner.listSessions(validated.value!);
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
logger.error(`Error in GET sessions for ${request.params.projectId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Paginated sessions
|
||||
app.get<{
|
||||
Params: { projectId: string };
|
||||
Querystring: {
|
||||
cursor?: string;
|
||||
limit?: string;
|
||||
includeTotalCount?: string;
|
||||
prefilterAll?: string;
|
||||
};
|
||||
}>('/api/projects/:projectId/sessions-paginated', async (request) => {
|
||||
try {
|
||||
const validated = validateProjectId(request.params.projectId);
|
||||
if (!validated.valid) {
|
||||
logger.error(`GET sessions-paginated rejected: ${validated.error ?? 'unknown'}`);
|
||||
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
|
||||
}
|
||||
|
||||
const cursor = request.query.cursor || null;
|
||||
const limit = coercePageLimit(
|
||||
request.query.limit ? Number(request.query.limit) : undefined,
|
||||
20
|
||||
);
|
||||
const options: SessionsPaginationOptions = {
|
||||
includeTotalCount: request.query.includeTotalCount !== 'false',
|
||||
prefilterAll: request.query.prefilterAll !== 'false',
|
||||
};
|
||||
|
||||
const result = await services.projectScanner.listSessionsPaginated(
|
||||
validated.value!,
|
||||
cursor,
|
||||
limit,
|
||||
options
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Error in GET sessions-paginated for ${request.params.projectId}:`, error);
|
||||
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
// Session detail
|
||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
||||
'/api/projects/:projectId/sessions/:sessionId',
|
||||
async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
if (!validatedProject.valid || !validatedSession.valid) {
|
||||
logger.error(
|
||||
`GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeProjectId = validatedProject.value!;
|
||||
const safeSessionId = validatedSession.value!;
|
||||
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
|
||||
|
||||
// Check cache first
|
||||
let sessionDetail = services.dataCache.get(cacheKey);
|
||||
if (sessionDetail) {
|
||||
return sessionDetail;
|
||||
}
|
||||
|
||||
// Get session metadata
|
||||
const session = await services.projectScanner.getSession(safeProjectId, safeSessionId);
|
||||
if (!session) {
|
||||
logger.error(`Session not found: ${safeSessionId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse session messages
|
||||
const parsedSession = await services.sessionParser.parseSession(
|
||||
safeProjectId,
|
||||
safeSessionId
|
||||
);
|
||||
|
||||
// Resolve subagents
|
||||
const subagents = await services.subagentResolver.resolveSubagents(
|
||||
safeProjectId,
|
||||
safeSessionId,
|
||||
parsedSession.taskCalls,
|
||||
parsedSession.messages
|
||||
);
|
||||
|
||||
// Build session detail with chunks
|
||||
sessionDetail = services.chunkBuilder.buildSessionDetail(
|
||||
session,
|
||||
parsedSession.messages,
|
||||
subagents
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
services.dataCache.set(cacheKey, sessionDetail);
|
||||
|
||||
return sessionDetail;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Conversation groups
|
||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
||||
'/api/projects/:projectId/sessions/:sessionId/groups',
|
||||
async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
if (!validatedProject.valid || !validatedSession.valid) {
|
||||
logger.error(
|
||||
`GET session-groups rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const safeProjectId = validatedProject.value!;
|
||||
const safeSessionId = validatedSession.value!;
|
||||
|
||||
const parsedSession = await services.sessionParser.parseSession(
|
||||
safeProjectId,
|
||||
safeSessionId
|
||||
);
|
||||
|
||||
const subagents = await services.subagentResolver.resolveSubagents(
|
||||
safeProjectId,
|
||||
safeSessionId,
|
||||
parsedSession.taskCalls,
|
||||
parsedSession.messages
|
||||
);
|
||||
|
||||
const groups = services.chunkBuilder.buildGroups(parsedSession.messages, subagents);
|
||||
return groups;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in GET session-groups for ${request.params.projectId}/${request.params.sessionId}:`,
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Session metrics
|
||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
||||
'/api/projects/:projectId/sessions/:sessionId/metrics',
|
||||
async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
if (!validatedProject.valid || !validatedSession.valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeProjectId = validatedProject.value!;
|
||||
const safeSessionId = validatedSession.value!;
|
||||
|
||||
// Try cache first
|
||||
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
|
||||
const cached = services.dataCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached.metrics;
|
||||
}
|
||||
|
||||
const parsedSession = await services.sessionParser.parseSession(
|
||||
safeProjectId,
|
||||
safeSessionId
|
||||
);
|
||||
return parsedSession.metrics;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in GET session-metrics for ${request.params.projectId}/${request.params.sessionId}:`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Waterfall data
|
||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
||||
'/api/projects/:projectId/sessions/:sessionId/waterfall',
|
||||
async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
if (!validatedProject.valid || !validatedSession.valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeProjectId = validatedProject.value!;
|
||||
const safeSessionId = validatedSession.value!;
|
||||
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
|
||||
|
||||
// Try cache first for session detail
|
||||
let detail = services.dataCache.get(cacheKey);
|
||||
|
||||
if (!detail) {
|
||||
const session = await services.projectScanner.getSession(safeProjectId, safeSessionId);
|
||||
if (!session) return null;
|
||||
|
||||
const parsedSession = await services.sessionParser.parseSession(
|
||||
safeProjectId,
|
||||
safeSessionId
|
||||
);
|
||||
const subagents = await services.subagentResolver.resolveSubagents(
|
||||
safeProjectId,
|
||||
safeSessionId,
|
||||
parsedSession.taskCalls,
|
||||
parsedSession.messages
|
||||
);
|
||||
|
||||
detail = services.chunkBuilder.buildSessionDetail(
|
||||
session,
|
||||
parsedSession.messages,
|
||||
subagents
|
||||
);
|
||||
services.dataCache.set(cacheKey, detail);
|
||||
}
|
||||
|
||||
return services.chunkBuilder.buildWaterfallData(detail.chunks, detail.processes);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in GET waterfall for ${request.params.projectId}/${request.params.sessionId}:`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
133
src/main/http/ssh.ts
Normal file
133
src/main/http/ssh.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* HTTP route handlers for SSH Connection Management.
|
||||
*
|
||||
* Routes:
|
||||
* - POST /api/ssh/connect - Connect to SSH host
|
||||
* - POST /api/ssh/disconnect - Disconnect SSH
|
||||
* - GET /api/ssh/state - Get connection state
|
||||
* - POST /api/ssh/test - Test connection
|
||||
* - GET /api/ssh/config-hosts - Get SSH config hosts
|
||||
* - POST /api/ssh/resolve-host - Resolve host config
|
||||
* - POST /api/ssh/save-last-connection - Save last connection
|
||||
* - GET /api/ssh/last-connection - Get last connection
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { ConfigManager } from '../services';
|
||||
|
||||
import type {
|
||||
SshConnectionConfig,
|
||||
SshConnectionManager,
|
||||
} from '../services/infrastructure/SshConnectionManager';
|
||||
import type { SshLastConnection } from '@shared/types';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:ssh');
|
||||
|
||||
export function registerSshRoutes(
|
||||
app: FastifyInstance,
|
||||
connectionManager: SshConnectionManager,
|
||||
modeSwitchCallback: (mode: 'local' | 'ssh') => Promise<void>
|
||||
): void {
|
||||
const configManager = ConfigManager.getInstance();
|
||||
|
||||
// Connect
|
||||
app.post<{ Body: SshConnectionConfig }>('/api/ssh/connect', async (request) => {
|
||||
try {
|
||||
await connectionManager.connect(request.body);
|
||||
await modeSwitchCallback('ssh');
|
||||
return { success: true, data: connectionManager.getStatus() };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('SSH connect failed:', message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
app.post('/api/ssh/disconnect', async () => {
|
||||
try {
|
||||
connectionManager.disconnect();
|
||||
await modeSwitchCallback('local');
|
||||
return { success: true, data: connectionManager.getStatus() };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('SSH disconnect failed:', message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get state
|
||||
app.get('/api/ssh/state', async () => {
|
||||
return connectionManager.getStatus();
|
||||
});
|
||||
|
||||
// Test connection
|
||||
app.post<{ Body: SshConnectionConfig }>('/api/ssh/test', async (request) => {
|
||||
try {
|
||||
const result = await connectionManager.testConnection(request.body);
|
||||
return { success: true, data: result };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get config hosts
|
||||
app.get('/api/ssh/config-hosts', async () => {
|
||||
try {
|
||||
const hosts = await connectionManager.getConfigHosts();
|
||||
return { success: true, data: hosts };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('Failed to get SSH config hosts:', message);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve host
|
||||
app.post<{ Body: { alias: string } }>('/api/ssh/resolve-host', async (request) => {
|
||||
try {
|
||||
const entry = await connectionManager.resolveHostConfig(request.body.alias);
|
||||
return { success: true, data: entry };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error(`Failed to resolve SSH host "${request.body.alias}":`, message);
|
||||
return { success: true, data: null };
|
||||
}
|
||||
});
|
||||
|
||||
// Save last connection
|
||||
app.post<{ Body: SshLastConnection }>('/api/ssh/save-last-connection', async (request) => {
|
||||
try {
|
||||
const config = request.body;
|
||||
configManager.updateConfig('ssh', {
|
||||
lastConnection: {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
authMethod: config.authMethod,
|
||||
privateKeyPath: config.privateKeyPath,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('Failed to save SSH connection:', message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get last connection
|
||||
app.get('/api/ssh/last-connection', async () => {
|
||||
try {
|
||||
const config = configManager.getConfig();
|
||||
return { success: true, data: config.ssh.lastConnection };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('Failed to get last SSH connection:', message);
|
||||
return { success: true, data: null };
|
||||
}
|
||||
});
|
||||
}
|
||||
77
src/main/http/subagents.ts
Normal file
77
src/main/http/subagents.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* HTTP route handlers for Subagent Operations.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId - Subagent detail
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { validateProjectId, validateSessionId, validateSubagentId } from '../ipc/guards';
|
||||
|
||||
import type { HttpServices } from './index';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:subagents');
|
||||
|
||||
export function registerSubagentRoutes(app: FastifyInstance, services: HttpServices): void {
|
||||
app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>(
|
||||
'/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId',
|
||||
async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
const validatedSubagent = validateSubagentId(request.params.subagentId);
|
||||
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
|
||||
logger.error(
|
||||
`GET subagent-detail rejected: ${
|
||||
validatedProject.error ??
|
||||
validatedSession.error ??
|
||||
validatedSubagent.error ??
|
||||
'Invalid parameters'
|
||||
}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeProjectId = validatedProject.value!;
|
||||
const safeSessionId = validatedSession.value!;
|
||||
const safeSubagentId = validatedSubagent.value!;
|
||||
|
||||
const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
|
||||
|
||||
// Check cache first
|
||||
let subagentDetail = services.dataCache.getSubagent(cacheKey);
|
||||
if (subagentDetail) {
|
||||
return subagentDetail;
|
||||
}
|
||||
|
||||
const fsProvider = services.projectScanner.getFileSystemProvider();
|
||||
const projectsDir = services.projectScanner.getProjectsDir();
|
||||
|
||||
const builtDetail = await services.chunkBuilder.buildSubagentDetail(
|
||||
safeProjectId,
|
||||
safeSessionId,
|
||||
safeSubagentId,
|
||||
services.sessionParser,
|
||||
services.subagentResolver,
|
||||
fsProvider,
|
||||
projectsDir
|
||||
);
|
||||
|
||||
if (!builtDetail) {
|
||||
logger.error(`Subagent not found: ${safeSubagentId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
subagentDetail = builtDetail;
|
||||
services.dataCache.setSubagent(cacheKey, subagentDetail);
|
||||
|
||||
return subagentDetail;
|
||||
} catch (error) {
|
||||
logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
48
src/main/http/updater.ts
Normal file
48
src/main/http/updater.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* HTTP route handlers for Update Operations.
|
||||
*
|
||||
* Routes:
|
||||
* - POST /api/updater/check - Check for updates
|
||||
* - POST /api/updater/download - Download update
|
||||
* - POST /api/updater/install - Install update
|
||||
*/
|
||||
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { HttpServices } from './index';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:updater');
|
||||
|
||||
export function registerUpdaterRoutes(app: FastifyInstance, services: HttpServices): void {
|
||||
app.post('/api/updater/check', async () => {
|
||||
try {
|
||||
await services.updaterService.checkForUpdates();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/updater/check:', getErrorMessage(error));
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/updater/download', async () => {
|
||||
try {
|
||||
await services.updaterService.downloadUpdate();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/updater/download:', getErrorMessage(error));
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/updater/install', async () => {
|
||||
try {
|
||||
services.updaterService.quitAndInstall();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/updater/install:', getErrorMessage(error));
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
126
src/main/http/utility.ts
Normal file
126
src/main/http/utility.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* HTTP route handlers for Utility Operations.
|
||||
*
|
||||
* Routes:
|
||||
* - GET /api/version - App version
|
||||
* - POST /api/read-claude-md - Read CLAUDE.md files
|
||||
* - POST /api/read-directory-claude-md - Read directory CLAUDE.md
|
||||
* - POST /api/read-mentioned-file - Read mentioned file
|
||||
* - POST /api/open-path - No-op in browser
|
||||
* - POST /api/open-external - No-op in browser
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
import { validateFilePath } from '../utils/pathValidation';
|
||||
import { countTokens } from '../utils/tokenizer';
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:utility');
|
||||
|
||||
export function registerUtilityRoutes(app: FastifyInstance): void {
|
||||
// App version
|
||||
app.get('/api/version', async () => {
|
||||
try {
|
||||
// Read version from package.json (works in both Electron and Node)
|
||||
const pkgPath = path.resolve(__dirname, '../../../package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string };
|
||||
return pkg.version;
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
});
|
||||
|
||||
// Read CLAUDE.md files
|
||||
app.post<{ Body: { projectRoot: string } }>('/api/read-claude-md', async (request) => {
|
||||
try {
|
||||
const { projectRoot } = request.body;
|
||||
const result = await readAllClaudeMdFiles(projectRoot);
|
||||
const files: Record<string, ClaudeMdFileInfo> = {};
|
||||
result.files.forEach((info, key) => {
|
||||
files[key] = info;
|
||||
});
|
||||
return files;
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/read-claude-md:', error);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
// Read directory CLAUDE.md
|
||||
app.post<{ Body: { dirPath: string } }>('/api/read-directory-claude-md', async (request) => {
|
||||
try {
|
||||
const { dirPath } = request.body;
|
||||
const info = await readDirectoryClaudeMd(dirPath);
|
||||
return info;
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/read-directory-claude-md:', error);
|
||||
return {
|
||||
path: request.body.dirPath,
|
||||
exists: false,
|
||||
charCount: 0,
|
||||
estimatedTokens: 0,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Read mentioned file
|
||||
app.post<{ Body: { absolutePath: string; projectRoot: string; maxTokens?: number } }>(
|
||||
'/api/read-mentioned-file',
|
||||
async (request) => {
|
||||
try {
|
||||
const { absolutePath, projectRoot, maxTokens = 25000 } = request.body;
|
||||
|
||||
const validation = validateFilePath(absolutePath, projectRoot || null);
|
||||
if (!validation.valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safePath = validation.normalizedPath!;
|
||||
|
||||
if (!fs.existsSync(safePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(safePath);
|
||||
if (!stats.isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(safePath, 'utf8');
|
||||
const estimatedTokens = countTokens(content);
|
||||
|
||||
if (estimatedTokens > maxTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: safePath,
|
||||
exists: true,
|
||||
charCount: content.length,
|
||||
estimatedTokens,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in POST /api/read-mentioned-file for ${request.body.absolutePath}:`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Open path - no-op in browser mode
|
||||
app.post('/api/open-path', async () => {
|
||||
return { success: false, error: 'Not available in browser mode' };
|
||||
});
|
||||
|
||||
// Open external - no-op in browser mode
|
||||
app.post<{ Body: { url: string } }>('/api/open-external', async () => {
|
||||
return { success: false, error: 'Not available in browser mode' };
|
||||
});
|
||||
}
|
||||
98
src/main/http/validation.ts
Normal file
98
src/main/http/validation.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* HTTP route handlers for Validation Operations.
|
||||
*
|
||||
* Routes:
|
||||
* - POST /api/validate/path - Validate file/directory path
|
||||
* - POST /api/validate/mentions - Batch validate path mentions
|
||||
* - POST /api/session/scroll-to-line - Deep link scroll handler
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:validation');
|
||||
|
||||
/**
|
||||
* Checks if a path is contained within a base directory.
|
||||
* Prevents path traversal attacks.
|
||||
*/
|
||||
function isPathContained(fullPath: string, basePath: string): boolean {
|
||||
const normalizedFull = path.normalize(fullPath);
|
||||
const normalizedBase = path.normalize(basePath);
|
||||
return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep);
|
||||
}
|
||||
|
||||
export function registerValidationRoutes(app: FastifyInstance): void {
|
||||
// Validate path
|
||||
app.post<{ Body: { relativePath: string; projectPath: string } }>(
|
||||
'/api/validate/path',
|
||||
async (request) => {
|
||||
try {
|
||||
const { relativePath, projectPath } = request.body;
|
||||
const fullPath = path.join(projectPath, relativePath);
|
||||
|
||||
if (!isPathContained(fullPath, projectPath)) {
|
||||
logger.warn('validate-path blocked path traversal attempt:', relativePath);
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
const stats = fs.statSync(fullPath);
|
||||
return { exists: true, isDirectory: stats.isDirectory() };
|
||||
} catch {
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Validate mentions
|
||||
app.post<{ Body: { mentions: { type: 'path'; value: string }[]; projectPath: string } }>(
|
||||
'/api/validate/mentions',
|
||||
async (request) => {
|
||||
const { mentions, projectPath } = request.body;
|
||||
const results = new Map<string, boolean>();
|
||||
|
||||
for (const mention of mentions) {
|
||||
const fullPath = path.join(projectPath, mention.value);
|
||||
if (!isPathContained(fullPath, projectPath)) {
|
||||
results.set(`@${mention.value}`, false);
|
||||
continue;
|
||||
}
|
||||
results.set(`@${mention.value}`, fs.existsSync(fullPath));
|
||||
}
|
||||
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
);
|
||||
|
||||
// Scroll to line
|
||||
app.post<{ Body: { sessionId: string; lineNumber: number } }>(
|
||||
'/api/session/scroll-to-line',
|
||||
async (request) => {
|
||||
try {
|
||||
const { sessionId, lineNumber } = request.body;
|
||||
|
||||
if (!sessionId) {
|
||||
logger.error('scroll-to-line called with empty sessionId');
|
||||
return { success: false, sessionId: '', lineNumber: 0 };
|
||||
}
|
||||
|
||||
if (typeof lineNumber !== 'number' || lineNumber < 0) {
|
||||
logger.error('scroll-to-line called with invalid lineNumber');
|
||||
return { success: false, sessionId, lineNumber: 0 };
|
||||
}
|
||||
|
||||
return { success: true, sessionId, lineNumber };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/session/scroll-to-line:', error);
|
||||
return { success: false, sessionId: '', lineNumber: 0 };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
||||
} from '@shared/constants';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import { join } from 'path';
|
||||
|
||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||
|
|
@ -32,8 +32,13 @@ const getIconPath = (): string => {
|
|||
};
|
||||
|
||||
const logger = createLogger('App');
|
||||
import { SSH_STATUS } from '@preload/constants/ipcChannels';
|
||||
// IPC channel constants (duplicated from @preload to avoid boundary violation)
|
||||
const SSH_STATUS = 'ssh:status';
|
||||
const HTTP_SERVER_START = 'httpServer:start';
|
||||
const HTTP_SERVER_STOP = 'httpServer:stop';
|
||||
const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
|
||||
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import {
|
||||
configManager,
|
||||
LocalFileSystemProvider,
|
||||
|
|
@ -55,13 +60,14 @@ let contextRegistry: ServiceContextRegistry;
|
|||
let notificationManager: NotificationManager;
|
||||
let updaterService: UpdaterService;
|
||||
let sshConnectionManager: SshConnectionManager;
|
||||
let httpServer: HttpServer;
|
||||
|
||||
// File watcher event cleanup functions
|
||||
let fileChangeCleanup: (() => void) | null = null;
|
||||
let todoChangeCleanup: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Wires file watcher events from a ServiceContext to the renderer.
|
||||
* Wires file watcher events from a ServiceContext to the renderer and HTTP SSE clients.
|
||||
* Cleans up previous listeners before adding new ones.
|
||||
*/
|
||||
function wireFileWatcherEvents(context: ServiceContext): void {
|
||||
|
|
@ -77,20 +83,22 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
todoChangeCleanup = null;
|
||||
}
|
||||
|
||||
// Wire file-change events
|
||||
// Wire file-change events to renderer and HTTP SSE
|
||||
const fileChangeHandler = (event: unknown) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('file-change', event);
|
||||
}
|
||||
httpServer?.broadcast('file-change', event);
|
||||
};
|
||||
context.fileWatcher.on('file-change', fileChangeHandler);
|
||||
fileChangeCleanup = () => context.fileWatcher.off('file-change', fileChangeHandler);
|
||||
|
||||
// Wire todo-change events
|
||||
// Wire todo-change events to renderer and HTTP SSE
|
||||
const todoChangeHandler = (event: unknown) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('todo-change', event);
|
||||
}
|
||||
httpServer?.broadcast('todo-change', event);
|
||||
};
|
||||
context.fileWatcher.on('todo-change', todoChangeHandler);
|
||||
todoChangeCleanup = () => context.fileWatcher.off('todo-change', todoChangeHandler);
|
||||
|
|
@ -98,6 +106,17 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
logger.info(`FileWatcher events wired for context: ${context.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mode switch requests from the HTTP server.
|
||||
* Switches the active context back to local when requested.
|
||||
*/
|
||||
async function handleModeSwitch(mode: 'local' | 'ssh'): Promise<void> {
|
||||
if (mode === 'local' && contextRegistry.getActiveContextId() !== 'local') {
|
||||
const { current } = contextRegistry.switch('local');
|
||||
onContextSwitched(current);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when context switches (called by SSH IPC handler).
|
||||
* Re-wires file watcher events and notifies renderer.
|
||||
|
|
@ -152,26 +171,116 @@ function initializeServices(): void {
|
|||
|
||||
// Initialize updater service
|
||||
updaterService = new UpdaterService();
|
||||
httpServer = new HttpServer();
|
||||
|
||||
// Initialize IPC handlers with registry
|
||||
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, onContextSwitched);
|
||||
|
||||
// Forward SSH state changes to renderer
|
||||
// HTTP Server control IPC handlers
|
||||
ipcMain.handle(HTTP_SERVER_START, async () => {
|
||||
try {
|
||||
if (httpServer.isRunning()) {
|
||||
return { success: true, data: { running: true, port: httpServer.getPort() } };
|
||||
}
|
||||
await startHttpServer(handleModeSwitch);
|
||||
// Persist the enabled state
|
||||
configManager.updateConfig('httpServer', { enabled: true, port: httpServer.getPort() });
|
||||
return { success: true, data: { running: true, port: httpServer.getPort() } };
|
||||
} catch (error) {
|
||||
logger.error('Failed to start HTTP server via IPC:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to start server',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(HTTP_SERVER_STOP, async () => {
|
||||
try {
|
||||
await httpServer.stop();
|
||||
// Persist the disabled state
|
||||
configManager.updateConfig('httpServer', { enabled: false });
|
||||
return { success: true, data: { running: false, port: httpServer.getPort() } };
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop HTTP server via IPC:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to stop server',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(HTTP_SERVER_GET_STATUS, () => {
|
||||
return { running: httpServer.isRunning(), port: httpServer.getPort() };
|
||||
});
|
||||
|
||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||
sshConnectionManager.on('state-change', (status: unknown) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(SSH_STATUS, status);
|
||||
}
|
||||
httpServer.broadcast('ssh:status', status);
|
||||
});
|
||||
|
||||
// Forward notification events to HTTP SSE clients
|
||||
notificationManager.on('notification-new', (notification: unknown) => {
|
||||
httpServer.broadcast('notification:new', notification);
|
||||
});
|
||||
notificationManager.on('notification-updated', (data: unknown) => {
|
||||
httpServer.broadcast('notification:updated', data);
|
||||
});
|
||||
notificationManager.on('notification-clicked', (data: unknown) => {
|
||||
httpServer.broadcast('notification:clicked', data);
|
||||
});
|
||||
|
||||
// Start HTTP server if enabled in config
|
||||
const appConfig = configManager.getConfig();
|
||||
if (appConfig.httpServer?.enabled) {
|
||||
void startHttpServer(handleModeSwitch);
|
||||
}
|
||||
|
||||
logger.info('Services initialized successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the HTTP sidecar server with services from the active context.
|
||||
*/
|
||||
async function startHttpServer(
|
||||
modeSwitchHandler: (mode: 'local' | 'ssh') => Promise<void>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = configManager.getConfig();
|
||||
const activeContext = contextRegistry.getActive();
|
||||
const port = await httpServer.start(
|
||||
{
|
||||
projectScanner: activeContext.projectScanner,
|
||||
sessionParser: activeContext.sessionParser,
|
||||
subagentResolver: activeContext.subagentResolver,
|
||||
chunkBuilder: activeContext.chunkBuilder,
|
||||
dataCache: activeContext.dataCache,
|
||||
updaterService,
|
||||
sshConnectionManager,
|
||||
},
|
||||
modeSwitchHandler,
|
||||
config.httpServer?.port ?? 3456
|
||||
);
|
||||
logger.info(`HTTP sidecar server running on port ${port}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start HTTP server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts down all services.
|
||||
*/
|
||||
function shutdownServices(): void {
|
||||
logger.info('Shutting down services...');
|
||||
|
||||
// Stop HTTP server
|
||||
if (httpServer?.isRunning()) {
|
||||
void httpServer.stop();
|
||||
}
|
||||
|
||||
// Clean up file watcher event listeners
|
||||
if (fileChangeCleanup) {
|
||||
fileChangeCleanup();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
AppConfig,
|
||||
DisplayConfig,
|
||||
GeneralConfig,
|
||||
HttpServerConfig,
|
||||
NotificationConfig,
|
||||
NotificationTrigger,
|
||||
} from '../services';
|
||||
|
|
@ -28,9 +29,15 @@ export type ConfigUpdateValidationResult =
|
|||
| ValidationSuccess<'notifications'>
|
||||
| ValidationSuccess<'general'>
|
||||
| ValidationSuccess<'display'>
|
||||
| ValidationSuccess<'httpServer'>
|
||||
| ValidationFailure;
|
||||
|
||||
const VALID_SECTIONS = new Set<ConfigSection>(['notifications', 'general', 'display']);
|
||||
const VALID_SECTIONS = new Set<ConfigSection>([
|
||||
'notifications',
|
||||
'general',
|
||||
'display',
|
||||
'httpServer',
|
||||
]);
|
||||
const MAX_SNOOZE_MINUTES = 24 * 60;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
|
|
@ -271,12 +278,58 @@ function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | V
|
|||
};
|
||||
}
|
||||
|
||||
function validateHttpServerSection(
|
||||
data: unknown
|
||||
): ValidationSuccess<'httpServer'> | ValidationFailure {
|
||||
if (!isPlainObject(data)) {
|
||||
return { valid: false, error: 'httpServer update must be an object' };
|
||||
}
|
||||
|
||||
const allowedKeys: (keyof HttpServerConfig)[] = ['enabled', 'port'];
|
||||
const result: Partial<HttpServerConfig> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (!allowedKeys.includes(key as keyof HttpServerConfig)) {
|
||||
return { valid: false, error: `httpServer.${key} is not a valid setting` };
|
||||
}
|
||||
|
||||
switch (key as keyof HttpServerConfig) {
|
||||
case 'enabled':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: 'httpServer.enabled must be a boolean' };
|
||||
}
|
||||
result.enabled = value;
|
||||
break;
|
||||
case 'port':
|
||||
if (!isFiniteNumber(value) || !Number.isInteger(value) || value < 1024 || value > 65535) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'httpServer.port must be an integer between 1024 and 65535',
|
||||
};
|
||||
}
|
||||
result.port = value;
|
||||
break;
|
||||
default:
|
||||
return { valid: false, error: `Unsupported httpServer key: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
section: 'httpServer',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateConfigUpdatePayload(
|
||||
section: unknown,
|
||||
data: unknown
|
||||
): ConfigUpdateValidationResult {
|
||||
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
|
||||
return { valid: false, error: 'Section must be one of: notifications, general, display' };
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Section must be one of: notifications, general, display, httpServer',
|
||||
};
|
||||
}
|
||||
|
||||
switch (section as ConfigSection) {
|
||||
|
|
@ -286,6 +339,8 @@ export function validateConfigUpdatePayload(
|
|||
return validateGeneralSection(data);
|
||||
case 'display':
|
||||
return validateDisplaySection(data);
|
||||
case 'httpServer':
|
||||
return validateHttpServerSection(data);
|
||||
default:
|
||||
return { valid: false, error: 'Invalid section' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,12 +204,18 @@ export interface SshPersistConfig {
|
|||
lastActiveContextId: string;
|
||||
}
|
||||
|
||||
export interface HttpServerConfig {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
notifications: NotificationConfig;
|
||||
general: GeneralConfig;
|
||||
display: DisplayConfig;
|
||||
sessions: SessionsConfig;
|
||||
ssh: SshPersistConfig;
|
||||
httpServer: HttpServerConfig;
|
||||
}
|
||||
|
||||
// Config section keys for type-safe updates
|
||||
|
|
@ -253,6 +259,10 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
profiles: [],
|
||||
lastActiveContextId: 'local',
|
||||
},
|
||||
httpServer: {
|
||||
enabled: false,
|
||||
port: 3456,
|
||||
},
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -377,6 +387,10 @@ export class ConfigManager {
|
|||
...DEFAULT_CONFIG.ssh,
|
||||
...(loaded.ssh ?? {}),
|
||||
},
|
||||
httpServer: {
|
||||
...DEFAULT_CONFIG.httpServer,
|
||||
...(loaded.httpServer ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
134
src/main/services/infrastructure/HttpServer.ts
Normal file
134
src/main/services/infrastructure/HttpServer.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* HttpServer - Fastify-based HTTP server for serving the renderer UI and API routes.
|
||||
*
|
||||
* Binds to 127.0.0.1 only for localhost security.
|
||||
* Dynamically allocates a port starting from 3456.
|
||||
* In production, serves static files from the renderer output directory.
|
||||
* In development, Vite dev server handles static files.
|
||||
*/
|
||||
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { type HttpServices, registerHttpRoutes } from '@main/http';
|
||||
import { broadcastEvent } from '@main/http/events';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { join } from 'path';
|
||||
|
||||
const logger = createLogger('Service:HttpServer');
|
||||
|
||||
export class HttpServer {
|
||||
private app: FastifyInstance | null = null;
|
||||
private port: number = 3456;
|
||||
private running: boolean = false;
|
||||
|
||||
/**
|
||||
* Start the HTTP server.
|
||||
* @param services - Service instances to pass to route handlers
|
||||
* @param sshModeSwitchCallback - Callback for SSH mode switching
|
||||
* @param preferredPort - Port to try first (default 3456)
|
||||
*/
|
||||
async start(
|
||||
services: HttpServices,
|
||||
sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise<void>,
|
||||
preferredPort: number = 3456
|
||||
): Promise<number> {
|
||||
this.app = Fastify({ logger: false });
|
||||
|
||||
// Register CORS - allow all localhost origins
|
||||
const localhostPattern = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
|
||||
await this.app.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
// Allow requests with no origin (same-origin, curl, etc.)
|
||||
if (!origin) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
// Allow any localhost origin
|
||||
if (localhostPattern.test(origin)) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
cb(new Error('Not allowed by CORS'), false);
|
||||
},
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Register static file serving (production only)
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (!isDev) {
|
||||
const rendererPath = join(__dirname, '../../renderer');
|
||||
await this.app.register(fastifyStatic, {
|
||||
root: rendererPath,
|
||||
prefix: '/',
|
||||
// Don't serve index.html for API routes
|
||||
wildcard: false,
|
||||
});
|
||||
|
||||
// Serve index.html for all non-API routes (SPA fallback)
|
||||
this.app.setNotFoundHandler(async (request, reply) => {
|
||||
if (request.url.startsWith('/api/')) {
|
||||
return reply.status(404).send({ error: 'Not found' });
|
||||
}
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
}
|
||||
|
||||
// Register all API routes
|
||||
registerHttpRoutes(this.app, services, sshModeSwitchCallback);
|
||||
|
||||
// Try ports starting from preferredPort
|
||||
for (let attempt = 0; attempt <= 10; attempt++) {
|
||||
const tryPort = preferredPort + attempt;
|
||||
try {
|
||||
await this.app.listen({ host: '127.0.0.1', port: tryPort });
|
||||
this.port = tryPort;
|
||||
this.running = true;
|
||||
logger.info(`HTTP server started on http://127.0.0.1:${tryPort}`);
|
||||
return tryPort;
|
||||
} catch (err: unknown) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
logger.info(`Port ${tryPort} in use, trying next...`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not find available port (tried ${preferredPort}-${preferredPort + 10})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server gracefully.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.app && this.running) {
|
||||
await this.app.close();
|
||||
this.running = false;
|
||||
this.app = null;
|
||||
logger.info('HTTP server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all connected SSE clients.
|
||||
*/
|
||||
broadcast(channel: string, data: unknown): void {
|
||||
broadcastEvent(channel, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current port the server is running on.
|
||||
*/
|
||||
getPort(): number {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server is currently running.
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,12 +13,14 @@
|
|||
* - SshConnectionManager: SSH connection lifecycle
|
||||
* - ServiceContext: Service bundle for a single workspace context
|
||||
* - ServiceContextRegistry: Registry coordinator for all contexts
|
||||
* - HttpServer: Fastify-based HTTP server for API and static file serving
|
||||
*/
|
||||
|
||||
export * from './ConfigManager';
|
||||
export * from './DataCache';
|
||||
export type * from './FileSystemProvider';
|
||||
export * from './FileWatcher';
|
||||
export * from './HttpServer';
|
||||
export * from './LocalFileSystemProvider';
|
||||
export * from './NotificationManager';
|
||||
export * from './ServiceContext';
|
||||
|
|
|
|||
|
|
@ -121,3 +121,16 @@ export const CONTEXT_SWITCH = 'context:switch';
|
|||
|
||||
/** Context changed event channel (main -> renderer) */
|
||||
export const CONTEXT_CHANGED = 'context:changed';
|
||||
|
||||
// =============================================================================
|
||||
// HTTP Server API Channels
|
||||
// =============================================================================
|
||||
|
||||
/** Start HTTP sidecar server */
|
||||
export const HTTP_SERVER_START = 'httpServer:start';
|
||||
|
||||
/** Stop HTTP sidecar server */
|
||||
export const HTTP_SERVER_STOP = 'httpServer:stop';
|
||||
|
||||
/** Get HTTP server status */
|
||||
export const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import {
|
|||
CONTEXT_GET_ACTIVE,
|
||||
CONTEXT_LIST,
|
||||
CONTEXT_SWITCH,
|
||||
HTTP_SERVER_GET_STATUS,
|
||||
HTTP_SERVER_START,
|
||||
HTTP_SERVER_STOP,
|
||||
SSH_CONNECT,
|
||||
SSH_DISCONNECT,
|
||||
SSH_GET_CONFIG_HOSTS,
|
||||
|
|
@ -44,6 +47,7 @@ import type {
|
|||
AppConfig,
|
||||
ContextInfo,
|
||||
ElectronAPI,
|
||||
HttpServerStatus,
|
||||
NotificationTrigger,
|
||||
SessionsPaginationOptions,
|
||||
SshConfigHostEntry,
|
||||
|
|
@ -393,6 +397,19 @@ const electronAPI: ElectronAPI = {
|
|||
};
|
||||
},
|
||||
},
|
||||
|
||||
// HTTP Server API
|
||||
httpServer: {
|
||||
start: async (): Promise<HttpServerStatus> => {
|
||||
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_START);
|
||||
},
|
||||
stop: async (): Promise<HttpServerStatus> => {
|
||||
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_STOP);
|
||||
},
|
||||
getStatus: async (): Promise<HttpServerStatus> => {
|
||||
return ipcRenderer.invoke(HTTP_SERVER_GET_STATUS);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Use contextBridge to securely expose the API to the renderer process
|
||||
|
|
|
|||
535
src/renderer/api/httpClient.ts
Normal file
535
src/renderer/api/httpClient.ts
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
/**
|
||||
* HTTP-based implementation of ElectronAPI for browser mode.
|
||||
*
|
||||
* Replaces Electron IPC with fetch() for request/response and
|
||||
* EventSource (SSE) for real-time events. Allows the renderer
|
||||
* to run in a regular browser connected to an HTTP server.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AppConfig,
|
||||
ClaudeMdFileInfo,
|
||||
ConfigAPI,
|
||||
ContextInfo,
|
||||
ConversationGroup,
|
||||
ElectronAPI,
|
||||
FileChangeEvent,
|
||||
HttpServerAPI,
|
||||
HttpServerStatus,
|
||||
NotificationsAPI,
|
||||
NotificationTrigger,
|
||||
PaginatedSessionsResult,
|
||||
Project,
|
||||
RepositoryGroup,
|
||||
SearchSessionsResult,
|
||||
Session,
|
||||
SessionAPI,
|
||||
SessionDetail,
|
||||
SessionMetrics,
|
||||
SessionsPaginationOptions,
|
||||
SshAPI,
|
||||
SshConfigHostEntry,
|
||||
SshConnectionConfig,
|
||||
SshConnectionStatus,
|
||||
SshLastConnection,
|
||||
SubagentDetail,
|
||||
TriggerTestResult,
|
||||
UpdaterAPI,
|
||||
WaterfallData,
|
||||
} from '@shared/types';
|
||||
|
||||
export class HttpAPIClient implements ElectronAPI {
|
||||
private baseUrl: string;
|
||||
private eventSource: EventSource | null = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures
|
||||
private eventListeners = new Map<string, Set<(...args: any[]) => void>>();
|
||||
|
||||
constructor(port: number) {
|
||||
this.baseUrl = `http://127.0.0.1:${port}`;
|
||||
this.initEventSource();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE event infrastructure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private initEventSource(): void {
|
||||
this.eventSource = new EventSource(`${this.baseUrl}/api/events`);
|
||||
this.eventSource.onopen = () => console.log('[HttpAPIClient] SSE connected');
|
||||
this.eventSource.onerror = () => {
|
||||
// Auto-reconnect is built into EventSource
|
||||
console.warn('[HttpAPIClient] SSE connection error, will reconnect...');
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures
|
||||
private addEventListener(channel: string, callback: (...args: any[]) => void): () => void {
|
||||
if (!this.eventListeners.has(channel)) {
|
||||
this.eventListeners.set(channel, new Set());
|
||||
// Register SSE listener for this channel once
|
||||
this.eventSource?.addEventListener(channel, ((event: MessageEvent) => {
|
||||
const data: unknown = JSON.parse(event.data as string);
|
||||
const listeners = this.eventListeners.get(channel);
|
||||
listeners?.forEach((cb) => cb(data));
|
||||
}) as EventListener);
|
||||
}
|
||||
this.eventListeners.get(channel)!.add(callback);
|
||||
|
||||
return () => {
|
||||
this.eventListeners.get(channel)?.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* JSON reviver that converts ISO 8601 date strings back to Date objects.
|
||||
* Electron IPC preserves Date instances via structured clone, but HTTP JSON
|
||||
* serialization turns them into strings. This restores them so that
|
||||
* `.getTime()` and other Date methods work in the renderer.
|
||||
*/
|
||||
private static readonly ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
|
||||
|
||||
private static reviveDates(_key: string, value: unknown): unknown {
|
||||
if (typeof value === 'string' && HttpAPIClient.ISO_DATE_RE.test(value)) {
|
||||
const d = new Date(value);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private async parseJson<T>(res: Response): Promise<T> {
|
||||
const text = await res.text();
|
||||
return JSON.parse(text, HttpAPIClient.reviveDates) as T;
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, { signal: controller.signal });
|
||||
return this.parseJson<T>(res);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return this.parseJson<T>(res);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private async del<T>(path: string, body?: unknown): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return this.parseJson<T>(res);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private async put<T>(path: string, body?: unknown): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return this.parseJson<T>(res);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core session/project APIs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getAppVersion = (): Promise<string> => this.get<string>('/api/version');
|
||||
|
||||
getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');
|
||||
|
||||
getSessions = (projectId: string): Promise<Session[]> =>
|
||||
this.get<Session[]>(`/api/projects/${encodeURIComponent(projectId)}/sessions`);
|
||||
|
||||
getSessionsPaginated = (
|
||||
projectId: string,
|
||||
cursor: string | null,
|
||||
limit?: number,
|
||||
options?: SessionsPaginationOptions
|
||||
): Promise<PaginatedSessionsResult> => {
|
||||
const params = new URLSearchParams();
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
if (limit) params.set('limit', String(limit));
|
||||
if (options?.includeTotalCount === false) params.set('includeTotalCount', 'false');
|
||||
if (options?.prefilterAll === false) params.set('prefilterAll', 'false');
|
||||
const qs = params.toString();
|
||||
const encodedId = encodeURIComponent(projectId);
|
||||
const path = `/api/projects/${encodedId}/sessions-paginated`;
|
||||
return this.get<PaginatedSessionsResult>(qs ? `${path}?${qs}` : path);
|
||||
};
|
||||
|
||||
searchSessions = (
|
||||
projectId: string,
|
||||
query: string,
|
||||
maxResults?: number
|
||||
): Promise<SearchSessionsResult> => {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (maxResults) params.set('maxResults', String(maxResults));
|
||||
return this.get<SearchSessionsResult>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/search?${params}`
|
||||
);
|
||||
};
|
||||
|
||||
getSessionDetail = (projectId: string, sessionId: string): Promise<SessionDetail | null> =>
|
||||
this.get<SessionDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
|
||||
);
|
||||
|
||||
getSessionMetrics = (projectId: string, sessionId: string): Promise<SessionMetrics | null> =>
|
||||
this.get<SessionMetrics | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/metrics`
|
||||
);
|
||||
|
||||
getWaterfallData = (projectId: string, sessionId: string): Promise<WaterfallData | null> =>
|
||||
this.get<WaterfallData | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/waterfall`
|
||||
);
|
||||
|
||||
getSubagentDetail = (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
subagentId: string
|
||||
): Promise<SubagentDetail | null> =>
|
||||
this.get<SubagentDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`
|
||||
);
|
||||
|
||||
getSessionGroups = (projectId: string, sessionId: string): Promise<ConversationGroup[]> =>
|
||||
this.get<ConversationGroup[]>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups`
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository grouping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getRepositoryGroups = (): Promise<RepositoryGroup[]> =>
|
||||
this.get<RepositoryGroup[]>('/api/repository-groups');
|
||||
|
||||
getWorktreeSessions = (worktreeId: string): Promise<Session[]> =>
|
||||
this.get<Session[]>(`/api/worktrees/${encodeURIComponent(worktreeId)}/sessions`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
validatePath = (
|
||||
relativePath: string,
|
||||
projectPath: string
|
||||
): Promise<{ exists: boolean; isDirectory?: boolean }> =>
|
||||
this.post<{ exists: boolean; isDirectory?: boolean }>('/api/validate/path', {
|
||||
relativePath,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
validateMentions = (
|
||||
mentions: { type: 'path'; value: string }[],
|
||||
projectPath: string
|
||||
): Promise<Record<string, boolean>> =>
|
||||
this.post<Record<string, boolean>>('/api/validate/mentions', { mentions, projectPath });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLAUDE.md reading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
readClaudeMdFiles = (projectRoot: string): Promise<Record<string, ClaudeMdFileInfo>> =>
|
||||
this.post<Record<string, ClaudeMdFileInfo>>('/api/read-claude-md', { projectRoot });
|
||||
|
||||
readDirectoryClaudeMd = (dirPath: string): Promise<ClaudeMdFileInfo> =>
|
||||
this.post<ClaudeMdFileInfo>('/api/read-directory-claude-md', { dirPath });
|
||||
|
||||
readMentionedFile = (
|
||||
absolutePath: string,
|
||||
projectRoot: string,
|
||||
maxTokens?: number
|
||||
): Promise<ClaudeMdFileInfo | null> =>
|
||||
this.post<ClaudeMdFileInfo | null>('/api/read-mentioned-file', {
|
||||
absolutePath,
|
||||
projectRoot,
|
||||
maxTokens,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications (nested API)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
notifications: NotificationsAPI = {
|
||||
get: (options) =>
|
||||
this.get(
|
||||
`/api/notifications?${new URLSearchParams(
|
||||
options
|
||||
? {
|
||||
limit: String(options.limit ?? 20),
|
||||
offset: String(options.offset ?? 0),
|
||||
}
|
||||
: {}
|
||||
)}`
|
||||
),
|
||||
markRead: (id) => this.post(`/api/notifications/${encodeURIComponent(id)}/read`),
|
||||
markAllRead: () => this.post('/api/notifications/read-all'),
|
||||
delete: (id) => this.del(`/api/notifications/${encodeURIComponent(id)}`),
|
||||
clear: () => this.del('/api/notifications'),
|
||||
getUnreadCount: () => this.get('/api/notifications/unread-count'),
|
||||
// IPC signature: (event: unknown, error: unknown) => void
|
||||
onNew: (callback) =>
|
||||
this.addEventListener('notification:new', (data: unknown) => callback(null, data)),
|
||||
// IPC signature: (event: unknown, payload: { total; unreadCount }) => void
|
||||
onUpdated: (callback) =>
|
||||
this.addEventListener('notification:updated', (data: unknown) =>
|
||||
callback(null, data as { total: number; unreadCount: number })
|
||||
),
|
||||
// IPC signature: (event: unknown, data: unknown) => void
|
||||
onClicked: (callback) =>
|
||||
this.addEventListener('notification:clicked', (data: unknown) => callback(null, data)),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config (nested API)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
config: ConfigAPI = {
|
||||
get: async (): Promise<AppConfig> => {
|
||||
const result = await this.get<{ success: boolean; data?: AppConfig; error?: string }>(
|
||||
'/api/config'
|
||||
);
|
||||
if (!result.success) throw new Error(result.error ?? 'Failed to get config');
|
||||
return result.data!;
|
||||
},
|
||||
update: async (section: string, data: object): Promise<AppConfig> => {
|
||||
const result = await this.post<{ success: boolean; data?: AppConfig; error?: string }>(
|
||||
'/api/config/update',
|
||||
{ section, data }
|
||||
);
|
||||
if (!result.success) throw new Error(result.error ?? 'Failed to update config');
|
||||
return result.data!;
|
||||
},
|
||||
addIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
|
||||
await this.post('/api/config/ignore-regex', { pattern });
|
||||
return this.config.get();
|
||||
},
|
||||
removeIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
|
||||
await this.del('/api/config/ignore-regex', { pattern });
|
||||
return this.config.get();
|
||||
},
|
||||
addIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
|
||||
await this.post('/api/config/ignore-repository', { repositoryId });
|
||||
return this.config.get();
|
||||
},
|
||||
removeIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
|
||||
await this.del('/api/config/ignore-repository', { repositoryId });
|
||||
return this.config.get();
|
||||
},
|
||||
snooze: async (minutes: number): Promise<AppConfig> => {
|
||||
await this.post('/api/config/snooze', { minutes });
|
||||
return this.config.get();
|
||||
},
|
||||
clearSnooze: async (): Promise<AppConfig> => {
|
||||
await this.post('/api/config/clear-snooze');
|
||||
return this.config.get();
|
||||
},
|
||||
addTrigger: async (trigger): Promise<AppConfig> => {
|
||||
await this.post('/api/config/triggers', trigger);
|
||||
return this.config.get();
|
||||
},
|
||||
updateTrigger: async (triggerId: string, updates): Promise<AppConfig> => {
|
||||
await this.put(`/api/config/triggers/${encodeURIComponent(triggerId)}`, updates);
|
||||
return this.config.get();
|
||||
},
|
||||
removeTrigger: async (triggerId: string): Promise<AppConfig> => {
|
||||
await this.del(`/api/config/triggers/${encodeURIComponent(triggerId)}`);
|
||||
return this.config.get();
|
||||
},
|
||||
getTriggers: async (): Promise<NotificationTrigger[]> => {
|
||||
const result = await this.get<{ success: boolean; data?: NotificationTrigger[] }>(
|
||||
'/api/config/triggers'
|
||||
);
|
||||
return result.data ?? [];
|
||||
},
|
||||
testTrigger: async (trigger: NotificationTrigger): Promise<TriggerTestResult> => {
|
||||
const result = await this.post<{
|
||||
success: boolean;
|
||||
data?: TriggerTestResult;
|
||||
error?: string;
|
||||
}>(`/api/config/triggers/${encodeURIComponent(trigger.id)}/test`, trigger);
|
||||
if (!result.success) throw new Error(result.error ?? 'Failed to test trigger');
|
||||
return result.data!;
|
||||
},
|
||||
selectFolders: async (): Promise<string[]> => {
|
||||
console.warn('[HttpAPIClient] selectFolders is not available in browser mode');
|
||||
return [];
|
||||
},
|
||||
openInEditor: async (): Promise<void> => {
|
||||
console.warn('[HttpAPIClient] openInEditor is not available in browser mode');
|
||||
},
|
||||
pinSession: (projectId: string, sessionId: string): Promise<void> =>
|
||||
this.post('/api/config/pin-session', { projectId, sessionId }),
|
||||
unpinSession: (projectId: string, sessionId: string): Promise<void> =>
|
||||
this.post('/api/config/unpin-session', { projectId, sessionId }),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
session: SessionAPI = {
|
||||
scrollToLine: (sessionId: string, lineNumber: number): Promise<void> =>
|
||||
this.post('/api/session/scroll-to-line', { sessionId, lineNumber }),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zoom (browser fallbacks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getZoomFactor = async (): Promise<number> => 1.0;
|
||||
|
||||
onZoomFactorChanged = (_callback: (zoomFactor: number) => void): (() => void) => {
|
||||
// No-op in browser mode — zoom is managed by the browser itself
|
||||
return () => {};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File change events (via SSE)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onFileChange = (callback: (event: FileChangeEvent) => void): (() => void) =>
|
||||
this.addEventListener('file-change', callback);
|
||||
|
||||
onTodoChange = (callback: (event: FileChangeEvent) => void): (() => void) =>
|
||||
this.addEventListener('todo-change', callback);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell operations (browser fallbacks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
openPath = async (
|
||||
_targetPath: string,
|
||||
_projectRoot?: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
console.warn('[HttpAPIClient] openPath is not available in browser mode');
|
||||
return { success: false, error: 'Not available in browser mode' };
|
||||
};
|
||||
|
||||
openExternal = async (url: string): Promise<{ success: boolean; error?: string }> => {
|
||||
window.open(url, '_blank');
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Updater (browser no-ops)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
updater: UpdaterAPI = {
|
||||
check: async (): Promise<void> => {
|
||||
console.warn('[HttpAPIClient] updater not available in browser mode');
|
||||
},
|
||||
download: async (): Promise<void> => {
|
||||
console.warn('[HttpAPIClient] updater not available in browser mode');
|
||||
},
|
||||
install: async (): Promise<void> => {
|
||||
console.warn('[HttpAPIClient] updater not available in browser mode');
|
||||
},
|
||||
onStatus: (_callback): (() => void) => {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSH
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ssh: SshAPI = {
|
||||
connect: (config: SshConnectionConfig): Promise<SshConnectionStatus> =>
|
||||
this.post('/api/ssh/connect', config),
|
||||
disconnect: (): Promise<SshConnectionStatus> => this.post('/api/ssh/disconnect'),
|
||||
getState: (): Promise<SshConnectionStatus> => this.get('/api/ssh/state'),
|
||||
test: (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post('/api/ssh/test', config),
|
||||
getConfigHosts: async (): Promise<SshConfigHostEntry[]> => {
|
||||
const result = await this.get<{ success: boolean; data?: SshConfigHostEntry[] }>(
|
||||
'/api/ssh/config-hosts'
|
||||
);
|
||||
return result.data ?? [];
|
||||
},
|
||||
resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
|
||||
const result = await this.post<{
|
||||
success: boolean;
|
||||
data?: SshConfigHostEntry | null;
|
||||
}>('/api/ssh/resolve-host', { alias });
|
||||
return result.data ?? null;
|
||||
},
|
||||
saveLastConnection: (config: SshLastConnection): Promise<void> =>
|
||||
this.post('/api/ssh/save-last-connection', config),
|
||||
getLastConnection: async (): Promise<SshLastConnection | null> => {
|
||||
const result = await this.get<{ success: boolean; data?: SshLastConnection | null }>(
|
||||
'/api/ssh/last-connection'
|
||||
);
|
||||
return result.data ?? null;
|
||||
},
|
||||
// IPC signature: (event: unknown, status: SshConnectionStatus) => void
|
||||
onStatus: (callback): (() => void) =>
|
||||
this.addEventListener('ssh:status', (data: unknown) =>
|
||||
callback(null, data as SshConnectionStatus)
|
||||
),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
context = {
|
||||
list: (): Promise<ContextInfo[]> => this.get<ContextInfo[]>('/api/contexts'),
|
||||
getActive: (): Promise<string> => this.get<string>('/api/contexts/active'),
|
||||
switch: (contextId: string): Promise<{ contextId: string }> =>
|
||||
this.post<{ contextId: string }>('/api/contexts/switch', { contextId }),
|
||||
onChanged: (callback: (event: unknown, data: ContextInfo) => void): (() => void) =>
|
||||
this.addEventListener('context:changed', (data: unknown) =>
|
||||
callback(null, data as ContextInfo)
|
||||
),
|
||||
};
|
||||
|
||||
// HTTP Server API — in browser mode, server is already running (we're using it)
|
||||
httpServer: HttpServerAPI = {
|
||||
start: (): Promise<HttpServerStatus> =>
|
||||
Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }),
|
||||
stop: (): Promise<HttpServerStatus> => {
|
||||
console.warn('[HttpAPIClient] Cannot stop HTTP server from browser mode');
|
||||
return Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) });
|
||||
},
|
||||
getStatus: (): Promise<HttpServerStatus> =>
|
||||
Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }),
|
||||
};
|
||||
}
|
||||
51
src/renderer/api/index.ts
Normal file
51
src/renderer/api/index.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Unified API adapter.
|
||||
*
|
||||
* When running inside Electron, the preload script exposes `window.electronAPI`.
|
||||
* When running in a browser (e.g. via the HTTP server), we fall back to an
|
||||
* HTTP+SSE client that implements the same interface.
|
||||
*
|
||||
* All renderer code should import `api` from this module instead of
|
||||
* accessing `window.electronAPI` directly.
|
||||
*
|
||||
* The instance is resolved lazily on first property access so that test code
|
||||
* can install mocks on `window.electronAPI` before the adapter resolves.
|
||||
*/
|
||||
|
||||
import { HttpAPIClient } from './httpClient';
|
||||
|
||||
import type { ElectronAPI } from '@shared/types/api';
|
||||
|
||||
function getHttpPort(): number {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return parseInt(params.get('port') ?? '3456', 10);
|
||||
}
|
||||
|
||||
let httpClient: HttpAPIClient | null = null;
|
||||
|
||||
function getImpl(): ElectronAPI {
|
||||
if (window.electronAPI) return window.electronAPI;
|
||||
// Lazily create the HTTP client only when actually needed (browser mode).
|
||||
// Caching avoids creating multiple EventSource connections.
|
||||
if (!httpClient) {
|
||||
httpClient = new HttpAPIClient(getHttpPort());
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy that lazily resolves the underlying ElectronAPI on first property access.
|
||||
* In Electron: delegates to `window.electronAPI` (set by preload).
|
||||
* In browser: delegates to `HttpAPIClient` (created on first use).
|
||||
* In tests: delegates to whatever mock is installed on `window.electronAPI`.
|
||||
*/
|
||||
export const api: ElectronAPI = new Proxy({} as ElectronAPI, {
|
||||
get(_target, prop, receiver) {
|
||||
const impl = getImpl();
|
||||
const value = Reflect.get(impl, prop, receiver) as unknown;
|
||||
if (typeof value === 'function') {
|
||||
return (value as (...args: unknown[]) => unknown).bind(impl);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -360,7 +361,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
const validatePaths = async (): Promise<void> => {
|
||||
try {
|
||||
const toValidate = pathMentions.map((m) => ({ type: 'path' as const, value: m.value }));
|
||||
const results = await window.electronAPI.validateMentions(toValidate, projectPath);
|
||||
const results = await api.validateMentions(toValidate, projectPath);
|
||||
if (isCurrent) {
|
||||
setValidatedPaths(results);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import {
|
||||
CODE_BG,
|
||||
|
|
@ -107,7 +108,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) {
|
||||
void window.electronAPI.openExternal(href);
|
||||
void api.openExternal(href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -203,7 +204,7 @@ const NewProjectCard = (): React.JSX.Element => {
|
|||
|
||||
const handleClick = async (): Promise<void> => {
|
||||
try {
|
||||
const selectedPaths = await window.electronAPI.config.selectFolders();
|
||||
const selectedPaths = await api.config.selectFolders();
|
||||
if (!selectedPaths || selectedPaths.length === 0) {
|
||||
return; // User cancelled
|
||||
}
|
||||
|
|
@ -221,7 +222,7 @@ const NewProjectCard = (): React.JSX.Element => {
|
|||
}
|
||||
|
||||
// No match found - open the folder in file manager as fallback
|
||||
const result = await window.electronAPI.openPath(selectedPath);
|
||||
const result = await api.openPath(selectedPath);
|
||||
if (!result.success) {
|
||||
logger.error('Failed to open folder:', result.error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -220,11 +221,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
latestSearchRequestRef.current = requestId;
|
||||
setLoading(true);
|
||||
try {
|
||||
const searchResult = await window.electronAPI.searchSessions(
|
||||
selectedProjectId,
|
||||
query.trim(),
|
||||
50
|
||||
);
|
||||
const searchResult = await api.searchSessions(selectedProjectId, query.trim(), 50);
|
||||
if (latestSearchRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
|
|
@ -91,7 +92,7 @@ export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTrigger
|
|||
setPreviewResult({ loading: true, totalCount: 0, errors: [] });
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.config.testTrigger(trigger);
|
||||
const result = await api.config.testTrigger(trigger);
|
||||
setPreviewResult({
|
||||
loading: false,
|
||||
totalCount: result.totalCount,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -87,7 +88,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const loadedConfig = await window.electronAPI.config.get();
|
||||
const loadedConfig = await api.config.get();
|
||||
setConfig(loadedConfig);
|
||||
setOptimisticConfig(loadedConfig);
|
||||
} catch (err) {
|
||||
|
|
@ -124,7 +125,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
|
||||
try {
|
||||
setSaving(true);
|
||||
const updatedConfig = await window.electronAPI.config.update(section, data);
|
||||
const updatedConfig = await api.config.update(section, data as object);
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
// Update global store so other components (like useTheme) see the change
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
||||
import type { RepositoryDropdownItem } from './useSettingsConfig';
|
||||
|
|
@ -99,7 +100,7 @@ export function useSettingsHandlers({
|
|||
async (minutes: number) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const updatedConfig = await window.electronAPI.config.snooze(minutes);
|
||||
const updatedConfig = await api.config.snooze(minutes);
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
setStoreState({ appConfig: updatedConfig });
|
||||
|
|
@ -115,7 +116,7 @@ export function useSettingsHandlers({
|
|||
const handleClearSnooze = useCallback(async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const updatedConfig = await window.electronAPI.config.clearSnooze();
|
||||
const updatedConfig = await api.config.clearSnooze();
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
setStoreState({ appConfig: updatedConfig });
|
||||
|
|
@ -130,7 +131,7 @@ export function useSettingsHandlers({
|
|||
async (item: RepositoryDropdownItem) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const updatedConfig = await window.electronAPI.config.addIgnoreRepository(item.id);
|
||||
const updatedConfig = await api.config.addIgnoreRepository(item.id);
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
setStoreState({ appConfig: updatedConfig });
|
||||
|
|
@ -147,7 +148,7 @@ export function useSettingsHandlers({
|
|||
async (repositoryId: string) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const updatedConfig = await window.electronAPI.config.removeIgnoreRepository(repositoryId);
|
||||
const updatedConfig = await api.config.removeIgnoreRepository(repositoryId);
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
setStoreState({ appConfig: updatedConfig });
|
||||
|
|
@ -165,7 +166,7 @@ export function useSettingsHandlers({
|
|||
async (trigger: Omit<NotificationTrigger, 'isBuiltin'>) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const updatedConfig = await window.electronAPI.config.addTrigger(trigger);
|
||||
const updatedConfig = await api.config.addTrigger(trigger);
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
setStoreState({ appConfig: updatedConfig });
|
||||
|
|
@ -198,7 +199,7 @@ export function useSettingsHandlers({
|
|||
|
||||
try {
|
||||
setSaving(true);
|
||||
const updatedConfig = await window.electronAPI.config.updateTrigger(triggerId, updates);
|
||||
const updatedConfig = await api.config.updateTrigger(triggerId, updates);
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
setStoreState({ appConfig: updatedConfig });
|
||||
|
|
@ -217,7 +218,7 @@ export function useSettingsHandlers({
|
|||
async (triggerId: string) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const updatedConfig = await window.electronAPI.config.removeTrigger(triggerId);
|
||||
const updatedConfig = await api.config.removeTrigger(triggerId);
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
setStoreState({ appConfig: updatedConfig });
|
||||
|
|
@ -296,12 +297,9 @@ export function useSettingsHandlers({
|
|||
},
|
||||
};
|
||||
|
||||
await window.electronAPI.config.update('notifications', defaultConfig.notifications);
|
||||
await window.electronAPI.config.update('general', defaultConfig.general);
|
||||
const updatedConfig = await window.electronAPI.config.update(
|
||||
'display',
|
||||
defaultConfig.display
|
||||
);
|
||||
await api.config.update('notifications', defaultConfig.notifications);
|
||||
await api.config.update('general', defaultConfig.general);
|
||||
const updatedConfig = await api.config.update('display', defaultConfig.display);
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
setStoreState({ appConfig: updatedConfig });
|
||||
|
|
@ -328,7 +326,7 @@ export function useSettingsHandlers({
|
|||
|
||||
const handleOpenInEditor = useCallback(async () => {
|
||||
try {
|
||||
await window.electronAPI.config.openInEditor();
|
||||
await api.config.openInEditor();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to open config in editor');
|
||||
}
|
||||
|
|
@ -348,16 +346,16 @@ export function useSettingsHandlers({
|
|||
const importedConfig = JSON.parse(text) as AppConfig;
|
||||
|
||||
if (importedConfig.notifications) {
|
||||
await window.electronAPI.config.update('notifications', importedConfig.notifications);
|
||||
await api.config.update('notifications', importedConfig.notifications);
|
||||
}
|
||||
if (importedConfig.general) {
|
||||
await window.electronAPI.config.update('general', importedConfig.general);
|
||||
await api.config.update('general', importedConfig.general);
|
||||
}
|
||||
if (importedConfig.display) {
|
||||
await window.electronAPI.config.update('display', importedConfig.display);
|
||||
await api.config.update('display', importedConfig.display);
|
||||
}
|
||||
|
||||
const updatedConfig = await window.electronAPI.config.get();
|
||||
const updatedConfig = await api.config.get();
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
setStoreState({ appConfig: updatedConfig });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import appIcon from '@renderer/favicon.png';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react';
|
||||
|
|
@ -44,7 +45,7 @@ export const AdvancedSection = ({
|
|||
}, [updateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.getAppVersion().then(setVersion).catch(console.error);
|
||||
api.getAppVersion().then(setVersion).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleCheckForUpdates = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
/**
|
||||
* GeneralSection - General settings including startup and appearance.
|
||||
* GeneralSection - General settings including startup, appearance, and browser access.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Check, Copy, Loader2 } from 'lucide-react';
|
||||
|
||||
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
|
||||
|
||||
import type { SafeConfig } from '../hooks/useSettingsConfig';
|
||||
import type { HttpServerStatus } from '@shared/types/api';
|
||||
|
||||
// Theme options
|
||||
const THEME_OPTIONS = [
|
||||
|
|
@ -26,6 +32,37 @@ export const GeneralSection = ({
|
|||
onGeneralToggle,
|
||||
onThemeChange,
|
||||
}: GeneralSectionProps): React.JSX.Element => {
|
||||
const [serverStatus, setServerStatus] = useState<HttpServerStatus>({ running: false, port: 3456 });
|
||||
const [serverLoading, setServerLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Fetch server status on mount
|
||||
useEffect(() => {
|
||||
void api.httpServer.getStatus().then(setServerStatus);
|
||||
}, []);
|
||||
|
||||
const handleServerToggle = useCallback(async (enabled: boolean) => {
|
||||
setServerLoading(true);
|
||||
try {
|
||||
const status = enabled
|
||||
? await api.httpServer.start()
|
||||
: await api.httpServer.stop();
|
||||
setServerStatus(status);
|
||||
} catch {
|
||||
// Status didn't change
|
||||
} finally {
|
||||
setServerLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const serverUrl = `http://localhost:${serverStatus.port}`;
|
||||
|
||||
const handleCopyUrl = useCallback(() => {
|
||||
void navigator.clipboard.writeText(serverUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [serverUrl]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsSectionHeader title="Startup" />
|
||||
|
|
@ -55,6 +92,58 @@ export const GeneralSection = ({
|
|||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingsSectionHeader title="Browser Access" />
|
||||
<SettingRow
|
||||
label="Enable server mode"
|
||||
description="Start an HTTP server to access the UI from a browser or embed in iframes"
|
||||
>
|
||||
{serverLoading ? (
|
||||
<Loader2 className="size-5 animate-spin" style={{ color: 'var(--color-text-muted)' }} />
|
||||
) : (
|
||||
<SettingsToggle
|
||||
enabled={serverStatus.running}
|
||||
onChange={handleServerToggle}
|
||||
disabled={saving}
|
||||
/>
|
||||
)}
|
||||
</SettingRow>
|
||||
|
||||
{serverStatus.running && (
|
||||
<div
|
||||
className="mb-2 flex items-center gap-3 rounded-md px-3 py-2.5"
|
||||
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
||||
>
|
||||
<div
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: '#22c55e' }}
|
||||
/>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Running on
|
||||
</span>
|
||||
<code
|
||||
className="rounded px-1.5 py-0.5 font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{serverUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: copied ? '#22c55e' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copied ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
/**
|
||||
* Reads current zoom factor and stays subscribed to zoom updates from main.
|
||||
*/
|
||||
|
|
@ -9,7 +11,7 @@ export function useZoomFactor(): number {
|
|||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
void window.electronAPI
|
||||
void api
|
||||
.getZoomFactor()
|
||||
.then((value) => {
|
||||
if (isMounted) {
|
||||
|
|
@ -20,7 +22,7 @@ export function useZoomFactor(): number {
|
|||
// Keep default 1 if zoom factor cannot be read.
|
||||
});
|
||||
|
||||
const unsubscribe = window.electronAPI.onZoomFactorChanged((value) => {
|
||||
const unsubscribe = api.onZoomFactorChanged((value) => {
|
||||
setZoomFactor(value);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Store index - combines all slices and exports the unified store.
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { createConfigSlice } from './slices/configSlice';
|
||||
|
|
@ -93,8 +94,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
};
|
||||
|
||||
// Listen for new notifications from main process
|
||||
if (window.electronAPI.notifications?.onNew) {
|
||||
const cleanup = window.electronAPI.notifications.onNew((_event: unknown, error: unknown) => {
|
||||
if (api.notifications?.onNew) {
|
||||
const cleanup = api.notifications.onNew((_event: unknown, error: unknown) => {
|
||||
// Cast the error to DetectedError type
|
||||
const notification = error as DetectedError;
|
||||
if (notification?.id) {
|
||||
|
|
@ -113,8 +114,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
|
||||
// Listen for notification updates from main process
|
||||
if (window.electronAPI.notifications?.onUpdated) {
|
||||
const cleanup = window.electronAPI.notifications.onUpdated(
|
||||
if (api.notifications?.onUpdated) {
|
||||
const cleanup = api.notifications.onUpdated(
|
||||
(_event: unknown, payload: { total: number; unreadCount: number }) => {
|
||||
const unreadCount =
|
||||
typeof payload.unreadCount === 'number' && Number.isFinite(payload.unreadCount)
|
||||
|
|
@ -129,8 +130,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
|
||||
// Navigate to error when user clicks a native OS notification
|
||||
if (window.electronAPI.notifications?.onClicked) {
|
||||
const cleanup = window.electronAPI.notifications.onClicked((_event: unknown, data: unknown) => {
|
||||
if (api.notifications?.onClicked) {
|
||||
const cleanup = api.notifications.onClicked((_event: unknown, data: unknown) => {
|
||||
const error = data as DetectedError;
|
||||
if (error?.id && error?.sessionId && error?.projectId) {
|
||||
useStore.getState().navigateToError(error);
|
||||
|
|
@ -161,8 +162,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
};
|
||||
|
||||
// Listen for task-list file changes to refresh currently viewed session metadata
|
||||
if (window.electronAPI.onTodoChange) {
|
||||
const cleanup = window.electronAPI.onTodoChange((event) => {
|
||||
if (api.onTodoChange) {
|
||||
const cleanup = api.onTodoChange((event) => {
|
||||
if (!event.sessionId || event.type === 'unlink') {
|
||||
return;
|
||||
}
|
||||
|
|
@ -198,8 +199,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
|
||||
// Listen for file changes to auto-refresh current session and detect new sessions
|
||||
if (window.electronAPI.onFileChange) {
|
||||
const cleanup = window.electronAPI.onFileChange((event) => {
|
||||
if (api.onFileChange) {
|
||||
const cleanup = api.onFileChange((event) => {
|
||||
// Skip unlink events
|
||||
if (event.type === 'unlink') {
|
||||
return;
|
||||
|
|
@ -234,8 +235,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
|
||||
// Listen for updater status events from main process
|
||||
if (window.electronAPI.updater?.onStatus) {
|
||||
const cleanup = window.electronAPI.updater.onStatus((_event: unknown, status: unknown) => {
|
||||
if (api.updater?.onStatus) {
|
||||
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {
|
||||
const s = status as UpdaterStatus;
|
||||
switch (s.type) {
|
||||
case 'checking':
|
||||
|
|
@ -279,8 +280,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
|
||||
// Listen for SSH connection status changes from main process
|
||||
if (window.electronAPI.ssh?.onStatus) {
|
||||
const cleanup = window.electronAPI.ssh.onStatus((_event: unknown, status: unknown) => {
|
||||
if (api.ssh?.onStatus) {
|
||||
const cleanup = api.ssh.onStatus((_event: unknown, status: unknown) => {
|
||||
const s = status as { state: string; host: string | null; error: string | null };
|
||||
useStore
|
||||
.getState()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Config slice - manages app configuration state and actions.
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
|
|
@ -40,7 +41,7 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
|||
fetchConfig: async () => {
|
||||
set({ configLoading: true, configError: null });
|
||||
try {
|
||||
const config = await window.electronAPI.config.get();
|
||||
const config = await api.config.get();
|
||||
set({
|
||||
appConfig: config,
|
||||
configLoading: false,
|
||||
|
|
@ -56,9 +57,9 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
|||
// Update a section of the app configuration
|
||||
updateConfig: async (section: string, data: Record<string, unknown>) => {
|
||||
try {
|
||||
await window.electronAPI.config.update(section, data);
|
||||
await api.config.update(section, data);
|
||||
// Refresh config after update
|
||||
const config = await window.electronAPI.config.get();
|
||||
const config = await api.config.get();
|
||||
set({ appConfig: config });
|
||||
} catch (error) {
|
||||
logger.error('Failed to update config:', error);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
* and provides actions for connecting/disconnecting.
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
import { getFullResetState } from '../utils/stateResetHelpers';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
|
|
@ -68,7 +70,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
});
|
||||
|
||||
try {
|
||||
const status = await window.electronAPI.ssh.connect(config);
|
||||
const status = await api.ssh.connect(config);
|
||||
set({
|
||||
connectionMode: status.state === 'connected' ? 'ssh' : 'local',
|
||||
connectionState: status.state,
|
||||
|
|
@ -93,7 +95,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
privateKeyPath: config.privateKeyPath,
|
||||
};
|
||||
set({ lastSshConfig: saved });
|
||||
void window.electronAPI.ssh.saveLastConnection(saved);
|
||||
void api.ssh.saveLastConnection(saved);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -106,7 +108,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
|
||||
disconnectSsh: async (): Promise<void> => {
|
||||
try {
|
||||
const status = await window.electronAPI.ssh.disconnect();
|
||||
const status = await api.ssh.disconnect();
|
||||
set({
|
||||
connectionMode: 'local',
|
||||
connectionState: status.state,
|
||||
|
|
@ -130,7 +132,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
config: SshConnectionConfig
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
return await window.electronAPI.ssh.test(config);
|
||||
return await api.ssh.test(config);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: message };
|
||||
|
|
@ -152,7 +154,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
|
||||
fetchSshConfigHosts: async (): Promise<void> => {
|
||||
try {
|
||||
const hosts = await window.electronAPI.ssh.getConfigHosts();
|
||||
const hosts = await api.ssh.getConfigHosts();
|
||||
set({ sshConfigHosts: hosts });
|
||||
} catch {
|
||||
// Gracefully ignore - SSH config may not exist
|
||||
|
|
@ -162,7 +164,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
|
||||
resolveConfigHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
|
||||
try {
|
||||
return await window.electronAPI.ssh.resolveHost(alias);
|
||||
return await api.ssh.resolveHost(alias);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -170,7 +172,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
|
||||
loadLastConnection: async (): Promise<void> => {
|
||||
try {
|
||||
const saved = await window.electronAPI.ssh.getLastConnection();
|
||||
const saved = await api.ssh.getLastConnection();
|
||||
set({ lastSshConfig: saved });
|
||||
} catch {
|
||||
// Gracefully ignore - no saved connection
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Notification slice - manages notifications state and actions.
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { createErrorNavigationRequest, findTabBySessionAndProject } from '@renderer/types/tabs';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
|||
set({ notificationsLoading: true, notificationsError: null });
|
||||
try {
|
||||
// Fetch the full stored history (manager currently caps storage at 100).
|
||||
const result = await window.electronAPI.notifications.get({
|
||||
const result = await api.notifications.get({
|
||||
limit: NOTIFICATIONS_FETCH_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
|
|
@ -80,7 +81,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
|||
// Mark a single notification as read
|
||||
markNotificationRead: async (id: string) => {
|
||||
try {
|
||||
const success = await window.electronAPI.notifications.markRead(id);
|
||||
const success = await api.notifications.markRead(id);
|
||||
if (!success) {
|
||||
await get().fetchNotifications();
|
||||
return;
|
||||
|
|
@ -101,7 +102,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
|||
// Mark all notifications as read
|
||||
markAllNotificationsRead: async () => {
|
||||
try {
|
||||
const success = await window.electronAPI.notifications.markAllRead();
|
||||
const success = await api.notifications.markAllRead();
|
||||
if (!success) {
|
||||
await get().fetchNotifications();
|
||||
return;
|
||||
|
|
@ -119,7 +120,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
|||
// Delete a single notification
|
||||
deleteNotification: async (id: string) => {
|
||||
try {
|
||||
const success = await window.electronAPI.notifications.delete(id);
|
||||
const success = await api.notifications.delete(id);
|
||||
if (!success) {
|
||||
await get().fetchNotifications();
|
||||
return;
|
||||
|
|
@ -138,7 +139,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
|||
// Clear all notifications
|
||||
clearNotifications: async () => {
|
||||
try {
|
||||
const success = await window.electronAPI.notifications.clear();
|
||||
const success = await api.notifications.clear();
|
||||
if (!success) {
|
||||
await get().fetchNotifications();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
* Project slice - manages project list state and selection.
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
import { getSessionResetState } from '../utils/stateResetHelpers';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
|
|
@ -39,7 +41,7 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
|
|||
fetchProjects: async () => {
|
||||
set({ projectsLoading: true, projectsError: null });
|
||||
try {
|
||||
const projects = await window.electronAPI.getProjects();
|
||||
const projects = await api.getProjects();
|
||||
// Sort by most recent session (descending)
|
||||
const sorted = [...projects].sort(
|
||||
(a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Repository slice - manages repository grouping state (worktree support).
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { getSessionResetState } from '../utils/stateResetHelpers';
|
||||
|
|
@ -52,7 +53,7 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
|
|||
fetchRepositoryGroups: async () => {
|
||||
set({ repositoryGroupsLoading: true, repositoryGroupsError: null });
|
||||
try {
|
||||
const groups = await window.electronAPI.getRepositoryGroups();
|
||||
const groups = await api.getRepositoryGroups();
|
||||
// Already sorted by most recent session in the scanner
|
||||
set({ repositoryGroups: groups, repositoryGroupsLoading: false });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Session detail slice - manages session detail, conversation, and stats.
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { asEnhancedChunkArray } from '@renderer/types/data';
|
||||
import { findTabBySession, truncateLabel } from '@renderer/types/tabs';
|
||||
import { processSessionClaudeMd } from '@renderer/utils/claudeMdTracker';
|
||||
|
|
@ -163,7 +164,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
});
|
||||
}
|
||||
try {
|
||||
const detail = await window.electronAPI.getSessionDetail(projectId, sessionId);
|
||||
const detail = await api.getSessionDetail(projectId, sessionId);
|
||||
if (requestGeneration !== sessionDetailFetchGeneration) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -192,7 +193,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
// Fetch real CLAUDE.md token data
|
||||
let claudeMdTokenData: Record<string, ClaudeMdFileInfo> = {};
|
||||
try {
|
||||
claudeMdTokenData = await window.electronAPI.readClaudeMdFiles(projectRoot);
|
||||
claudeMdTokenData = await api.readClaudeMdFiles(projectRoot);
|
||||
if (requestGeneration !== sessionDetailFetchGeneration) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -227,7 +228,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
Array.from(directoryPaths).map(async (fullPath) => {
|
||||
try {
|
||||
const dirPath = fullPath.replace(/[\\/]CLAUDE\.md$/, '');
|
||||
const fileInfo = await window.electronAPI.readDirectoryClaudeMd(dirPath);
|
||||
const fileInfo = await api.readDirectoryClaudeMd(dirPath);
|
||||
return { fullPath, fileInfo, error: false };
|
||||
} catch (err) {
|
||||
logger.error('Failed to read directory CLAUDE.md:', fullPath, err);
|
||||
|
|
@ -324,7 +325,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
const mentionedFileResults = await Promise.all(
|
||||
Array.from(mentionedFilePaths).map(async (filePath) => {
|
||||
try {
|
||||
const fileInfo = await window.electronAPI.readMentionedFile(filePath, projectRoot);
|
||||
const fileInfo = await api.readMentionedFile(filePath, projectRoot);
|
||||
return { filePath, fileInfo };
|
||||
} catch (err) {
|
||||
logger.error('Failed to read mentioned file:', filePath, err);
|
||||
|
|
@ -471,7 +472,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
sessionRefreshInFlight.add(refreshKey);
|
||||
|
||||
try {
|
||||
const detail = await window.electronAPI.getSessionDetail(projectId, sessionId);
|
||||
const detail = await api.getSessionDetail(projectId, sessionId);
|
||||
|
||||
// Drop stale responses if a newer refresh started while this one was in flight.
|
||||
if (sessionRefreshGeneration.get(refreshKey) !== generation) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Session slice - manages session list state and pagination.
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
|
|
@ -71,7 +72,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
fetchSessions: async (projectId: string) => {
|
||||
set({ sessionsLoading: true, sessionsError: null });
|
||||
try {
|
||||
const sessions = await window.electronAPI.getSessions(projectId);
|
||||
const sessions = await api.getSessions(projectId);
|
||||
// Sort by createdAt (descending)
|
||||
const sorted = [...sessions].sort((a, b) => b.createdAt - a.createdAt);
|
||||
set({ sessions: sorted, sessionsLoading: false });
|
||||
|
|
@ -94,7 +95,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
sessionsTotalCount: 0,
|
||||
});
|
||||
try {
|
||||
const result = await window.electronAPI.getSessionsPaginated(projectId, null, 20, {
|
||||
const result = await api.getSessionsPaginated(projectId, null, 20, {
|
||||
includeTotalCount: false,
|
||||
prefilterAll: false,
|
||||
});
|
||||
|
|
@ -128,15 +129,10 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
|
||||
set({ sessionsLoadingMore: true });
|
||||
try {
|
||||
const result = await window.electronAPI.getSessionsPaginated(
|
||||
selectedProjectId,
|
||||
sessionsCursor,
|
||||
20,
|
||||
{
|
||||
includeTotalCount: false,
|
||||
prefilterAll: false,
|
||||
}
|
||||
);
|
||||
const result = await api.getSessionsPaginated(selectedProjectId, sessionsCursor, 20, {
|
||||
includeTotalCount: false,
|
||||
prefilterAll: false,
|
||||
});
|
||||
set((prevState) => ({
|
||||
sessions: [...prevState.sessions, ...result.sessions],
|
||||
sessionsCursor: result.nextCursor,
|
||||
|
|
@ -208,7 +204,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
projectRefreshGeneration.set(projectId, generation);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.getSessionsPaginated(projectId, null, 20, {
|
||||
const result = await api.getSessionsPaginated(projectId, null, 20, {
|
||||
includeTotalCount: false,
|
||||
prefilterAll: false,
|
||||
});
|
||||
|
|
@ -242,10 +238,10 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
|
||||
try {
|
||||
if (isPinned) {
|
||||
await window.electronAPI.config.unpinSession(projectId, sessionId);
|
||||
await api.config.unpinSession(projectId, sessionId);
|
||||
set({ pinnedSessionIds: state.pinnedSessionIds.filter((id) => id !== sessionId) });
|
||||
} else {
|
||||
await window.electronAPI.config.pinSession(projectId, sessionId);
|
||||
await api.config.pinSession(projectId, sessionId);
|
||||
set({ pinnedSessionIds: [sessionId, ...state.pinnedSessionIds] });
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -263,7 +259,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
}
|
||||
|
||||
try {
|
||||
const config = await window.electronAPI.config.get();
|
||||
const config = await api.config.get();
|
||||
const pins = config.sessions?.pinnedSessions?.[projectId] ?? [];
|
||||
set({ pinnedSessionIds: pins.map((p) => p.sessionId) });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
* Subagent slice - manages subagent drill-down state.
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
import type { AppState, BreadcrumbItem } from '../types';
|
||||
import type { SubagentDetail } from '@renderer/types/data';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
|
@ -48,7 +50,7 @@ export const createSubagentSlice: StateCreator<AppState, [], [], SubagentSlice>
|
|||
) => {
|
||||
set({ subagentDetailLoading: true, subagentDetailError: null });
|
||||
try {
|
||||
const detail = await window.electronAPI.getSubagentDetail(projectId, sessionId, subagentId);
|
||||
const detail = await api.getSubagentDetail(projectId, sessionId, subagentId);
|
||||
|
||||
if (!detail) {
|
||||
set({
|
||||
|
|
@ -108,7 +110,7 @@ export const createSubagentSlice: StateCreator<AppState, [], [], SubagentSlice>
|
|||
|
||||
set({ subagentDetailLoading: true, subagentDetailError: null });
|
||||
|
||||
window.electronAPI
|
||||
api
|
||||
.getSubagentDetail(projectId, sessionId, targetItem.id)
|
||||
.then((detail) => {
|
||||
if (detail) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Update slice - manages OTA auto-update state and actions.
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
|
|
@ -54,20 +55,20 @@ export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (s
|
|||
|
||||
checkForUpdates: () => {
|
||||
set({ updateStatus: 'checking', updateError: null });
|
||||
window.electronAPI.updater.check().catch((error) => {
|
||||
api.updater.check().catch((error) => {
|
||||
logger.error('Failed to check for updates:', error);
|
||||
});
|
||||
},
|
||||
|
||||
downloadUpdate: () => {
|
||||
set({ showUpdateDialog: false, showUpdateBanner: true, downloadProgress: 0 });
|
||||
window.electronAPI.updater.download().catch((error) => {
|
||||
api.updater.download().catch((error) => {
|
||||
logger.error('Failed to download update:', error);
|
||||
});
|
||||
},
|
||||
|
||||
installUpdate: () => {
|
||||
window.electronAPI.updater.install().catch((error) => {
|
||||
api.updater.install().catch((error) => {
|
||||
logger.error('Failed to install update:', error);
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,27 @@ export interface SshAPI {
|
|||
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTTP Server API
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* HTTP server status returned from main process.
|
||||
*/
|
||||
export interface HttpServerStatus {
|
||||
running: boolean;
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP Server API for controlling the sidecar server.
|
||||
*/
|
||||
export interface HttpServerAPI {
|
||||
start: () => Promise<HttpServerStatus>;
|
||||
stop: () => Promise<HttpServerStatus>;
|
||||
getStatus: () => Promise<HttpServerStatus>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Electron API
|
||||
// =============================================================================
|
||||
|
|
@ -334,6 +355,9 @@ export interface ElectronAPI {
|
|||
switch: (contextId: string) => Promise<{ contextId: string }>;
|
||||
onChanged: (callback: (event: unknown, data: ContextInfo) => void) => () => void;
|
||||
};
|
||||
|
||||
// HTTP Server API
|
||||
httpServer: HttpServerAPI;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -275,4 +275,11 @@ export interface AppConfig {
|
|||
/** Pinned sessions per project. Key is projectId, value is array of pinned sessions */
|
||||
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
|
||||
};
|
||||
/** HTTP sidecar server settings for iframe embedding */
|
||||
httpServer?: {
|
||||
/** Whether the HTTP server is enabled */
|
||||
enabled: boolean;
|
||||
/** Port for the HTTP server (default 3456) */
|
||||
port: number;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue