# Phase 4: Enhanced Features ## Цель Качественные улучшения UX diff view: клавиатурная навигация между hunks, отслеживание "просмотренных" файлов, timeline изменений файла, git fallback для случаев когда JSONL данные неполные. --- ## Feature 1: Keyboard Navigation ### Цель Навигация по hunks и файлам через клавиатуру (как в GitHub PR review). `j`/`k` или `↑`/`↓` для перехода между hunks, `n`/`p` для перехода между файлами. ### Реализация #### Hook: `src/renderer/hooks/useDiffNavigation.ts` (NEW) ```typescript interface DiffNavigationState { /** Текущий hunk index в выбранном файле */ currentHunkIndex: number; /** Общее количество hunks в файле */ totalHunks: number; /** Перейти к следующему hunk */ goToNextHunk: () => void; /** Перейти к предыдущему hunk */ goToPrevHunk: () => void; /** Перейти к следующему файлу */ goToNextFile: () => void; /** Перейти к предыдущему файлу */ goToPrevFile: () => void; /** Перейти к конкретному hunk */ goToHunk: (index: number) => void; /** Accept текущий hunk */ acceptCurrentHunk: () => void; /** Reject текущий hunk */ rejectCurrentHunk: () => void; } export function useDiffNavigation( files: FileChangeSummary[], selectedFilePath: string | null, onSelectFile: (path: string) => void, onHunkAccepted?: (filePath: string, hunkIndex: number) => void, onHunkRejected?: (filePath: string, hunkIndex: number) => void, ): DiffNavigationState; ``` **Ключевые shortcuts:** | Key | Action | Context | |-----|--------|---------| | `j` или `↓` | Next hunk | Diff dialog open | | `k` или `↑` | Previous hunk | Diff dialog open | | `n` | Next file | Diff dialog open | | `p` или `Shift+N` | Previous file | Diff dialog open | | `a` | Accept current hunk | Diff dialog open | | `x` | Reject current hunk | Diff dialog open | | `Shift+A` | Accept all hunks in file | Diff dialog open | | `Shift+X` | Reject all hunks in file | Diff dialog open | | `Enter` | Toggle hunk collapse | Diff dialog open | | `?` | Show shortcuts help | Diff dialog open | | `Escape` | Close diff dialog | Diff dialog open | **ВАЖНО (H1 fix): Конфликт с useKeyboardShortcuts!** Существующий `useKeyboardShortcuts.ts` уже занимает `Cmd+Shift+K` (цикл contexts). Все Phase 4 shortcuts работают **ТОЛЬКО внутри открытого ChangeReviewDialog** (модальный контекст). Это предотвращает конфликты — глобальные shortcuts не срабатывают когда dialog открыт. НЕ добавляем shortcuts в `useKeyboardShortcuts.ts` — вместо этого регистрируем локальный handler внутри `useDiffNavigation` hook с guard `if (!isDialogOpen) return`. **Реализация через МОДАЛЬНЫЙ контекст (НЕ useKeyboardShortcuts.ts):** ```typescript useEffect(() => { if (!isDialogOpen) return; const handler = (event: KeyboardEvent) => { // Не перехватываем если фокус в input/textarea if ( event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement ) return; switch (event.key) { case 'j': case 'ArrowDown': event.preventDefault(); goToNextHunk(); break; case 'k': case 'ArrowUp': event.preventDefault(); goToPrevHunk(); break; case 'n': event.preventDefault(); goToNextFile(); break; case 'p': event.preventDefault(); goToPrevFile(); break; case 'a': if (!event.shiftKey) { event.preventDefault(); acceptCurrentHunk(); } else { event.preventDefault(); acceptAllFile(); } break; case 'x': if (!event.shiftKey) { event.preventDefault(); rejectCurrentHunk(); } else { event.preventDefault(); rejectAllFile(); } break; case 'Escape': event.preventDefault(); onClose(); break; } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [isDialogOpen, currentHunkIndex, selectedFilePath]); ``` **Scroll-to-hunk через CodeMirror API — VERIFIED:** ```typescript // VERIFIED: goToNextChunk и goToPreviousChunk — это (view: EditorView) => boolean функции. // Вызываются НАПРЯМУЮ, НЕ через .run(): import { goToNextChunk, goToPreviousChunk } from '@codemirror/merge'; function scrollToHunk(editorView: EditorView, direction: 'next' | 'prev'): boolean { if (direction === 'next') { return goToNextChunk(editorView); // Прямой вызов! Возвращает boolean. } else { return goToPreviousChunk(editorView); } // true = нашёл chunk и перешёл, false = конец/начало (нет больше chunks) } ``` #### Компонент: `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` (NEW) Всплывающая подсказка с shortcut list (показывается по `?`). ```typescript interface KeyboardShortcutsHelpProps { open: boolean; onOpenChange: (open: boolean) => void; } ``` **~40 LOC**: Простая таблица с иконками клавиш и описаниями. --- ## Feature 2: "Viewed" File Tracking ### Цель Пользователь может отметить файл как "просмотренный" (как в GitHub). Состояние сохраняется в localStorage. ### Реализация #### Storage: `src/renderer/utils/diffViewedStorage.ts` (NEW) **Паттерн**: Повторяет `teamMessageReadStorage.ts` — простой localStorage с JSON serialization. ```typescript const STORAGE_PREFIX = 'diff-viewed'; const MAX_ENTRIES_PER_SCOPE = 5000; // M2 fix: cap per-scope entries const MAX_TOTAL_ENTRIES = 50; // M2 fix: max number of scope keys in storage /** * Ключ = `diff-viewed:{teamName}:{scopeKey}`. * Значение = JSON object `{ files: string[], updatedAt: string }`. * * R3 FIX: Формат хранения — ВСЕГДА объект { files, updatedAt }, НЕ плоский string[]. * Это обеспечивает совместимость get/set/cleanup. * * M2 fix: scopeKey включает version hash (computedAt) для инвалидации * при перевычислении changeSet. * * ФОРМАТ scopeKey: * - Task mode: `task:{taskId}` (пример: `task:42`) * - Agent mode: `agent:{memberName}` (пример: `agent:researcher`) * - Full team: `team` (для полного team review без фильтрации) * * Вызывающий код генерирует scopeKey: * ```typescript * function buildScopeKey(mode: 'task' | 'agent' | 'team', id?: string): string { * if (mode === 'task') return `task:${id}`; * if (mode === 'agent') return `agent:${id}`; * return 'team'; * } * ``` * * Инвалидация: При изменении computedAt в activeChangeSet, viewed state * сбрасывается через useEffect в useViewedFiles (version bump → re-read). */ interface ViewedStorageEntry { files: string[]; updatedAt: string; } function getStorageKey(teamName: string, scopeKey: string): string { return `${STORAGE_PREFIX}:${teamName}:${scopeKey}`; } function parseEntry(raw: string | null): ViewedStorageEntry | null { if (!raw) return null; try { const parsed = JSON.parse(raw); // R3 FIX: Миграция из старого формата (plain string[]) → новый формат if (Array.isArray(parsed)) { return { files: parsed, updatedAt: new Date(0).toISOString() }; } if (parsed && Array.isArray(parsed.files)) { return parsed as ViewedStorageEntry; } return null; } catch { return null; } } // ВАЖНО: Все localStorage операции обёрнуты в try-catch. // QuotaExceededError возможен при переполнении (~5MB limit). // При ошибке: логируем warning, операция no-op (viewed state теряется, не критично). function saveEntry(teamName: string, scopeKey: string, entry: ViewedStorageEntry): void { try { localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry)); } catch (error) { console.warn('[diffViewedStorage] localStorage write failed:', error); // QuotaExceededError — попробуем очистить старые entries и retry try { cleanupOldViewedEntries(); localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry)); } catch { // Полный отказ — молча проглатываем, viewed state не критичен } } } /** M2 fix: Cleanup старых entries при переполнении */ export function cleanupOldViewedEntries(): void { const keys: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key?.startsWith(STORAGE_PREFIX)) keys.push(key); } // Если слишком много — удаляем самые старые по updatedAt if (keys.length > MAX_TOTAL_ENTRIES) { const sorted = keys .map(k => ({ key: k, entry: parseEntry(localStorage.getItem(k)) })) .sort((a, b) => (a.entry?.updatedAt ?? '').localeCompare(b.entry?.updatedAt ?? '')); for (let i = 0; i < sorted.length - MAX_TOTAL_ENTRIES; i++) { localStorage.removeItem(sorted[i].key); } } } /** Получить Set просмотренных файлов */ export function getViewedFiles(teamName: string, scopeKey: string): Set { const entry = parseEntry(localStorage.getItem(getStorageKey(teamName, scopeKey))); return entry ? new Set(entry.files) : new Set(); } /** Отметить файл как просмотренный */ export function markFileViewed(teamName: string, scopeKey: string, filePath: string): void { const set = getViewedFiles(teamName, scopeKey); set.add(filePath); saveEntry(teamName, scopeKey, { files: [...set], updatedAt: new Date().toISOString(), }); } /** Отметить файл как НЕ просмотренный */ export function unmarkFileViewed(teamName: string, scopeKey: string, filePath: string): void { const set = getViewedFiles(teamName, scopeKey); set.delete(filePath); if (set.size === 0) { localStorage.removeItem(getStorageKey(teamName, scopeKey)); return; } saveEntry(teamName, scopeKey, { files: [...set], updatedAt: new Date().toISOString(), }); } /** Отметить все файлы как просмотренные */ export function markAllViewed(teamName: string, scopeKey: string, filePaths: string[]): void { saveEntry(teamName, scopeKey, { files: filePaths, updatedAt: new Date().toISOString(), }); } /** Сбросить все отметки */ export function clearViewed(teamName: string, scopeKey: string): void { localStorage.removeItem(getStorageKey(teamName, scopeKey)); } ``` #### Hook: `src/renderer/hooks/useViewedFiles.ts` (NEW) ```typescript import { useState, useCallback, useMemo } from 'react'; import * as storage from '@renderer/utils/diffViewedStorage'; interface UseViewedFilesResult { viewedSet: Set; isViewed: (filePath: string) => boolean; markViewed: (filePath: string) => void; unmarkViewed: (filePath: string) => void; markAllViewed: (filePaths: string[]) => void; clearAll: () => void; viewedCount: number; totalCount: number; /** Прогресс 0-100 */ progress: number; } export function useViewedFiles( teamName: string, scopeKey: string, totalFiles: string[] ): UseViewedFilesResult { // version bump pattern (из useTeamMessagesRead) const [version, setVersion] = useState(0); const viewedSet = useMemo(() => { if (version < 0) return new Set(); return storage.getViewedFiles(teamName, scopeKey); }, [teamName, scopeKey, version]); const markViewed = useCallback((filePath: string) => { storage.markFileViewed(teamName, scopeKey, filePath); setVersion(v => v + 1); }, [teamName, scopeKey]); const unmarkViewed = useCallback((filePath: string) => { storage.unmarkFileViewed(teamName, scopeKey, filePath); setVersion(v => v + 1); }, [teamName, scopeKey]); const markAllViewed = useCallback((filePaths: string[]) => { storage.markAllViewed(teamName, scopeKey, filePaths); setVersion(v => v + 1); }, [teamName, scopeKey]); const clearAll = useCallback(() => { storage.clearViewed(teamName, scopeKey); setVersion(v => v + 1); }, [teamName, scopeKey]); const viewedCount = totalFiles.filter(f => viewedSet.has(f)).length; return { viewedSet, isViewed: (fp) => viewedSet.has(fp), markViewed, unmarkViewed, markAllViewed, clearAll, viewedCount, totalCount: totalFiles.length, progress: totalFiles.length > 0 ? Math.round((viewedCount / totalFiles.length) * 100) : 0, }; } ``` #### Компонент: `src/renderer/components/team/review/ViewedProgressBar.tsx` (NEW) ```typescript interface ViewedProgressBarProps { viewed: number; total: number; progress: number; } ``` Тонкий progress bar в header ChangeReviewDialog: ``` [████████░░░░░░░░░░] 5/12 files viewed (42%) ``` #### Интеграция в `ReviewFileTree.tsx` (MODIFY) Checkbox рядом с каждым файлом: ```typescript
{ if (e.target.checked) markViewed(file.filePath); else unmarkViewed(file.filePath); }} className="rounded border-border" aria-label={`Mark ${file.relativePath} as viewed`} /> {file.relativePath}
``` **Auto-mark**: Файл автоматически помечается viewed когда пользователь прокрутил весь diff до конца (через IntersectionObserver на последний hunk). --- ## Feature 3: File Edit Timeline ### Цель Показать хронологию изменений файла в рамках задачи: какие Edit/Write операции произошли, в каком порядке, с какими tool_use. ### Реализация #### Типы: `src/shared/types/review.ts` (MODIFY) ```typescript /** Одно событие в timeline файла */ export interface FileEditEvent { /** tool_use.id */ toolUseId: string; /** Тип операции */ toolName: 'Edit' | 'Write' | 'MultiEdit' | 'NotebookEdit'; /** Timestamp из JSONL */ timestamp: string; /** Краткое описание: "Edited 3 lines", "Created new file", etc */ summary: string; /** +/- строк */ linesAdded: number; linesRemoved: number; /** Индекс snippet в FileChangeSummary.snippets[] */ snippetIndex: number; } /** Timeline для файла */ export interface FileEditTimeline { filePath: string; events: FileEditEvent[]; /** Общая длительность (first event → last event) */ durationMs: number; } ``` #### Backend: `ChangeExtractorService.ts` (MODIFY — добавить timeline generation) Timeline генерируется автоматически при `getAgentChanges()` / `getTaskChanges()`: ```typescript // При сборе snippets — также записываем timeline events private buildTimeline(snippets: SnippetDiff[]): FileEditEvent[] { return snippets.map((s, idx) => ({ toolUseId: s.toolUseId, toolName: s.toolName, timestamp: s.timestamp, summary: this.generateEditSummary(s), linesAdded: Math.max(0, s.newString.split('\n').length - s.oldString.split('\n').length), linesRemoved: Math.max(0, s.oldString.split('\n').length - s.newString.split('\n').length), snippetIndex: idx, })); } private generateEditSummary(snippet: SnippetDiff): string { switch (snippet.type) { case 'write-new': return 'Created new file'; case 'write-update': return 'Wrote full file content'; case 'multi-edit': return `Multi-edit (${snippet.oldString.split('\n').length} lines)`; case 'edit': { const added = snippet.newString.split('\n').length; const removed = snippet.oldString.split('\n').length; if (removed === 0) return `Added ${added} line${added !== 1 ? 's' : ''}`; if (added === 0) return `Removed ${removed} line${removed !== 1 ? 's' : ''}`; return `Changed ${removed} → ${added} lines`; } default: return 'File modified'; } } ``` #### Компонент: `src/renderer/components/team/review/FileEditTimeline.tsx` (NEW) **Паттерн**: Визуально похож на `ActivityItem.tsx` — вертикальная timeline с цветными точками. ```typescript interface FileEditTimelineProps { timeline: FileEditTimeline; /** Клик по event → scroll к snippet в diff view */ onEventClick?: (snippetIndex: number) => void; /** Текущий highlighted event */ activeSnippetIndex?: number; } ``` **Layout:** ``` ● 10:23:45 Created new file [+42] │ ● 10:24:12 Changed 5 → 8 lines [+3] │ ● 10:25:01 Multi-edit (12 lines) [+2 -3] │ ● 10:26:33 Added 15 lines [+15] ``` **~120 LOC**: Timeline items с timestamp, summary, +/- badge, clickable. #### Интеграция в `ChangeReviewDialog.tsx` (MODIFY) Timeline показывается в sidebar под file tree (collapsible section): ```typescript // Под ReviewFileTree {selectedFile && (
{timelineOpen && ( scrollToSnippet(idx)} activeSnippetIndex={currentHunkIndex} /> )}
)} ``` --- ## Feature 4: Git Fallback ### Цель Когда JSONL данные неполные (Write без original, повреждённый файл) — использовать git для получения diff информации. ### Реализация #### Backend: `src/main/services/team/GitDiffFallback.ts` (NEW) ```typescript import { execFile } from 'child_process'; import { promisify } from 'util'; const execFileAsync = promisify(execFile); export class GitDiffFallback { // Все git операции имеют timeout 10s — на больших repo git может зависнуть. // При timeout execFile выбрасывает error с signal='SIGTERM', catch → return null/false/[]. // M3 fix: кеш isGitRepo результатов — один exec per projectPath за сессию private gitRepoCache = new Map(); /** * Получить содержимое файла из конкретного коммита. * Используется когда file-history-snapshot недоступен. */ async getFileAtCommit( projectPath: string, filePath: string, commitHash: string ): Promise { try { const relativePath = filePath.replace(projectPath + '/', ''); const { stdout } = await execFileAsync('git', [ 'show', `${commitHash}:${relativePath}` ], { cwd: projectPath, maxBuffer: 10 * 1024 * 1024, // 10MB timeout: 10_000, }); return stdout; } catch { return null; // File didn't exist at that commit } } /** * Найти коммит ближайший к timestamp. * Используется для определения "original" состояния файла. */ async findCommitNearTimestamp( projectPath: string, filePath: string, timestamp: string ): Promise { try { const relativePath = filePath.replace(projectPath + '/', ''); const { stdout } = await execFileAsync('git', [ 'log', '--format=%H', '--before', timestamp, '-1', '--', relativePath ], { cwd: projectPath, timeout: 10_000 }); return stdout.trim() || null; } catch { return null; } } /** * Получить git diff для файла между двумя точками. * Fallback когда JSONL snippet chain неполный. */ async getGitDiff( projectPath: string, filePath: string, fromCommit: string, toCommit: string = 'HEAD' ): Promise { try { const relativePath = filePath.replace(projectPath + '/', ''); const { stdout } = await execFileAsync('git', [ 'diff', fromCommit, toCommit, '--', relativePath ], { cwd: projectPath, timeout: 10_000 }); return stdout || null; } catch { return null; } } /** * Получить историю изменений файла (для timeline enrichment). */ async getFileLog( projectPath: string, filePath: string, maxCount: number = 20 ): Promise> { try { const relativePath = filePath.replace(projectPath + '/', ''); const { stdout } = await execFileAsync('git', [ 'log', `--max-count=${maxCount}`, '--format=%H|%aI|%s', '--', relativePath ], { cwd: projectPath, timeout: 10_000 }); return stdout.trim().split('\n') .filter(line => line.includes('|')) .map(line => { const [hash, timestamp, ...msgParts] = line.split('|'); return { hash, timestamp, message: msgParts.join('|') }; }); } catch { return []; } } /** * Проверить: является ли projectPath git repo. * M3 fix: результат кешируется per projectPath (один exec за сессию). */ async isGitRepo(projectPath: string): Promise { if (this.gitRepoCache.has(projectPath)) { return this.gitRepoCache.get(projectPath)!; } try { await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath, timeout: 10_000, }); this.gitRepoCache.set(projectPath, true); return true; } catch { this.gitRepoCache.set(projectPath, false); return false; } } } ``` **Интеграция с существующим `GitIdentityResolver`:** ```typescript // GitIdentityResolver уже имеет getBranch() и worktree detection. // GitDiffFallback добавляет file-level операции. // Оба используют execFile('git', ...) — одинаковый паттерн. ``` **Loading states для git operations:** Git fallback может быть медленным (особенно на больших repo). В UI: - `FileContentResolver.resolveFileContent()` уже возвращает Promise -- компонент показывает loading spinner - Добавить `source` field в ответ, чтобы UI мог показать badge "Git fallback" / "JSONL" / "Disk" - При timeout (10s) показать toast: "Git operation timed out. Showing current file version." #### Модификация: `FileContentResolver.ts` (MODIFY — Phase 2 + Phase 4) Добавляем git fallback как третий уровень: ```typescript async resolveFileContent( teamName: string, memberName: string, filePath: string ): Promise<...> { // Level 1: file-history-snapshot backup const backup = await this.tryFileHistoryBackup(filePath); if (backup) return { ...backup, source: 'file-history' }; // Level 2: Snippet chain reconstruction const snippetResult = await this.trySnippetReconstruction(memberName, filePath); if (snippetResult) return { ...snippetResult, source: 'snippet-reconstruction' }; // Level 3 (Phase 4): Git fallback const gitResult = await this.tryGitFallback(filePath); if (gitResult) return { ...gitResult, source: 'git-fallback' }; // Level 4: Current disk (worst case) return this.readCurrentDisk(filePath); } private async tryGitFallback(filePath: string): Promise<...> { const projectPath = this.getProjectPath(filePath); if (!projectPath) return null; const isGit = await this.gitFallback.isGitRepo(projectPath); if (!isGit) return null; // Найти ближайший коммит к первому изменению const firstSnippetTimestamp = /* ... */; const commitHash = await this.gitFallback.findCommitNearTimestamp( projectPath, filePath, firstSnippetTimestamp ); if (!commitHash) return null; const original = await this.gitFallback.getFileAtCommit( projectPath, filePath, commitHash ); if (!original) return null; // Modified = текущий файл на диске const modified = await readFile(filePath, 'utf8'); return { original, modified }; } ``` #### Интеграция в `src/main/index.ts` (MODIFY) ```typescript // В initializeIpcHandlers(): // GitDiffFallback создаётся здесь и передаётся в review handlers import { GitDiffFallback } from '@main/services/team/GitDiffFallback'; const gitDiffFallback = new GitDiffFallback(); // Передать gitFallback в ReviewHandlerDeps (через 10-й позиционный параметр): // initializeReviewHandlers принимает deps — добавляем gitFallback // (ReviewHandlerDeps.gitFallback? добавлен в Phase 4) ``` **ВАЖНО**: `ReviewHandlerDeps` расширяется в Phase 4: ```typescript // В review.ts interface ReviewHandlerDeps { extractor: ChangeExtractorService; applier?: ChangeApplierService; // Phase 2 contentResolver?: FileContentResolver; // Phase 2 gitFallback?: GitDiffFallback; // Phase 4 } ``` `FileContentResolver` конструктор также расширяется для принятия optional `GitDiffFallback`: ```typescript // В FileContentResolver.ts constructor( private readonly logsFinder: TeamMemberLogsFinder, private readonly gitFallback?: GitDiffFallback, // Phase 4 optional ) {} ``` #### Обновление `src/shared/types/api.ts` и `src/renderer/api/httpClient.ts` (MODIFY) Добавить Phase 4 метод в `ReviewAPI` interface: ```typescript // В ReviewAPI (api.ts): getGitFileLog: (projectPath: string, filePath: string) => Promise>; // В HttpAPIClient (httpClient.ts): getGitFileLog: async (projectPath: string, filePath: string) => window.electronAPI.review.getGitFileLog(projectPath, filePath), ``` #### IPC channel: `src/preload/constants/ipcChannels.ts` (MODIFY) ```typescript // Phase 4 additions export const REVIEW_GET_GIT_FILE_LOG = 'review:getGitFileLog'; ``` #### IPC handler: `src/main/ipc/review.ts` (MODIFY) Добавить handler и регистрацию в `registerReviewHandlers()`: ```typescript // Handler async function handleGetGitFileLog( _event: IpcMainInvokeEvent, projectPath: string, filePath: string ): Promise>> { return wrapReviewHandler(async () => { const deps = getReviewDeps(); if (!deps.gitFallback) { return []; } return deps.gitFallback.getFileLog(projectPath, filePath); }); } // В registerReviewHandlers(): ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog); // В removeReviewHandlers(): ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG); ``` #### Preload: `src/preload/index.ts` (MODIFY) ```typescript review: { // ... Phase 1-3 methods // Phase 4 getGitFileLog: (projectPath: string, filePath: string) => invokeIpcWithResult>( REVIEW_GET_GIT_FILE_LOG, projectPath, filePath ), }, ``` --- ## Feature 5: Auto-Viewed Detection ### Цель Автоматически помечать файл как "viewed" когда пользователь прокрутил diff до конца. ### Реализация ```typescript // В CodeMirrorDiffView.tsx const endSentinelRef = useRef(null); useEffect(() => { if (!endSentinelRef.current) return; const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { // Файл просмотрен до конца onFullyViewed?.(); } } }, { threshold: 1.0 } ); observer.observe(endSentinelRef.current); return () => observer.disconnect(); }, [onFullyViewed]); // Sentinel element после CodeMirror editor return (
{/* CodeMirror mount point */}
{/* Invisible sentinel */}
); ``` **Настройка**: Авто-viewed можно отключить через toggle в ReviewToolbar. --- ## Файлы | Файл | Тип | ~LOC | |------|-----|---:| | **Feature 1: Keyboard Navigation** | | | | `src/renderer/hooks/useDiffNavigation.ts` | NEW | 120 | | `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` | NEW | 40 | | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | MODIFY | +30 | | `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +15 | | **Feature 2: Viewed Tracking** | | | | `src/renderer/utils/diffViewedStorage.ts` | NEW | 60 | | `src/renderer/hooks/useViewedFiles.ts` | NEW | 80 | | `src/renderer/components/team/review/ViewedProgressBar.tsx` | NEW | 35 | | `src/renderer/components/team/review/ReviewFileTree.tsx` | MODIFY | +30 | | `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +20 | | **Feature 3: Edit Timeline** | | | | `src/shared/types/review.ts` | MODIFY | +30 | | `src/main/services/team/ChangeExtractorService.ts` | MODIFY | +50 | | `src/renderer/components/team/review/FileEditTimeline.tsx` | NEW | 120 | | `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +25 | | **Feature 4: Git Fallback** | | | | `src/main/services/team/GitDiffFallback.ts` | NEW | 180 | | `src/main/services/team/FileContentResolver.ts` | MODIFY | +60 | | `src/main/ipc/review.ts` | MODIFY | +20 | | `src/preload/constants/ipcChannels.ts` | MODIFY | +1 | | `src/preload/index.ts` | MODIFY | +5 | | `src/main/services/team/index.ts` | MODIFY | +1 | | `src/main/index.ts` | MODIFY | +5 | | `src/shared/types/api.ts` | MODIFY | +5 | | `src/renderer/api/httpClient.ts` | MODIFY | +5 | | **Feature 5: Auto-Viewed** | | | | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | MODIFY | +25 | | `src/renderer/components/team/review/ReviewToolbar.tsx` | MODIFY | +15 | | **Итого** | 7 NEW + 17 MODIFY | ~975 | --- ## Edge Cases ### Keyboard Navigation 1. **Пустой файл (0 hunks)** — j/k no-op, показываем "No changes" 2. **Фокус в search input** — не перехватываем shortcuts 3. **Последний/первый hunk** — wrap-around или stop (настройка) 4. **Dialog закрыт** — все handlers disabled ### Viewed Tracking 5. **localStorage full** — graceful catch, показываем toast warning 6. **Scope key collision** — включаем version hash в key для уникальности 7. **Файлы изменились после viewed** — сбрасываем viewed при новом computedAt 8. **Bulk mark viewed** — batch update localStorage (не per-file) ### Edit Timeline 9. **Файл с 50+ edits** — виртуальный скроллинг не нужен (timeline compact), но добавляем "Show all" toggle при >20 10. **Timestamp parsing error** — показываем "Unknown time" 11. **Одинаковые timestamps** — сортировка по lineNumber (порядок в JSONL) ### Git Fallback 12. **Не git repo** — `isGitRepo()` возвращает false, skip git fallback 13. **Git binary not found** — catch ENOENT, log warning 14. **Shallow clone** — `git show` может не найти старый коммит, return null 15. **Uncommitted changes** — `getFileAtCommit('HEAD')` возвращает последний коммит, не рабочую копию 16. **File renamed** — git log --follow не используем (сложно), просто return null для старого пути 17. **Large files (>10MB)** — maxBuffer ограничивает, return null при error ### Auto-Viewed 18. **Scroll fast past** — IntersectionObserver с threshold 1.0 требует полного показа sentinel 19. **Dialog resize** — observer автоматически пере-вычисляет 20. **CodeMirror collapsed sections** — sentinel всегда после editor, collapsed не влияет --- ## Тестирование ### Keyboard Navigation - Unit test для `useDiffNavigation` — корректный index management, boundary handling - Test: shortcuts не перехватываются когда фокус в input - Test: Escape закрывает dialog ### Viewed Tracking - Unit test для `diffViewedStorage` — CRUD операции, edge cases - Unit test для `useViewedFiles` — progress calculation, version bump - Test: localStorage failure handling ### Edit Timeline - Unit test для `buildTimeline()` — summary generation, sorting - Unit test для `generateEditSummary()` — все типы операций ### Git Fallback - Unit test для `GitDiffFallback` с mock execFile - Test: isGitRepo false → skip - Test: execFile error → return null - Integration test: git fallback as last resort in FileContentResolver ### Auto-Viewed - Test: IntersectionObserver callback triggers markViewed - Test: disable toggle prevents auto-marking