agent-ecosystem/docs/iterations/diff-view/continuous-scroll/phase-5-polish.md
iliya 0df816bba6 feat: enhance diff view with continuous scroll and lazy loading
- Introduced a continuous scroll mode for the diff view, allowing users to review multiple files in a single scrollable container.
- Added lazy loading functionality to improve performance by loading file content as it approaches the viewport.
- Implemented a new portion collapse feature to allow users to expand unchanged regions incrementally, enhancing context retention during reviews.
- Updated navigation to support smooth scrolling between files and improved keyboard shortcuts for file navigation.
- Enhanced the review toolbar to manage actions across all files, including bulk accept/reject options.
- Added new hooks and components to support the continuous scroll and lazy loading features, ensuring a seamless user experience.
2026-02-25 15:39:14 +02:00

988 lines
38 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 5: Polish + EditorView Map + Toolbar адаптация
## 1. Обзор
Финальная фаза Continuous Scroll Diff View. Задачи:
- **EditorView Map** -- централизованный реестр всех EditorView экземпляров в ContinuousScrollView, необходимый для глобальных действий (Accept All, Reject All) и keyboard navigation.
- **Keyboard shortcuts координация** -- Cmd+Y/N/Enter должны корректно определять, с каким EditorView работать, когда на экране отображаются десятки файлов одновременно.
- **Auto-viewed для каждого файла** -- каждый FileSectionDiff отслеживает свой viewed-статус через IntersectionObserver.
- **ReviewToolbar адаптация** -- кнопки "Accept All" и "Reject All" теперь оперируют ВСЕМИ файлами, а не текущим. Добавляется progress indicator.
- **ChangeReviewDialog адаптация** -- handlers переключаются на multi-file режим, per-file discard counters.
- **Cleanup и edge-cases** -- корректная очистка при unmount, batch-обновления для 50+ файлов.
**Предусловия:** Phase 1 (ContinuousScrollView), Phase 2 (lazy loading), Phase 3 (navigation), Phase 4 (portionCollapse) -- все завершены.
---
## 2. EditorView Map в ContinuousScrollView
### 2.1. Структура данных
```typescript
// ContinuousScrollView.tsx (внутри компонента)
const editorViewMapRef = useRef<Map<string, EditorView>>(new Map());
```
Map хранит `filePath -> EditorView` для каждого смонтированного FileSectionDiff. Используется `useRef`, а не `useState`, потому что:
- EditorView-инстансы не являются React-состоянием
- Изменение Map не должно вызывать ре-рендер ContinuousScrollView
- Доступ к Map нужен синхронно из event handlers
### 2.2. Callback-интерфейс FileSectionDiff
FileSectionDiff использует единый callback для регистрации/дерегистрации EditorView, как определено в Phase 1:
```typescript
// FileSectionDiff.tsx — props interface (из Phase 1, секция 2.2)
interface FileSectionDiffProps {
filePath: string;
original: string;
modified: string;
fileName: string;
readOnly: boolean;
showMergeControls: boolean;
collapseUnchanged: boolean;
discardCounter: number;
// ... другие props
/**
* Вызывается при создании EditorView (view !== null) и при уничтожении (view === null).
* Единый callback по паттерну Phase 1.
*/
onEditorViewReady: (filePath: string, view: EditorView | null) => void;
}
```
**Важно:** Используется ОДИН callback `onEditorViewReady(filePath, view | null)`, а НЕ два отдельных (`onEditorViewReady` + `onEditorViewDestroyed`). Это соответствует дизайну Phase 1 (секция 2.2 FileSectionDiff), где `view === null` сигнализирует об уничтожении EditorView.
### 2.3. Реализация в FileSectionDiff
FileSectionDiff оборачивает CodeMirrorDiffView и управляет lifecycle:
```typescript
// FileSectionDiff.tsx (из Phase 1, секция 2.2)
const localEditorViewRef = useRef<EditorView | null>(null);
// Sync to parent Map при mount/unmount
useEffect(() => {
return () => {
// При unmount сообщить parent что view уничтожен
onEditorViewReady(filePath, null);
};
}, [filePath, onEditorViewReady]);
// Нужен useEffect чтобы проверить ref после рендера CodeMirrorDiffView
useEffect(() => {
if (localEditorViewRef.current) {
onEditorViewReady(filePath, localEditorViewRef.current);
}
});
```
**Важно:** CodeMirrorDiffView устанавливает `editorViewRef.current` синхронно в своём useEffect. Наш вторичный useEffect (без deps) ловит это на следующем render cycle.
**Альтернативная реализация с requestAnimationFrame** (для гарантии синхронизации):
```typescript
useEffect(() => {
const rafId = requestAnimationFrame(() => {
const view = localEditorViewRef.current;
if (view) {
onEditorViewReady(filePath, view);
}
});
return () => {
cancelAnimationFrame(rafId);
if (localEditorViewRef.current) {
onEditorViewReady(filePath, null);
localEditorViewRef.current = null;
}
};
}, [filePath, discardCounter]);
```
### 2.4. Регистрация в ContinuousScrollView
```typescript
// ContinuousScrollView.tsx (единый handler по паттерну Phase 1)
const handleEditorViewReady = useCallback(
(filePath: string, view: EditorView | null) => {
if (view) {
editorViewMapRef.current.set(filePath, view);
} else {
editorViewMapRef.current.delete(filePath);
}
},
[]
);
```
Передаётся каждому `FileSectionDiff`:
```tsx
<FileSectionDiff
filePath={file.filePath}
onEditorViewReady={handleEditorViewReady}
// ... другие props
/>
```
### 2.5. Передача Map наружу
ContinuousScrollView передает Map наружу через `useImperativeHandle`:
```typescript
// ContinuousScrollView.tsx
export interface ContinuousScrollViewHandle {
getEditorViewMap: () => Map<string, EditorView>;
getActiveEditorView: () => EditorView | null;
}
const ContinuousScrollView = forwardRef<ContinuousScrollViewHandle, ContinuousScrollViewProps>(
(props, ref) => {
const editorViewMapRef = useRef<Map<string, EditorView>>(new Map());
useImperativeHandle(ref, () => ({
getEditorViewMap: () => editorViewMapRef.current,
getActiveEditorView: () => {
// Логика определения активного editor (см. секцию 3)
return resolveActiveEditorView(editorViewMapRef.current, props.activeFilePath);
},
}), [props.activeFilePath]);
// ...
}
);
```
В ChangeReviewDialog:
```typescript
const continuousScrollRef = useRef<ContinuousScrollViewHandle>(null);
<ContinuousScrollView ref={continuousScrollRef} ... />
// Использование:
const map = continuousScrollRef.current?.getEditorViewMap();
const activeView = continuousScrollRef.current?.getActiveEditorView();
```
**Решение:** используем `useImperativeHandle` -- он инкапсулирует логику определения активного editor внутри ContinuousScrollView, где есть доступ к scroll-spy данным.
---
## 3. Keyboard shortcuts координация (Cmd+Y/N)
### 3.1. Проблема
В single-file режиме `editorViewRef.current` -- всегда один EditorView. В continuous scroll -- их может быть десятки. Нужно определить, какой EditorView является "активным" для команд accept/reject.
### 3.2. Алгоритм resolveActiveEditorView
```typescript
function resolveActiveEditorView(
editorViewMap: Map<string, EditorView>,
activeFilePath: string
): EditorView | null {
// 1. Приоритет: EditorView, который имеет фокус
const activeEl = document.activeElement;
if (activeEl) {
for (const [, view] of editorViewMap) {
if (view.dom.contains(activeEl)) {
return view;
}
}
}
// 2. Fallback: EditorView для activeFilePath (из scroll-spy)
if (activeFilePath) {
return editorViewMap.get(activeFilePath) ?? null;
}
return null;
}
```
**Логика приоритетов:**
1. Если пользователь кликнул в CodeMirror editor (ставит фокус) -- используем именно этот editor. `document.activeElement` будет внутри `.cm-content` элемента.
2. Если фокус вне editor (например, после скролла мышью) -- используем editor для файла, определенного scroll-spy как видимый (`activeFilePath`).
### 3.3. Интеграция с useDiffNavigation (Phase 3)
Phase 3 уже определяет `continuousOptions?: ContinuousNavigationOptions` как 10-й параметр `useDiffNavigation`. Этот объект включает:
```typescript
interface ContinuousNavigationOptions {
editorViewRefs: Map<string, EditorView>;
activeFilePath: string | null;
scrollToFile: (filePath: string) => void;
enabled: boolean;
}
```
Внутри `useDiffNavigation` Phase 3 реализует helper `getActiveEditorView()`, который определяет активный editor по приоритету: focused > activeFilePath > first editor.
**Phase 5 НЕ добавляет новых параметров в useDiffNavigation.** Вся логика определения активного editor уже заложена в Phase 3 через `continuousOptions`. Phase 5 лишь использует эту инфраструктуру:
```typescript
// В ChangeReviewDialog.tsx — передача continuousOptions (определено Phase 3)
const continuousOptions = useMemo(
(): ContinuousNavigationOptions | undefined => {
if (!isContinuousMode) return undefined;
return {
editorViewRefs: continuousScrollRef.current?.getEditorViewMap() ?? new Map(),
activeFilePath: continuousScrollActiveFilePath,
scrollToFile: scrollToFile,
enabled: true,
};
},
[isContinuousMode, continuousScrollActiveFilePath, scrollToFile]
);
const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [],
selectedReviewFilePath,
handleSelectFile,
editorViewRef,
open,
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
() => onOpenChange(false),
handleSaveCurrentFile,
continuousOptions // <-- 10-й параметр из Phase 3
);
```
### 3.4. Cmd+Y: Accept + goToNextChunk
Поток действий:
1. `resolveActiveEditorView()` -> получаем EditorView
2. `acceptChunk(view)` -- принимает текущий chunk в этом editor
3. `requestAnimationFrame(() => goToNextChunk(view))` -- прокручивает к следующему chunk
4. **Cross-file transition:** если это был последний chunk в файле, Phase 3 обрабатывает cross-file navigation через `isLastChunkInFile()` и `scrollToFile()`.
### 3.5. Cmd+N: Reject + goToNextChunk
Аналогично Cmd+Y, но вызывает `rejectChunk(view)`. Обработка через IPC-listener `window.electronAPI.review.onCmdN`:
```typescript
// В ChangeReviewDialog.tsx — модификация IPC listener
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
const view = isContinuousMode
? continuousScrollRef.current?.getActiveEditorView() ?? null
: editorViewRef.current;
if (view) {
rejectChunk(view);
requestAnimationFrame(() => goToNextChunk(view));
}
});
return cleanup ?? undefined;
}, [open, isContinuousMode]);
```
### 3.6. Cmd+Enter: Save file
Сохраняет только `activeFilePath`, не все файлы:
```typescript
// Cmd+Enter handler
if (isMeta && event.key === 'Enter') {
event.preventDefault();
if (activeFilePath) {
saveEditedFile(activeFilePath);
}
return;
}
```
Где `activeFilePath` -- из scroll-spy (ContinuousScrollView props).
### 3.7. Alt+J: Next change
```typescript
// Alt+J handler (реализовано в Phase 3 keyboard handler)
if (event.altKey && event.key.toLowerCase() === 'j') {
event.preventDefault();
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (view) goToNextChunk(view);
return;
}
```
---
## 4. Auto-viewed для каждого файла
### 4.1. Текущий механизм (single-file mode)
В CodeMirrorDiffView.tsx:
- `endSentinelRef` -- невидимый `<div>` после editor
- IntersectionObserver с `threshold: 1.0`
- При пересечении вызывается `onFullyViewed()` callback
- В ChangeReviewDialog: `handleFullyViewed` -> `markViewed(selectedReviewFilePath)`
### 4.2. Continuous mode: per-file sentinel
Каждый `FileSectionDiff` содержит свой sentinel для auto-viewed:
```typescript
// FileSectionDiff.tsx
const endSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!endSentinelRef.current || !autoViewed) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
onFullyViewed(filePath);
}
}
},
{ threshold: 0.85 } // НЕ 1.0 — portionCollapse может компактить файл
);
observer.observe(endSentinelRef.current);
return () => observer.disconnect();
}, [filePath, autoViewed, onFullyViewed]);
return (
<div>
<CodeMirrorDiffView ... />
{/* Sentinel для auto-viewed detection */}
<div ref={endSentinelRef} className="h-px shrink-0" />
</div>
);
```
### 4.3. Threshold: 0.85 вместо 1.0
Обоснование:
- `threshold: 1.0` означает "100% элемента видимо". Для sentinel в 1px это работает.
- Но в continuous mode sentinel может быть в viewport из-за подскролла следующего файла, пока текущий файл ещё не полностью просмотрен.
- Решение: sentinel размещаем ПОСЛЕ CodeMirrorDiffView внутри FileSectionDiff. Threshold 0.85 дает некоторый margin для portionCollapse, который может сильно уменьшить высоту файла.
- Sentinel для 1px элемента с threshold 0.85 сработает, когда sentinel "почти полностью" видим -- это надежно.
### 4.4. onFullyViewed callback в ContinuousScrollView
```typescript
// ContinuousScrollView.tsx
const handleFileFullyViewed = useCallback((filePath: string) => {
if (autoViewed && !isViewed(filePath)) {
markViewed(filePath);
}
}, [autoViewed, isViewed, markViewed]);
```
Передается каждому FileSectionDiff:
```tsx
<FileSectionDiff
filePath={file.filePath}
autoViewed={autoViewed}
onFullyViewed={handleFileFullyViewed}
// ...
/>
```
### 4.5. Отличие от single-file mode
В single-file mode за один скролл пользователь видит один файл. В continuous mode несколько файлов могут быть "viewed" за один скролл. Это корректное поведение:
- Маленькие файлы (1-5 строк diff) мгновенно проскакивают viewport
- Их sentinel пересекается с viewport -> onFullyViewed срабатывает
- `markViewed()` идемпотентен (useViewedFiles проверяет через Set)
### 4.6. autoViewed toggle
Toggle в toolbar контролирует глобальный `autoViewed` state. Когда выключен:
- IntersectionObserver все ещё работает, но `handleFileFullyViewed` проверяет `autoViewed` flag и делает early return
- Альтернатива: не создавать IntersectionObserver при `autoViewed === false` (более оптимально)
Предпочтительная реализация (оптимизированная):
```typescript
// FileSectionDiff.tsx
useEffect(() => {
if (!endSentinelRef.current || !autoViewed) return;
// Observer создается только когда autoViewed=true
// ...
}, [filePath, autoViewed, onFullyViewed]);
```
---
## 5. Модификация ReviewToolbar.tsx
### 5.1. Accept All / Reject All -- все файлы
Текущие tooltip:
- "Accept all changes in current file"
- "Reject all changes in current file"
В continuous mode:
- "Accept all changes across all files"
- "Reject all changes across all files"
**Реализация:** ReviewToolbar получает новый prop `isContinuousMode`:
```typescript
interface ReviewToolbarProps {
stats: { pending: number; accepted: number; rejected: number };
changeStats: ChangeStats;
collapseUnchanged: boolean;
applying: boolean;
autoViewed: boolean;
onAutoViewedChange: (auto: boolean) => void;
onAcceptAll: () => void;
onRejectAll: () => void;
onApply: () => void;
onCollapseUnchangedChange: (collapse: boolean) => void;
editedCount?: number;
/** Phase 5: continuous scroll mode -- changes tooltip text */
isContinuousMode?: boolean;
}
```
Tooltip:
```tsx
<TooltipContent side="bottom">
{isContinuousMode
? 'Accept all changes across all files'
: 'Accept all changes in current file'}
</TooltipContent>
```
### 5.2. Progress indicator: "12 of 45 changes reviewed"
Новый UI элемент между change stats и action buttons.
```typescript
// ReviewToolbar.tsx — новый prop
interface ReviewToolbarProps {
// ...
/** Total hunks reviewed (accepted + rejected) */
reviewedCount?: number;
/** Total hunks across all files */
totalHunks?: number;
}
```
Вычисление в ChangeReviewDialog:
```typescript
const reviewProgress = useMemo(() => {
if (!activeChangeSet) return { reviewed: 0, total: 0 };
let total = 0;
let reviewed = 0;
for (const file of activeChangeSet.files) {
for (let i = 0; i < file.snippets.length; i++) {
total++;
const key = `${file.filePath}:${i}`;
const decision = hunkDecisions[key];
if (decision === 'accepted' || decision === 'rejected') {
reviewed++;
}
}
}
return { reviewed, total };
}, [activeChangeSet, hunkDecisions]);
```
Отображение в ReviewToolbar:
```tsx
{/* Progress indicator */}
{totalHunks !== undefined && totalHunks > 0 && (
<div className="flex items-center gap-2 text-xs">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-zinc-700/50">
<div
className="h-full rounded-full bg-blue-500/70 transition-all duration-300"
style={{ width: `${totalHunks > 0 ? (reviewedCount! / totalHunks) * 100 : 0}%` }}
/>
</div>
<span className="text-text-muted">
{reviewedCount} of {totalHunks} reviewed
</span>
</div>
)}
```
**Позиция в toolbar:** после change stats (`+N -M across K files`), перед separator (`<div className="h-4 w-px bg-border" />`).
### 5.3. Итоговый layout toolbar (слева направо)
1. Decision stats badges (pending, accepted, rejected)
2. Change stats (+N -M across K files)
3. Review progress bar ("12 of 45 reviewed")
4. `flex-1` spacer
5. Collapse toggle
6. Auto-viewed toggle
7. Separator
8. Edited count badge (если есть)
9. Separator (если есть edited)
10. Accept All button
11. Reject All button
12. Apply button
---
## 6. Модификация ChangeReviewDialog.tsx
### 6.1. handleAcceptAll -- все файлы
Текущая реализация:
```typescript
const handleAcceptAll = useCallback(() => {
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}, [selectedReviewFilePath, acceptAllFile]);
```
Continuous mode:
```typescript
const handleAcceptAll = useCallback(() => {
if (isContinuousMode) {
// 1. Store: пометить все hunks во всех файлах как accepted
acceptAll(); // store action — уже помечает ВСЕ файлы
// 2. CM: применить acceptAllChunks к каждому EditorView
const map = continuousScrollRef.current?.getEditorViewMap();
if (map) {
const views = Array.from(map.values());
// Batch: используем requestAnimationFrame для предотвращения layout thrashing
requestAnimationFrame(() => {
for (const view of views) {
acceptAllChunks(view);
}
});
}
} else {
// Single-file mode (без изменений)
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}
}, [isContinuousMode, acceptAll, selectedReviewFilePath, acceptAllFile]);
```
### 6.2. handleRejectAll -- все файлы
```typescript
const handleRejectAll = useCallback(() => {
if (isContinuousMode) {
// 1. Store: пометить все hunks во всех файлах как rejected
rejectAll(); // store action
// 2. CM: применить rejectAllChunks к каждому EditorView
const map = continuousScrollRef.current?.getEditorViewMap();
if (map) {
const views = Array.from(map.values());
requestAnimationFrame(() => {
for (const view of views) {
rejectAllChunks(view);
}
});
}
} else {
const view = editorViewRef.current;
if (view) rejectAllChunks(view);
if (selectedReviewFilePath) rejectAllFile(selectedReviewFilePath);
}
}, [isContinuousMode, rejectAll, selectedReviewFilePath, rejectAllFile]);
```
### 6.3. handleSaveFile -- по activeFilePath
```typescript
const handleSaveFile = useCallback((filePath: string) => {
void saveEditedFile(filePath);
}, [saveEditedFile]);
// Для toolbar/keyboard: сохраняет activeFilePath
const handleSaveActiveFile = useCallback(() => {
if (isContinuousMode) {
// activeFilePath определяется scroll-spy в ContinuousScrollView
// Передается через state или callback
const activePath = continuousScrollActiveFilePath;
if (activePath) handleSaveFile(activePath);
} else {
if (selectedReviewFilePath) handleSaveFile(selectedReviewFilePath);
}
}, [isContinuousMode, continuousScrollActiveFilePath, selectedReviewFilePath, handleSaveFile]);
```
### 6.4. handleDiscardFile -- per-file
```typescript
const handleDiscardFile = useCallback((filePath: string) => {
// В continuous mode editorStateCache НЕ используется
// (все editors живут одновременно — cache не нужен, см. Phase 1 секция 4.3)
discardFileEdits(filePath);
setDiscardCounters(prev => ({
...prev,
[filePath]: (prev[filePath] ?? 0) + 1
}));
}, [discardFileEdits]);
// Для keyboard/toolbar: discard activeFilePath
const handleDiscardActiveFile = useCallback(() => {
const activePath = isContinuousMode
? continuousScrollActiveFilePath
: selectedReviewFilePath;
if (activePath) handleDiscardFile(activePath);
}, [isContinuousMode, continuousScrollActiveFilePath, selectedReviewFilePath, handleDiscardFile]);
```
**Важно:** `editorStateCache` не используется в continuous mode. Phase 1 (секция 4.3) устанавливает, что в continuous mode все editors живут одновременно и нет необходимости в кеше EditorState. Discard реализуется через `discardCounters` (пересоздание через key).
### 6.5. isContinuousMode state
```typescript
// ChangeReviewDialog.tsx
// Phase 5: continuous scroll mode
// Вычисляется, не является toggle:
const isContinuousMode = (activeChangeSet?.files.length ?? 0) > 1;
```
**Решение:** `isContinuousMode` вычисляется, не является toggle. Continuous mode включается когда файлов > 1. Для одного файла -- обычный single-file mode (без ContinuousScrollView).
### 6.6. activeFilePath из ContinuousScrollView
ContinuousScrollView определяет видимый файл через scroll-spy и сообщает родителю:
```typescript
// ContinuousScrollView.tsx props
interface ContinuousScrollViewProps {
// ...
onActiveFileChange: (filePath: string) => void;
}
```
В ChangeReviewDialog:
```typescript
const [continuousScrollActiveFilePath, setContinuousScrollActiveFilePath] = useState<string | null>(null);
<ContinuousScrollView
onActiveFileChange={setContinuousScrollActiveFilePath}
// ...
/>
```
---
## 7. Per-file discard counter
### 7.1. Проблема
Текущий `discardCounter` -- одно число для всего диалога. При discard оно инкрементируется, и CodeMirrorDiffView пересоздается через `key={filePath}:${discardCounter}`.
В continuous mode каждый файл имеет свой `CodeMirrorDiffView`. Инкремент общего counter пересоздаст ВСЕ EditorView -- это неэффективно и потеряет scroll position.
### 7.2. Решение: Record<string, number>
```typescript
// ChangeReviewDialog.tsx
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
```
### 7.3. Использование в FileSectionDiff key
```tsx
// ContinuousScrollView.tsx — передает counter каждому FileSectionDiff
{files.map(file => (
<FileSectionDiff
key={`${file.filePath}:${discardCounters[file.filePath] ?? 0}`}
filePath={file.filePath}
discardCounter={discardCounters[file.filePath] ?? 0}
// ...
/>
))}
```
Внутри FileSectionDiff, CodeMirrorDiffView:
```tsx
<CodeMirrorDiffView
key={`${filePath}:${discardCounter}`}
// ...
/>
```
### 7.4. Discard action
```typescript
const handleDiscardFile = useCallback((filePath: string) => {
// 1. Удаляем edited content из store
discardFileEdits(filePath);
// 2. Инкрементируем counter ТОЛЬКО для этого файла
setDiscardCounters(prev => ({
...prev,
[filePath]: (prev[filePath] ?? 0) + 1,
}));
}, [discardFileEdits]);
```
Результат: пересоздается ТОЛЬКО EditorView для конкретного файла. Все остальные EditorViews сохраняют состояние.
### 7.5. Обратная совместимость
Для single-file mode (когда ContinuousScrollView не используется) сохраняется существующий `discardCounter: number` без изменений. `discardCounters: Record<string, number>` используется только в continuous mode. Оба варианта сосуществуют в ChangeReviewDialog:
```typescript
// Single-file mode: существующий counter
const [discardCounter, setDiscardCounter] = useState(0);
// Continuous mode: per-file counters
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
```
---
## 8. Cleanup при закрытии
### 8.1. EditorView Map
При unmount ContinuousScrollView:
1. Каждый FileSectionDiff вызывает `onEditorViewReady(filePath, null)` (единый callback)
2. Map автоматически очищается
3. EditorView.destroy() вызывается внутри CodeMirrorDiffView cleanup
```typescript
// ContinuousScrollView.tsx
useEffect(() => {
return () => {
// Safety: на случай если unmount происходит до cleanup дочерних
editorViewMapRef.current.clear();
};
}, []);
```
### 8.2. Store state
`clearChangeReview()` из changeReviewSlice уже сбрасывает:
- `activeChangeSet`
- `hunkDecisions`
- `fileDecisions`
- `fileContents`
- `fileContentsLoading`
- `editedContents`
- `applying`
- `applyError`
Дополнительных действий не требуется.
### 8.3. Viewed state
`viewedSet` persistent через `localStorage` (useViewedFiles -> diffViewedStorage). НЕ очищается при закрытии диалога -- это намеренное поведение (пользователь может закрыть и открыть диалог, и viewed файлы останутся).
### 8.4. discardCounters
React state -- автоматически GC при unmount компонента. Не persistent.
---
## 9. Edge-cases
### 9.1. 50 EditorViews в памяти
**Проблема:** каждый EditorView -- DOM-элемент с syntax highlighting, diff computations, merge extensions.
**Смягчение:**
- portionCollapse (Phase 4) минимизирует видимый контент: свёрнутые regions не рендерят DOM-ноды
- Lazy loading (Phase 2) гарантирует, что контент загружается по мере необходимости, а не все сразу
**Если профилирование покажет проблемы:**
- Будущая оптимизация: destroy EditorView для файлов далеко за пределами viewport
- `onEditorViewReady(filePath, null)` уже в интерфейсе -- переход на destroy/recreate модель не потребует изменения API
- Placeholder вместо destroyed EditorView (высота сохраняется через cached `scrollHeight`)
**Реализация (не в Phase 5, на будущее):**
```typescript
// Идея: IntersectionObserver с rootMargin для pre-destroy
const DESTROY_MARGIN = '2000px'; // destroy если > 2000px от viewport
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
const filePath = entry.target.dataset.filePath!;
if (entry.isIntersecting) {
// Восстановить EditorView
} else {
// Destroy EditorView, сохранить высоту
}
}
},
{ rootMargin: DESTROY_MARGIN }
);
```
### 9.2. Accept All + 50 файлов
**Проблема:** `acceptAllChunks` на 50 EditorView может вызвать layout thrashing.
**Решение:**
```typescript
// Batch: один rAF на все view updates
requestAnimationFrame(() => {
const map = continuousScrollRef.current?.getEditorViewMap();
if (!map) return;
for (const view of map.values()) {
acceptAllChunks(view);
}
});
```
Это группирует все DOM-мутации в один frame. CodeMirror batches DOM updates внутри `dispatch()`, так что 50 dispatches в одном rAF -- приемлемо.
**Store:** `acceptAll()` уже batched -- одна транзакция `set()` обновляет все `hunkDecisions` и `fileDecisions`.
### 9.3. Cmd+Y/N без видимых chunks
**Сценарий:** все chunks в текущем файле уже accepted/rejected. Пользователь нажимает Cmd+Y.
**Поведение:** `acceptChunk(view)` от @codemirror/merge не делает ничего, если нет chunk под cursor. `goToNextChunk(view)` аналогично -- no-op.
**Это корректно.** Не нужен дополнительный feedback (звук, toast и т.д.).
### 9.4. File save в continuous mode
**Сценарий:** пользователь нажимает Cmd+Enter. Сохраняется только `activeFilePath`, НЕ все отредактированные файлы.
**Обоснование:**
- Пользователь ожидает "save THIS file", не "save ALL files"
- Для массового save есть Apply All Changes
- Если добавить "Save All Edited" -- это отдельная фича (не в Phase 5)
### 9.5. Scroll position после Accept All / Reject All
**Проблема:** Accept All может значительно изменить высоту контента (deleted chunks исчезают). Scroll position может сместиться.
**Решение:** браузер автоматически корректирует scroll при изменении высоты элементов ВЫШЕ viewport. Для элементов В viewport -- пользователь увидит изменение, что ожидаемо.
Если нужно сохранить позицию:
```typescript
// Перед Accept All
const scrollTop = scrollContainerRef.current?.scrollTop ?? 0;
// ... apply accept all ...
requestAnimationFrame(() => {
scrollContainerRef.current?.scrollTo({ top: scrollTop });
});
```
Но это может быть нежелательно (пользователь хочет видеть результат). **Решение: не корректировать scroll.**
### 9.6. Race condition: onEditorViewReady + component key change
**Сценарий:** discard file -> key меняется -> old FileSectionDiff unmount -> new mount.
**Порядок:**
1. Old component: cleanup effect -> `onEditorViewReady(filePath, null)` -> Map.delete
2. New component: effect -> `onEditorViewReady(filePath, newView)` -> Map.set
React гарантирует cleanup effects ПЕРЕД mount effects. Race condition невозможна.
### 9.7. EditorView для файла с unavailable content
Если `fileContent.contentSource === 'unavailable'`, FileSectionDiff рендерит fallback (ReviewDiffContent), не CodeMirrorDiffView. EditorView не создается -> не попадает в Map.
При Accept All/Reject All -- файлы без EditorView обрабатываются только через store (hunkDecisions). Это корректно.
---
## 10. Проверка
### 10.1. Автоматические тесты
**Unit tests:**
| Тест | Файл | Что проверяет |
|------|------|---------------|
| resolveActiveEditorView с focused editor | `resolveActiveEditorView.test.ts` | Возвращает focused EditorView из Map |
| resolveActiveEditorView fallback на activeFilePath | `resolveActiveEditorView.test.ts` | Возвращает EditorView для activeFilePath |
| resolveActiveEditorView пустая Map | `resolveActiveEditorView.test.ts` | Возвращает null |
| discardCounters per-file increment | `ChangeReviewDialog.test.ts` | Инкремент только для одного файла |
| reviewProgress computation | `ChangeReviewDialog.test.ts` | Корректный подсчет reviewed/total |
| ReviewToolbar tooltip в continuous mode | `ReviewToolbar.test.ts` | "across all files" текст |
**Integration tests:**
| Тест | Что проверяет |
|------|---------------|
| Accept All в continuous mode | Store + все EditorViews обновлены |
| Reject All в continuous mode | Store + все EditorViews обновлены |
| Discard one file | Только один EditorView пересоздан |
| Auto-viewed multiple files | Несколько файлов помечены viewed за один скролл |
| Keyboard Cmd+Y с focused editor | Accept в focused editor, не в activeFilePath |
### 10.2. Ручное тестирование
**Чеклист:**
- [ ] Открыть review dialog с 5+ файлами
- [ ] Проскроллить вниз — auto-viewed помечает файлы по мере скролла
- [ ] Выключить auto-viewed toggle — скролл не помечает файлы
- [ ] Cmd+Y в focused editor — принимает chunk в этом editor
- [ ] Cmd+Y без фокуса — принимает chunk в activeFilePath editor
- [ ] Cmd+N — отклоняет chunk + переходит к следующему
- [ ] Cmd+Enter — сохраняет только текущий файл
- [ ] "Accept All" кнопка — все chunks во всех файлах accepted
- [ ] "Reject All" кнопка — все chunks во всех файлах rejected
- [ ] Discard файла — только этот EditorView пересоздается
- [ ] Progress bar обновляется при accept/reject
- [ ] Закрытие и повторное открытие — viewed state сохранен
- [ ] 20+ файлов — scroll не лагает
- [ ] Accept All + 20 файлов — без видимого зависания
### 10.3. Performance профилирование
- [ ] Chrome DevTools Performance: rAF timing при Accept All с 20 файлов (должен быть < 100ms)
- [ ] Memory: heap snapshot с 20 EditorViews (ожидание: ~50-80MB total)
- [ ] Layout: no forced synchronous layouts при scroll
---
## Приложение: Полный diff изменений по файлам
### Новые файлы
Нет новых файлов в Phase 5 (все компоненты созданы в Phase 1-4).
### Модифицируемые файлы
| Файл | Изменения |
|------|-----------|
| `ContinuousScrollView.tsx` | EditorView Map, useImperativeHandle, onActiveFileChange callback |
| `FileSectionDiff.tsx` | onEditorViewReady(filePath, view \| null) единый callback, per-file sentinel, autoViewed |
| `ChangeReviewDialog.tsx` | isContinuousMode, handleAcceptAll/RejectAll multi-file, discardCounters, continuousScrollActiveFilePath state, EditorView Map через ref |
| `ReviewToolbar.tsx` | isContinuousMode tooltip, progress indicator, reviewedCount/totalHunks props |
| `useDiffNavigation.ts` | Без дополнительных изменений Phase 5 вся continuous mode логика уже реализована в Phase 3 (continuousOptions, getActiveEditorView, cross-file navigation) |
### Неизменяемые файлы
| Файл | Причина |
|------|---------|
| `CodeMirrorDiffView.tsx` | Без изменений все обертывается через FileSectionDiff |
| `CodeMirrorDiffUtils.ts` | acceptAllChunks/rejectAllChunks уже поддерживают per-view вызов |
| `changeReviewSlice.ts` | acceptAll()/rejectAll() уже работают со всеми файлами |
| `useViewedFiles.ts` | markViewed() уже поддерживает per-file вызовы |
| `ReviewFileTree.tsx` | Без изменений в Phase 5 (модифицирован в Phase 1) |
| `KeyboardShortcutsHelp.tsx` | Без изменений в Phase 5 (модифицирован в Phase 3) |