# Фаза 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:** `
`
- `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 && (
NEW
)}
```
4. **Content source badge** (условный, только когда fileContent загружен):
```tsx
{fileContent?.contentSource && (
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
)}
```
`CONTENT_SOURCE_LABELS` определяется в этом же файле (вынесен из `ChangeReviewDialog.tsx` строка 38-44):
```typescript
const CONTENT_SOURCE_LABELS: Record = {
'file-history': 'File History',
'snippet-reconstruction': 'Reconstructed',
'disk-current': 'Current Disk',
'git-fallback': 'Git Fallback',
unavailable: 'Unavailable',
};
```
5. **File decision indicator** (условный):
```tsx
{fileDecision && (
{fileDecision}
)}
```
Цвета:
- `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: `` + "Discard" — `bg-orange-500/15 text-orange-400 hover:bg-orange-500/25`, вызывает `onDiscard(file.filePath)`
- Save: `` + "Save File" — `bg-green-500/15 text-green-400 hover:bg-green-500/25`, вызывает `onSave(file.filePath)`, `disabled={applying}`
- Во время `applying` вместо `` показывается `` (спиннер)
- Кнопка Save имеет `disabled:opacity-50` для disabled state
- Обе кнопки обёрнуты в `` (из `@renderer/components/ui/tooltip`)
- Save tooltip показывает keyboard shortcut `Cmd+Enter` через ``:
```tsx
Save file to disk
⌘↵
```
- Discard tooltip: "Discard all edits for this file"
**Импорты иконок:** `Save`, `Undo2`, `Loader2` из `lucide-react`
7. **Кнопки обёрнуты в `ml-auto` контейнер:**
```tsx
{hasEdits && ( /* Discard + Save */ )}
```
#### Sticky позиционирование
```tsx
```
**Важно:** `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'` — показать ``
**Важно:** Также проверить `fileContent.modifiedFullContent !== null`. В текущем коде (строка 523) условие: `fileContent.contentSource !== 'unavailable' && fileContent.modifiedFullContent !== null`. Если `modifiedFullContent === null` — тоже fallback на ReviewDiffContent.
3. **CodeMirror diff:** Иначе — полноценный CodeMirror:
```tsx
onHunkAccepted(file.filePath, idx)}
onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)}
onFullyViewed={handleFullyViewed}
editorViewRef={localEditorViewRef}
onContentChanged={(content) => onContentChanged(file.filePath, content)}
/>
```
**Обрати внимание:**
- `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(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).current = view;
}
```
Это происходит в useEffect (строка 666), после чего наш вторичный useEffect (без deps) на следующем render cycle ловит значение и вызывает `onEditorViewReady`.
6. **Sentinel для auto-viewed:**
В continuous mode встроенный sentinel `CodeMirrorDiffView` (`endSentinelRef` внутри компонента, строка 281) может некорректно работать, т.к. `CodeMirrorDiffView` рендерится внутри `
` с `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(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
```
**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) => (
{/* Header area */}
{fileName}
{/* Content shimmer lines */}
);
```
**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;
/** Подавление scroll-spy во время programmatic scroll */
isProgrammaticScroll: RefObject;
}
interface UseVisibleFileSectionReturn {
/**
* Регистрация file section элемента для наблюдения.
* Возвращает ref callback — передать в div section.
* Пример: