/** * IPC handlers for code review / diff view feature. * * Паттерн: module-level state + guard + wrapReviewHandler (как teams.ts) */ import { createIpcWrapper } from '@main/ipc/ipcWrapper'; import { EditorFileWatcher } from '@main/services/editor'; import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore'; import { validateFilePath } from '@main/utils/pathValidation'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { REVIEW_APPLY_DECISIONS, REVIEW_CHECK_CONFLICT, REVIEW_CLEAR_DECISIONS, REVIEW_FILE_CHANGE, REVIEW_GET_AGENT_CHANGES, REVIEW_GET_CHANGE_STATS, REVIEW_GET_FILE_CONTENT, REVIEW_GET_GIT_FILE_LOG, REVIEW_GET_TASK_CHANGES, REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES, REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, REVIEW_LOAD_DECISIONS, REVIEW_PREVIEW_REJECT, REVIEW_REJECT_FILE, REVIEW_REJECT_HUNKS, REVIEW_SAVE_DECISIONS, REVIEW_SAVE_EDITED_FILE, REVIEW_UNWATCH_FILES, REVIEW_WATCH_FILES, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs/promises'; import * as path from 'path'; import type { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; import type { FileContentResolver } from '@main/services/team/FileContentResolver'; import type { GitDiffFallback } from '@main/services/team/GitDiffFallback'; import type { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import type { IpcResult } from '@shared/types/ipc'; import type { AgentChangeSet, ApplyReviewRequest, ApplyReviewResult, ChangeStats, ConflictCheckResult, FileChangeWithContent, HunkDecision, RejectResult, SnippetDiff, TaskChangeRequestOptions, TaskChangeSetV2, TeamTaskChangeSummariesResponse, TeamTaskChangeSummaryRequest, } from '@shared/types/review'; import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron'; const wrapReviewHandler = createIpcWrapper('IPC:review'); const logger = createLogger('IPC:review'); const TEAM_TASK_CHANGE_SUMMARY_IPC_RAW_REQUEST_LIMIT = 1_000; const TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT = 201; // --- Module-level state --- let changeExtractor: ChangeExtractorService | null = null; let reviewApplier: ReviewApplierService | null = null; let fileContentResolver: FileContentResolver | null = null; let gitDiffFallback: GitDiffFallback | null = null; const reviewDecisionStore = new ReviewDecisionStore(); const reviewFileWatcher = new EditorFileWatcher(); let reviewWatcherProjectRoot: string | null = null; let reviewMainWindowRef: BrowserWindow | null = null; function getChangeExtractor(): ChangeExtractorService { if (!changeExtractor) throw new Error('Review handlers not initialized'); return changeExtractor; } function getApplier(): ReviewApplierService { if (!reviewApplier) throw new Error('ReviewApplierService not initialized'); return reviewApplier; } function getContentResolver(): FileContentResolver { if (!fileContentResolver) throw new Error('FileContentResolver not initialized'); return fileContentResolver; } // --- Forward-compatible config object --- export interface ReviewHandlerDeps { extractor: ChangeExtractorService; applier?: ReviewApplierService; contentResolver?: FileContentResolver; gitFallback?: GitDiffFallback; } export function initializeReviewHandlers(deps: ReviewHandlerDeps): void { changeExtractor = deps.extractor; if (deps.applier) reviewApplier = deps.applier; if (deps.contentResolver) fileContentResolver = deps.contentResolver; if (deps.gitFallback) gitDiffFallback = deps.gitFallback; } export function registerReviewHandlers(ipcMain: IpcMain): void { // Phase 1 ipcMain.handle(REVIEW_GET_AGENT_CHANGES, handleGetAgentChanges); ipcMain.handle(REVIEW_GET_TASK_CHANGES, handleGetTaskChanges); ipcMain.handle(REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES, handleGetTeamTaskChangeSummaries); ipcMain.handle(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, handleInvalidateTaskChangeSummaries); ipcMain.handle(REVIEW_GET_CHANGE_STATS, handleGetChangeStats); // Phase 2 ipcMain.handle(REVIEW_CHECK_CONFLICT, handleCheckConflict); ipcMain.handle(REVIEW_REJECT_HUNKS, handleRejectHunks); ipcMain.handle(REVIEW_REJECT_FILE, handleRejectFile); ipcMain.handle(REVIEW_PREVIEW_REJECT, handlePreviewReject); ipcMain.handle(REVIEW_APPLY_DECISIONS, handleApplyDecisions); ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent); // Editable diff ipcMain.handle(REVIEW_SAVE_EDITED_FILE, handleSaveEditedFile); ipcMain.handle(REVIEW_WATCH_FILES, handleWatchReviewFiles); ipcMain.handle(REVIEW_UNWATCH_FILES, handleUnwatchReviewFiles); // Phase 4 ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog); // Decision persistence ipcMain.handle(REVIEW_LOAD_DECISIONS, handleLoadDecisions); ipcMain.handle(REVIEW_SAVE_DECISIONS, handleSaveDecisions); ipcMain.handle(REVIEW_CLEAR_DECISIONS, handleClearDecisions); } export function removeReviewHandlers(ipcMain: IpcMain): void { // Phase 1 ipcMain.removeHandler(REVIEW_GET_AGENT_CHANGES); ipcMain.removeHandler(REVIEW_GET_TASK_CHANGES); ipcMain.removeHandler(REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES); ipcMain.removeHandler(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES); ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS); // Phase 2 ipcMain.removeHandler(REVIEW_CHECK_CONFLICT); ipcMain.removeHandler(REVIEW_REJECT_HUNKS); ipcMain.removeHandler(REVIEW_REJECT_FILE); ipcMain.removeHandler(REVIEW_PREVIEW_REJECT); ipcMain.removeHandler(REVIEW_APPLY_DECISIONS); ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT); // Editable diff ipcMain.removeHandler(REVIEW_SAVE_EDITED_FILE); ipcMain.removeHandler(REVIEW_WATCH_FILES); ipcMain.removeHandler(REVIEW_UNWATCH_FILES); // Phase 4 ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG); // Decision persistence ipcMain.removeHandler(REVIEW_LOAD_DECISIONS); ipcMain.removeHandler(REVIEW_SAVE_DECISIONS); ipcMain.removeHandler(REVIEW_CLEAR_DECISIONS); reviewFileWatcher.stop(); reviewWatcherProjectRoot = null; } export function setReviewMainWindow(win: BrowserWindow | null): void { reviewMainWindowRef = win; } // --- Phase 1 Handlers --- async function handleGetAgentChanges( _event: IpcMainInvokeEvent, teamName: string, memberName: string ): Promise> { return wrapReviewHandler('getAgentChanges', () => getChangeExtractor().getAgentChanges(teamName, memberName) ); } function sanitizeTaskChangeOptions(options?: unknown): TaskChangeRequestOptions | undefined { if (!options || typeof options !== 'object') { return undefined; } const raw = options as Record; return { owner: typeof raw.owner === 'string' ? raw.owner : undefined, status: typeof raw.status === 'string' ? raw.status : undefined, since: typeof raw.since === 'string' ? raw.since : undefined, intervals: Array.isArray(raw.intervals) ? (raw.intervals.filter( (i): i is { startedAt: string; completedAt?: string } => Boolean(i) && typeof i === 'object' && typeof (i as Record).startedAt === 'string' && ((i as Record).completedAt === undefined || typeof (i as Record).completedAt === 'string') ) as { startedAt: string; completedAt?: string }[]) : undefined, stateBucket: raw.stateBucket === 'approved' || raw.stateBucket === 'review' || raw.stateBucket === 'completed' || raw.stateBucket === 'active' ? raw.stateBucket : undefined, summaryOnly: raw.summaryOnly === true, forceFresh: raw.forceFresh === true, }; } function sanitizeTeamTaskChangeSummaryRequests(requests: unknown): TeamTaskChangeSummaryRequest[] { if (!Array.isArray(requests)) { return []; } const sanitizedRequests: TeamTaskChangeSummaryRequest[] = []; const seenTaskIds = new Set(); for (const request of requests.slice(0, TEAM_TASK_CHANGE_SUMMARY_IPC_RAW_REQUEST_LIMIT)) { if (sanitizedRequests.length >= TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT) { break; } if (!request || typeof request !== 'object') { continue; } const raw = request as Record; if (typeof raw.taskId !== 'string') { continue; } const taskId = raw.taskId.trim(); if (!taskId || seenTaskIds.has(taskId)) { continue; } seenTaskIds.add(taskId); sanitizedRequests.push({ taskId, options: sanitizeTaskChangeOptions(raw.options), }); } return sanitizedRequests; } async function handleGetTaskChanges( _event: IpcMainInvokeEvent, teamName: string, taskId: string, options?: unknown ): Promise> { const opts = sanitizeTaskChangeOptions(options); return wrapReviewHandler('getTaskChanges', () => getChangeExtractor().getTaskChanges(teamName, taskId, opts) ); } async function handleGetTeamTaskChangeSummaries( _event: IpcMainInvokeEvent, teamName: string, requests: unknown ): Promise> { const sanitizedRequests = sanitizeTeamTaskChangeSummaryRequests(requests); return wrapReviewHandler('getTeamTaskChangeSummaries', () => getChangeExtractor().getTeamTaskChangeSummaries(teamName, sanitizedRequests) ); } async function handleInvalidateTaskChangeSummaries( _event: IpcMainInvokeEvent, teamName: string, taskIds: string[] ): Promise> { return wrapReviewHandler('invalidateTaskChangeSummaries', async () => { await getChangeExtractor().invalidateTaskChangeSummaries( teamName, Array.isArray(taskIds) ? taskIds.filter((taskId) => typeof taskId === 'string') : [] ); }); } async function handleGetChangeStats( _event: IpcMainInvokeEvent, teamName: string, memberName: string ): Promise> { return wrapReviewHandler('getChangeStats', () => getChangeExtractor().getChangeStats(teamName, memberName) ); } // --- Phase 2 Handlers --- async function handleCheckConflict( _event: IpcMainInvokeEvent, filePath: string, expectedModified: string ): Promise> { return wrapReviewHandler('checkConflict', () => getApplier().checkConflict(filePath, expectedModified) ); } async function handleRejectHunks( _event: IpcMainInvokeEvent, teamName: string, filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[] ): Promise> { return wrapReviewHandler('rejectHunks', () => getApplier().rejectHunks(teamName, filePath, original, modified, hunkIndices, snippets) ); } async function handleRejectFile( _event: IpcMainInvokeEvent, teamName: string, filePath: string, original: string, modified: string ): Promise> { return wrapReviewHandler('rejectFile', () => getApplier().rejectFile(teamName, filePath, original, modified) ); } async function handlePreviewReject( _event: IpcMainInvokeEvent, filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[] ): Promise> { return wrapReviewHandler('previewReject', () => getApplier().previewReject(filePath, original, modified, hunkIndices, snippets) ); } async function handleApplyDecisions( _event: IpcMainInvokeEvent, request: ApplyReviewRequest ): Promise> { if (!request || !Array.isArray(request.decisions)) { return { success: false, error: 'Invalid request: decisions array required' }; } return wrapReviewHandler('applyDecisions', async () => { // Build file contents map for the applier. Prefer renderer-provided context // (snippets + full contents), falling back to resolver when missing. const fileContents = new Map(); const memberName = request.memberName ?? ''; for (const d of request.decisions) { const snippets = d.snippets ?? []; // If renderer provided full contents, use them directly. if (d.originalFullContent !== undefined || d.modifiedFullContent !== undefined) { fileContents.set(d.filePath, { filePath: d.filePath, relativePath: d.filePath.split(/[\\/]/).filter(Boolean).slice(-3).join('/'), snippets, linesAdded: 0, linesRemoved: 0, isNewFile: d.isNewFile ?? snippets.some((s) => s.type === 'write-new'), originalFullContent: d.originalFullContent ?? null, modifiedFullContent: d.modifiedFullContent ?? null, // Source is informational only; "unavailable" avoids lying. contentSource: 'unavailable', }); continue; } // Fallback: resolve in main process (best-effort; task mode may not have memberName). const resolved = await getContentResolver().getFileContent( request.teamName, memberName, d.filePath, snippets ); fileContents.set(d.filePath, resolved); } const result = await getApplier().applyReviewDecisions(request, fileContents); // Invalidate resolved file content cache after applying decisions so subsequent // diff operations read the latest disk state (avoids "stuck" decisions in instant-apply flows). try { for (const d of request.decisions) { getContentResolver().invalidateFile(d.filePath); } } catch (error) { logger.debug('applyDecisions cache invalidation failed:', error); } return result; }); } async function handleGetFileContent( _event: IpcMainInvokeEvent, teamName: string, memberName: string, filePath: string, snippets: SnippetDiff[] = [] ): Promise> { return wrapReviewHandler('getFileContent', () => getContentResolver().getFileContent(teamName, memberName, filePath, snippets) ); } // --- Editable diff Handlers --- async function handleSaveEditedFile( _event: IpcMainInvokeEvent, filePath: string, content: string, projectPath?: string ): Promise> { if (!filePath || typeof content !== 'string') { return { success: false, error: 'Invalid parameters' }; } const resolvedProjectPath = projectPath && typeof projectPath === 'string' ? projectPath : null; const pathCheck = validateFilePath(filePath, resolvedProjectPath); if (!pathCheck.valid) { logger.error(`saveEditedFile blocked: ${String(pathCheck.error)} (path: ${String(filePath)})`); return { success: false, error: `Path validation failed: ${String(pathCheck.error)}` }; } return wrapReviewHandler('saveEditedFile', async () => { const result = await getApplier().saveEditedFile(pathCheck.normalizedPath!, content); // Invalidate cached content so next fetch reads the saved version from disk getContentResolver().invalidateFile(pathCheck.normalizedPath!); return result; }); } async function handleWatchReviewFiles( _event: IpcMainInvokeEvent, projectPath: string, filePaths: string[] ): Promise> { return wrapReviewHandler('watchFiles', async () => { const normalizedProjectPath = await validateReviewProjectPath(projectPath); const shouldRestart = reviewWatcherProjectRoot !== normalizedProjectPath || !reviewFileWatcher.isWatching(); if (shouldRestart) { reviewFileWatcher.stop(); reviewWatcherProjectRoot = normalizedProjectPath; reviewFileWatcher.start(normalizedProjectPath, (event) => { safeSendToRenderer(reviewMainWindowRef, REVIEW_FILE_CHANGE, event); }); } reviewFileWatcher.setWatchedFiles(Array.isArray(filePaths) ? filePaths : []); }); } async function handleUnwatchReviewFiles(): Promise> { return wrapReviewHandler('unwatchFiles', async () => { reviewFileWatcher.stop(); reviewWatcherProjectRoot = null; }); } // --- Phase 4 Handlers --- async function validateReviewProjectPath(projectPath: string): Promise { if (!projectPath || typeof projectPath !== 'string') { throw new Error('Invalid project path'); } if (!path.isAbsolute(projectPath)) { throw new Error('Project path must be absolute'); } const normalized = path.resolve(path.normalize(projectPath)); const stat = await fs.stat(normalized); if (!stat.isDirectory()) { throw new Error('Project path is not a directory'); } return normalized; } async function handleGetGitFileLog( _event: IpcMainInvokeEvent, projectPath: string, filePath: string ): Promise> { return wrapReviewHandler('getGitFileLog', async () => { if (!gitDiffFallback) { return []; } return gitDiffFallback.getFileLog(projectPath, filePath); }); } // --- Decision Persistence Handlers --- async function handleLoadDecisions( _event: IpcMainInvokeEvent, teamName: string, scopeKey: string, scopeToken: string | null = null ): Promise< IpcResult<{ hunkDecisions: Record; fileDecisions: Record; hunkContextHashesByFile?: Record>; } | null> > { return wrapReviewHandler('loadDecisions', () => reviewDecisionStore.load(teamName, scopeKey, scopeToken ?? undefined) ); } async function handleSaveDecisions( _event: IpcMainInvokeEvent, teamName: string, scopeKey: string, scopeToken: string, hunkDecisions: Record, fileDecisions: Record, hunkContextHashesByFile: Record> | null = null ): Promise> { return wrapReviewHandler('saveDecisions', () => reviewDecisionStore.save(teamName, scopeKey, { scopeToken, hunkDecisions, fileDecisions, hunkContextHashesByFile: hunkContextHashesByFile ?? undefined, }) ); } async function handleClearDecisions( _event: IpcMainInvokeEvent, teamName: string, scopeKey: string, scopeToken: string | null = null ): Promise> { return wrapReviewHandler('clearDecisions', () => reviewDecisionStore.clear(teamName, scopeKey, scopeToken ?? undefined) ); }