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

34 KiB
Raw Blame History

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)

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):

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:

// 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 (показывается по ?).

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.

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)

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)

interface ViewedProgressBarProps {
  viewed: number;
  total: number;
  progress: number;
}

Тонкий progress bar в header ChangeReviewDialog:

[████████░░░░░░░░░░] 5/12 files viewed (42%)

Интеграция в ReviewFileTree.tsx (MODIFY)

Checkbox рядом с каждым файлом:

<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)

/** Одно событие в 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():

// При сборе 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 с цветными точками.

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):

// Под 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)

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:

// 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 как третий уровень:

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)

// В 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:

// В review.ts
interface ReviewHandlerDeps {
  extractor: ChangeExtractorService;
  applier?: ChangeApplierService;       // Phase 2
  contentResolver?: FileContentResolver; // Phase 2
  gitFallback?: GitDiffFallback;         // Phase 4
}

FileContentResolver конструктор также расширяется для принятия optional GitDiffFallback:

// В 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:

// В 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)

// Phase 4 additions
export const REVIEW_GET_GIT_FILE_LOG = 'review:getGitFileLog';

IPC handler: src/main/ipc/review.ts (MODIFY)

Добавить handler и регистрацию в registerReviewHandlers():

// 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)

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 до конца.

Реализация

// В 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

  1. localStorage full — graceful catch, показываем toast warning
  2. Scope key collision — включаем version hash в key для уникальности
  3. Файлы изменились после viewed — сбрасываем viewed при новом computedAt
  4. Bulk mark viewed — batch update localStorage (не per-file)

Edit Timeline

  1. Файл с 50+ edits — виртуальный скроллинг не нужен (timeline compact), но добавляем "Show all" toggle при >20
  2. Timestamp parsing error — показываем "Unknown time"
  3. Одинаковые timestamps — сортировка по lineNumber (порядок в JSONL)

Git Fallback

  1. Не git repoisGitRepo() возвращает false, skip git fallback
  2. Git binary not found — catch ENOENT, log warning
  3. Shallow clonegit show может не найти старый коммит, return null
  4. Uncommitted changesgetFileAtCommit('HEAD') возвращает последний коммит, не рабочую копию
  5. File renamed — git log --follow не используем (сложно), просто return null для старого пути
  6. Large files (>10MB) — maxBuffer ограничивает, return null при error

Auto-Viewed

  1. Scroll fast past — IntersectionObserver с threshold 1.0 требует полного показа sentinel
  2. Dialog resize — observer автоматически пере-вычисляет
  3. 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