- 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.
48 KiB
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
Текущая сигнатура (без изменений)
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
// --- НОВАЯ сигнатура (расширение, 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
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. Это позволяет:
- Не дублировать keyboard handler логику
- Постепенно мигрировать:
ChangeReviewDialogпросто передаётcontinuousOptionsкогда continuous mode включён - Сохранить обратную совместимость -- без
continuousOptionsхук работает как раньше
Внутренняя реализация -- helper: getActiveEditorView()
/**
* Определяет "активный" 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()
/**
* Определяет путь активного файла для контекста навигации.
*
* В 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()
/**
* Находит 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()
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
ВАЖНО: API getChunks.
getChunks реэкспортируется из @codemirror/merge. Сигнатура:
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.
/**
* Проверяет, находится ли курсор на последнем 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()
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(). Вместо этого:
- Вызывается
scrollToFile(nextFilePath)изuseContinuousScrollNav scrollToFileвыполняетelement.scrollIntoView({ behavior: 'smooth' })isProgrammaticScrollподавляет scroll-spywaitForScrollEnd()ждёт стабилизации (timeout 500ms, изnavigation/utils.ts)isProgrammaticScroll = false, scroll-spy обнаруживает новый видимый файлactiveFilePathобновляется черезonVisibleFileChangecallback
Изменения в goToPrevFile()
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()
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()
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()
const acceptCurrentHunk = useCallback(() => {
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
if (activePath && onHunkAccepted) {
onHunkAccepted(activePath, currentHunkIndex);
}
}, [selectedFilePath, currentHunkIndex, onHunkAccepted, continuousOptions]);
Изменения в rejectCurrentHunk()
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.
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 к секции файлаisProgrammaticScrollref -- подавление scroll-spy при программном scroll
Phase 3 изменения:
-
Убрать keyboard handler (Alt+Arrow) из useContinuousScrollNav. Keyboard навигация теперь полностью в
useDiffNavigation. Это устраняет конфликт двойной регистрации event listener. -
Убрать
activeFilePathиfilePathsиз options -- они больше не нужны хуку (keyboard handler убран). Упрощённый interface:
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>;
}
- scrollToFile -- без
setActiveFilePath:
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):
function waitForScrollEnd(container: HTMLElement, timeoutMs?: number): Promise<void>
container-- scroll container DOM elementtimeoutMs-- 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
// Новый state для continuous mode (Phase 3)
const [isContinuousMode, setIsContinuousMode] = useState(false);
EditorView Map для continuous mode
// Map всех EditorViews в continuous mode
// Заполняется через callback из ContinuousScrollView (Phase 1)
// Уже существует из Phase 1: editorViewMapRef
const editorViewMapRef = useRef(new Map<string, EditorView>());
Получение данных из useContinuousScrollNav
// useContinuousScrollNav теперь принимает options object (Phase 1 interface,
// упрощённый в Phase 3):
const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
scrollContainerRef,
isOpen: open,
});
Передача continuousOptions в useDiffNavigation
// Формируем 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 адаптация
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 адаптация
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 адаптация
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
{/* 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 адаптация
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):
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
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:
isProgrammaticScroll.current = trueустанавливается ДОscrollIntoView- Scroll-spy IntersectionObserver проверяет
isProgrammaticScroll.currentвupdateTopmostVisible()и ИГНОРИРУЕТ обновления - После стабилизации scroll (через
waitForScrollEnd(container, 500)) -- сбрасывается вfalse - 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():
- Focused editor --
view.hasFocus(CM API). Пользователь кликнул в editor для редактирования. - activeFilePath editor -- editor файла, определённого scroll-spy как видимый. Пользователь скроллит, но не кликает в editor.
- Первый 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 нового файла может быть не готов.
Решение:
- В Phase 1/2 ВСЕ EditorView создаются при mount (lazy loading загружает контент, но DOM + EditorView создаются сразу для загруженных файлов)
requestAnimationFrameиспользуется для задержкиgoToNextChunkпосле scroll- Если 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
Тест-кейсы:
- goToNextFile в continuous mode -- вызывает scrollToFile, НЕ вызывает onSelectFile
- goToNextFile в legacy mode -- вызывает onSelectFile, НЕ вызывает scrollToFile
- getActiveEditorView: focused editor приоритет -- mock view.hasFocus
- getActiveEditorView: fallback на activeFilePath -- когда hasFocus false для всех
- goToNextHunk: isLastChunkInFile true -- вызывает scrollToFile для следующего файла, НЕ вызывает goToNextChunk
- goToNextHunk: isLastChunkInFile false -- вызывает goToNextChunk, НЕ переходит к файлу
- goToPrevHunk cross-file -- при isFirstChunkInFile=true, вызывает scrollToFile для предыдущего файла
- Keyboard: Alt+ArrowDown -- вызывает goToNextFile
- Keyboard: Alt+ArrowUp -- вызывает goToPrevFile
- Keyboard: Alt+J -- вызывает goToNextHunk (с cross-file)
- Keyboard: Cmd+Y + cross-file -- acceptChunk + goToNextFile если isLastChunkInFile
- handleSaveCurrentFile в continuous mode -- сохраняет activeFilePath
- handleSelectFile в continuous mode -- вызывает scrollToFile вместо selectReviewFile
- isLastChunkInFile: 0 chunks -- returns true
- isLastChunkInFile: cursor before last chunk -- returns false
- isLastChunkInFile: cursor at last chunk.fromB -- returns true
Ручная проверка
- Открыть review dialog в continuous mode с 5+ файлами
- Клик по файлу в sidebar -- плавный scroll к секции
- Alt+ArrowDown/Up -- навигация между файлами
- Alt+J -- переход к следующему hunk
- На последнем hunk файла: Alt+J -- scroll к следующему файлу, первый hunk
- Cmd+Y на последнем hunk -- accept + scroll к следующему файлу
- Cmd+Enter -- сохраняет видимый файл (не первый в списке)
- Переключить на 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 |