agent-ecosystem/docs/iterations/diff-view/continuous-scroll/phase-3-navigation.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

996 lines
48 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 3: Click-to-Scroll + Навигация
## Обзор
Фаза 3 адаптирует навигацию для continuous scroll mode. В текущей реализации (file-at-a-time) каждый файл показывается отдельно: `goToNextFile()` вызывает `onSelectFile()`, который уничтожает текущий EditorView и создаёт новый. В continuous mode все файлы видны одновременно в одном scroll container, поэтому навигация переключается на программный scroll.
**Ключевые изменения:**
- Клик по файлу в sidebar = smooth scroll к секции файла (вместо уничтожения/создания editor)
- Keyboard shortcuts (Alt+ArrowDown/Up) = scroll к следующему/предыдущему файлу
- Cross-file hunk navigation: при достижении последнего hunk файла -- автоматический scroll к следующему файлу
- `useDiffNavigation` работает с `Map<string, EditorView>` вместо одного `editorViewRef`
- Публичный интерфейс `DiffNavigationState` НЕ меняется -- изменяется только внутренняя реализация
**Зависимости:** Phase 1 (ContinuousScrollView, useVisibleFileSection, useContinuousScrollNav) и Phase 2 (lazy loading, EditorView Map из Phase 1).
---
## Модификации
### 1. useDiffNavigation.ts -- полная переработка для continuous mode
**Файл:** `src/renderer/hooks/useDiffNavigation.ts`
#### Текущая сигнатура (без изменений)
```typescript
import { useCallback, useEffect, useRef, useState } from 'react';
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
import type { EditorView } from '@codemirror/view';
import type { FileChangeSummary } from '@shared/types/review';
// --- Return interface НЕ МЕНЯЕТСЯ ---
interface DiffNavigationState {
currentHunkIndex: number;
totalHunks: number;
goToNextHunk: () => void;
goToPrevHunk: () => void;
goToNextFile: () => void;
goToPrevFile: () => void;
goToHunk: (index: number) => void;
acceptCurrentHunk: () => void;
rejectCurrentHunk: () => void;
showShortcutsHelp: boolean;
setShowShortcutsHelp: (show: boolean) => void;
}
```
#### Новый optional параметр continuousOptions
```typescript
// --- НОВАЯ сигнатура (расширение, backward compatible) ---
export function 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,
continuousOptions?: ContinuousNavigationOptions // <-- НОВЫЙ 10-й параметр
): DiffNavigationState;
```
**Важно:** НЕ используем overloads. Один вариант сигнатуры с optional 10-м параметром. Overloads здесь избыточны -- `continuousOptions` опционален, TypeScript корректно проверяет типы без overload.
#### Новый тип ContinuousNavigationOptions
```typescript
interface ContinuousNavigationOptions {
/**
* Map всех EditorView по filePath. Заполняется в ContinuousScrollView.
* Это НЕ ref -- передаётся сам Map (через .current снаружи).
* Передаётся как value, но мутируется извне (Map reference стабильна).
*/
editorViewRefs: Map<string, EditorView>;
/**
* Текущий видимый файл из scroll-spy (Phase 1 useVisibleFileSection).
* НЕ selectedFilePath -- это activeFilePath.
* Обновляется при скролле.
*/
activeFilePath: string | null;
/**
* Программный scroll к секции файла из useContinuousScrollNav (Phase 1).
* Вызывает scrollIntoView + подавление scroll-spy.
*/
scrollToFile: (filePath: string) => void;
/** Флаг continuous mode -- определяет какую логику использовать. */
enabled: boolean;
}
```
**Дизайн-решение:** Вместо создания отдельного хука (useContinuousDiffNavigation), расширяем существующий через optional 10-й параметр `continuousOptions`. Это позволяет:
1. Не дублировать keyboard handler логику
2. Постепенно мигрировать: `ChangeReviewDialog` просто передаёт `continuousOptions` когда continuous mode включён
3. Сохранить обратную совместимость -- без `continuousOptions` хук работает как раньше
#### Внутренняя реализация -- helper: getActiveEditorView()
```typescript
/**
* Определяет "активный" EditorView для навигации.
*
* Приоритет:
* 1. Focused editor -- если какой-то CM editor сейчас имеет фокус
* 2. activeFilePath editor -- editor файла, определённого scroll-spy как видимый
* 3. Fallback: первый editor в Map
*
* В legacy mode: просто возвращает editorViewRef.current.
*/
function getActiveEditorView(
editorViewRef: React.RefObject<EditorView | null>,
continuousOptions?: ContinuousNavigationOptions
): EditorView | null {
// Legacy mode
if (!continuousOptions?.enabled) {
return editorViewRef.current;
}
const { editorViewRefs, activeFilePath } = continuousOptions;
// 1. Focused editor -- используем view.hasFocus (CM API)
for (const [, view] of editorViewRefs) {
if (view.hasFocus) return view;
}
// 2. activeFilePath editor
if (activeFilePath) {
const view = editorViewRefs.get(activeFilePath);
if (view) return view;
}
// 3. Fallback: первый editor
const firstEntry = editorViewRefs.values().next();
return firstEntry.done ? null : firstEntry.value;
}
```
**ИСПРАВЛЕНИЕ:** Оригинальный вариант использовал `document.activeElement.closest('.cm-editor')` + сравнение с `view.dom`. Это ненадёжно -- CM editor может содержать nested elements, и `closest` не всегда корректно разрешает до внешнего `.cm-editor`. Используем встроенный `view.hasFocus` -- это официальный CM API для проверки фокуса.
#### Внутренняя реализация -- helper: getActiveFilePath()
```typescript
/**
* Определяет путь активного файла для контекста навигации.
*
* В continuous mode: activeFilePath из scroll-spy.
* В legacy mode: selectedFilePath.
*/
function getActiveFilePath(
selectedFilePath: string | null,
continuousOptions?: ContinuousNavigationOptions
): string | null {
if (continuousOptions?.enabled && continuousOptions.activeFilePath) {
return continuousOptions.activeFilePath;
}
return selectedFilePath;
}
```
#### Внутренняя реализация -- helper: getFilePathForView()
```typescript
/**
* Находит filePath для данного EditorView в Map.
* Нужно для определения "в каком файле мы сейчас" при focused editor.
*/
function getFilePathForView(
view: EditorView,
editorViewRefs: Map<string, EditorView>
): string | null {
for (const [filePath, v] of editorViewRefs) {
if (v === view) return filePath;
}
return null;
}
```
#### Внутренняя реализация -- helpers: isLastChunkInFile() / isFirstChunkInFile()
```typescript
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
```
**ВАЖНО: API `getChunks`.**
`getChunks` реэкспортируется из `@codemirror/merge`. Сигнатура:
```typescript
function getChunks(state: EditorState): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null;
```
Где `Chunk` имеет поля:
- `fromA`, `toA` -- диапазон в original document (side A)
- `fromB`, `toB` -- диапазон в modified document (side B)
- `changes` -- внутренние изменения
В `unifiedMergeView` (которую мы используем) side всегда `"b"`. Позиции курсора соответствуют side B.
```typescript
/**
* Проверяет, находится ли курсор на последнем chunk файла.
* Нужно для cross-file navigation: если на последнем chunk -- scroll к следующему файлу.
*
* Алгоритм:
* 1. Получаем chunks из CM state через getChunks()
* 2. Определяем текущую позицию курсора (view.state.selection.main.head)
* 3. Проверяем: курсор находится в или после последнего chunk
*
* ВАЖНО: goToNextChunk -- это StateCommand. Возвращает boolean:
* - true: перешёл к следующему chunk (dispatch вызван)
* - false: нет chunks в документе ИЛИ только один chunk и курсор уже в нём
*
* goToNextChunk возвращает false НЕ когда "нет больше chunks после текущего",
* а когда chunks.length === 0 или chunks.length === 1 && cursor уже в нём.
* При >1 chunks goToNextChunk ВСЕГДА возвращает true (циклическая навигация!).
*
* Поэтому мы НЕ можем полагаться на return value goToNextChunk для определения
* "последний ли это chunk". Нужна отдельная проверка через getChunks().
*/
function isLastChunkInFile(view: EditorView): boolean {
const result = getChunks(view.state);
if (!result || result.chunks.length === 0) return true;
const cursorPos = view.state.selection.main.head;
const chunks = result.chunks;
const lastChunk = chunks[chunks.length - 1];
// Курсор в пределах последнего chunk или после него
// fromB -- начало chunk в modified document
// toB -- конец chunk (1 past end of last line)
return cursorPos >= lastChunk.fromB;
}
/**
* Аналогично для первого chunk.
*/
function isFirstChunkInFile(view: EditorView): boolean {
const result = getChunks(view.state);
if (!result || result.chunks.length === 0) return true;
const cursorPos = view.state.selection.main.head;
const firstChunk = result.chunks[0];
// Курсор в пределах первого chunk или перед ним
return cursorPos <= firstChunk.toB;
}
```
**ИСПРАВЛЕНИЕ:** Уточнено поведение `goToNextChunk` -- это **циклическая** навигация (moveByChunk берёт `chunks[(pos + offset) % chunks.length]`). При >1 chunks всегда возвращает `true`. Поэтому:
- `const moved = goToNextChunk(view); if (!moved)` -- значит 0 или 1 chunk, а НЕ "последний chunk"
- Для определения "последний chunk" нужен `isLastChunkInFile()`
- В `goToNextHunk` правильная логика: **сначала** проверить `isLastChunkInFile`, **потом** решить -- переходить к следующему файлу или вызвать `goToNextChunk`
#### Изменения в goToNextFile()
```typescript
const goToNextFile = useCallback(() => {
if (files.length === 0) return;
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
const nextIdx = currentIdx < files.length - 1 ? currentIdx + 1 : 0;
const nextFilePath = files[nextIdx].filePath;
if (continuousOptions?.enabled) {
// Continuous mode: smooth scroll к следующему файлу
continuousOptions.scrollToFile(nextFilePath);
// НЕ вызываем onSelectFile -- scroll-spy обновит activeFilePath сам
} else {
// Legacy mode: переключение файла
onSelectFile(nextFilePath);
}
}, [files, selectedFilePath, onSelectFile, continuousOptions]);
```
**Важно:** В continuous mode `goToNextFile()` НЕ вызывает `onSelectFile()`. Вместо этого:
1. Вызывается `scrollToFile(nextFilePath)` из `useContinuousScrollNav`
2. `scrollToFile` выполняет `element.scrollIntoView({ behavior: 'smooth' })`
3. `isProgrammaticScroll` подавляет scroll-spy
4. `waitForScrollEnd()` ждёт стабилизации (timeout 500ms, из `navigation/utils.ts`)
5. `isProgrammaticScroll = false`, scroll-spy обнаруживает новый видимый файл
6. `activeFilePath` обновляется через `onVisibleFileChange` callback
#### Изменения в goToPrevFile()
```typescript
const goToPrevFile = useCallback(() => {
if (files.length === 0) return;
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
const prevIdx = currentIdx > 0 ? currentIdx - 1 : files.length - 1;
const prevFilePath = files[prevIdx].filePath;
if (continuousOptions?.enabled) {
continuousOptions.scrollToFile(prevFilePath);
} else {
onSelectFile(prevFilePath);
}
}, [files, selectedFilePath, onSelectFile, continuousOptions]);
```
#### Изменения в goToNextHunk()
```typescript
const goToNextHunk = useCallback(() => {
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (!view) return;
if (continuousOptions?.enabled) {
// Cross-file hunk navigation
if (isLastChunkInFile(view)) {
// Уже на последнем hunk файла -- переход к следующему файлу
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
if (currentIdx < files.length - 1) {
const nextFilePath = files[currentIdx + 1].filePath;
continuousOptions.scrollToFile(nextFilePath);
// После scroll -- перейти к первому hunk нового файла
// Используем requestAnimationFrame чтобы дождаться scroll + render
requestAnimationFrame(() => {
const nextView = continuousOptions.editorViewRefs.get(nextFilePath);
if (nextView) {
// Перемещаем курсор в начало файла, потом goToNextChunk
nextView.dispatch({
selection: { anchor: 0 },
});
goToNextChunk(nextView);
}
});
}
// Если это последний файл -- no-op (конец списка)
} else {
// Не последний chunk -- обычная навигация внутри файла
goToNextChunk(view);
}
} else {
// Legacy mode: навигация внутри текущего файла
goToNextChunk(view);
}
setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1));
}, [editorViewRef, totalHunks, setCurrentHunkIndex, files, selectedFilePath, continuousOptions]);
```
**ИСПРАВЛЕНИЕ (критическое):** Оригинальный вариант вызывал `goToNextChunk(view)` ПЕРЕД проверкой `isLastChunkInFile`. Проблема: `goToNextChunk` -- циклическая навигация. Если курсор на последнем chunk, `goToNextChunk` перейдёт к ПЕРВОМУ chunk (wrap-around), а потом `isLastChunkInFile` вернёт `false`. Результат: cross-file navigation никогда не сработает.
Правильная логика: **сначала** `isLastChunkInFile()`, **потом** решение -- переход к следующему файлу ИЛИ `goToNextChunk()` для навигации внутри файла.
#### Изменения в goToPrevHunk()
```typescript
const goToPrevHunk = useCallback(() => {
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (!view) return;
if (continuousOptions?.enabled) {
if (isFirstChunkInFile(view)) {
// Первый hunk файла -- переход к предыдущему файлу
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
if (currentIdx > 0) {
const prevFilePath = files[currentIdx - 1].filePath;
continuousOptions.scrollToFile(prevFilePath);
requestAnimationFrame(() => {
const prevView = continuousOptions.editorViewRefs.get(prevFilePath);
if (prevView) {
// Перемещаем курсор в конец файла, потом goToPreviousChunk
const docLength = prevView.state.doc.length;
prevView.dispatch({
selection: { anchor: docLength },
});
goToPreviousChunk(prevView);
}
});
}
} else {
// Не первый chunk -- обычная навигация назад
goToPreviousChunk(view);
}
} else {
goToPreviousChunk(view);
}
setCurrentHunkIndex((prev) => Math.max(prev - 1, 0));
}, [editorViewRef, setCurrentHunkIndex, files, selectedFilePath, continuousOptions]);
```
#### Изменения в acceptCurrentHunk()
```typescript
const acceptCurrentHunk = useCallback(() => {
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
if (activePath && onHunkAccepted) {
onHunkAccepted(activePath, currentHunkIndex);
}
}, [selectedFilePath, currentHunkIndex, onHunkAccepted, continuousOptions]);
```
#### Изменения в rejectCurrentHunk()
```typescript
const rejectCurrentHunk = useCallback(() => {
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
if (activePath && onHunkRejected) {
onHunkRejected(activePath, currentHunkIndex);
}
}, [selectedFilePath, currentHunkIndex, onHunkRejected, continuousOptions]);
```
#### Keyboard handler -- адаптация
**ВАЖНО: Конфликт с useContinuousScrollNav (Phase 1).**
В Phase 1 `useContinuousScrollNav` регистрирует keyboard listener для Alt+ArrowDown/Up. В Phase 3 `useDiffNavigation` тоже хочет обрабатывать эти клавиши. Два обработчика на одно событие -- конфликт.
**Решение:** Удалить keyboard handler для Alt+Arrow из `useContinuousScrollNav` (Phase 1). Вся keyboard обработка навигации живёт в `useDiffNavigation`. Причина: `useDiffNavigation` уже обрабатывает все shortcuts и имеет доступ к `continuousOptions.scrollToFile`. Дублирование нарушает single-responsibility.
```typescript
useEffect(() => {
if (!isDialogOpen) return;
const handler = (event: KeyboardEvent) => {
// Skip if CM keymap already handled
if (event.defaultPrevented) return;
// Skip inputs/textareas
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
const isMeta = event.metaKey || event.ctrlKey;
// Alt+J -> next change (работает в обоих режимах)
if (event.altKey && event.key.toLowerCase() === 'j') {
event.preventDefault();
goToNextHunk();
return;
}
// Alt+K -> prev change (НОВЫЙ shortcut)
if (event.altKey && event.key.toLowerCase() === 'k') {
event.preventDefault();
goToPrevHunk();
return;
}
// Alt+ArrowDown -> next file (scroll в continuous mode, onSelectFile в legacy)
if (event.altKey && event.key === 'ArrowDown') {
event.preventDefault();
goToNextFile();
return;
}
// Alt+ArrowUp -> prev file
if (event.altKey && event.key === 'ArrowUp') {
event.preventDefault();
goToPrevFile();
return;
}
// Cmd+Enter -> save active file
if (isMeta && event.key === 'Enter') {
event.preventDefault();
onSaveFileRef.current?.();
return;
}
// Cmd+Y -> accept chunk + next (на active editor)
if (isMeta && event.key.toLowerCase() === 'y') {
event.preventDefault();
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (view) {
acceptChunk(view);
requestAnimationFrame(() => {
if (continuousOptions?.enabled && isLastChunkInFile(view)) {
// Cross-file: scroll к следующему файлу после accept последнего chunk
goToNextFile();
} else {
goToNextChunk(view);
}
});
}
return;
}
// ? -> toggle shortcuts help
if (event.key === '?' && !isMeta && !event.altKey) {
event.preventDefault();
setShowShortcutsHelp((prev) => !prev);
return;
}
// Escape handling
if (event.key === 'Escape') {
if (showShortcutsHelp) {
event.preventDefault();
setShowShortcutsHelp(false);
}
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [
isDialogOpen,
showShortcutsHelp,
editorViewRef,
continuousOptions,
goToNextFile,
goToPrevFile,
goToNextHunk,
goToPrevHunk,
]);
```
**ИСПРАВЛЕНИЕ:** Alt+J/K теперь вызывают `goToNextHunk()` / `goToPrevHunk()` (callback из хука), а не напрямую `goToNextChunk(view)`. Это обеспечивает cross-file навигацию в continuous mode. В оригинале Alt+J вызывал `goToNextChunk` напрямую -- cross-file не работал бы.
#### Полная таблица keyboard shortcuts
| Shortcut | Action | Legacy mode | Continuous mode |
|----------|--------|:-----------:|:---------------:|
| `Alt+J` | Next change (hunk) | goToNextHunk (внутри файла) | goToNextHunk (cross-file) |
| `Alt+K` | Prev change (hunk) | goToPrevHunk (внутри файла) | goToPrevHunk (cross-file) |
| `Alt+ArrowDown` | Next file | goToNextFile (onSelectFile) | goToNextFile (scrollToFile) |
| `Alt+ArrowUp` | Prev file | goToPrevFile (onSelectFile) | goToPrevFile (scrollToFile) |
| `Cmd+Y` | Accept change + next | acceptChunk + goToNextChunk | acceptChunk + cross-file navigation |
| `Cmd+N` | Reject change + next | rejectChunk + goToNextChunk (IPC) | rejectChunk + cross-file navigation (IPC) |
| `Cmd+Enter` | Save file | save selectedFilePath | save activeFilePath |
| `?` | Toggle shortcuts help | toggle | toggle |
| `Escape` | Close help / dialog | close help или dialog | close help или dialog |
| `Ctrl+Alt+ArrowDown` | Next change (CM keymap) | goToNextChunk (built-in) | goToNextChunk (built-in per-editor) |
| `Ctrl+Alt+ArrowUp` | Prev change (CM keymap) | goToPreviousChunk (built-in) | goToPreviousChunk (built-in per-editor) |
**Примечание:** Ctrl+Alt+Arrow -- это встроенный CM keymap, не наш. Он работает per-editor (без cross-file). Это ОК -- пользователи, привыкшие к CM keymap, получают привычное поведение внутри файла. Alt+J/K -- наш shortcut с cross-file.
---
### 2. useContinuousScrollNav.ts -- изменения для Phase 3
**Файл:** `src/renderer/hooks/useContinuousScrollNav.ts`
Phase 1 реализует:
- `scrollToFile(filePath)` -- программный scroll к секции файла
- `isProgrammaticScroll` ref -- подавление scroll-spy при программном scroll
Phase 3 изменения:
1. **Убрать keyboard handler (Alt+Arrow) из useContinuousScrollNav.** Keyboard навигация теперь полностью в `useDiffNavigation`. Это устраняет конфликт двойной регистрации event listener.
2. **Убрать `activeFilePath` и `filePaths` из options** -- они больше не нужны хуку (keyboard handler убран). Упрощённый interface:
```typescript
interface UseContinuousScrollNavOptions {
/** Ref на scroll container */
scrollContainerRef: RefObject<HTMLElement>;
/** Диалог открыт (для cleanup) */
isOpen: boolean;
}
interface UseContinuousScrollNavReturn {
/** Scroll к файлу по filePath (smooth) */
scrollToFile: (filePath: string) => void;
/** Ref-flag: true пока идёт programmatic scroll */
isProgrammaticScroll: RefObject<boolean>;
}
```
3. **scrollToFile -- без `setActiveFilePath`:**
```typescript
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;
// Suppress scroll-spy during programmatic scroll
isProgrammaticScroll.current = true;
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Дождаться стабилизации scroll, потом разрешить scroll-spy
void waitForScrollEnd(container, 500).then(() => {
isProgrammaticScroll.current = false;
// scroll-spy сам обнаружит новый видимый файл и обновит activeFilePath
});
},
[scrollContainerRef]
);
```
**ИСПРАВЛЕНИЕ:** Оригинальный вариант вызывал `setActiveFilePath(filePath)` внутри `scrollToFile`. Проблема: `setActiveFilePath` не является частью hook state `useContinuousScrollNav` -- он живёт в parent (`ChangeReviewDialog` как `useState`). Передавать setter внутрь нарушает separation of concerns. Вместо этого: после `isProgrammaticScroll = false` scroll-spy (`useVisibleFileSection`) сам обнаружит видимый файл и вызовет `onVisibleFileChange`, который обновит `activeFilePath` в parent. Задержка ~100ms (debounce scroll-spy), но это ОК -- UI уже показывает правильный файл.
**waitForScrollEnd signature** (из `src/renderer/hooks/navigation/utils.ts`):
```typescript
function waitForScrollEnd(container: HTMLElement, timeoutMs?: number): Promise<void>
```
- `container` -- scroll container DOM element
- `timeoutMs` -- fallback timeout (default 400ms, мы передаём 500ms для запаса smooth scroll)
- Возвращает Promise, resolve когда scrollTop стабилизировался (3 consecutive frames без изменений)
---
### 3. ChangeReviewDialog.tsx -- интеграция
**Файл:** `src/renderer/components/team/review/ChangeReviewDialog.tsx`
#### Новый state: continuous mode toggle
```typescript
// Новый state для continuous mode (Phase 3)
const [isContinuousMode, setIsContinuousMode] = useState(false);
```
#### EditorView Map для continuous mode
```typescript
// Map всех EditorViews в continuous mode
// Заполняется через callback из ContinuousScrollView (Phase 1)
// Уже существует из Phase 1: editorViewMapRef
const editorViewMapRef = useRef(new Map<string, EditorView>());
```
#### Получение данных из useContinuousScrollNav
```typescript
// useContinuousScrollNav теперь принимает options object (Phase 1 interface,
// упрощённый в Phase 3):
const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
scrollContainerRef,
isOpen: open,
});
```
#### Передача continuousOptions в useDiffNavigation
```typescript
// Формируем continuousOptions только когда continuous mode включён.
//
// ВАЖНО: НЕ оборачивать editorViewMapRef.current в useMemo deps --
// .current не реактивен. Map reference стабильна (useRef), мутируется извне.
// useDiffNavigation обращается к Map.get() в момент вызова (не при создании options).
// activeFilePath и scrollToFile -- реактивны, они меняются.
const continuousOptions = useMemo(
(): ContinuousNavigationOptions | undefined => {
if (!isContinuousMode) return undefined;
return {
editorViewRefs: editorViewMapRef.current,
activeFilePath: continuousScrollNav.activeFilePath,
scrollToFile: continuousScrollNav.scrollToFile,
enabled: true,
};
},
[isContinuousMode, continuousScrollNav.activeFilePath, continuousScrollNav.scrollToFile]
);
const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [],
selectedReviewFilePath,
handleSelectFile,
editorViewRef, // Legacy ref (используется если continuousOptions undefined)
open,
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
() => onOpenChange(false),
handleSaveCurrentFile,
continuousOptions // <-- НОВЫЙ 10-й параметр
);
```
**Примечание:** `continuousScrollNav.activeFilePath` -- это state из `useContinuousScrollNav` или state из parent (`ChangeReviewDialog`). В Phase 1 `activeFilePath` управляется через `onVisibleFileChange` callback. Уточнение: `activeFilePath` -- это `useState` в `ChangeReviewDialog`, обновляется через `setActiveFilePath` callback, переданный в `ContinuousScrollView.onVisibleFileChange`.
#### handleSelectFile адаптация
```typescript
const handleSelectFile = useCallback(
(filePath: string | null) => {
if (isContinuousMode && filePath) {
// В continuous mode: scroll к секции вместо переключения
scrollToFile(filePath);
// НЕ вызываем selectReviewFile -- sidebar highlight управляется через activeFilePath
return;
}
// Legacy mode: старая логика
const view = editorViewRef.current;
if (view && selectedReviewFilePath) {
editorStateCache.current.set(selectedReviewFilePath, view.state);
}
setCachedInitialState(filePath ? editorStateCache.current.get(filePath) : undefined);
selectReviewFile(filePath);
},
[isContinuousMode, selectedReviewFilePath, selectReviewFile, scrollToFile]
);
```
#### handleSaveCurrentFile адаптация
```typescript
const handleSaveCurrentFile = useCallback(() => {
// В continuous mode сохраняем activeFilePath (видимый), не selectedReviewFilePath
const targetFile = isContinuousMode
? activeFilePath // из useState в ChangeReviewDialog
: selectedReviewFilePath;
if (targetFile) void saveEditedFile(targetFile);
}, [isContinuousMode, selectedReviewFilePath, activeFilePath, saveEditedFile]);
```
#### handleAcceptAll / handleRejectAll адаптация
```typescript
const handleAcceptAll = useCallback(() => {
if (isContinuousMode) {
// В continuous mode: accept all на ACTIVE file's editor
if (activeFilePath) {
const view = editorViewMapRef.current.get(activeFilePath);
if (view) acceptAllChunks(view);
acceptAllFile(activeFilePath);
}
} else {
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}
}, [isContinuousMode, selectedReviewFilePath, activeFilePath, acceptAllFile]);
```
#### Sidebar: подсветка activeFilePath в continuous mode
```typescript
{/* File tree -- selectedFilePath меняется на activeFilePath в continuous mode */}
<ReviewFileTree
files={activeChangeSet.files}
selectedFilePath={
isContinuousMode
? activeFilePath // из scroll-spy
: selectedReviewFilePath // из store
}
onSelectFile={handleSelectFile}
viewedSet={viewedSet}
onMarkViewed={markViewed}
onUnmarkViewed={unmarkViewed}
/>
```
**Примечание:** Phase 1 добавила `activeFilePath` prop в `ReviewFileTree` для мягкой подсветки (border-l). В continuous mode мы просто передаём `activeFilePath` как `selectedFilePath` -- полноценная подсветка (`bg-blue-500/20`). Это проще и визуально понятнее: один выделенный файл в tree.
#### Cmd+N IPC listener адаптация
```typescript
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
const view = isContinuousMode
? getActiveEditorView(editorViewRef, continuousOptions)
: editorViewRef.current;
if (view) {
rejectChunk(view);
requestAnimationFrame(() => {
if (isContinuousMode && isLastChunkInFile(view)) {
// Cross-file: scroll к следующему файлу
diffNav.goToNextFile();
} else {
goToNextChunk(view);
}
});
}
});
return cleanup ?? undefined;
}, [open, isContinuousMode, continuousOptions, diffNav]);
```
**Примечание:** `getActiveEditorView` и `isLastChunkInFile` -- helper функции из `useDiffNavigation`. Для использования в `ChangeReviewDialog` нужно:
- Либо экспортировать helpers из `useDiffNavigation.ts`
- Либо дублировать логику (нежелательно)
- Либо добавить метод в return interface: `diffNav.getActiveView()` / `diffNav.isOnLastChunk()`
**Рекомендация:** Экспортировать `getActiveEditorView` и `isLastChunkInFile` как named exports из `useDiffNavigation.ts`. Они чистые функции, не зависят от hook state.
---
### 4. KeyboardShortcutsHelp.tsx -- новые shortcuts
**Файл:** `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx`
Добавляются новые shortcuts. Текущий массив `shortcuts` (строки 10-18):
```typescript
const shortcuts = [
{ keys: ['\u2325+J'], action: 'Next change' },
{ keys: ['\u2325+K'], action: 'Previous change' }, // НОВЫЙ
{ keys: ['\u2325+\u2193'], action: 'Next file' }, // НОВЫЙ
{ keys: ['\u2325+\u2191'], action: 'Previous file' }, // НОВЫЙ
{ keys: ['\u2318+Y'], action: 'Accept change' },
{ keys: ['\u2318+N'], action: 'Reject change' },
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
{ keys: ['\u2318+Z'], action: 'Undo' },
{ keys: ['\u2318+\u21E7+Z'], action: 'Redo' },
{ keys: ['?'], action: 'Toggle this help' }, // НОВЫЙ
{ keys: ['Esc'], action: 'Close dialog' },
];
```
---
## Return Interface
```typescript
interface DiffNavigationState {
currentHunkIndex: number;
totalHunks: number;
goToNextHunk: () => void;
goToPrevHunk: () => void;
goToNextFile: () => void;
goToPrevFile: () => void;
goToHunk: (index: number) => void;
acceptCurrentHunk: () => void;
rejectCurrentHunk: () => void;
showShortcutsHelp: boolean;
setShowShortcutsHelp: (show: boolean) => void;
}
```
Интерфейс **НЕ меняется**. Все вызовы `diffNav.goToNextFile()`, `diffNav.goToNextHunk()` и т.д. в ChangeReviewDialog продолжают работать без изменений. Внутренняя реализация каждого метода проверяет `continuousOptions?.enabled` и выбирает стратегию.
---
## Edge-cases
### 1. scrollToFile + scroll-spy подавление
**Проблема:** При `scrollToFile(nextFile)` scroll-spy может обнаружить промежуточные файлы (мелькание activeFilePath).
**Решение:** `isProgrammaticScroll` ref в `useContinuousScrollNav`. При программном scroll:
1. `isProgrammaticScroll.current = true` устанавливается ДО `scrollIntoView`
2. Scroll-spy IntersectionObserver проверяет `isProgrammaticScroll.current` в `updateTopmostVisible()` и ИГНОРИРУЕТ обновления
3. После стабилизации scroll (через `waitForScrollEnd(container, 500)`) -- сбрасывается в `false`
4. Scroll-spy автоматически обнаруживает видимый файл на следующем intersection event
**Таймаут:** `waitForScrollEnd` имеет fallback timeout. Сигнатура: `waitForScrollEnd(container: HTMLElement, timeoutMs?: number): Promise<void>`. Default timeout 400ms. Мы передаём 500ms. Smooth scroll в Chromium занимает ~300-400ms. 500ms достаточно.
### 2. Cross-file hunk navigation: определение границы файла
**Проблема:** Как определить что мы на последнем/первом hunk файла?
**Решение:** Функции `isLastChunkInFile(view)` / `isFirstChunkInFile(view)` используют `getChunks(view.state)` для получения списка chunks, и сравнивают позицию курсора (`view.state.selection.main.head`) с позицией первого/последнего chunk.
**Критическая деталь `goToNextChunk`:**
- `goToNextChunk` -- это `StateCommand` (тип: `(target: { state, dispatch }) => boolean`)
- `EditorView` реализует этот интерфейс (имеет `.state` и `.dispatch()`)
- `goToNextChunk` реализует **циклическую** навигацию: `chunks[(pos + offset) % chunks.length]`
- При >1 chunks `goToNextChunk` **ВСЕГДА** возвращает `true` (перешёл к следующему chunk, даже если wrap-around к первому)
- `false` возвращается ТОЛЬКО когда: chunks.length === 0, или chunks.length === 1 && cursor уже в этом chunk
Поэтому использовать `const moved = goToNextChunk(view); if (!moved)` для определения "последний chunk" -- **некорректно**. Нужна явная проверка `isLastChunkInFile()`.
### 3. Multiple EditorViews: какой active?
**Проблема:** В continuous mode 10+ EditorView одновременно. Какой считать "активным" для keyboard shortcuts?
**Решение:** Приоритет в `getActiveEditorView()`:
1. **Focused editor** -- `view.hasFocus` (CM API). Пользователь кликнул в editor для редактирования.
2. **activeFilePath editor** -- editor файла, определённого scroll-spy как видимый. Пользователь скроллит, но не кликает в editor.
3. **Первый editor** -- fallback, если ни один не подходит.
**Нюанс:** Когда пользователь кликает в sidebar (ReviewFileTree), фокус уходит из CM editor. `view.hasFocus` становится `false` для всех. В этом случае activeFilePath editor используется корректно.
### 4. goToNextChunk на пустом файле (0 chunks)
**Проблема:** Файл целиком новый (`isNewFile: true`) -- весь контент является одним "inserted" chunk. Или файл без diff (identical). `goToNextChunk` возвращает `false` при 0 chunks.
**Решение:** `isLastChunkInFile` и `isFirstChunkInFile` возвращают `true` при 0 chunks. В `goToNextHunk` continuous mode: если `isLastChunkInFile` true и 0 chunks -- переходим к следующему файлу. Это корректно: файл без changes пропускается.
Для new file (1 chunk covering entire file): `isLastChunkInFile` вернёт `true` если курсор >= chunk.fromB. При первом заходе курсор в позиции 0 = chunk.fromB = 0, значит `isLastChunkInFile` true -- сразу переход к следующему файлу. Это может быть нежелательно для больших new files. **Решение:** Для файлов с 1 chunk можно добавить проверку `cursorPos >= lastChunk.toB - 1` (конец chunk, не начало). Но это edge case, оставляем для будущей итерации.
### 5. Cmd+Enter save: какой файл сохраняется?
**Проблема:** В continuous mode несколько файлов видны одновременно. `Cmd+Enter` должен сохранять конкретный файл.
**Решение:** Сохраняется файл из `handleSaveCurrentFile`:
- В continuous mode: `activeFilePath` из scroll-spy
- В legacy mode: `selectedReviewFilePath` из store
`onSaveFileRef.current` в keyboard handler вызывает `handleSaveCurrentFile`, который уже адаптирован.
### 6. Cross-file navigation + requestAnimationFrame timing
**Проблема:** При переходе к следующему файлу, `scrollToFile` триггерит smooth scroll. EditorView нового файла может быть не готов.
**Решение:**
1. В Phase 1/2 ВСЕ EditorView создаются при mount (lazy loading загружает контент, но DOM + EditorView создаются сразу для загруженных файлов)
2. `requestAnimationFrame` используется для задержки `goToNextChunk` после scroll
3. Если EditorView ещё не доступен (файл ещё не загружен через lazy loading) -- `continuousOptions.editorViewRefs.get(filePath)` вернёт `undefined`, navigation no-op
**Потенциальная проблема:** rAF может сработать до завершения smooth scroll. Но для `goToNextChunk` / `goToPreviousChunk` это ОК -- CM сам scrollIntoView к chunk. Визуально: scroll к файлу + мгновенный jump к первому chunk.
### 7. Wrap-around: конец/начало списка файлов
**Поведение:**
- `goToNextFile()` на последнем файле: wrap к первому файлу (index 0). Это текущее поведение legacy mode, сохраняем.
- `goToNextHunk()` на последнем hunk последнего файла: no-op (не wrap). Это отличается от goToNextFile -- hunk navigation останавливается на границе.
- `goToPrevHunk()` на первом hunk первого файла: no-op.
### 8. Editor state cache в continuous mode
**Проблема:** В legacy mode `editorStateCache` хранит EditorState для восстановления undo history при переключении файлов. В continuous mode все editors живут одновременно -- cache не нужен.
**Решение:** `editorStateCache` используется только в legacy mode (`handleSelectFile` проверяет `isContinuousMode`). В continuous mode undo history каждого EditorView сохраняется автоматически (editor не уничтожается при навигации).
### 9. goToNextChunk циклическая навигация vs наше поведение
**Ситуация:** `goToNextChunk` при >1 chunks делает wrap-around (с последнего chunk на первый). Наше cross-file поведение ожидает "стоп на последнем chunk -- перейти к следующему файлу".
**Решение:** Мы НЕ вызываем `goToNextChunk` когда `isLastChunkInFile` true. Поэтому wrap-around не происходит. `goToNextChunk` вызывается только когда мы знаем что есть следующий chunk в текущем файле.
---
## Проверка
### Unit тесты
```
test/renderer/hooks/useDiffNavigation.test.ts
```
**Тест-кейсы:**
1. **goToNextFile в continuous mode** -- вызывает scrollToFile, НЕ вызывает onSelectFile
2. **goToNextFile в legacy mode** -- вызывает onSelectFile, НЕ вызывает scrollToFile
3. **getActiveEditorView: focused editor приоритет** -- mock view.hasFocus
4. **getActiveEditorView: fallback на activeFilePath** -- когда hasFocus false для всех
5. **goToNextHunk: isLastChunkInFile true** -- вызывает scrollToFile для следующего файла, НЕ вызывает goToNextChunk
6. **goToNextHunk: isLastChunkInFile false** -- вызывает goToNextChunk, НЕ переходит к файлу
7. **goToPrevHunk cross-file** -- при isFirstChunkInFile=true, вызывает scrollToFile для предыдущего файла
8. **Keyboard: Alt+ArrowDown** -- вызывает goToNextFile
9. **Keyboard: Alt+ArrowUp** -- вызывает goToPrevFile
10. **Keyboard: Alt+J** -- вызывает goToNextHunk (с cross-file)
11. **Keyboard: Cmd+Y + cross-file** -- acceptChunk + goToNextFile если isLastChunkInFile
12. **handleSaveCurrentFile в continuous mode** -- сохраняет activeFilePath
13. **handleSelectFile в continuous mode** -- вызывает scrollToFile вместо selectReviewFile
14. **isLastChunkInFile: 0 chunks** -- returns true
15. **isLastChunkInFile: cursor before last chunk** -- returns false
16. **isLastChunkInFile: cursor at last chunk.fromB** -- returns true
### Ручная проверка
1. Открыть review dialog в continuous mode с 5+ файлами
2. Клик по файлу в sidebar -- плавный scroll к секции
3. Alt+ArrowDown/Up -- навигация между файлами
4. Alt+J -- переход к следующему hunk
5. На последнем hunk файла: Alt+J -- scroll к следующему файлу, первый hunk
6. Cmd+Y на последнем hunk -- accept + scroll к следующему файлу
7. Cmd+Enter -- сохраняет видимый файл (не первый в списке)
8. Переключить на legacy mode -- все shortcuts работают как раньше
### Интеграция с Phase 1/2
- scrollToFile корректно подавляет scroll-spy (isProgrammaticScroll)
- activeFilePath обновляется после программного scroll (через scroll-spy, не принудительно)
- EditorView Map содержит все созданные editors
- Sidebar highlight синхронизирован с activeFilePath в continuous mode
- Lazy loading не мешает навигации (placeholder для незагруженных файлов)
---
## Файлы
| Файл | Тип | ~LOC изменений |
|------|-----|---:|
| `src/renderer/hooks/useDiffNavigation.ts` | MODIFY | ~200 (helpers + goToNext/Prev переработка + keyboard) |
| `src/renderer/hooks/useContinuousScrollNav.ts` | MODIFY | ~-30 (удаление keyboard handler, упрощение interface) |
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | ~60 (continuousOptions, handleSelectFile, handleSave) |
| `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` | MODIFY | ~10 (новые shortcuts) |
| `test/renderer/hooks/useDiffNavigation.test.ts` | MODIFY | ~200 (новые тест-кейсы для continuous mode) |
| **Итого** | 0 NEW + 5 MODIFY | ~440 |