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

48 KiB
Raw Permalink Blame History

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. Это позволяет:

  1. Не дублировать keyboard handler логику
  2. Постепенно мигрировать: ChangeReviewDialog просто передаёт continuousOptions когда continuous mode включён
  3. Сохранить обратную совместимость -- без 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(). Вместо этого:

  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()

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 к секции файла
  • isProgrammaticScroll ref -- подавление scroll-spy при программном scroll

Phase 3 изменения:

  1. Убрать keyboard handler (Alt+Arrow) из useContinuousScrollNav. Keyboard навигация теперь полностью в useDiffNavigation. Это устраняет конфликт двойной регистрации event listener.

  2. Убрать 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>;
}
  1. 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 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

// Новый 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:

  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