Update implementation details for accept/reject, per-task scoping, and enhanced features phases with localStorage error handling and expanded specifications.
34 KiB
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- Добавить
sourcefield в ответ, чтобы 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
- Пустой файл (0 hunks) — j/k no-op, показываем "No changes"
- Фокус в search input — не перехватываем shortcuts
- Последний/первый hunk — wrap-around или stop (настройка)
- Dialog закрыт — все handlers disabled
Viewed Tracking
- localStorage full — graceful catch, показываем toast warning
- Scope key collision — включаем version hash в key для уникальности
- Файлы изменились после viewed — сбрасываем viewed при новом computedAt
- Bulk mark viewed — batch update localStorage (не per-file)
Edit Timeline
- Файл с 50+ edits — виртуальный скроллинг не нужен (timeline compact), но добавляем "Show all" toggle при >20
- Timestamp parsing error — показываем "Unknown time"
- Одинаковые timestamps — сортировка по lineNumber (порядок в JSONL)
Git Fallback
- Не git repo —
isGitRepo()возвращает false, skip git fallback - Git binary not found — catch ENOENT, log warning
- Shallow clone —
git showможет не найти старый коммит, return null - Uncommitted changes —
getFileAtCommit('HEAD')возвращает последний коммит, не рабочую копию - File renamed — git log --follow не используем (сложно), просто return null для старого пути
- Large files (>10MB) — maxBuffer ограничивает, return null при error
Auto-Viewed
- Scroll fast past — IntersectionObserver с threshold 1.0 требует полного показа sentinel
- Dialog resize — observer автоматически пере-вычисляет
- 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