agent-ecosystem/docs/iterations/diff-view/phase-4-enhanced-features.md
iliya 373d1a722b docs: update diff-view iteration plans (phases 2-4)
Update implementation details for accept/reject, per-task scoping,
and enhanced features phases with localStorage error handling and
expanded specifications.
2026-02-24 21:37:36 +02:00

994 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<string> {
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<string>;
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<string>();
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
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isViewed(file.filePath)}
onChange={(e) => {
if (e.target.checked) markViewed(file.filePath);
else unmarkViewed(file.filePath);
}}
className="rounded border-border"
aria-label={`Mark ${file.relativePath} as viewed`}
/>
<span className={isViewed(file.filePath) ? 'text-text-muted line-through' : 'text-text'}>
{file.relativePath}
</span>
</div>
```
**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 && (
<div className="border-t border-border pt-3">
<button
onClick={() => setTimelineOpen(!timelineOpen)}
className="flex items-center gap-1 text-xs text-text-secondary hover:text-text w-full"
>
<Clock className="w-3.5 h-3.5" />
Edit Timeline ({selectedTimeline.events.length})
<ChevronDown className={`w-3 h-3 transition-transform ${timelineOpen ? 'rotate-180' : ''}`} />
</button>
{timelineOpen && (
<FileEditTimeline
timeline={selectedTimeline}
onEventClick={(idx) => scrollToSnippet(idx)}
activeSnippetIndex={currentHunkIndex}
/>
)}
</div>
)}
```
---
## 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<string, boolean>();
/**
* Получить содержимое файла из конкретного коммита.
* Используется когда file-history-snapshot недоступен.
*/
async getFileAtCommit(
projectPath: string,
filePath: string,
commitHash: string
): Promise<string | null> {
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<string | null> {
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<string | null> {
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<Array<{ hash: string; timestamp: string; message: string }>> {
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<boolean> {
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<Array<{ hash: string; timestamp: string; message: string }>>;
// В 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<IpcResult<Array<{ hash: string; timestamp: string; message: string }>>> {
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<Array<{ hash: string; timestamp: string; message: string }>>(
REVIEW_GET_GIT_FILE_LOG, projectPath, filePath
),
},
```
---
## Feature 5: Auto-Viewed Detection
### Цель
Автоматически помечать файл как "viewed" когда пользователь прокрутил diff до конца.
### Реализация
```typescript
// В CodeMirrorDiffView.tsx
const endSentinelRef = useRef<HTMLDivElement>(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 (
<div>
<div ref={containerRef} /> {/* CodeMirror mount point */}
<div ref={endSentinelRef} className="h-1" /> {/* Invisible sentinel */}
</div>
);
```
**Настройка**: Авто-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