agent-ecosystem/docs/iterations/diff-view/continuous-scroll/phase-1-continuous-scroll-and-scroll-spy.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

1543 lines
67 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.

# Фаза 1: Continuous Scroll + Scroll-Spy
## 1. Обзор
**Цель:** Заменить текущий single-file diff view на непрерывный scroll всех файлов (как на GitHub PR review).
**Текущее поведение:** `ChangeReviewDialog` показывает один файл за раз. Пользователь кликает по файлу в `ReviewFileTree` — диалог переключает контент. Для каждого файла создаётся/уничтожается один `CodeMirrorDiffView`. При переключении undo history сохраняется в `editorStateCache`. Контент файла загружается lazy (useEffect при смене `selectedReviewFilePath`).
**Новое поведение:**
- Все файлы рендерятся в одном scroll-контейнере вертикально, один за другом
- Каждый файл имеет sticky header (имя, badges, кнопки) — прилипает к верху при скролле
- File tree подсвечивает текущий видимый файл (scroll-spy)
- Клик по файлу в tree = smooth scroll к файлу в контенте
- Все CodeMirror editors живут одновременно — нет необходимости в editorStateCache
- Keyboard navigation: Alt+ArrowDown/Up для перехода между файлами
- Контент всех файлов загружается при открытии диалога (bulk load для фазы 1)
---
## 2. Новые файлы
### 2.1. `FileSectionHeader.tsx`
**Путь:** `src/renderer/components/team/review/FileSectionHeader.tsx`
**Назначение:** Sticky header для каждой file section в continuous scroll. Извлечён из `ChangeReviewDialog.tsx` (строки 437-509 — блок `{/* File header with content source badge and save/discard */}`).
#### Props Interface
```typescript
import type { FileChangeSummary, FileChangeWithContent, HunkDecision } from '@shared/types';
interface FileSectionHeaderProps {
/** Данные файла (relativePath, isNewFile, filePath и т.д.) */
file: FileChangeSummary;
/** Загруженный контент файла (для отображения contentSource badge). null = ещё не загружен */
fileContent: FileChangeWithContent | null;
/** Решение по файлу целиком ('accepted' | 'rejected' | 'pending' | undefined) */
fileDecision: HunkDecision | undefined;
/** Есть ли несохранённые ручные правки для этого файла */
hasEdits: boolean;
/** Идёт ли сейчас операция сохранения/применения (disabled state для кнопки Save) */
applying: boolean;
/** Callback: пользователь нажал "Discard" для отмены ручных правок */
onDiscard: (filePath: string) => void;
/** Callback: пользователь нажал "Save File" для записи на диск */
onSave: (filePath: string) => void;
}
```
#### Что рендерит
1. **Sticky container:** `<div className="sticky top-0 z-10 ...">`
- `data-file-path={file.filePath}` — для scroll-spy (querySelector)
- `bg-surface-sidebar` фон (непрозрачный, чтобы контент под sticky не просвечивал)
- `border-b border-border`
2. **Имя файла:** `file.relativePath``text-xs font-medium text-text`
3. **NEW badge** (условный):
```tsx
{file.isNewFile && (
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
NEW
</span>
)}
```
4. **Content source badge** (условный, только когда fileContent загружен):
```tsx
{fileContent?.contentSource && (
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-text-muted">
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
</span>
)}
```
`CONTENT_SOURCE_LABELS` определяется в этом же файле (вынесен из `ChangeReviewDialog.tsx` строка 38-44):
```typescript
const CONTENT_SOURCE_LABELS: Record<string, string> = {
'file-history': 'File History',
'snippet-reconstruction': 'Reconstructed',
'disk-current': 'Current Disk',
'git-fallback': 'Git Fallback',
unavailable: 'Unavailable',
};
```
5. **File decision indicator** (условный):
```tsx
{fileDecision && (
<span className={`rounded px-1.5 py-0.5 text-[10px] ${colorClass}`}>
{fileDecision}
</span>
)}
```
Цвета:
- `accepted` -> `bg-green-500/20 text-green-400`
- `rejected` -> `bg-red-500/20 text-red-400`
- `pending` -> `bg-zinc-500/20 text-zinc-400`
6. **Save/Discard кнопки** (условные, только когда `hasEdits === true`):
- Discard: `<Undo2 />` + "Discard" — `bg-orange-500/15 text-orange-400 hover:bg-orange-500/25`, вызывает `onDiscard(file.filePath)`
- Save: `<Save />` + "Save File" — `bg-green-500/15 text-green-400 hover:bg-green-500/25`, вызывает `onSave(file.filePath)`, `disabled={applying}`
- Во время `applying` вместо `<Save />` показывается `<Loader2 className="size-3 animate-spin" />` (спиннер)
- Кнопка Save имеет `disabled:opacity-50` для disabled state
- Обе кнопки обёрнуты в `<Tooltip>` (из `@renderer/components/ui/tooltip`)
- Save tooltip показывает keyboard shortcut `Cmd+Enter` через `<kbd>`:
```tsx
<TooltipContent side="bottom">
<span>Save file to disk</span>
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
⌘↵
</kbd>
</TooltipContent>
```
- Discard tooltip: "Discard all edits for this file"
**Импорты иконок:** `Save`, `Undo2`, `Loader2` из `lucide-react`
7. **Кнопки обёрнуты в `ml-auto` контейнер:**
```tsx
<div className="ml-auto flex items-center gap-1.5">
{hasEdits && ( /* Discard + Save */ )}
</div>
```
#### Sticky позиционирование
```tsx
<div
className="sticky top-0 z-10 flex items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2"
data-file-path={file.filePath}
>
```
**Важно:** `z-10` гарантирует, что sticky header перекрывает контент CodeMirror. Фон ОБЯЗАТЕЛЬНО непрозрачный (`bg-surface-sidebar`), чтобы diff-строки не просвечивали через header.
**Важно:** Когда несколько sticky headers "стопятся" (файл A scrolled out, файл B виден) — только один header виден сверху. Это нативное поведение CSS `position: sticky`: каждый header прилипает в рамках своего parent section div.
---
### 2.2. `FileSectionDiff.tsx`
**Путь:** `src/renderer/components/team/review/FileSectionDiff.tsx`
**Назначение:** Diff-контент для одного файла в continuous scroll. Извлечён из `ChangeReviewDialog.tsx` (строки 511-561 — блоки loading state, CodeMirror diff view, fallback snippet view).
#### Props Interface
```typescript
import type { EditorView } from '@codemirror/view';
import type { FileChangeSummary, FileChangeWithContent } from '@shared/types';
interface FileSectionDiffProps {
/** Данные файла */
file: FileChangeSummary;
/** Загруженный контент (null = ещё не загружен) */
fileContent: FileChangeWithContent | null;
/** Контент загружается */
isLoading: boolean;
/** Collapse unchanged regions в CodeMirror */
collapseUnchanged: boolean;
/** Callback при accept hunk */
onHunkAccepted: (filePath: string, hunkIndex: number) => void;
/** Callback при reject hunk */
onHunkRejected: (filePath: string, hunkIndex: number) => void;
/** Callback: файл полностью просмотрен (sentinel виден в viewport) */
onFullyViewed: (filePath: string) => void;
/** Callback: ручная правка контента (debounced из CodeMirror) */
onContentChanged: (filePath: string, content: string) => void;
/** Callback для регистрации EditorView в общий Map. Вызывается при создании/уничтожении */
onEditorViewReady: (filePath: string, view: EditorView | null) => void;
/**
* Counter для force-rebuild editor (инкрементируется при discard).
* Используется как часть key для CodeMirrorDiffView.
*/
discardCounter: number;
/** Auto-viewed включён (для sentinel IntersectionObserver) */
autoViewed: boolean;
/** Файл уже помечен как viewed (не вызывать onFullyViewed повторно) */
isViewed: boolean;
}
```
#### Логика рендеринга
1. **Loading state:** Если `isLoading` — показать `FileSectionPlaceholder` (из соседнего файла)
2. **Unavailable fallback:** Если `!fileContent || fileContent.contentSource === 'unavailable'` — показать `<ReviewDiffContent file={file} />`
**Важно:** Также проверить `fileContent.modifiedFullContent !== null`. В текущем коде (строка 523) условие: `fileContent.contentSource !== 'unavailable' && fileContent.modifiedFullContent !== null`. Если `modifiedFullContent === null` — тоже fallback на ReviewDiffContent.
3. **CodeMirror diff:** Иначе — полноценный CodeMirror:
```tsx
<DiffErrorBoundary
filePath={file.filePath}
oldString={fileContent.originalFullContent ?? ''}
newString={fileContent.modifiedFullContent!}
>
<CodeMirrorDiffView
key={`${file.filePath}:${discardCounter}`}
original={fileContent.originalFullContent ?? ''}
modified={fileContent.modifiedFullContent!}
fileName={file.relativePath}
readOnly={false}
showMergeControls={true}
collapseUnchanged={collapseUnchanged}
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)}
onFullyViewed={handleFullyViewed}
editorViewRef={localEditorViewRef}
onContentChanged={(content) => onContentChanged(file.filePath, content)}
/>
</DiffErrorBoundary>
```
**Обрати внимание:**
- `initialState` **не передаётся** — в continuous mode нет cache, editors живут одновременно
- `onFullyViewed` передаётся как `handleFullyViewed` (локальный callback без аргументов, т.к. `CodeMirrorDiffView.onFullyViewed` имеет тип `() => void`)
- `DiffErrorBoundary.props`: `filePath` (string), `oldString` (optional string), `newString` (optional string), `onRetry` (optional callback). Без `onRetry` — нет кнопки retry, только показ ошибки
4. **handleFullyViewed — bridge между sentinel и parent callback:**
`CodeMirrorDiffView.onFullyViewed` имеет сигнатуру `() => void`. Наш `FileSectionDiff.onFullyViewed` принимает `(filePath: string) => void`. Нужен bridge:
```typescript
const handleFullyViewed = useCallback(() => {
onFullyViewed(file.filePath);
}, [file.filePath, onFullyViewed]);
```
Этот `handleFullyViewed` передаётся и в `CodeMirrorDiffView.onFullyViewed`, и в sentinel observer (оба вызывают одну функцию). Однако в continuous mode мы используем **собственный sentinel** вместо встроенного `CodeMirrorDiffView` sentinel (см. ниже).
5. **EditorView регистрация:**
```typescript
const localEditorViewRef = useRef<EditorView | null>(null);
// Sync to parent Map при mount/unmount
useEffect(() => {
return () => {
// При unmount сообщить parent что view уничтожен
onEditorViewReady(file.filePath, null);
};
}, [file.filePath, onEditorViewReady]);
// Нужен useEffect чтобы проверить ref после рендера CodeMirrorDiffView
useEffect(() => {
if (localEditorViewRef.current) {
onEditorViewReady(file.filePath, localEditorViewRef.current);
}
});
```
**Как CodeMirrorDiffView устанавливает ref:**
В `CodeMirrorDiffView.tsx` строки 685-688 — при создании EditorView он синхронно записывает view в `externalViewRef.current`:
```typescript
const extRef = externalViewRefHolder.current;
if (extRef) {
(extRef as React.MutableRefObject<EditorView | null>).current = view;
}
```
Это происходит в useEffect (строка 666), после чего наш вторичный useEffect (без deps) на следующем render cycle ловит значение и вызывает `onEditorViewReady`.
6. **Sentinel для auto-viewed:**
В continuous mode встроенный sentinel `CodeMirrorDiffView` (`endSentinelRef` внутри компонента, строка 281) может некорректно работать, т.к. `CodeMirrorDiffView` рендерится внутри `<div className="flex-col" style={{ maxHeight }}>` с `maxHeight: '100%'`. В continuous scroll нет фиксированной высоты — CodeMirror занимает весь свой контент. Поэтому встроенный sentinel (`threshold: 1.0`, строка 755) может не сработать.
**Решение:** Внешний sentinel в `FileSectionDiff`, с `threshold: 0.85` (а не 1.0). Причина: в continuous scroll с collapsed unchanged regions файл может не занимать 100% viewport, и sentinel может быть виден на 85-90%.
```tsx
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!sentinelRef.current || !autoViewed || isViewed) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
onFullyViewed(file.filePath);
}
}
},
{ threshold: 0.85 }
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [autoViewed, isViewed, file.filePath, onFullyViewed]);
```
Sentinel div в конце секции:
```tsx
<div ref={sentinelRef} className="h-1 shrink-0" />
```
**Edge case:** Для очень коротких файлов (3-5 строк) sentinel может быть сразу виден при mount. Это ОК — файл просмотрен.
**Дублирование:** Встроенный `CodeMirrorDiffView.onFullyViewed` тоже вызовет `handleFullyViewed` при scroll end внутри CM. Можно передать `onFullyViewed={undefined}` в CodeMirrorDiffView чтобы отключить встроенный observer (проп optional). Или оставить оба — двойной вызов `markViewed` для уже viewed файла — no-op (проверяется через `isViewed`).
**Рекомендация:** Передать `onFullyViewed={undefined}` в CodeMirrorDiffView и полагаться только на внешний sentinel.
#### Важные замечания
- `DiffErrorBoundary` оборачивает только CodeMirror, не fallback `ReviewDiffContent`
- `key` включает `discardCounter` для force-rebuild при discard edits
- Условие для CodeMirror рендеринга: `!isLoading && fileContent && fileContent.contentSource !== 'unavailable' && fileContent.modifiedFullContent !== null`
---
### 2.3. `FileSectionPlaceholder.tsx`
**Путь:** `src/renderer/components/team/review/FileSectionPlaceholder.tsx`
**Назначение:** Skeleton placeholder для file section пока контент загружается.
#### Props Interface
```typescript
interface FileSectionPlaceholderProps {
/** Имя файла для отображения в заголовке skeleton */
fileName: string;
}
```
#### Что рендерит
```tsx
export const FileSectionPlaceholder = ({ fileName }: FileSectionPlaceholderProps) => (
<div className="animate-pulse">
{/* Header area */}
<div className="flex items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2">
<span className="text-xs font-medium text-text-muted">{fileName}</span>
<div className="h-4 w-16 rounded bg-surface-raised" />
</div>
{/* Content shimmer lines */}
<div className="space-y-2 p-4">
<div className="h-4 w-3/4 rounded bg-surface-raised" />
<div className="h-4 w-1/2 rounded bg-surface-raised" />
<div className="h-4 w-5/6 rounded bg-surface-raised" />
<div className="h-4 w-2/3 rounded bg-surface-raised" />
</div>
</div>
);
```
**CSS:** `animate-pulse` — встроенная Tailwind анимация для skeleton loading. Пульсирует opacity между 1 и 0.5.
**Высота:** Примерно 120-140px, достаточно чтобы placeholder не "прыгал" при загрузке контента. Но это не идеальное совпадение с финальной высотой diff — абсолютной точности не требуется.
---
### 2.4. `useVisibleFileSection.ts`
**Путь:** `src/renderer/hooks/useVisibleFileSection.ts`
**Назначение:** Scroll-spy хук. Отслеживает какой файл сейчас виден в viewport. По паттерну `useVisibleAIGroup.ts`.
#### Interface
```typescript
import { type RefObject } from 'react';
interface UseVisibleFileSectionOptions {
/** Callback: вызывается при смене видимого файла */
onVisibleFileChange: (filePath: string) => void;
/** Scroll container ref (ContinuousScrollView outer div) */
scrollContainerRef: RefObject<HTMLElement>;
/** Подавление scroll-spy во время programmatic scroll */
isProgrammaticScroll: RefObject<boolean>;
}
interface UseVisibleFileSectionReturn {
/**
* Регистрация file section элемента для наблюдения.
* Возвращает ref callback — передать в div section.
* Пример: <div ref={registerFileSectionRef(file.filePath)}>
*/
registerFileSectionRef: (filePath: string) => (element: HTMLElement | null) => void;
}
```
#### Реализация (описание)
```typescript
export function useVisibleFileSection(
options: UseVisibleFileSectionOptions
): UseVisibleFileSectionReturn {
const { onVisibleFileChange, scrollContainerRef, isProgrammaticScroll } = options;
// Set видимых filePath
const visibleFilePaths = useRef<Set<string>>(new Set());
// Map: filePath -> HTMLElement
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
// Observer ref
const observerRef = useRef<IntersectionObserver | null>(null);
// Debounce timer
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Определить topmost visible file
const updateTopmostVisible = useCallback(() => {
// Если programmatic scroll — не обновлять (иначе race condition)
if (isProgrammaticScroll.current) return;
if (visibleFilePaths.current.size === 0) return;
let topmostPath: string | null = null;
let minTop = Infinity;
visibleFilePaths.current.forEach((filePath) => {
const element = elementRefs.current.get(filePath);
if (element) {
const rect = element.getBoundingClientRect();
if (rect.top < minTop) {
minTop = rect.top;
topmostPath = filePath;
}
}
});
if (topmostPath) {
onVisibleFileChange(topmostPath);
}
}, [onVisibleFileChange, isProgrammaticScroll]);
// Debounced версия
const debouncedUpdate = useCallback(() => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(updateTopmostVisible, 100);
}, [updateTopmostVisible]);
// Создание IntersectionObserver
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
let changed = false;
for (const entry of entries) {
const filePath = entry.target.getAttribute('data-file-path');
if (!filePath) continue;
if (entry.isIntersecting && entry.intersectionRatio >= 0.1) {
if (!visibleFilePaths.current.has(filePath)) {
visibleFilePaths.current.add(filePath);
changed = true;
}
} else {
if (visibleFilePaths.current.has(filePath)) {
visibleFilePaths.current.delete(filePath);
changed = true;
}
}
}
if (changed) {
debouncedUpdate();
}
},
{
root: scrollContainerRef.current,
threshold: 0.1,
rootMargin: '0px',
}
);
return () => {
observerRef.current?.disconnect();
clearTimeout(debounceRef.current);
};
}, [scrollContainerRef, debouncedUpdate]);
// Register ref callback
const registerFileSectionRef = useCallback((filePath: string) => {
return (element: HTMLElement | null) => {
const observer = observerRef.current;
if (!observer) return;
// Cleanup previous
const prev = elementRefs.current.get(filePath);
if (prev) {
observer.unobserve(prev);
elementRefs.current.delete(filePath);
visibleFilePaths.current.delete(filePath);
}
// Register new
if (element) {
element.setAttribute('data-file-path', filePath);
elementRefs.current.set(filePath, element);
observer.observe(element);
}
};
}, []);
return { registerFileSectionRef };
}
```
#### Ключевые отличия от `useVisibleAIGroup`
| Аспект | `useVisibleAIGroup` | `useVisibleFileSection` |
|--------|--------------------|-----------------------|
| threshold | `0.5` (default, configurable via `threshold?` option) | `0.1` (файлы длинные, 50% может быть за viewport) |
| debounce | нет (вызывает updateTopmostVisible синхронно) | 100ms (стабильность при быстром скролле) |
| programmatic scroll suppression | нет | да (`isProgrammaticScroll` ref) |
| data attribute | `data-aigroup-id` | `data-file-path` |
| root | опциональный `rootRef?.current ?? null` | обязательный `scrollContainerRef.current` |
| callback name | `onVisibleChange` | `onVisibleFileChange` |
#### Edge Cases
- **Пустой список файлов:** Observer создаётся, но никто не регистрируется — no-op
- **Один файл:** Всегда виден, `onVisibleFileChange` вызовется один раз при mount
- **Быстрый scroll:** Debounce 100ms группирует обновления
- **Resize окна:** IntersectionObserver автоматически пересчитывает intersections
- **scrollContainerRef.current is null при первом рендере:** Observer создаётся с `root: null` — будет наблюдать viewport вместо контейнера. Решение: добавить guard `if (!scrollContainerRef.current) return;` в useEffect, либо убедиться что ref установлен до mount дочерних компонентов (ref на тот же div что и scrollContainerRef)
---
### 2.5. `useContinuousScrollNav.ts`
**Путь:** `src/renderer/hooks/useContinuousScrollNav.ts`
**Назначение:** Навигация в continuous scroll — scroll-to-file, keyboard shortcuts, подавление scroll-spy во время programmatic scroll.
#### Interface
```typescript
import type { RefObject } from 'react';
interface UseContinuousScrollNavOptions {
/** Ref на scroll container (ContinuousScrollView outer div) */
scrollContainerRef: RefObject<HTMLElement>;
/** Упорядоченный список filePath (порядок = порядок рендеринга) */
filePaths: string[];
/** Текущий активный файл (от scroll-spy) */
activeFilePath: string | null;
/** Диалог открыт (для keyboard listeners) */
isOpen: boolean;
}
interface UseContinuousScrollNavReturn {
/** Scroll к файлу по filePath (smooth) */
scrollToFile: (filePath: string) => void;
/**
* Ref-flag: true пока идёт programmatic scroll.
* Передаётся в useVisibleFileSection для подавления scroll-spy.
*/
isProgrammaticScroll: RefObject<boolean>;
}
```
#### Реализация (описание)
```typescript
import { waitForScrollEnd } from '@renderer/hooks/navigation/utils';
export function useContinuousScrollNav(
options: UseContinuousScrollNavOptions
): UseContinuousScrollNavReturn {
const { scrollContainerRef, filePaths, activeFilePath, isOpen } = options;
const isProgrammaticScroll = useRef(false);
const scrollToFile = useCallback(
(filePath: string) => {
const container = scrollContainerRef.current;
if (!container) return;
const section = container.querySelector<HTMLElement>(
`[data-file-path="${CSS.escape(filePath)}"]`
);
if (!section) return;
// Подавить scroll-spy
isProgrammaticScroll.current = true;
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Дождаться окончания scroll и снять подавление
// waitForScrollEnd default timeout = 400ms, передаём 500ms для запаса
void waitForScrollEnd(container, 500).then(() => {
isProgrammaticScroll.current = false;
});
},
[scrollContainerRef]
);
// Keyboard: Alt+ArrowDown = next file, Alt+ArrowUp = prev file
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
if (!e.altKey) return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const currentIdx = filePaths.indexOf(activeFilePath ?? '');
const nextIdx = currentIdx < filePaths.length - 1 ? currentIdx + 1 : 0;
scrollToFile(filePaths[nextIdx]);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const currentIdx = filePaths.indexOf(activeFilePath ?? '');
const prevIdx = currentIdx > 0 ? currentIdx - 1 : filePaths.length - 1;
scrollToFile(filePaths[prevIdx]);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isOpen, filePaths, activeFilePath, scrollToFile]);
return {
scrollToFile,
isProgrammaticScroll,
};
}
```
#### Race condition: scroll-spy vs programmatic scroll
**Проблема:** Когда пользователь кликает на файл в tree — мы вызываем `scrollToFile()`. Scroll-spy видит промежуточные файлы пролетающие мимо viewport и обновляет `activeFilePath`. Это "мигание" в tree.
**Решение:**
1. `isProgrammaticScroll` ref устанавливается в `true` перед `scrollIntoView`
2. `useVisibleFileSection` проверяет этот ref в `updateTopmostVisible` и молчит
3. `waitForScrollEnd()` (из `navigation/utils.ts`) ждёт стабилизации `scrollTop` (3 стабильных кадра `requestAnimationFrame` с `Math.abs(currentScrollTop - lastScrollTop) < 1`)
4. После стабилизации ref сбрасывается в `false`
5. Scroll-spy продолжает работать нормально
**Timeout:** `waitForScrollEnd` имеет дефолтный fallback timeout 400ms (строка 172 в `navigation/utils.ts`). Smooth scroll в Chromium занимает ~300-400ms. Передаём 500ms для запаса.
#### `CSS.escape(filePath)`
**Важно:** `filePath` может содержать спецсимволы (точки, слеши). `CSS.escape()` экранирует их для `querySelector`. Пример: `src/utils/path.ts` -> `src\/utils\/path\.ts` в селекторе.
#### Edge case: `filePaths.indexOf(activeFilePath ?? '')` returns -1
Если `activeFilePath` нет в `filePaths` (или null), `indexOf` вернёт -1. Тогда:
- ArrowDown: `nextIdx = -1 < length - 1 ? 0 : 0` = 0 — переход к первому файлу. OK.
- ArrowUp: `prevIdx = -1 > 0 ? ... : length - 1` = last — переход к последнему файлу. OK.
---
### 2.6. `ContinuousScrollView.tsx`
**Путь:** `src/renderer/components/team/review/ContinuousScrollView.tsx`
**Назначение:** Главный контейнер continuous scroll. Заменяет single-file diff area в `ChangeReviewDialog`.
#### Props Interface
```typescript
import type { EditorView } from '@codemirror/view';
import type {
FileChangeSummary,
FileChangeWithContent,
HunkDecision,
} from '@shared/types';
interface ContinuousScrollViewProps {
/** Список файлов из activeChangeSet.files */
files: FileChangeSummary[];
/** Загруженный контент: filePath -> FileChangeWithContent */
fileContents: Record<string, FileChangeWithContent>;
/** Флаги загрузки контента: filePath -> boolean */
fileContentsLoading: Record<string, boolean>;
/** Set просмотренных файлов */
viewedSet: Set<string>;
/** Ручные правки: filePath -> content string */
editedContents: Record<string, string>;
/** Решения по файлам: filePath -> HunkDecision */
fileDecisions: Record<string, HunkDecision>;
/** Collapse unchanged regions */
collapseUnchanged: boolean;
/** Applying in progress */
applying: boolean;
/** Auto-viewed включён */
autoViewed: boolean;
/** Counter для force rebuild editors при discard */
discardCounter: number;
// -- Callbacks --
/** Hunk accepted в CodeMirror */
onHunkAccepted: (filePath: string, hunkIndex: number) => void;
/** Hunk rejected в CodeMirror */
onHunkRejected: (filePath: string, hunkIndex: number) => void;
/** Файл полностью просмотрен (auto-viewed) */
onFullyViewed: (filePath: string) => void;
/** Ручная правка контента */
onContentChanged: (filePath: string, content: string) => void;
/** Discard edits для файла */
onDiscard: (filePath: string) => void;
/** Save файла на диск */
onSave: (filePath: string) => void;
/** Callback: видимый файл изменился (scroll-spy). Parent обновляет activeFilePath */
onVisibleFileChange: (filePath: string) => void;
// -- Exposed refs --
/** Ref для scroll container (передаётся из parent для scroll-to-file) */
scrollContainerRef: React.RefObject<HTMLDivElement>;
/** Map EditorView по filePath. Parent использует для keyboard shortcuts */
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
/** Ref: подавление scroll-spy (от useContinuousScrollNav) */
isProgrammaticScroll: React.RefObject<boolean>;
}
```
#### Структура рендеринга
```tsx
export const ContinuousScrollView = ({
files,
fileContents,
fileContentsLoading,
viewedSet,
editedContents,
fileDecisions,
collapseUnchanged,
applying,
autoViewed,
discardCounter,
onHunkAccepted,
onHunkRejected,
onFullyViewed,
onContentChanged,
onDiscard,
onSave,
onVisibleFileChange,
scrollContainerRef,
editorViewMapRef,
isProgrammaticScroll,
}: ContinuousScrollViewProps) => {
// Scroll-spy
const { registerFileSectionRef } = useVisibleFileSection({
onVisibleFileChange,
scrollContainerRef,
isProgrammaticScroll,
});
// EditorView registration callback
const handleEditorViewReady = useCallback(
(filePath: string, view: EditorView | null) => {
if (view) {
editorViewMapRef.current.set(filePath, view);
} else {
editorViewMapRef.current.delete(filePath);
}
},
[editorViewMapRef]
);
return (
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto"
>
{files.map((file) => {
const filePath = file.filePath;
const content = fileContents[filePath] ?? null;
const isLoading = fileContentsLoading[filePath] ?? false;
const hasEdits = filePath in editedContents;
const isViewed = viewedSet.has(filePath);
const decision = fileDecisions[filePath];
return (
<div
key={filePath}
ref={registerFileSectionRef(filePath)}
className="border-b border-border"
>
<FileSectionHeader
file={file}
fileContent={content}
fileDecision={decision}
hasEdits={hasEdits}
applying={applying}
onDiscard={onDiscard}
onSave={onSave}
/>
{isLoading ? (
<FileSectionPlaceholder fileName={file.relativePath} />
) : (
<FileSectionDiff
file={file}
fileContent={content}
isLoading={false}
collapseUnchanged={collapseUnchanged}
onHunkAccepted={onHunkAccepted}
onHunkRejected={onHunkRejected}
onFullyViewed={onFullyViewed}
onContentChanged={onContentChanged}
onEditorViewReady={handleEditorViewReady}
discardCounter={discardCounter}
autoViewed={autoViewed}
isViewed={isViewed}
/>
)}
</div>
);
})}
{files.length === 0 && (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
No file changes detected
</div>
)}
</div>
);
};
```
**Замечание по `isLoading`:** Когда loading=true, показывается placeholder вместо `FileSectionDiff`. Когда loading завершится (контент загружен) — перерисовка покажет diff. `FileSectionDiff` получает `isLoading={false}` потому что condition уже обработан выше.
**Замечание по `data-file-path`:** Атрибут устанавливается в двух местах:
1. На section div через `registerFileSectionRef` (для scroll-spy IntersectionObserver)
2. На sticky header внутри `FileSectionHeader` (для `querySelector` в `scrollToFile`)
`scrollToFile` использует `querySelector('[data-file-path="..."]')` — найдёт **первый** элемент, а это header (он вложен в section). Чтобы `scrollIntoView` скроллил к началу секции (а не к header внутри), нужно убедиться что selector находит section div. **Решение:** Убрать `data-file-path` из `FileSectionHeader` и оставить только на section div. Тогда `scrollToFile` найдёт section div, а `scrollIntoView({ block: 'start' })` покажет начало секции = sticky header.
#### EditorView Map
```typescript
// В parent (ChangeReviewDialog):
const editorViewMapRef = useRef(new Map<string, EditorView>());
```
**Зачем:** Keyboard shortcuts (`Cmd+Y`, `Cmd+N`) теперь должны знать, к какому EditorView применить действие. Логика:
1. Если EditorView имеет фокус (`view.hasFocus`) — применить к нему
2. Иначе — применить к EditorView `activeFilePath` (от scroll-spy)
3. Fallback — первый EditorView в Map
```typescript
// Helper в ChangeReviewDialog:
function getTargetEditorView(): EditorView | null {
// 1. Focused editor
for (const view of editorViewMapRef.current.values()) {
if (view.hasFocus) return view;
}
// 2. Active file's editor
if (activeFilePath) {
return editorViewMapRef.current.get(activeFilePath) ?? null;
}
// 3. First available
const first = editorViewMapRef.current.values().next();
return first.done ? null : first.value;
}
```
---
## 3. Модификации существующих файлов
### 3.1. `ReviewFileTree.tsx`
#### Новые props
```typescript
interface ReviewFileTreeProps {
files: FileChangeSummary[];
selectedFilePath: string | null;
onSelectFile: (filePath: string) => void;
viewedSet?: Set<string>;
onMarkViewed?: (filePath: string) => void;
onUnmarkViewed?: (filePath: string) => void;
// === НОВЫЕ ===
/** Активный файл от scroll-spy (мягкая подсветка) */
activeFilePath?: string;
}
```
#### Отличие `selectedFilePath` vs `activeFilePath`
| | `selectedFilePath` | `activeFilePath` |
|---|---|---|
| Источник | Клик по файлу в tree | Scroll-spy (IntersectionObserver) |
| Визуал | `bg-blue-500/20 text-blue-300` (сильная подсветка) | `border-l-2 border-blue-400` (мягкий индикатор) |
| Поведение | Клик -> scrollToFile | Автоматически обновляется при скролле |
| При клике | Совпадает с activeFilePath | Может отставать (debounce 100ms) |
**Визуальная логика в TreeItem:**
```typescript
const isSelected = node.file.filePath === selectedFilePath;
const isActive = node.file.filePath === activeFilePath && !isSelected;
```
Стили:
```typescript
className={cn(
'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
isSelected
? 'bg-blue-500/20 text-blue-300' // Клик
: isActive
? 'border-l-2 border-blue-400 text-text' // Scroll-spy
: 'text-text-secondary hover:bg-surface-raised hover:text-text'
)}
```
#### Пробросить `activeFilePath` через TreeItem
TreeItem в текущем коде принимает inline props (не interface, а destructured объект, строки 108-126):
```typescript
const TreeItem = ({
node,
selectedFilePath,
onSelectFile,
depth,
hunkDecisions,
viewedSet,
onMarkViewed,
onUnmarkViewed,
}: { ... }) => { ... }
```
Нужно добавить `activeFilePath?: string` в этот inline type и пробрасывать дальше в рекурсивные `<TreeItem>` (строка 195-206).
#### Auto-scroll в tree при смене `activeFilePath`
```typescript
// В ReviewFileTree
useEffect(() => {
if (!activeFilePath) return;
const btn = document.querySelector<HTMLElement>(
`[data-tree-file="${CSS.escape(activeFilePath)}"]`
);
if (btn) {
btn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, [activeFilePath]);
```
#### data-tree-file attribute
Добавить на `<button>` файла в TreeItem (строка 131):
```tsx
<button
data-tree-file={node.file.filePath}
onClick={() => onSelectFile(node.file!.filePath)}
className={cn( ... )}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
```
---
### 3.2. `ChangeReviewDialog.tsx`
#### Что УБРАТЬ
1. **`editorViewRef`** (строка 88: `const editorViewRef = useRef<EditorView | null>(null)`) — заменён на `editorViewMapRef`
2. **`editorStateCache`** (строка 95: `const editorStateCache = useRef(new Map<string, EditorState>())`) — не нужен в continuous mode (editors живут одновременно)
3. **`cachedInitialState`** / `setCachedInitialState` (строка 97) — не нужен
4. **`handleSelectFile`** (строки 125-135) — логика сохранения EditorState в cache больше не нужна
5. **Single-file diff area** (строки 432-568) — заменён на `ContinuousScrollView`
6. **`selectedFile` useMemo** (строки 239-242) — не нужен для выбора файла для рендеринга (но нужен для timeline sidebar — см. ниже)
7. **`fileContent` / `isFileContentLoading` derived values** (строки 244-247) — загрузка теперь в bulk при открытии
8. **Lazy-load useEffect** (строки 224-237) — заменяется на bulk-load (см. "Что ДОБАВИТЬ")
9. **`hasCurrentFileEdits`** (строки 120-122) — больше не нужен на уровне dialog (managed per-file в FileSectionHeader)
10. **Import `EditorState`** (строка 25) — больше не используется
#### Что ДОБАВИТЬ
1. **`activeFilePath` state:**
```typescript
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
```
2. **`editorViewMapRef`:**
```typescript
const editorViewMapRef = useRef(new Map<string, EditorView>());
```
3. **`scrollContainerRef`:**
```typescript
const scrollContainerRef = useRef<HTMLDivElement>(null);
```
4. **`discardCounter` state** — уже есть (строка 93), оставить
5. **`useContinuousScrollNav`:**
```typescript
const filePaths = useMemo(
() => (activeChangeSet?.files ?? []).map((f) => f.filePath),
[activeChangeSet]
);
const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
scrollContainerRef,
filePaths,
activeFilePath,
isOpen: open,
});
```
**Замечание:** `allFilePaths` (строка 103-106) уже вычисляет то же самое для `useViewedFiles`. Можно переиспользовать:
```typescript
const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
scrollContainerRef,
filePaths: allFilePaths,
activeFilePath,
isOpen: open,
});
```
6. **Bulk-load контента при открытии:**
Заменить lazy-load useEffect (строки 224-237) на:
```typescript
useEffect(() => {
if (!open || !activeChangeSet) return;
for (const file of activeChangeSet.files) {
if (!fileContents[file.filePath] && !fileContentsLoading[file.filePath]) {
void fetchFileContent(teamName, memberName, file.filePath);
}
}
}, [open, activeChangeSet, teamName, memberName, fileContents, fileContentsLoading, fetchFileContent]);
```
**Важно:** `fetchFileContent` внутри себя проверяет `if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;` (строка 264 в changeReviewSlice), поэтому дублирования запросов не будет. Но для чистоты проверяем и на стороне вызова.
**Замечание:** `fetchFileContent` принимает `(teamName, memberName | undefined, filePath)`. В mode='task' `memberName` может быть undefined — это ОК, store обработает.
7. **`onSelectFile` в FileTree теперь вызывает `scrollToFile`:**
```typescript
const handleTreeFileClick = useCallback(
(filePath: string) => {
scrollToFile(filePath);
},
[scrollToFile]
);
```
8. **`handleAcceptAll` / `handleRejectAll`** — работают с `activeFilePath`:
```typescript
const handleAcceptAll = useCallback(() => {
const targetPath = activeFilePath;
if (!targetPath) return;
const view = editorViewMapRef.current.get(targetPath);
if (view) acceptAllChunks(view);
acceptAllFile(targetPath);
}, [activeFilePath, acceptAllFile]);
const handleRejectAll = useCallback(() => {
const targetPath = activeFilePath;
if (!targetPath) return;
const view = editorViewMapRef.current.get(targetPath);
if (view) rejectAllChunks(view);
rejectAllFile(targetPath);
}, [activeFilePath, rejectAllFile]);
```
9. **`handleSaveCurrentFile` / `handleDiscardCurrentFile`** — теперь принимают `filePath` как аргумент:
**Важно:** `FileSectionHeader` вызывает `onDiscard(file.filePath)` и `onSave(file.filePath)`, передавая filePath. Поэтому callbacks должны принимать filePath:
```typescript
const handleSaveFile = useCallback(
(filePath: string) => {
void saveEditedFile(filePath);
},
[saveEditedFile]
);
const handleDiscardFile = useCallback(
(filePath: string) => {
discardFileEdits(filePath);
setDiscardCounter((c) => c + 1);
},
[discardFileEdits]
);
```
**Замечание о discardCounter:** В текущем single-file mode `discardCounter` инкрементируется глобально и используется в `key`. В continuous mode counter глобальный — при discard одного файла ВСЕ editors пересоздадутся. Это неоптимально, но допустимо для фазы 1. Оптимизация (per-file counter) — в будущем.
10. **`handleFullyViewed`** — теперь принимает filePath:
```typescript
const handleFullyViewed = useCallback(
(filePath: string) => {
if (autoViewed && !isViewed(filePath)) {
markViewed(filePath);
}
},
[autoViewed, isViewed, markViewed]
);
```
11. **`getTargetEditorView` helper:**
```typescript
const getTargetEditorView = useCallback((): EditorView | null => {
for (const view of editorViewMapRef.current.values()) {
if (view.hasFocus) return view;
}
if (activeFilePath) {
return editorViewMapRef.current.get(activeFilePath) ?? null;
}
const first = editorViewMapRef.current.values().next();
return first.done ? null : first.value;
}, [activeFilePath]);
```
12. **Cmd+N IPC listener** — использует `getTargetEditorView`:
```typescript
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
const view = getTargetEditorView();
if (view) {
rejectChunk(view);
requestAnimationFrame(() => goToNextChunk(view));
}
});
return cleanup ?? undefined;
}, [open, getTargetEditorView]);
```
13. **`useDiffNavigation` — адаптация:**
Текущая сигнатура:
```typescript
useDiffNavigation(
files: FileChangeSummary[],
selectedFilePath: string | null,
onSelectFile: (path: string) => void,
editorViewRef: React.RefObject<EditorView | null>,
isDialogOpen: boolean,
onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
onClose?: () => void,
onSaveFile?: () => void
)
```
В continuous mode:
- `selectedFilePath` -> `activeFilePath`
- `onSelectFile` -> `scrollToFile`
- `editorViewRef` -> нужен прокси ref, или рефакторинг хука
**Что используется из `useDiffNavigation`:**
- `diffNav.showShortcutsHelp` / `diffNav.setShowShortcutsHelp` — для `KeyboardShortcutsHelp` (строка 337-339)
- `diffNav.goToHunk(idx)` — для `FileEditTimeline.onEventClick` (строка 424)
- `diffNav.currentHunkIndex` — для `FileEditTimeline.activeSnippetIndex` (строка 425)
- Keyboard handlers (Cmd+Y, Alt+J, Cmd+Enter) — дублируют CM keymap + `useContinuousScrollNav`
**Решение для фазы 1:**
- Создать прокси ref `activeEditorViewRef` что всегда указывает на `getTargetEditorView()`:
```typescript
const activeEditorViewRef = useRef<EditorView | null>(null);
// Sync при смене activeFilePath
useEffect(() => {
activeEditorViewRef.current = editorViewMapRef.current.get(activeFilePath ?? '') ?? null;
}, [activeFilePath]);
```
- Передать в `useDiffNavigation`:
```typescript
const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [],
activeFilePath,
scrollToFile,
activeEditorViewRef,
open,
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
() => onOpenChange(false),
handleSaveFile.bind(null, activeFilePath ?? '')
);
```
- **Проблема:** `handleSaveFile` ожидает filePath, а `onSaveFile` в useDiffNavigation — `() => void`. Нужен wrapper:
```typescript
const handleSaveActiveFile = useCallback(() => {
if (activeFilePath) void saveEditedFile(activeFilePath);
}, [activeFilePath, saveEditedFile]);
```
Keyboard handlers в `useDiffNavigation` (Cmd+Y, Alt+J) будут работать через `activeEditorViewRef` — они проверяют `event.defaultPrevented` (строка 123), поэтому если CM keymap уже обработал — пропустят.
#### Diff content area — замена
**Было** (строки 432-568):
```tsx
<div className="flex-1 overflow-y-auto">
{selectedFile ? ( /* single file diff */ ) : ( /* "Select a file" placeholder */ )}
</div>
```
**Стало:**
```tsx
<ContinuousScrollView
files={activeChangeSet.files}
fileContents={fileContents}
fileContentsLoading={fileContentsLoading}
viewedSet={viewedSet}
editedContents={editedContents}
fileDecisions={fileDecisions}
collapseUnchanged={collapseUnchanged}
applying={applying}
autoViewed={autoViewed}
discardCounter={discardCounter}
onHunkAccepted={(fp, idx) => setHunkDecision(fp, idx, 'accepted')}
onHunkRejected={(fp, idx) => setHunkDecision(fp, idx, 'rejected')}
onFullyViewed={handleFullyViewed}
onContentChanged={updateEditedContent}
onDiscard={handleDiscardFile}
onSave={handleSaveFile}
onVisibleFileChange={setActiveFilePath}
scrollContainerRef={scrollContainerRef}
editorViewMapRef={editorViewMapRef}
isProgrammaticScroll={isProgrammaticScroll}
/>
```
#### Edit Timeline sidebar section
Timeline привязана к `activeFilePath` вместо `selectedReviewFilePath`. Нужно оставить `selectedFile` useMemo, но привязать к `activeFilePath`:
```typescript
const activeFile = useMemo(() => {
if (!activeChangeSet || !activeFilePath) return null;
return activeChangeSet.files.find((f) => f.filePath === activeFilePath) ?? null;
}, [activeChangeSet, activeFilePath]);
```
Sidebar секция (строки 406-429):
```tsx
{activeFile?.timeline && activeFile.timeline.events.length > 0 && (
<div className="border-t border-border">
<button
onClick={() => setTimelineOpen(!timelineOpen)}
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs text-text-secondary hover:text-text"
>
<Clock className="size-3.5" />
<span>Edit Timeline ({activeFile.timeline.events.length})</span>
<ChevronDown className={cn('ml-auto size-3 transition-transform', timelineOpen && 'rotate-180')} />
</button>
{timelineOpen && (
<FileEditTimeline
timeline={activeFile.timeline}
onEventClick={(idx) => diffNav.goToHunk(idx)}
activeSnippetIndex={diffNav.currentHunkIndex}
/>
)}
</div>
)}
```
#### File tree — передать `activeFilePath`
```tsx
<ReviewFileTree
files={activeChangeSet.files}
selectedFilePath={null} // В continuous mode нет "selected" — только active
activeFilePath={activeFilePath}
onSelectFile={handleTreeFileClick}
viewedSet={viewedSet}
onMarkViewed={markViewed}
onUnmarkViewed={unmarkViewed}
/>
```
**Замечание:** `selectedFilePath={null}` — в continuous mode нет отдельного "selected" state. Подсветка только через `activeFilePath`.
#### Что делать с `selectReviewFile` из store
`selectReviewFile(filePath)` из `changeReviewSlice` (строка 148-150) устанавливает `selectedReviewFilePath` в store. В continuous mode это больше не используется для переключения контента.
Рекомендация: **не трогать store** — просто не вызывать `selectReviewFile` из ChangeReviewDialog. Store action останется для обратной совместимости. `selectedReviewFilePath` по-прежнему инициализируется при `fetchAgentChanges`/`fetchTaskChanges` (строки 121, 138) — это ОК, просто не используется в UI.
#### Удалить неиспользуемые импорты
После рефакторинга убрать:
- `import type { EditorState } from '@codemirror/state'`
- Если `CONTENT_SOURCE_LABELS` вынесен в `FileSectionHeader` — убрать из ChangeReviewDialog
- `CodeMirrorDiffView` (рендерится в `FileSectionDiff`)
- `DiffErrorBoundary` (рендерится в `FileSectionDiff`)
- `ReviewDiffContent` (рендерится в `FileSectionDiff`)
**Оставить:** `acceptAllChunks`, `rejectAllChunks` из `CodeMirrorDiffUtils` — используются в `handleAcceptAll`/`handleRejectAll`.
**Оставить:** `rejectChunk` из `@codemirror/merge` — используется в Cmd+N handler.
**Оставить:** `goToNextChunk` из `@codemirror/merge` — используется в Cmd+N handler.
---
## 4. Критические детали
### 4.1. Scroll-spy + Programmatic scroll race
**Последовательность при клике на файл в tree:**
1. User кликает файл B в tree
2. `handleTreeFileClick('B')` -> `scrollToFile('B')`
3. `isProgrammaticScroll.current = true`
4. `section.scrollIntoView({ behavior: 'smooth' })`
5. Scroll анимация: файл A проскакивает мимо viewport, файл B появляется
6. IntersectionObserver вызывает callback для файлов A, B, C...
7. `useVisibleFileSection.updateTopmostVisible()` проверяет `isProgrammaticScroll` -> **молчит**
8. `waitForScrollEnd()` resolve через ~300-400ms
9. `isProgrammaticScroll.current = false`
10. Debounced update (100ms) запустится при следующем IO callback — обновит activeFilePath на B
**Edge case:** Если пользователь кликает на другой файл пока предыдущий scroll ещё идёт:
- `isProgrammaticScroll` останется `true`
- Новый `scrollIntoView` перезаписывает scroll target
- Старый `waitForScrollEnd` promise resolve (scrollTop стабилизируется) — сбросит flag
- Новый `waitForScrollEnd` заменит промис — **потенциальный race**: flag может сброситься преждевременно
**Решение:** Использовать counter или AbortController:
```typescript
const scrollGeneration = useRef(0);
const scrollToFile = useCallback((filePath: string) => {
// ...
const gen = ++scrollGeneration.current;
isProgrammaticScroll.current = true;
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
void waitForScrollEnd(container, 500).then(() => {
if (scrollGeneration.current === gen) {
isProgrammaticScroll.current = false;
}
});
}, [scrollContainerRef]);
```
### 4.2. EditorView Map vs один ref
**Было:** Один `editorViewRef = useRef<EditorView | null>(null)` — переписывался при каждом переключении файла.
**Стало:** `editorViewMapRef = useRef(new Map<string, EditorView>())` — все editors хранятся одновременно.
**Memory:** Каждый EditorView ~ 50-100KB. Для 50 файлов = 2.5-5MB. Приемлемо.
**Lifecycle:**
- Mount: `FileSectionDiff` создаёт CodeMirrorDiffView -> EditorView, регистрирует в Map
- Unmount: `FileSectionDiff` cleanup -> удаляет из Map
- В continuous mode **все** editors живут одновременно (пока все файлы в DOM)
### 4.3. editorStateCache не нужен
В continuous mode все editors живут одновременно. Нет "переключения файла" — нет необходимости сохранять/восстанавливать EditorState. Undo history живёт в самом EditorView.
**Discard edits:** Вместо сброса cache entry — `key` prop с `discardCounter` пересоздаёт CodeMirrorDiffView.
**Замечание:** Текущий `handleDiscardCurrentFile` (строка 153-160) также делает `editorStateCache.current.delete()` и `setCachedInitialState(undefined)`. Оба удаляются.
### 4.4. Keyboard Cmd+Y/N
В continuous scroll фокус может быть:
1. Внутри конкретного CodeMirror (user кликнул в diff) -> CM keymap обработает
2. Вне CodeMirror (user скроллит мышью) -> document keydown handler -> `getTargetEditorView()`
**Приоритет:**
1. CM keymap (если фокус в CM) — обработает и вернёт `true`, event не propagates
2. `useDiffNavigation` keyboard handler проверяет `event.defaultPrevented` (строка 123 в useDiffNavigation.ts) — если CM уже обработал, пропускает
3. Если CM не обработал — `useDiffNavigation` handler использует `activeEditorViewRef.current`
4. Cmd+N IPC handler (через `window.electronAPI.review.onCmdN`) — работает через `getTargetEditorView()`
**Конфликт Cmd+Y:** `useDiffNavigation` вызывает `acceptChunk(view)` (строка 149-153), а CM keymap тоже содержит `Mod-y` handler. Если фокус в CM — CM обработает первым и `event.defaultPrevented` будет true. Если фокус вне CM — useDiffNavigation handler сработает. Нет конфликта.
### 4.5. Performance: много CodeMirror editors одновременно
**Проблема:** 30+ CodeMirror editors в DOM одновременно = нагрузка на рендеринг.
**Mitigation (фаза 2):** Lazy loading — контент загружается по мере scroll. Editors создаются только для загруженных файлов.
**Mitigation (будущая фаза 3):** Virtualized rendering — только видимые файлы + буфер рендерятся в DOM. Файлы за пределами viewport заменяются placeholder фиксированной высоты.
**Для фазы 1:** Не оптимизировать — загрузить контент всех файлов при открытии (bulk). 20-30 файлов — OK для начала.
### 4.6. `data-file-path` дублирование
Атрибут `data-file-path` устанавливается:
1. `registerFileSectionRef` в `useVisibleFileSection` -> на section `<div>` (для IntersectionObserver)
2. Документ ранее предлагал его на sticky header в `FileSectionHeader`
**Решение:** Оставить `data-file-path` **только на section div** (через `registerFileSectionRef`). `scrollToFile` найдёт section div через `querySelector`, `scrollIntoView({ block: 'start' })` покажет начало секции. Scroll-spy observer тоже наблюдает section div. Один источник правды.
В `FileSectionHeader` `data-file-path` **не добавлять**.
### 4.7. Загрузка контента при mode='task'
`fetchFileContent(teamName, memberName, filePath)` — в mode='task' `memberName` может быть `undefined`. Текущая signature в store (строка 261): `fetchFileContent(teamName: string, memberName: string | undefined, filePath: string)`. Это ОК.
---
## 5. Порядок реализации
### Шаг 1: Создать `FileSectionPlaceholder.tsx`
- Простой компонент, без зависимостей
- Тестирование: визуально убедиться что skeleton выглядит ок
### Шаг 2: Создать `FileSectionHeader.tsx`
- Извлечь из ChangeReviewDialog строки 437-509
- Вынести `CONTENT_SOURCE_LABELS`
- Добавить sticky positioning
- Импортировать `Save`, `Undo2`, `Loader2` из lucide-react, `Tooltip`/`TooltipTrigger`/`TooltipContent`
- **НЕ добавлять** `data-file-path` на header (только на section div в ContinuousScrollView)
- Тестирование: рендерить standalone, проверить sticky поведение
### Шаг 3: Создать `FileSectionDiff.tsx`
- Извлечь из ChangeReviewDialog строки 511-561
- Добавить проверку `fileContent.modifiedFullContent !== null` в условие рендеринга CodeMirror
- Добавить sentinel для auto-viewed (threshold: 0.85)
- Добавить editorView registration callback
- Передать `onFullyViewed={undefined}` в CodeMirrorDiffView (отключить встроенный sentinel)
- Тестирование: рендерить с mock data, проверить что CodeMirror создаётся
### Шаг 4: Создать `useVisibleFileSection.ts`
- По паттерну `useVisibleAIGroup.ts`
- Добавить debounce и isProgrammaticScroll
- Guard на `scrollContainerRef.current` is null
- Тестирование: unit test с mock IntersectionObserver
### Шаг 5: Создать `useContinuousScrollNav.ts`
- scrollToFile с waitForScrollEnd(container, 500)
- Keyboard listeners (Alt+Arrow)
- Scroll generation counter для предотвращения race при быстрых кликах
- Тестирование: unit test keyboard events
### Шаг 6: Создать `ContinuousScrollView.tsx`
- Собрать все компоненты вместе
- files.map -> section (header + diff)
- Интегрировать useVisibleFileSection
- `data-file-path` только на section div (через registerFileSectionRef)
- Тестирование: рендерить с 3-5 файлами, проверить scroll и sticky headers
### Шаг 7: Модифицировать `ReviewFileTree.tsx`
- Добавить `activeFilePath` prop в `ReviewFileTreeProps`
- Добавить `activeFilePath` в inline props TreeItem и пробросить рекурсивно
- Добавить `data-tree-file` attribute на button
- Добавить auto-scroll useEffect
- Добавить визуальную подсветку active файла (isActive condition + border-l-2 стиль)
- Тестирование: проверить подсветку active vs selected
### Шаг 8: Модифицировать `ChangeReviewDialog.tsx`
- Заменить single-file area на ContinuousScrollView
- Убрать: editorViewRef, editorStateCache, cachedInitialState, handleSelectFile, hasCurrentFileEdits, selectedFile (заменить на activeFile)
- Добавить: activeFilePath, editorViewMapRef, scrollContainerRef, activeEditorViewRef
- Заменить lazy-load на bulk-load useEffect
- Интегрировать useContinuousScrollNav
- Адаптировать: handleAcceptAll/handleRejectAll, handleSaveFile (принимает filePath), handleDiscardFile (принимает filePath), handleFullyViewed (принимает filePath)
- Адаптировать useDiffNavigation: activeFilePath, scrollToFile, activeEditorViewRef
- Адаптировать Cmd+N handler: getTargetEditorView
- Timeline sidebar: selectedFile -> activeFile
- FileTree: selectedFilePath={null}, activeFilePath, onSelectFile=handleTreeFileClick
- Убрать неиспользуемые импорты
- Тестирование: полный E2E flow
---
## 6. Проверка
### Функциональная проверка
- [ ] Открыть ChangeReviewDialog с 3+ файлами
- [ ] Все файлы отображаются вертикально друг под другом
- [ ] Sticky headers прилипают при скролле
- [ ] File tree подсвечивает текущий файл при скролле (scroll-spy)
- [ ] Клик по файлу в tree = smooth scroll к файлу в контенте
- [ ] Нет "мигания" active файла при programmatic scroll
- [ ] Alt+ArrowDown/Up переключает между файлами
- [ ] Cmd+Y accept chunk работает (focused editor и fallback через getTargetEditorView)
- [ ] Cmd+N reject chunk работает (через IPC и через useDiffNavigation fallback)
- [ ] Accept All / Reject All применяются к active файлу
- [ ] Save/Discard кнопки в header работают (каждый файл независимо)
- [ ] Auto-viewed работает (скроллить до конца файла — sentinel 85%)
- [ ] Viewed checkbox в tree работает
- [ ] Edit timeline sidebar показывается для active файла
- [ ] Escape закрывает диалог
- [ ] Быстрый двойной клик по разным файлам в tree — нет race condition (scroll generation counter)
### Edge cases
- [ ] 0 файлов — показывает "No file changes detected"
- [ ] 1 файл — scroll-spy стабильно, нет navigation issues
- [ ] Файл с `contentSource: 'unavailable'` — показывает ReviewDiffContent fallback
- [ ] Файл с `modifiedFullContent === null` — показывает ReviewDiffContent fallback
- [ ] Файл загружается — показывает FileSectionPlaceholder
- [ ] Файл с isNewFile — показывает NEW badge в sticky header
- [ ] Очень длинный файл (1000+ строк) — smooth scroll работает
- [ ] Collapse unchanged toggle — все editors обновляются (через collapseUnchanged prop -> CodeMirrorDiffView reconfigure)
- [ ] Discard edits — editor пересоздаётся (key с discardCounter; глобальный counter, все editors rebuild — ОК для фазы 1)
- [ ] Resize window — IntersectionObserver пересчитывает, scroll-spy корректен
- [ ] mode='task' с memberName=undefined — bulk-load работает
### Performance
- [ ] 20 файлов — открытие < 2 секунд
- [ ] Scroll не лагает с 10+ CodeMirror editors
- [ ] Memory не утекает при close/reopen диалога (EditorView.destroy() через cleanup в CodeMirrorDiffView + editorViewMapRef cleanup)