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/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/static": "^9.0.0",
|
||||||
"@tanstack/react-virtual": "^3.10.8",
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"electron-updater": "^6.7.3",
|
"electron-updater": "^6.7.3",
|
||||||
|
"fastify": "^5.7.4",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mdast-util-to-hast": "^13.2.1",
|
"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,
|
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
||||||
} from '@shared/constants';
|
} from '@shared/constants';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||||
|
|
@ -32,8 +32,13 @@ const getIconPath = (): string => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const logger = createLogger('App');
|
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 {
|
import {
|
||||||
configManager,
|
configManager,
|
||||||
LocalFileSystemProvider,
|
LocalFileSystemProvider,
|
||||||
|
|
@ -55,13 +60,14 @@ let contextRegistry: ServiceContextRegistry;
|
||||||
let notificationManager: NotificationManager;
|
let notificationManager: NotificationManager;
|
||||||
let updaterService: UpdaterService;
|
let updaterService: UpdaterService;
|
||||||
let sshConnectionManager: SshConnectionManager;
|
let sshConnectionManager: SshConnectionManager;
|
||||||
|
let httpServer: HttpServer;
|
||||||
|
|
||||||
// File watcher event cleanup functions
|
// File watcher event cleanup functions
|
||||||
let fileChangeCleanup: (() => void) | null = null;
|
let fileChangeCleanup: (() => void) | null = null;
|
||||||
let todoChangeCleanup: (() => 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.
|
* Cleans up previous listeners before adding new ones.
|
||||||
*/
|
*/
|
||||||
function wireFileWatcherEvents(context: ServiceContext): void {
|
function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
|
|
@ -77,20 +83,22 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
todoChangeCleanup = null;
|
todoChangeCleanup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire file-change events
|
// Wire file-change events to renderer and HTTP SSE
|
||||||
const fileChangeHandler = (event: unknown) => {
|
const fileChangeHandler = (event: unknown) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('file-change', event);
|
mainWindow.webContents.send('file-change', event);
|
||||||
}
|
}
|
||||||
|
httpServer?.broadcast('file-change', event);
|
||||||
};
|
};
|
||||||
context.fileWatcher.on('file-change', fileChangeHandler);
|
context.fileWatcher.on('file-change', fileChangeHandler);
|
||||||
fileChangeCleanup = () => context.fileWatcher.off('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) => {
|
const todoChangeHandler = (event: unknown) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('todo-change', event);
|
mainWindow.webContents.send('todo-change', event);
|
||||||
}
|
}
|
||||||
|
httpServer?.broadcast('todo-change', event);
|
||||||
};
|
};
|
||||||
context.fileWatcher.on('todo-change', todoChangeHandler);
|
context.fileWatcher.on('todo-change', todoChangeHandler);
|
||||||
todoChangeCleanup = () => context.fileWatcher.off('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}`);
|
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).
|
* Callback invoked when context switches (called by SSH IPC handler).
|
||||||
* Re-wires file watcher events and notifies renderer.
|
* Re-wires file watcher events and notifies renderer.
|
||||||
|
|
@ -152,26 +171,116 @@ function initializeServices(): void {
|
||||||
|
|
||||||
// Initialize updater service
|
// Initialize updater service
|
||||||
updaterService = new UpdaterService();
|
updaterService = new UpdaterService();
|
||||||
|
httpServer = new HttpServer();
|
||||||
|
|
||||||
// Initialize IPC handlers with registry
|
// Initialize IPC handlers with registry
|
||||||
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, onContextSwitched);
|
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) => {
|
sshConnectionManager.on('state-change', (status: unknown) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send(SSH_STATUS, status);
|
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');
|
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.
|
* Shuts down all services.
|
||||||
*/
|
*/
|
||||||
function shutdownServices(): void {
|
function shutdownServices(): void {
|
||||||
logger.info('Shutting down services...');
|
logger.info('Shutting down services...');
|
||||||
|
|
||||||
|
// Stop HTTP server
|
||||||
|
if (httpServer?.isRunning()) {
|
||||||
|
void httpServer.stop();
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up file watcher event listeners
|
// Clean up file watcher event listeners
|
||||||
if (fileChangeCleanup) {
|
if (fileChangeCleanup) {
|
||||||
fileChangeCleanup();
|
fileChangeCleanup();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
DisplayConfig,
|
DisplayConfig,
|
||||||
GeneralConfig,
|
GeneralConfig,
|
||||||
|
HttpServerConfig,
|
||||||
NotificationConfig,
|
NotificationConfig,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
|
|
@ -28,9 +29,15 @@ export type ConfigUpdateValidationResult =
|
||||||
| ValidationSuccess<'notifications'>
|
| ValidationSuccess<'notifications'>
|
||||||
| ValidationSuccess<'general'>
|
| ValidationSuccess<'general'>
|
||||||
| ValidationSuccess<'display'>
|
| ValidationSuccess<'display'>
|
||||||
|
| ValidationSuccess<'httpServer'>
|
||||||
| ValidationFailure;
|
| 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;
|
const MAX_SNOOZE_MINUTES = 24 * 60;
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
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(
|
export function validateConfigUpdatePayload(
|
||||||
section: unknown,
|
section: unknown,
|
||||||
data: unknown
|
data: unknown
|
||||||
): ConfigUpdateValidationResult {
|
): ConfigUpdateValidationResult {
|
||||||
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
|
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) {
|
switch (section as ConfigSection) {
|
||||||
|
|
@ -286,6 +339,8 @@ export function validateConfigUpdatePayload(
|
||||||
return validateGeneralSection(data);
|
return validateGeneralSection(data);
|
||||||
case 'display':
|
case 'display':
|
||||||
return validateDisplaySection(data);
|
return validateDisplaySection(data);
|
||||||
|
case 'httpServer':
|
||||||
|
return validateHttpServerSection(data);
|
||||||
default:
|
default:
|
||||||
return { valid: false, error: 'Invalid section' };
|
return { valid: false, error: 'Invalid section' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,12 +204,18 @@ export interface SshPersistConfig {
|
||||||
lastActiveContextId: string;
|
lastActiveContextId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HttpServerConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
notifications: NotificationConfig;
|
notifications: NotificationConfig;
|
||||||
general: GeneralConfig;
|
general: GeneralConfig;
|
||||||
display: DisplayConfig;
|
display: DisplayConfig;
|
||||||
sessions: SessionsConfig;
|
sessions: SessionsConfig;
|
||||||
ssh: SshPersistConfig;
|
ssh: SshPersistConfig;
|
||||||
|
httpServer: HttpServerConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config section keys for type-safe updates
|
// Config section keys for type-safe updates
|
||||||
|
|
@ -253,6 +259,10 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
profiles: [],
|
profiles: [],
|
||||||
lastActiveContextId: 'local',
|
lastActiveContextId: 'local',
|
||||||
},
|
},
|
||||||
|
httpServer: {
|
||||||
|
enabled: false,
|
||||||
|
port: 3456,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -377,6 +387,10 @@ export class ConfigManager {
|
||||||
...DEFAULT_CONFIG.ssh,
|
...DEFAULT_CONFIG.ssh,
|
||||||
...(loaded.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
|
* - SshConnectionManager: SSH connection lifecycle
|
||||||
* - ServiceContext: Service bundle for a single workspace context
|
* - ServiceContext: Service bundle for a single workspace context
|
||||||
* - ServiceContextRegistry: Registry coordinator for all contexts
|
* - ServiceContextRegistry: Registry coordinator for all contexts
|
||||||
|
* - HttpServer: Fastify-based HTTP server for API and static file serving
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './ConfigManager';
|
export * from './ConfigManager';
|
||||||
export * from './DataCache';
|
export * from './DataCache';
|
||||||
export type * from './FileSystemProvider';
|
export type * from './FileSystemProvider';
|
||||||
export * from './FileWatcher';
|
export * from './FileWatcher';
|
||||||
|
export * from './HttpServer';
|
||||||
export * from './LocalFileSystemProvider';
|
export * from './LocalFileSystemProvider';
|
||||||
export * from './NotificationManager';
|
export * from './NotificationManager';
|
||||||
export * from './ServiceContext';
|
export * from './ServiceContext';
|
||||||
|
|
|
||||||
|
|
@ -121,3 +121,16 @@ export const CONTEXT_SWITCH = 'context:switch';
|
||||||
|
|
||||||
/** Context changed event channel (main -> renderer) */
|
/** Context changed event channel (main -> renderer) */
|
||||||
export const CONTEXT_CHANGED = 'context:changed';
|
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_GET_ACTIVE,
|
||||||
CONTEXT_LIST,
|
CONTEXT_LIST,
|
||||||
CONTEXT_SWITCH,
|
CONTEXT_SWITCH,
|
||||||
|
HTTP_SERVER_GET_STATUS,
|
||||||
|
HTTP_SERVER_START,
|
||||||
|
HTTP_SERVER_STOP,
|
||||||
SSH_CONNECT,
|
SSH_CONNECT,
|
||||||
SSH_DISCONNECT,
|
SSH_DISCONNECT,
|
||||||
SSH_GET_CONFIG_HOSTS,
|
SSH_GET_CONFIG_HOSTS,
|
||||||
|
|
@ -44,6 +47,7 @@ import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
ContextInfo,
|
ContextInfo,
|
||||||
ElectronAPI,
|
ElectronAPI,
|
||||||
|
HttpServerStatus,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
SessionsPaginationOptions,
|
SessionsPaginationOptions,
|
||||||
SshConfigHostEntry,
|
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
|
// 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 React, { useEffect, useMemo, useState } from 'react';
|
||||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
@ -360,7 +361,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
||||||
const validatePaths = async (): Promise<void> => {
|
const validatePaths = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const toValidate = pathMentions.map((m) => ({ type: 'path' as const, value: m.value }));
|
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) {
|
if (isCurrent) {
|
||||||
setValidatedPaths(results);
|
setValidatedPaths(results);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
import {
|
import {
|
||||||
CODE_BG,
|
CODE_BG,
|
||||||
|
|
@ -107,7 +108,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (href) {
|
if (href) {
|
||||||
void window.electronAPI.openExternal(href);
|
void api.openExternal(href);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -203,7 +204,7 @@ const NewProjectCard = (): React.JSX.Element => {
|
||||||
|
|
||||||
const handleClick = async (): Promise<void> => {
|
const handleClick = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const selectedPaths = await window.electronAPI.config.selectFolders();
|
const selectedPaths = await api.config.selectFolders();
|
||||||
if (!selectedPaths || selectedPaths.length === 0) {
|
if (!selectedPaths || selectedPaths.length === 0) {
|
||||||
return; // User cancelled
|
return; // User cancelled
|
||||||
}
|
}
|
||||||
|
|
@ -221,7 +222,7 @@ const NewProjectCard = (): React.JSX.Element => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// No match found - open the folder in file manager as fallback
|
// 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) {
|
if (!result.success) {
|
||||||
logger.error('Failed to open folder:', result.error);
|
logger.error('Failed to open folder:', result.error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -220,11 +221,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
latestSearchRequestRef.current = requestId;
|
latestSearchRequestRef.current = requestId;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const searchResult = await window.electronAPI.searchSessions(
|
const searchResult = await api.searchSessions(selectedProjectId, query.trim(), 50);
|
||||||
selectedProjectId,
|
|
||||||
query.trim(),
|
|
||||||
50
|
|
||||||
);
|
|
||||||
if (latestSearchRequestRef.current !== requestId) {
|
if (latestSearchRequestRef.current !== requestId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
|
@ -91,7 +92,7 @@ export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTrigger
|
||||||
setPreviewResult({ loading: true, totalCount: 0, errors: [] });
|
setPreviewResult({ loading: true, totalCount: 0, errors: [] });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.config.testTrigger(trigger);
|
const result = await api.config.testTrigger(trigger);
|
||||||
setPreviewResult({
|
setPreviewResult({
|
||||||
loading: false,
|
loading: false,
|
||||||
totalCount: result.totalCount,
|
totalCount: result.totalCount,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -87,7 +88,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const loadedConfig = await window.electronAPI.config.get();
|
const loadedConfig = await api.config.get();
|
||||||
setConfig(loadedConfig);
|
setConfig(loadedConfig);
|
||||||
setOptimisticConfig(loadedConfig);
|
setOptimisticConfig(loadedConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -124,7 +125,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const updatedConfig = await window.electronAPI.config.update(section, data);
|
const updatedConfig = await api.config.update(section, data as object);
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
// Update global store so other components (like useTheme) see the change
|
// Update global store so other components (like useTheme) see the change
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
||||||
import type { RepositoryDropdownItem } from './useSettingsConfig';
|
import type { RepositoryDropdownItem } from './useSettingsConfig';
|
||||||
|
|
@ -99,7 +100,7 @@ export function useSettingsHandlers({
|
||||||
async (minutes: number) => {
|
async (minutes: number) => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const updatedConfig = await window.electronAPI.config.snooze(minutes);
|
const updatedConfig = await api.config.snooze(minutes);
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
setStoreState({ appConfig: updatedConfig });
|
setStoreState({ appConfig: updatedConfig });
|
||||||
|
|
@ -115,7 +116,7 @@ export function useSettingsHandlers({
|
||||||
const handleClearSnooze = useCallback(async () => {
|
const handleClearSnooze = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const updatedConfig = await window.electronAPI.config.clearSnooze();
|
const updatedConfig = await api.config.clearSnooze();
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
setStoreState({ appConfig: updatedConfig });
|
setStoreState({ appConfig: updatedConfig });
|
||||||
|
|
@ -130,7 +131,7 @@ export function useSettingsHandlers({
|
||||||
async (item: RepositoryDropdownItem) => {
|
async (item: RepositoryDropdownItem) => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const updatedConfig = await window.electronAPI.config.addIgnoreRepository(item.id);
|
const updatedConfig = await api.config.addIgnoreRepository(item.id);
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
setStoreState({ appConfig: updatedConfig });
|
setStoreState({ appConfig: updatedConfig });
|
||||||
|
|
@ -147,7 +148,7 @@ export function useSettingsHandlers({
|
||||||
async (repositoryId: string) => {
|
async (repositoryId: string) => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const updatedConfig = await window.electronAPI.config.removeIgnoreRepository(repositoryId);
|
const updatedConfig = await api.config.removeIgnoreRepository(repositoryId);
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
setStoreState({ appConfig: updatedConfig });
|
setStoreState({ appConfig: updatedConfig });
|
||||||
|
|
@ -165,7 +166,7 @@ export function useSettingsHandlers({
|
||||||
async (trigger: Omit<NotificationTrigger, 'isBuiltin'>) => {
|
async (trigger: Omit<NotificationTrigger, 'isBuiltin'>) => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const updatedConfig = await window.electronAPI.config.addTrigger(trigger);
|
const updatedConfig = await api.config.addTrigger(trigger);
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
setStoreState({ appConfig: updatedConfig });
|
setStoreState({ appConfig: updatedConfig });
|
||||||
|
|
@ -198,7 +199,7 @@ export function useSettingsHandlers({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const updatedConfig = await window.electronAPI.config.updateTrigger(triggerId, updates);
|
const updatedConfig = await api.config.updateTrigger(triggerId, updates);
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
setStoreState({ appConfig: updatedConfig });
|
setStoreState({ appConfig: updatedConfig });
|
||||||
|
|
@ -217,7 +218,7 @@ export function useSettingsHandlers({
|
||||||
async (triggerId: string) => {
|
async (triggerId: string) => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const updatedConfig = await window.electronAPI.config.removeTrigger(triggerId);
|
const updatedConfig = await api.config.removeTrigger(triggerId);
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
setStoreState({ appConfig: updatedConfig });
|
setStoreState({ appConfig: updatedConfig });
|
||||||
|
|
@ -296,12 +297,9 @@ export function useSettingsHandlers({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await window.electronAPI.config.update('notifications', defaultConfig.notifications);
|
await api.config.update('notifications', defaultConfig.notifications);
|
||||||
await window.electronAPI.config.update('general', defaultConfig.general);
|
await api.config.update('general', defaultConfig.general);
|
||||||
const updatedConfig = await window.electronAPI.config.update(
|
const updatedConfig = await api.config.update('display', defaultConfig.display);
|
||||||
'display',
|
|
||||||
defaultConfig.display
|
|
||||||
);
|
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
setStoreState({ appConfig: updatedConfig });
|
setStoreState({ appConfig: updatedConfig });
|
||||||
|
|
@ -328,7 +326,7 @@ export function useSettingsHandlers({
|
||||||
|
|
||||||
const handleOpenInEditor = useCallback(async () => {
|
const handleOpenInEditor = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.config.openInEditor();
|
await api.config.openInEditor();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to open config in editor');
|
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;
|
const importedConfig = JSON.parse(text) as AppConfig;
|
||||||
|
|
||||||
if (importedConfig.notifications) {
|
if (importedConfig.notifications) {
|
||||||
await window.electronAPI.config.update('notifications', importedConfig.notifications);
|
await api.config.update('notifications', importedConfig.notifications);
|
||||||
}
|
}
|
||||||
if (importedConfig.general) {
|
if (importedConfig.general) {
|
||||||
await window.electronAPI.config.update('general', importedConfig.general);
|
await api.config.update('general', importedConfig.general);
|
||||||
}
|
}
|
||||||
if (importedConfig.display) {
|
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);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
setStoreState({ appConfig: updatedConfig });
|
setStoreState({ appConfig: updatedConfig });
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import appIcon from '@renderer/favicon.png';
|
import appIcon from '@renderer/favicon.png';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react';
|
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react';
|
||||||
|
|
@ -44,7 +45,7 @@ export const AdvancedSection = ({
|
||||||
}, [updateStatus]);
|
}, [updateStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electronAPI.getAppVersion().then(setVersion).catch(console.error);
|
api.getAppVersion().then(setVersion).catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCheckForUpdates = useCallback(() => {
|
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 { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
|
||||||
|
|
||||||
import type { SafeConfig } from '../hooks/useSettingsConfig';
|
import type { SafeConfig } from '../hooks/useSettingsConfig';
|
||||||
|
import type { HttpServerStatus } from '@shared/types/api';
|
||||||
|
|
||||||
// Theme options
|
// Theme options
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
|
|
@ -26,6 +32,37 @@ export const GeneralSection = ({
|
||||||
onGeneralToggle,
|
onGeneralToggle,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
}: GeneralSectionProps): React.JSX.Element => {
|
}: 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsSectionHeader title="Startup" />
|
<SettingsSectionHeader title="Startup" />
|
||||||
|
|
@ -55,6 +92,58 @@ export const GeneralSection = ({
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads current zoom factor and stays subscribed to zoom updates from main.
|
* Reads current zoom factor and stays subscribed to zoom updates from main.
|
||||||
*/
|
*/
|
||||||
|
|
@ -9,7 +11,7 @@ export function useZoomFactor(): number {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
void window.electronAPI
|
void api
|
||||||
.getZoomFactor()
|
.getZoomFactor()
|
||||||
.then((value) => {
|
.then((value) => {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
|
|
@ -20,7 +22,7 @@ export function useZoomFactor(): number {
|
||||||
// Keep default 1 if zoom factor cannot be read.
|
// Keep default 1 if zoom factor cannot be read.
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubscribe = window.electronAPI.onZoomFactorChanged((value) => {
|
const unsubscribe = api.onZoomFactorChanged((value) => {
|
||||||
setZoomFactor(value);
|
setZoomFactor(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
* Store index - combines all slices and exports the unified store.
|
* Store index - combines all slices and exports the unified store.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import { createConfigSlice } from './slices/configSlice';
|
import { createConfigSlice } from './slices/configSlice';
|
||||||
|
|
@ -93,8 +94,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for new notifications from main process
|
// Listen for new notifications from main process
|
||||||
if (window.electronAPI.notifications?.onNew) {
|
if (api.notifications?.onNew) {
|
||||||
const cleanup = window.electronAPI.notifications.onNew((_event: unknown, error: unknown) => {
|
const cleanup = api.notifications.onNew((_event: unknown, error: unknown) => {
|
||||||
// Cast the error to DetectedError type
|
// Cast the error to DetectedError type
|
||||||
const notification = error as DetectedError;
|
const notification = error as DetectedError;
|
||||||
if (notification?.id) {
|
if (notification?.id) {
|
||||||
|
|
@ -113,8 +114,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for notification updates from main process
|
// Listen for notification updates from main process
|
||||||
if (window.electronAPI.notifications?.onUpdated) {
|
if (api.notifications?.onUpdated) {
|
||||||
const cleanup = window.electronAPI.notifications.onUpdated(
|
const cleanup = api.notifications.onUpdated(
|
||||||
(_event: unknown, payload: { total: number; unreadCount: number }) => {
|
(_event: unknown, payload: { total: number; unreadCount: number }) => {
|
||||||
const unreadCount =
|
const unreadCount =
|
||||||
typeof payload.unreadCount === 'number' && Number.isFinite(payload.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
|
// Navigate to error when user clicks a native OS notification
|
||||||
if (window.electronAPI.notifications?.onClicked) {
|
if (api.notifications?.onClicked) {
|
||||||
const cleanup = window.electronAPI.notifications.onClicked((_event: unknown, data: unknown) => {
|
const cleanup = api.notifications.onClicked((_event: unknown, data: unknown) => {
|
||||||
const error = data as DetectedError;
|
const error = data as DetectedError;
|
||||||
if (error?.id && error?.sessionId && error?.projectId) {
|
if (error?.id && error?.sessionId && error?.projectId) {
|
||||||
useStore.getState().navigateToError(error);
|
useStore.getState().navigateToError(error);
|
||||||
|
|
@ -161,8 +162,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for task-list file changes to refresh currently viewed session metadata
|
// Listen for task-list file changes to refresh currently viewed session metadata
|
||||||
if (window.electronAPI.onTodoChange) {
|
if (api.onTodoChange) {
|
||||||
const cleanup = window.electronAPI.onTodoChange((event) => {
|
const cleanup = api.onTodoChange((event) => {
|
||||||
if (!event.sessionId || event.type === 'unlink') {
|
if (!event.sessionId || event.type === 'unlink') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -198,8 +199,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for file changes to auto-refresh current session and detect new sessions
|
// Listen for file changes to auto-refresh current session and detect new sessions
|
||||||
if (window.electronAPI.onFileChange) {
|
if (api.onFileChange) {
|
||||||
const cleanup = window.electronAPI.onFileChange((event) => {
|
const cleanup = api.onFileChange((event) => {
|
||||||
// Skip unlink events
|
// Skip unlink events
|
||||||
if (event.type === 'unlink') {
|
if (event.type === 'unlink') {
|
||||||
return;
|
return;
|
||||||
|
|
@ -234,8 +235,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for updater status events from main process
|
// Listen for updater status events from main process
|
||||||
if (window.electronAPI.updater?.onStatus) {
|
if (api.updater?.onStatus) {
|
||||||
const cleanup = window.electronAPI.updater.onStatus((_event: unknown, status: unknown) => {
|
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {
|
||||||
const s = status as UpdaterStatus;
|
const s = status as UpdaterStatus;
|
||||||
switch (s.type) {
|
switch (s.type) {
|
||||||
case 'checking':
|
case 'checking':
|
||||||
|
|
@ -279,8 +280,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for SSH connection status changes from main process
|
// Listen for SSH connection status changes from main process
|
||||||
if (window.electronAPI.ssh?.onStatus) {
|
if (api.ssh?.onStatus) {
|
||||||
const cleanup = window.electronAPI.ssh.onStatus((_event: unknown, status: unknown) => {
|
const cleanup = api.ssh.onStatus((_event: unknown, status: unknown) => {
|
||||||
const s = status as { state: string; host: string | null; error: string | null };
|
const s = status as { state: string; host: string | null; error: string | null };
|
||||||
useStore
|
useStore
|
||||||
.getState()
|
.getState()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
* Config slice - manages app configuration state and actions.
|
* Config slice - manages app configuration state and actions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
|
@ -40,7 +41,7 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
||||||
fetchConfig: async () => {
|
fetchConfig: async () => {
|
||||||
set({ configLoading: true, configError: null });
|
set({ configLoading: true, configError: null });
|
||||||
try {
|
try {
|
||||||
const config = await window.electronAPI.config.get();
|
const config = await api.config.get();
|
||||||
set({
|
set({
|
||||||
appConfig: config,
|
appConfig: config,
|
||||||
configLoading: false,
|
configLoading: false,
|
||||||
|
|
@ -56,9 +57,9 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
||||||
// Update a section of the app configuration
|
// Update a section of the app configuration
|
||||||
updateConfig: async (section: string, data: Record<string, unknown>) => {
|
updateConfig: async (section: string, data: Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.config.update(section, data);
|
await api.config.update(section, data);
|
||||||
// Refresh config after update
|
// Refresh config after update
|
||||||
const config = await window.electronAPI.config.get();
|
const config = await api.config.get();
|
||||||
set({ appConfig: config });
|
set({ appConfig: config });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to update config:', error);
|
logger.error('Failed to update config:', error);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
* and provides actions for connecting/disconnecting.
|
* and provides actions for connecting/disconnecting.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
|
|
||||||
import { getFullResetState } from '../utils/stateResetHelpers';
|
import { getFullResetState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
|
@ -68,7 +70,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await window.electronAPI.ssh.connect(config);
|
const status = await api.ssh.connect(config);
|
||||||
set({
|
set({
|
||||||
connectionMode: status.state === 'connected' ? 'ssh' : 'local',
|
connectionMode: status.state === 'connected' ? 'ssh' : 'local',
|
||||||
connectionState: status.state,
|
connectionState: status.state,
|
||||||
|
|
@ -93,7 +95,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
privateKeyPath: config.privateKeyPath,
|
privateKeyPath: config.privateKeyPath,
|
||||||
};
|
};
|
||||||
set({ lastSshConfig: saved });
|
set({ lastSshConfig: saved });
|
||||||
void window.electronAPI.ssh.saveLastConnection(saved);
|
void api.ssh.saveLastConnection(saved);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|
@ -106,7 +108,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
|
|
||||||
disconnectSsh: async (): Promise<void> => {
|
disconnectSsh: async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const status = await window.electronAPI.ssh.disconnect();
|
const status = await api.ssh.disconnect();
|
||||||
set({
|
set({
|
||||||
connectionMode: 'local',
|
connectionMode: 'local',
|
||||||
connectionState: status.state,
|
connectionState: status.state,
|
||||||
|
|
@ -130,7 +132,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
config: SshConnectionConfig
|
config: SshConnectionConfig
|
||||||
): Promise<{ success: boolean; error?: string }> => {
|
): Promise<{ success: boolean; error?: string }> => {
|
||||||
try {
|
try {
|
||||||
return await window.electronAPI.ssh.test(config);
|
return await api.ssh.test(config);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return { success: false, error: message };
|
return { success: false, error: message };
|
||||||
|
|
@ -152,7 +154,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
|
|
||||||
fetchSshConfigHosts: async (): Promise<void> => {
|
fetchSshConfigHosts: async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const hosts = await window.electronAPI.ssh.getConfigHosts();
|
const hosts = await api.ssh.getConfigHosts();
|
||||||
set({ sshConfigHosts: hosts });
|
set({ sshConfigHosts: hosts });
|
||||||
} catch {
|
} catch {
|
||||||
// Gracefully ignore - SSH config may not exist
|
// Gracefully ignore - SSH config may not exist
|
||||||
|
|
@ -162,7 +164,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
|
|
||||||
resolveConfigHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
|
resolveConfigHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
|
||||||
try {
|
try {
|
||||||
return await window.electronAPI.ssh.resolveHost(alias);
|
return await api.ssh.resolveHost(alias);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +172,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
|
|
||||||
loadLastConnection: async (): Promise<void> => {
|
loadLastConnection: async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const saved = await window.electronAPI.ssh.getLastConnection();
|
const saved = await api.ssh.getLastConnection();
|
||||||
set({ lastSshConfig: saved });
|
set({ lastSshConfig: saved });
|
||||||
} catch {
|
} catch {
|
||||||
// Gracefully ignore - no saved connection
|
// Gracefully ignore - no saved connection
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
* Notification slice - manages notifications state and actions.
|
* Notification slice - manages notifications state and actions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { createErrorNavigationRequest, findTabBySessionAndProject } from '@renderer/types/tabs';
|
import { createErrorNavigationRequest, findTabBySessionAndProject } from '@renderer/types/tabs';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
|
@ -54,7 +55,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
||||||
set({ notificationsLoading: true, notificationsError: null });
|
set({ notificationsLoading: true, notificationsError: null });
|
||||||
try {
|
try {
|
||||||
// Fetch the full stored history (manager currently caps storage at 100).
|
// 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,
|
limit: NOTIFICATIONS_FETCH_LIMIT,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -80,7 +81,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
||||||
// Mark a single notification as read
|
// Mark a single notification as read
|
||||||
markNotificationRead: async (id: string) => {
|
markNotificationRead: async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const success = await window.electronAPI.notifications.markRead(id);
|
const success = await api.notifications.markRead(id);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
await get().fetchNotifications();
|
await get().fetchNotifications();
|
||||||
return;
|
return;
|
||||||
|
|
@ -101,7 +102,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
||||||
// Mark all notifications as read
|
// Mark all notifications as read
|
||||||
markAllNotificationsRead: async () => {
|
markAllNotificationsRead: async () => {
|
||||||
try {
|
try {
|
||||||
const success = await window.electronAPI.notifications.markAllRead();
|
const success = await api.notifications.markAllRead();
|
||||||
if (!success) {
|
if (!success) {
|
||||||
await get().fetchNotifications();
|
await get().fetchNotifications();
|
||||||
return;
|
return;
|
||||||
|
|
@ -119,7 +120,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
||||||
// Delete a single notification
|
// Delete a single notification
|
||||||
deleteNotification: async (id: string) => {
|
deleteNotification: async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const success = await window.electronAPI.notifications.delete(id);
|
const success = await api.notifications.delete(id);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
await get().fetchNotifications();
|
await get().fetchNotifications();
|
||||||
return;
|
return;
|
||||||
|
|
@ -138,7 +139,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
||||||
// Clear all notifications
|
// Clear all notifications
|
||||||
clearNotifications: async () => {
|
clearNotifications: async () => {
|
||||||
try {
|
try {
|
||||||
const success = await window.electronAPI.notifications.clear();
|
const success = await api.notifications.clear();
|
||||||
if (!success) {
|
if (!success) {
|
||||||
await get().fetchNotifications();
|
await get().fetchNotifications();
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* Project slice - manages project list state and selection.
|
* Project slice - manages project list state and selection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
|
|
||||||
import { getSessionResetState } from '../utils/stateResetHelpers';
|
import { getSessionResetState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
|
@ -39,7 +41,7 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
|
||||||
fetchProjects: async () => {
|
fetchProjects: async () => {
|
||||||
set({ projectsLoading: true, projectsError: null });
|
set({ projectsLoading: true, projectsError: null });
|
||||||
try {
|
try {
|
||||||
const projects = await window.electronAPI.getProjects();
|
const projects = await api.getProjects();
|
||||||
// Sort by most recent session (descending)
|
// Sort by most recent session (descending)
|
||||||
const sorted = [...projects].sort(
|
const sorted = [...projects].sort(
|
||||||
(a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)
|
(a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
* Repository slice - manages repository grouping state (worktree support).
|
* Repository slice - manages repository grouping state (worktree support).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { getSessionResetState } from '../utils/stateResetHelpers';
|
import { getSessionResetState } from '../utils/stateResetHelpers';
|
||||||
|
|
@ -52,7 +53,7 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
|
||||||
fetchRepositoryGroups: async () => {
|
fetchRepositoryGroups: async () => {
|
||||||
set({ repositoryGroupsLoading: true, repositoryGroupsError: null });
|
set({ repositoryGroupsLoading: true, repositoryGroupsError: null });
|
||||||
try {
|
try {
|
||||||
const groups = await window.electronAPI.getRepositoryGroups();
|
const groups = await api.getRepositoryGroups();
|
||||||
// Already sorted by most recent session in the scanner
|
// Already sorted by most recent session in the scanner
|
||||||
set({ repositoryGroups: groups, repositoryGroupsLoading: false });
|
set({ repositoryGroups: groups, repositoryGroupsLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
* Session detail slice - manages session detail, conversation, and stats.
|
* Session detail slice - manages session detail, conversation, and stats.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { asEnhancedChunkArray } from '@renderer/types/data';
|
import { asEnhancedChunkArray } from '@renderer/types/data';
|
||||||
import { findTabBySession, truncateLabel } from '@renderer/types/tabs';
|
import { findTabBySession, truncateLabel } from '@renderer/types/tabs';
|
||||||
import { processSessionClaudeMd } from '@renderer/utils/claudeMdTracker';
|
import { processSessionClaudeMd } from '@renderer/utils/claudeMdTracker';
|
||||||
|
|
@ -163,7 +164,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const detail = await window.electronAPI.getSessionDetail(projectId, sessionId);
|
const detail = await api.getSessionDetail(projectId, sessionId);
|
||||||
if (requestGeneration !== sessionDetailFetchGeneration) {
|
if (requestGeneration !== sessionDetailFetchGeneration) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +193,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
// Fetch real CLAUDE.md token data
|
// Fetch real CLAUDE.md token data
|
||||||
let claudeMdTokenData: Record<string, ClaudeMdFileInfo> = {};
|
let claudeMdTokenData: Record<string, ClaudeMdFileInfo> = {};
|
||||||
try {
|
try {
|
||||||
claudeMdTokenData = await window.electronAPI.readClaudeMdFiles(projectRoot);
|
claudeMdTokenData = await api.readClaudeMdFiles(projectRoot);
|
||||||
if (requestGeneration !== sessionDetailFetchGeneration) {
|
if (requestGeneration !== sessionDetailFetchGeneration) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -227,7 +228,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
Array.from(directoryPaths).map(async (fullPath) => {
|
Array.from(directoryPaths).map(async (fullPath) => {
|
||||||
try {
|
try {
|
||||||
const dirPath = fullPath.replace(/[\\/]CLAUDE\.md$/, '');
|
const dirPath = fullPath.replace(/[\\/]CLAUDE\.md$/, '');
|
||||||
const fileInfo = await window.electronAPI.readDirectoryClaudeMd(dirPath);
|
const fileInfo = await api.readDirectoryClaudeMd(dirPath);
|
||||||
return { fullPath, fileInfo, error: false };
|
return { fullPath, fileInfo, error: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to read directory CLAUDE.md:', fullPath, 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(
|
const mentionedFileResults = await Promise.all(
|
||||||
Array.from(mentionedFilePaths).map(async (filePath) => {
|
Array.from(mentionedFilePaths).map(async (filePath) => {
|
||||||
try {
|
try {
|
||||||
const fileInfo = await window.electronAPI.readMentionedFile(filePath, projectRoot);
|
const fileInfo = await api.readMentionedFile(filePath, projectRoot);
|
||||||
return { filePath, fileInfo };
|
return { filePath, fileInfo };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to read mentioned file:', filePath, err);
|
logger.error('Failed to read mentioned file:', filePath, err);
|
||||||
|
|
@ -471,7 +472,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
||||||
sessionRefreshInFlight.add(refreshKey);
|
sessionRefreshInFlight.add(refreshKey);
|
||||||
|
|
||||||
try {
|
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.
|
// Drop stale responses if a newer refresh started while this one was in flight.
|
||||||
if (sessionRefreshGeneration.get(refreshKey) !== generation) {
|
if (sessionRefreshGeneration.get(refreshKey) !== generation) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
* Session slice - manages session list state and pagination.
|
* Session slice - manages session list state and pagination.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
|
@ -71,7 +72,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
fetchSessions: async (projectId: string) => {
|
fetchSessions: async (projectId: string) => {
|
||||||
set({ sessionsLoading: true, sessionsError: null });
|
set({ sessionsLoading: true, sessionsError: null });
|
||||||
try {
|
try {
|
||||||
const sessions = await window.electronAPI.getSessions(projectId);
|
const sessions = await api.getSessions(projectId);
|
||||||
// Sort by createdAt (descending)
|
// Sort by createdAt (descending)
|
||||||
const sorted = [...sessions].sort((a, b) => b.createdAt - a.createdAt);
|
const sorted = [...sessions].sort((a, b) => b.createdAt - a.createdAt);
|
||||||
set({ sessions: sorted, sessionsLoading: false });
|
set({ sessions: sorted, sessionsLoading: false });
|
||||||
|
|
@ -94,7 +95,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
sessionsTotalCount: 0,
|
sessionsTotalCount: 0,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.getSessionsPaginated(projectId, null, 20, {
|
const result = await api.getSessionsPaginated(projectId, null, 20, {
|
||||||
includeTotalCount: false,
|
includeTotalCount: false,
|
||||||
prefilterAll: false,
|
prefilterAll: false,
|
||||||
});
|
});
|
||||||
|
|
@ -128,15 +129,10 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
|
|
||||||
set({ sessionsLoadingMore: true });
|
set({ sessionsLoadingMore: true });
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.getSessionsPaginated(
|
const result = await api.getSessionsPaginated(selectedProjectId, sessionsCursor, 20, {
|
||||||
selectedProjectId,
|
includeTotalCount: false,
|
||||||
sessionsCursor,
|
prefilterAll: false,
|
||||||
20,
|
});
|
||||||
{
|
|
||||||
includeTotalCount: false,
|
|
||||||
prefilterAll: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
set((prevState) => ({
|
set((prevState) => ({
|
||||||
sessions: [...prevState.sessions, ...result.sessions],
|
sessions: [...prevState.sessions, ...result.sessions],
|
||||||
sessionsCursor: result.nextCursor,
|
sessionsCursor: result.nextCursor,
|
||||||
|
|
@ -208,7 +204,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
projectRefreshGeneration.set(projectId, generation);
|
projectRefreshGeneration.set(projectId, generation);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.getSessionsPaginated(projectId, null, 20, {
|
const result = await api.getSessionsPaginated(projectId, null, 20, {
|
||||||
includeTotalCount: false,
|
includeTotalCount: false,
|
||||||
prefilterAll: false,
|
prefilterAll: false,
|
||||||
});
|
});
|
||||||
|
|
@ -242,10 +238,10 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
await window.electronAPI.config.unpinSession(projectId, sessionId);
|
await api.config.unpinSession(projectId, sessionId);
|
||||||
set({ pinnedSessionIds: state.pinnedSessionIds.filter((id) => id !== sessionId) });
|
set({ pinnedSessionIds: state.pinnedSessionIds.filter((id) => id !== sessionId) });
|
||||||
} else {
|
} else {
|
||||||
await window.electronAPI.config.pinSession(projectId, sessionId);
|
await api.config.pinSession(projectId, sessionId);
|
||||||
set({ pinnedSessionIds: [sessionId, ...state.pinnedSessionIds] });
|
set({ pinnedSessionIds: [sessionId, ...state.pinnedSessionIds] });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -263,7 +259,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await window.electronAPI.config.get();
|
const config = await api.config.get();
|
||||||
const pins = config.sessions?.pinnedSessions?.[projectId] ?? [];
|
const pins = config.sessions?.pinnedSessions?.[projectId] ?? [];
|
||||||
set({ pinnedSessionIds: pins.map((p) => p.sessionId) });
|
set({ pinnedSessionIds: pins.map((p) => p.sessionId) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* Subagent slice - manages subagent drill-down state.
|
* Subagent slice - manages subagent drill-down state.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
|
|
||||||
import type { AppState, BreadcrumbItem } from '../types';
|
import type { AppState, BreadcrumbItem } from '../types';
|
||||||
import type { SubagentDetail } from '@renderer/types/data';
|
import type { SubagentDetail } from '@renderer/types/data';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
@ -48,7 +50,7 @@ export const createSubagentSlice: StateCreator<AppState, [], [], SubagentSlice>
|
||||||
) => {
|
) => {
|
||||||
set({ subagentDetailLoading: true, subagentDetailError: null });
|
set({ subagentDetailLoading: true, subagentDetailError: null });
|
||||||
try {
|
try {
|
||||||
const detail = await window.electronAPI.getSubagentDetail(projectId, sessionId, subagentId);
|
const detail = await api.getSubagentDetail(projectId, sessionId, subagentId);
|
||||||
|
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
set({
|
set({
|
||||||
|
|
@ -108,7 +110,7 @@ export const createSubagentSlice: StateCreator<AppState, [], [], SubagentSlice>
|
||||||
|
|
||||||
set({ subagentDetailLoading: true, subagentDetailError: null });
|
set({ subagentDetailLoading: true, subagentDetailError: null });
|
||||||
|
|
||||||
window.electronAPI
|
api
|
||||||
.getSubagentDetail(projectId, sessionId, targetItem.id)
|
.getSubagentDetail(projectId, sessionId, targetItem.id)
|
||||||
.then((detail) => {
|
.then((detail) => {
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
* Update slice - manages OTA auto-update state and actions.
|
* Update slice - manages OTA auto-update state and actions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
|
@ -54,20 +55,20 @@ export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (s
|
||||||
|
|
||||||
checkForUpdates: () => {
|
checkForUpdates: () => {
|
||||||
set({ updateStatus: 'checking', updateError: null });
|
set({ updateStatus: 'checking', updateError: null });
|
||||||
window.electronAPI.updater.check().catch((error) => {
|
api.updater.check().catch((error) => {
|
||||||
logger.error('Failed to check for updates:', error);
|
logger.error('Failed to check for updates:', error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadUpdate: () => {
|
downloadUpdate: () => {
|
||||||
set({ showUpdateDialog: false, showUpdateBanner: true, downloadProgress: 0 });
|
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);
|
logger.error('Failed to download update:', error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
installUpdate: () => {
|
installUpdate: () => {
|
||||||
window.electronAPI.updater.install().catch((error) => {
|
api.updater.install().catch((error) => {
|
||||||
logger.error('Failed to install update:', error);
|
logger.error('Failed to install update:', error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,27 @@ export interface SshAPI {
|
||||||
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void;
|
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
|
// Main Electron API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -334,6 +355,9 @@ export interface ElectronAPI {
|
||||||
switch: (contextId: string) => Promise<{ contextId: string }>;
|
switch: (contextId: string) => Promise<{ contextId: string }>;
|
||||||
onChanged: (callback: (event: unknown, data: ContextInfo) => void) => () => void;
|
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 */
|
/** Pinned sessions per project. Key is projectId, value is array of pinned sessions */
|
||||||
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
|
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