agent-ecosystem/docs/iterations/diff-view/continuous-scroll/phase-1-continuous-scroll-and-scroll-spy.md
iliya 0df816bba6 feat: enhance diff view with continuous scroll and lazy loading
- Introduced a continuous scroll mode for the diff view, allowing users to review multiple files in a single scrollable container.
- Added lazy loading functionality to improve performance by loading file content as it approaches the viewport.
- Implemented a new portion collapse feature to allow users to expand unchanged regions incrementally, enhancing context retention during reviews.
- Updated navigation to support smooth scrolling between files and improved keyboard shortcuts for file navigation.
- Enhanced the review toolbar to manage actions across all files, including bulk accept/reject options.
- Added new hooks and components to support the continuous scroll and lazy loading features, ensuring a seamless user experience.
2026-02-25 15:39:14 +02:00

67 KiB
Raw Blame History

Фаза 1: Continuous Scroll + Scroll-Spy

1. Обзор

Цель: Заменить текущий single-file diff view на непрерывный scroll всех файлов (как на GitHub PR review).

Текущее поведение: ChangeReviewDialog показывает один файл за раз. Пользователь кликает по файлу в ReviewFileTree — диалог переключает контент. Для каждого файла создаётся/уничтожается один CodeMirrorDiffView. При переключении undo history сохраняется в editorStateCache. Контент файла загружается lazy (useEffect при смене selectedReviewFilePath).

Новое поведение:

  • Все файлы рендерятся в одном scroll-контейнере вертикально, один за другом
  • Каждый файл имеет sticky header (имя, badges, кнопки) — прилипает к верху при скролле
  • File tree подсвечивает текущий видимый файл (scroll-spy)
  • Клик по файлу в tree = smooth scroll к файлу в контенте
  • Все CodeMirror editors живут одновременно — нет необходимости в editorStateCache
  • Keyboard navigation: Alt+ArrowDown/Up для перехода между файлами
  • Контент всех файлов загружается при открытии диалога (bulk load для фазы 1)

2. Новые файлы

2.1. FileSectionHeader.tsx

Путь: src/renderer/components/team/review/FileSectionHeader.tsx

Назначение: Sticky header для каждой file section в continuous scroll. Извлечён из ChangeReviewDialog.tsx (строки 437-509 — блок {/* File header with content source badge and save/discard */}).

Props Interface

import type { FileChangeSummary, FileChangeWithContent, HunkDecision } from '@shared/types';

interface FileSectionHeaderProps {
  /** Данные файла (relativePath, isNewFile, filePath и т.д.) */
  file: FileChangeSummary;

  /** Загруженный контент файла (для отображения contentSource badge). null = ещё не загружен */
  fileContent: FileChangeWithContent | null;

  /** Решение по файлу целиком ('accepted' | 'rejected' | 'pending' | undefined) */
  fileDecision: HunkDecision | undefined;

  /** Есть ли несохранённые ручные правки для этого файла */
  hasEdits: boolean;

  /** Идёт ли сейчас операция сохранения/применения (disabled state для кнопки Save) */
  applying: boolean;

  /** Callback: пользователь нажал "Discard" для отмены ручных правок */
  onDiscard: (filePath: string) => void;

  /** Callback: пользователь нажал "Save File" для записи на диск */
  onSave: (filePath: string) => void;
}

Что рендерит

  1. Sticky container: <div className="sticky top-0 z-10 ...">

    • data-file-path={file.filePath} — для scroll-spy (querySelector)
    • bg-surface-sidebar фон (непрозрачный, чтобы контент под sticky не просвечивал)
    • border-b border-border
  2. Имя файла: file.relativePathtext-xs font-medium text-text

  3. NEW badge (условный):

    {file.isNewFile && (
      <span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
        NEW
      </span>
    )}
    
  4. Content source badge (условный, только когда fileContent загружен):

    {fileContent?.contentSource && (
      <span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-text-muted">
        {CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
      </span>
    )}
    

    CONTENT_SOURCE_LABELS определяется в этом же файле (вынесен из ChangeReviewDialog.tsx строка 38-44):

    const CONTENT_SOURCE_LABELS: Record<string, string> = {
      'file-history': 'File History',
      'snippet-reconstruction': 'Reconstructed',
      'disk-current': 'Current Disk',
      'git-fallback': 'Git Fallback',
      unavailable: 'Unavailable',
    };
    
  5. File decision indicator (условный):

    {fileDecision && (
      <span className={`rounded px-1.5 py-0.5 text-[10px] ${colorClass}`}>
        {fileDecision}
      </span>
    )}
    

    Цвета:

    • accepted -> bg-green-500/20 text-green-400
    • rejected -> bg-red-500/20 text-red-400
    • pending -> bg-zinc-500/20 text-zinc-400
  6. Save/Discard кнопки (условные, только когда hasEdits === true):

    • Discard: <Undo2 /> + "Discard" — bg-orange-500/15 text-orange-400 hover:bg-orange-500/25, вызывает onDiscard(file.filePath)
    • Save: <Save /> + "Save File" — bg-green-500/15 text-green-400 hover:bg-green-500/25, вызывает onSave(file.filePath), disabled={applying}
    • Во время applying вместо <Save /> показывается <Loader2 className="size-3 animate-spin" /> (спиннер)
    • Кнопка Save имеет disabled:opacity-50 для disabled state
    • Обе кнопки обёрнуты в <Tooltip> (из @renderer/components/ui/tooltip)
    • Save tooltip показывает keyboard shortcut Cmd+Enter через <kbd>:
      <TooltipContent side="bottom">
        <span>Save file to disk</span>
        <kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
          ⌘↵
        </kbd>
      </TooltipContent>
      
    • Discard tooltip: "Discard all edits for this file"

    Импорты иконок: Save, Undo2, Loader2 из lucide-react

  7. Кнопки обёрнуты в ml-auto контейнер:

    <div className="ml-auto flex items-center gap-1.5">
      {hasEdits && ( /* Discard + Save */ )}
    </div>
    

Sticky позиционирование

<div
  className="sticky top-0 z-10 flex items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2"
  data-file-path={file.filePath}
>

Важно: z-10 гарантирует, что sticky header перекрывает контент CodeMirror. Фон ОБЯЗАТЕЛЬНО непрозрачный (bg-surface-sidebar), чтобы diff-строки не просвечивали через header.

Важно: Когда несколько sticky headers "стопятся" (файл A scrolled out, файл B виден) — только один header виден сверху. Это нативное поведение CSS position: sticky: каждый header прилипает в рамках своего parent section div.


2.2. FileSectionDiff.tsx

Путь: src/renderer/components/team/review/FileSectionDiff.tsx

Назначение: Diff-контент для одного файла в continuous scroll. Извлечён из ChangeReviewDialog.tsx (строки 511-561 — блоки loading state, CodeMirror diff view, fallback snippet view).

Props Interface

import type { EditorView } from '@codemirror/view';
import type { FileChangeSummary, FileChangeWithContent } from '@shared/types';

interface FileSectionDiffProps {
  /** Данные файла */
  file: FileChangeSummary;

  /** Загруженный контент (null = ещё не загружен) */
  fileContent: FileChangeWithContent | null;

  /** Контент загружается */
  isLoading: boolean;

  /** Collapse unchanged regions в CodeMirror */
  collapseUnchanged: boolean;

  /** Callback при accept hunk */
  onHunkAccepted: (filePath: string, hunkIndex: number) => void;

  /** Callback при reject hunk */
  onHunkRejected: (filePath: string, hunkIndex: number) => void;

  /** Callback: файл полностью просмотрен (sentinel виден в viewport) */
  onFullyViewed: (filePath: string) => void;

  /** Callback: ручная правка контента (debounced из CodeMirror) */
  onContentChanged: (filePath: string, content: string) => void;

  /** Callback для регистрации EditorView в общий Map. Вызывается при создании/уничтожении */
  onEditorViewReady: (filePath: string, view: EditorView | null) => void;

  /**
   * Counter для force-rebuild editor (инкрементируется при discard).
   * Используется как часть key для CodeMirrorDiffView.
   */
  discardCounter: number;

  /** Auto-viewed включён (для sentinel IntersectionObserver) */
  autoViewed: boolean;

  /** Файл уже помечен как viewed (не вызывать onFullyViewed повторно) */
  isViewed: boolean;
}

Логика рендеринга

  1. Loading state: Если isLoading — показать FileSectionPlaceholder (из соседнего файла)

  2. Unavailable fallback: Если !fileContent || fileContent.contentSource === 'unavailable' — показать <ReviewDiffContent file={file} />

    Важно: Также проверить fileContent.modifiedFullContent !== null. В текущем коде (строка 523) условие: fileContent.contentSource !== 'unavailable' && fileContent.modifiedFullContent !== null. Если modifiedFullContent === null — тоже fallback на ReviewDiffContent.

  3. CodeMirror diff: Иначе — полноценный CodeMirror:

    <DiffErrorBoundary
      filePath={file.filePath}
      oldString={fileContent.originalFullContent ?? ''}
      newString={fileContent.modifiedFullContent!}
    >
      <CodeMirrorDiffView
        key={`${file.filePath}:${discardCounter}`}
        original={fileContent.originalFullContent ?? ''}
        modified={fileContent.modifiedFullContent!}
        fileName={file.relativePath}
        readOnly={false}
        showMergeControls={true}
        collapseUnchanged={collapseUnchanged}
        onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
        onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)}
        onFullyViewed={handleFullyViewed}
        editorViewRef={localEditorViewRef}
        onContentChanged={(content) => onContentChanged(file.filePath, content)}
      />
    </DiffErrorBoundary>
    

    Обрати внимание:

    • initialState не передаётся — в continuous mode нет cache, editors живут одновременно
    • onFullyViewed передаётся как handleFullyViewed (локальный callback без аргументов, т.к. CodeMirrorDiffView.onFullyViewed имеет тип () => void)
    • DiffErrorBoundary.props: filePath (string), oldString (optional string), newString (optional string), onRetry (optional callback). Без onRetry — нет кнопки retry, только показ ошибки
  4. handleFullyViewed — bridge между sentinel и parent callback:

    CodeMirrorDiffView.onFullyViewed имеет сигнатуру () => void. Наш FileSectionDiff.onFullyViewed принимает (filePath: string) => void. Нужен bridge:

    const handleFullyViewed = useCallback(() => {
      onFullyViewed(file.filePath);
    }, [file.filePath, onFullyViewed]);
    

    Этот handleFullyViewed передаётся и в CodeMirrorDiffView.onFullyViewed, и в sentinel observer (оба вызывают одну функцию). Однако в continuous mode мы используем собственный sentinel вместо встроенного CodeMirrorDiffView sentinel (см. ниже).

  5. EditorView регистрация:

    const localEditorViewRef = useRef<EditorView | null>(null);
    
    // Sync to parent Map при mount/unmount
    useEffect(() => {
      return () => {
        // При unmount сообщить parent что view уничтожен
        onEditorViewReady(file.filePath, null);
      };
    }, [file.filePath, onEditorViewReady]);
    
    // Нужен useEffect чтобы проверить ref после рендера CodeMirrorDiffView
    useEffect(() => {
      if (localEditorViewRef.current) {
        onEditorViewReady(file.filePath, localEditorViewRef.current);
      }
    });
    

    Как CodeMirrorDiffView устанавливает ref: В CodeMirrorDiffView.tsx строки 685-688 — при создании EditorView он синхронно записывает view в externalViewRef.current:

    const extRef = externalViewRefHolder.current;
    if (extRef) {
      (extRef as React.MutableRefObject<EditorView | null>).current = view;
    }
    

    Это происходит в useEffect (строка 666), после чего наш вторичный useEffect (без deps) на следующем render cycle ловит значение и вызывает onEditorViewReady.

  6. Sentinel для auto-viewed:

    В continuous mode встроенный sentinel CodeMirrorDiffView (endSentinelRef внутри компонента, строка 281) может некорректно работать, т.к. CodeMirrorDiffView рендерится внутри <div className="flex-col" style={{ maxHeight }}> с maxHeight: '100%'. В continuous scroll нет фиксированной высоты — CodeMirror занимает весь свой контент. Поэтому встроенный sentinel (threshold: 1.0, строка 755) может не сработать.

    Решение: Внешний sentinel в FileSectionDiff, с threshold: 0.85 (а не 1.0). Причина: в continuous scroll с collapsed unchanged regions файл может не занимать 100% viewport, и sentinel может быть виден на 85-90%.

    const sentinelRef = useRef<HTMLDivElement>(null);
    
    useEffect(() => {
      if (!sentinelRef.current || !autoViewed || isViewed) return;
    
      const observer = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting) {
              onFullyViewed(file.filePath);
            }
          }
        },
        { threshold: 0.85 }
      );
    
      observer.observe(sentinelRef.current);
      return () => observer.disconnect();
    }, [autoViewed, isViewed, file.filePath, onFullyViewed]);
    

    Sentinel div в конце секции:

    <div ref={sentinelRef} className="h-1 shrink-0" />
    

    Edge case: Для очень коротких файлов (3-5 строк) sentinel может быть сразу виден при mount. Это ОК — файл просмотрен.

    Дублирование: Встроенный CodeMirrorDiffView.onFullyViewed тоже вызовет handleFullyViewed при scroll end внутри CM. Можно передать onFullyViewed={undefined} в CodeMirrorDiffView чтобы отключить встроенный observer (проп optional). Или оставить оба — двойной вызов markViewed для уже viewed файла — no-op (проверяется через isViewed).

    Рекомендация: Передать onFullyViewed={undefined} в CodeMirrorDiffView и полагаться только на внешний sentinel.

Важные замечания

  • DiffErrorBoundary оборачивает только CodeMirror, не fallback ReviewDiffContent
  • key включает discardCounter для force-rebuild при discard edits
  • Условие для CodeMirror рендеринга: !isLoading && fileContent && fileContent.contentSource !== 'unavailable' && fileContent.modifiedFullContent !== null

2.3. FileSectionPlaceholder.tsx

Путь: src/renderer/components/team/review/FileSectionPlaceholder.tsx

Назначение: Skeleton placeholder для file section пока контент загружается.

Props Interface

interface FileSectionPlaceholderProps {
  /** Имя файла для отображения в заголовке skeleton */
  fileName: string;
}

Что рендерит

export const FileSectionPlaceholder = ({ fileName }: FileSectionPlaceholderProps) => (
  <div className="animate-pulse">
    {/* Header area */}
    <div className="flex items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2">
      <span className="text-xs font-medium text-text-muted">{fileName}</span>
      <div className="h-4 w-16 rounded bg-surface-raised" />
    </div>

    {/* Content shimmer lines */}
    <div className="space-y-2 p-4">
      <div className="h-4 w-3/4 rounded bg-surface-raised" />
      <div className="h-4 w-1/2 rounded bg-surface-raised" />
      <div className="h-4 w-5/6 rounded bg-surface-raised" />
      <div className="h-4 w-2/3 rounded bg-surface-raised" />
    </div>
  </div>
);

CSS: animate-pulse — встроенная Tailwind анимация для skeleton loading. Пульсирует opacity между 1 и 0.5.

Высота: Примерно 120-140px, достаточно чтобы placeholder не "прыгал" при загрузке контента. Но это не идеальное совпадение с финальной высотой diff — абсолютной точности не требуется.


2.4. useVisibleFileSection.ts

Путь: src/renderer/hooks/useVisibleFileSection.ts

Назначение: Scroll-spy хук. Отслеживает какой файл сейчас виден в viewport. По паттерну useVisibleAIGroup.ts.

Interface

import { type RefObject } from 'react';

interface UseVisibleFileSectionOptions {
  /** Callback: вызывается при смене видимого файла */
  onVisibleFileChange: (filePath: string) => void;

  /** Scroll container ref (ContinuousScrollView outer div) */
  scrollContainerRef: RefObject<HTMLElement>;

  /** Подавление scroll-spy во время programmatic scroll */
  isProgrammaticScroll: RefObject<boolean>;
}

interface UseVisibleFileSectionReturn {
  /**
   * Регистрация file section элемента для наблюдения.
   * Возвращает ref callback — передать в div section.
   * Пример: <div ref={registerFileSectionRef(file.filePath)}>
   */
  registerFileSectionRef: (filePath: string) => (element: HTMLElement | null) => void;
}

Реализация (описание)

export function useVisibleFileSection(
  options: UseVisibleFileSectionOptions
): UseVisibleFileSectionReturn {
  const { onVisibleFileChange, scrollContainerRef, isProgrammaticScroll } = options;

  // Set видимых filePath
  const visibleFilePaths = useRef<Set<string>>(new Set());

  // Map: filePath -> HTMLElement
  const elementRefs = useRef<Map<string, HTMLElement>>(new Map());

  // Observer ref
  const observerRef = useRef<IntersectionObserver | null>(null);

  // Debounce timer
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  // Определить topmost visible file
  const updateTopmostVisible = useCallback(() => {
    // Если programmatic scroll — не обновлять (иначе race condition)
    if (isProgrammaticScroll.current) return;

    if (visibleFilePaths.current.size === 0) return;

    let topmostPath: string | null = null;
    let minTop = Infinity;

    visibleFilePaths.current.forEach((filePath) => {
      const element = elementRefs.current.get(filePath);
      if (element) {
        const rect = element.getBoundingClientRect();
        if (rect.top < minTop) {
          minTop = rect.top;
          topmostPath = filePath;
        }
      }
    });

    if (topmostPath) {
      onVisibleFileChange(topmostPath);
    }
  }, [onVisibleFileChange, isProgrammaticScroll]);

  // Debounced версия
  const debouncedUpdate = useCallback(() => {
    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(updateTopmostVisible, 100);
  }, [updateTopmostVisible]);

  // Создание IntersectionObserver
  useEffect(() => {
    observerRef.current = new IntersectionObserver(
      (entries) => {
        let changed = false;

        for (const entry of entries) {
          const filePath = entry.target.getAttribute('data-file-path');
          if (!filePath) continue;

          if (entry.isIntersecting && entry.intersectionRatio >= 0.1) {
            if (!visibleFilePaths.current.has(filePath)) {
              visibleFilePaths.current.add(filePath);
              changed = true;
            }
          } else {
            if (visibleFilePaths.current.has(filePath)) {
              visibleFilePaths.current.delete(filePath);
              changed = true;
            }
          }
        }

        if (changed) {
          debouncedUpdate();
        }
      },
      {
        root: scrollContainerRef.current,
        threshold: 0.1,
        rootMargin: '0px',
      }
    );

    return () => {
      observerRef.current?.disconnect();
      clearTimeout(debounceRef.current);
    };
  }, [scrollContainerRef, debouncedUpdate]);

  // Register ref callback
  const registerFileSectionRef = useCallback((filePath: string) => {
    return (element: HTMLElement | null) => {
      const observer = observerRef.current;
      if (!observer) return;

      // Cleanup previous
      const prev = elementRefs.current.get(filePath);
      if (prev) {
        observer.unobserve(prev);
        elementRefs.current.delete(filePath);
        visibleFilePaths.current.delete(filePath);
      }

      // Register new
      if (element) {
        element.setAttribute('data-file-path', filePath);
        elementRefs.current.set(filePath, element);
        observer.observe(element);
      }
    };
  }, []);

  return { registerFileSectionRef };
}

Ключевые отличия от useVisibleAIGroup

Аспект useVisibleAIGroup useVisibleFileSection
threshold 0.5 (default, configurable via threshold? option) 0.1 (файлы длинные, 50% может быть за viewport)
debounce нет (вызывает updateTopmostVisible синхронно) 100ms (стабильность при быстром скролле)
programmatic scroll suppression нет да (isProgrammaticScroll ref)
data attribute data-aigroup-id data-file-path
root опциональный rootRef?.current ?? null обязательный scrollContainerRef.current
callback name onVisibleChange onVisibleFileChange

Edge Cases

  • Пустой список файлов: Observer создаётся, но никто не регистрируется — no-op
  • Один файл: Всегда виден, onVisibleFileChange вызовется один раз при mount
  • Быстрый scroll: Debounce 100ms группирует обновления
  • Resize окна: IntersectionObserver автоматически пересчитывает intersections
  • scrollContainerRef.current is null при первом рендере: Observer создаётся с root: null — будет наблюдать viewport вместо контейнера. Решение: добавить guard if (!scrollContainerRef.current) return; в useEffect, либо убедиться что ref установлен до mount дочерних компонентов (ref на тот же div что и scrollContainerRef)

2.5. useContinuousScrollNav.ts

Путь: src/renderer/hooks/useContinuousScrollNav.ts

Назначение: Навигация в continuous scroll — scroll-to-file, keyboard shortcuts, подавление scroll-spy во время programmatic scroll.

Interface

import type { RefObject } from 'react';

interface UseContinuousScrollNavOptions {
  /** Ref на scroll container (ContinuousScrollView outer div) */
  scrollContainerRef: RefObject<HTMLElement>;

  /** Упорядоченный список filePath (порядок = порядок рендеринга) */
  filePaths: string[];

  /** Текущий активный файл (от scroll-spy) */
  activeFilePath: string | null;

  /** Диалог открыт (для keyboard listeners) */
  isOpen: boolean;
}

interface UseContinuousScrollNavReturn {
  /** Scroll к файлу по filePath (smooth) */
  scrollToFile: (filePath: string) => void;

  /**
   * Ref-flag: true пока идёт programmatic scroll.
   * Передаётся в useVisibleFileSection для подавления scroll-spy.
   */
  isProgrammaticScroll: RefObject<boolean>;
}

Реализация (описание)

import { waitForScrollEnd } from '@renderer/hooks/navigation/utils';

export function useContinuousScrollNav(
  options: UseContinuousScrollNavOptions
): UseContinuousScrollNavReturn {
  const { scrollContainerRef, filePaths, activeFilePath, isOpen } = options;

  const isProgrammaticScroll = useRef(false);

  const scrollToFile = useCallback(
    (filePath: string) => {
      const container = scrollContainerRef.current;
      if (!container) return;

      const section = container.querySelector<HTMLElement>(
        `[data-file-path="${CSS.escape(filePath)}"]`
      );
      if (!section) return;

      // Подавить scroll-spy
      isProgrammaticScroll.current = true;

      section.scrollIntoView({ behavior: 'smooth', block: 'start' });

      // Дождаться окончания scroll и снять подавление
      // waitForScrollEnd default timeout = 400ms, передаём 500ms для запаса
      void waitForScrollEnd(container, 500).then(() => {
        isProgrammaticScroll.current = false;
      });
    },
    [scrollContainerRef]
  );

  // Keyboard: Alt+ArrowDown = next file, Alt+ArrowUp = prev file
  useEffect(() => {
    if (!isOpen) return;

    const handler = (e: KeyboardEvent) => {
      if (!e.altKey) return;
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;

      if (e.key === 'ArrowDown') {
        e.preventDefault();
        const currentIdx = filePaths.indexOf(activeFilePath ?? '');
        const nextIdx = currentIdx < filePaths.length - 1 ? currentIdx + 1 : 0;
        scrollToFile(filePaths[nextIdx]);
      }

      if (e.key === 'ArrowUp') {
        e.preventDefault();
        const currentIdx = filePaths.indexOf(activeFilePath ?? '');
        const prevIdx = currentIdx > 0 ? currentIdx - 1 : filePaths.length - 1;
        scrollToFile(filePaths[prevIdx]);
      }
    };

    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, [isOpen, filePaths, activeFilePath, scrollToFile]);

  return {
    scrollToFile,
    isProgrammaticScroll,
  };
}

Race condition: scroll-spy vs programmatic scroll

Проблема: Когда пользователь кликает на файл в tree — мы вызываем scrollToFile(). Scroll-spy видит промежуточные файлы пролетающие мимо viewport и обновляет activeFilePath. Это "мигание" в tree.

Решение:

  1. isProgrammaticScroll ref устанавливается в true перед scrollIntoView
  2. useVisibleFileSection проверяет этот ref в updateTopmostVisible и молчит
  3. waitForScrollEnd() (из navigation/utils.ts) ждёт стабилизации scrollTop (3 стабильных кадра requestAnimationFrame с Math.abs(currentScrollTop - lastScrollTop) < 1)
  4. После стабилизации ref сбрасывается в false
  5. Scroll-spy продолжает работать нормально

Timeout: waitForScrollEnd имеет дефолтный fallback timeout 400ms (строка 172 в navigation/utils.ts). Smooth scroll в Chromium занимает ~300-400ms. Передаём 500ms для запаса.

CSS.escape(filePath)

Важно: filePath может содержать спецсимволы (точки, слеши). CSS.escape() экранирует их для querySelector. Пример: src/utils/path.ts -> src\/utils\/path\.ts в селекторе.

Edge case: filePaths.indexOf(activeFilePath ?? '') returns -1

Если activeFilePath нет в filePaths (или null), indexOf вернёт -1. Тогда:

  • ArrowDown: nextIdx = -1 < length - 1 ? 0 : 0 = 0 — переход к первому файлу. OK.
  • ArrowUp: prevIdx = -1 > 0 ? ... : length - 1 = last — переход к последнему файлу. OK.

2.6. ContinuousScrollView.tsx

Путь: src/renderer/components/team/review/ContinuousScrollView.tsx

Назначение: Главный контейнер continuous scroll. Заменяет single-file diff area в ChangeReviewDialog.

Props Interface

import type { EditorView } from '@codemirror/view';
import type {
  FileChangeSummary,
  FileChangeWithContent,
  HunkDecision,
} from '@shared/types';

interface ContinuousScrollViewProps {
  /** Список файлов из activeChangeSet.files */
  files: FileChangeSummary[];

  /** Загруженный контент: filePath -> FileChangeWithContent */
  fileContents: Record<string, FileChangeWithContent>;

  /** Флаги загрузки контента: filePath -> boolean */
  fileContentsLoading: Record<string, boolean>;

  /** Set просмотренных файлов */
  viewedSet: Set<string>;

  /** Ручные правки: filePath -> content string */
  editedContents: Record<string, string>;

  /** Решения по файлам: filePath -> HunkDecision */
  fileDecisions: Record<string, HunkDecision>;

  /** Collapse unchanged regions */
  collapseUnchanged: boolean;

  /** Applying in progress */
  applying: boolean;

  /** Auto-viewed включён */
  autoViewed: boolean;

  /** Counter для force rebuild editors при discard */
  discardCounter: number;

  // -- Callbacks --

  /** Hunk accepted в CodeMirror */
  onHunkAccepted: (filePath: string, hunkIndex: number) => void;

  /** Hunk rejected в CodeMirror */
  onHunkRejected: (filePath: string, hunkIndex: number) => void;

  /** Файл полностью просмотрен (auto-viewed) */
  onFullyViewed: (filePath: string) => void;

  /** Ручная правка контента */
  onContentChanged: (filePath: string, content: string) => void;

  /** Discard edits для файла */
  onDiscard: (filePath: string) => void;

  /** Save файла на диск */
  onSave: (filePath: string) => void;

  /** Callback: видимый файл изменился (scroll-spy). Parent обновляет activeFilePath */
  onVisibleFileChange: (filePath: string) => void;

  // -- Exposed refs --

  /** Ref для scroll container (передаётся из parent для scroll-to-file) */
  scrollContainerRef: React.RefObject<HTMLDivElement>;

  /** Map EditorView по filePath. Parent использует для keyboard shortcuts */
  editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;

  /** Ref: подавление scroll-spy (от useContinuousScrollNav) */
  isProgrammaticScroll: React.RefObject<boolean>;
}

Структура рендеринга

export const ContinuousScrollView = ({
  files,
  fileContents,
  fileContentsLoading,
  viewedSet,
  editedContents,
  fileDecisions,
  collapseUnchanged,
  applying,
  autoViewed,
  discardCounter,
  onHunkAccepted,
  onHunkRejected,
  onFullyViewed,
  onContentChanged,
  onDiscard,
  onSave,
  onVisibleFileChange,
  scrollContainerRef,
  editorViewMapRef,
  isProgrammaticScroll,
}: ContinuousScrollViewProps) => {
  // Scroll-spy
  const { registerFileSectionRef } = useVisibleFileSection({
    onVisibleFileChange,
    scrollContainerRef,
    isProgrammaticScroll,
  });

  // EditorView registration callback
  const handleEditorViewReady = useCallback(
    (filePath: string, view: EditorView | null) => {
      if (view) {
        editorViewMapRef.current.set(filePath, view);
      } else {
        editorViewMapRef.current.delete(filePath);
      }
    },
    [editorViewMapRef]
  );

  return (
    <div
      ref={scrollContainerRef}
      className="flex-1 overflow-y-auto"
    >
      {files.map((file) => {
        const filePath = file.filePath;
        const content = fileContents[filePath] ?? null;
        const isLoading = fileContentsLoading[filePath] ?? false;
        const hasEdits = filePath in editedContents;
        const isViewed = viewedSet.has(filePath);
        const decision = fileDecisions[filePath];

        return (
          <div
            key={filePath}
            ref={registerFileSectionRef(filePath)}
            className="border-b border-border"
          >
            <FileSectionHeader
              file={file}
              fileContent={content}
              fileDecision={decision}
              hasEdits={hasEdits}
              applying={applying}
              onDiscard={onDiscard}
              onSave={onSave}
            />

            {isLoading ? (
              <FileSectionPlaceholder fileName={file.relativePath} />
            ) : (
              <FileSectionDiff
                file={file}
                fileContent={content}
                isLoading={false}
                collapseUnchanged={collapseUnchanged}
                onHunkAccepted={onHunkAccepted}
                onHunkRejected={onHunkRejected}
                onFullyViewed={onFullyViewed}
                onContentChanged={onContentChanged}
                onEditorViewReady={handleEditorViewReady}
                discardCounter={discardCounter}
                autoViewed={autoViewed}
                isViewed={isViewed}
              />
            )}
          </div>
        );
      })}

      {files.length === 0 && (
        <div className="flex h-full items-center justify-center text-sm text-text-muted">
          No file changes detected
        </div>
      )}
    </div>
  );
};

Замечание по isLoading: Когда loading=true, показывается placeholder вместо FileSectionDiff. Когда loading завершится (контент загружен) — перерисовка покажет diff. FileSectionDiff получает isLoading={false} потому что condition уже обработан выше.

Замечание по data-file-path: Атрибут устанавливается в двух местах:

  1. На section div через registerFileSectionRef (для scroll-spy IntersectionObserver)
  2. На sticky header внутри FileSectionHeader (для querySelector в scrollToFile)

scrollToFile использует querySelector('[data-file-path="..."]') — найдёт первый элемент, а это header (он вложен в section). Чтобы scrollIntoView скроллил к началу секции (а не к header внутри), нужно убедиться что selector находит section div. Решение: Убрать data-file-path из FileSectionHeader и оставить только на section div. Тогда scrollToFile найдёт section div, а scrollIntoView({ block: 'start' }) покажет начало секции = sticky header.

EditorView Map

// В parent (ChangeReviewDialog):
const editorViewMapRef = useRef(new Map<string, EditorView>());

Зачем: Keyboard shortcuts (Cmd+Y, Cmd+N) теперь должны знать, к какому EditorView применить действие. Логика:

  1. Если EditorView имеет фокус (view.hasFocus) — применить к нему
  2. Иначе — применить к EditorView activeFilePath (от scroll-spy)
  3. Fallback — первый EditorView в Map
// Helper в ChangeReviewDialog:
function getTargetEditorView(): EditorView | null {
  // 1. Focused editor
  for (const view of editorViewMapRef.current.values()) {
    if (view.hasFocus) return view;
  }
  // 2. Active file's editor
  if (activeFilePath) {
    return editorViewMapRef.current.get(activeFilePath) ?? null;
  }
  // 3. First available
  const first = editorViewMapRef.current.values().next();
  return first.done ? null : first.value;
}

3. Модификации существующих файлов

3.1. ReviewFileTree.tsx

Новые props

interface ReviewFileTreeProps {
  files: FileChangeSummary[];
  selectedFilePath: string | null;
  onSelectFile: (filePath: string) => void;
  viewedSet?: Set<string>;
  onMarkViewed?: (filePath: string) => void;
  onUnmarkViewed?: (filePath: string) => void;

  // === НОВЫЕ ===
  /** Активный файл от scroll-spy (мягкая подсветка) */
  activeFilePath?: string;
}

Отличие selectedFilePath vs activeFilePath

selectedFilePath activeFilePath
Источник Клик по файлу в tree Scroll-spy (IntersectionObserver)
Визуал bg-blue-500/20 text-blue-300 (сильная подсветка) border-l-2 border-blue-400 (мягкий индикатор)
Поведение Клик -> scrollToFile Автоматически обновляется при скролле
При клике Совпадает с activeFilePath Может отставать (debounce 100ms)

Визуальная логика в TreeItem:

const isSelected = node.file.filePath === selectedFilePath;
const isActive = node.file.filePath === activeFilePath && !isSelected;

Стили:

className={cn(
  'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
  isSelected
    ? 'bg-blue-500/20 text-blue-300'            // Клик
    : isActive
      ? 'border-l-2 border-blue-400 text-text'  // Scroll-spy
      : 'text-text-secondary hover:bg-surface-raised hover:text-text'
)}

Пробросить activeFilePath через TreeItem

TreeItem в текущем коде принимает inline props (не interface, а destructured объект, строки 108-126):

const TreeItem = ({
  node,
  selectedFilePath,
  onSelectFile,
  depth,
  hunkDecisions,
  viewedSet,
  onMarkViewed,
  onUnmarkViewed,
}: { ... }) => { ... }

Нужно добавить activeFilePath?: string в этот inline type и пробрасывать дальше в рекурсивные <TreeItem> (строка 195-206).

Auto-scroll в tree при смене activeFilePath

// В ReviewFileTree
useEffect(() => {
  if (!activeFilePath) return;

  const btn = document.querySelector<HTMLElement>(
    `[data-tree-file="${CSS.escape(activeFilePath)}"]`
  );
  if (btn) {
    btn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }
}, [activeFilePath]);

data-tree-file attribute

Добавить на <button> файла в TreeItem (строка 131):

<button
  data-tree-file={node.file.filePath}
  onClick={() => onSelectFile(node.file!.filePath)}
  className={cn( ... )}
  style={{ paddingLeft: `${depth * 12 + 8}px` }}
>

3.2. ChangeReviewDialog.tsx

Что УБРАТЬ

  1. editorViewRef (строка 88: const editorViewRef = useRef<EditorView | null>(null)) — заменён на editorViewMapRef
  2. editorStateCache (строка 95: const editorStateCache = useRef(new Map<string, EditorState>())) — не нужен в continuous mode (editors живут одновременно)
  3. cachedInitialState / setCachedInitialState (строка 97) — не нужен
  4. handleSelectFile (строки 125-135) — логика сохранения EditorState в cache больше не нужна
  5. Single-file diff area (строки 432-568) — заменён на ContinuousScrollView
  6. selectedFile useMemo (строки 239-242) — не нужен для выбора файла для рендеринга (но нужен для timeline sidebar — см. ниже)
  7. fileContent / isFileContentLoading derived values (строки 244-247) — загрузка теперь в bulk при открытии
  8. Lazy-load useEffect (строки 224-237) — заменяется на bulk-load (см. "Что ДОБАВИТЬ")
  9. hasCurrentFileEdits (строки 120-122) — больше не нужен на уровне dialog (managed per-file в FileSectionHeader)
  10. Import EditorState (строка 25) — больше не используется

Что ДОБАВИТЬ

  1. activeFilePath state:

    const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
    
  2. editorViewMapRef:

    const editorViewMapRef = useRef(new Map<string, EditorView>());
    
  3. scrollContainerRef:

    const scrollContainerRef = useRef<HTMLDivElement>(null);
    
  4. discardCounter state — уже есть (строка 93), оставить

  5. useContinuousScrollNav:

    const filePaths = useMemo(
      () => (activeChangeSet?.files ?? []).map((f) => f.filePath),
      [activeChangeSet]
    );
    
    const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
      scrollContainerRef,
      filePaths,
      activeFilePath,
      isOpen: open,
    });
    

    Замечание: allFilePaths (строка 103-106) уже вычисляет то же самое для useViewedFiles. Можно переиспользовать:

    const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
      scrollContainerRef,
      filePaths: allFilePaths,
      activeFilePath,
      isOpen: open,
    });
    
  6. Bulk-load контента при открытии: Заменить lazy-load useEffect (строки 224-237) на:

    useEffect(() => {
      if (!open || !activeChangeSet) return;
    
      for (const file of activeChangeSet.files) {
        if (!fileContents[file.filePath] && !fileContentsLoading[file.filePath]) {
          void fetchFileContent(teamName, memberName, file.filePath);
        }
      }
    }, [open, activeChangeSet, teamName, memberName, fileContents, fileContentsLoading, fetchFileContent]);
    

    Важно: fetchFileContent внутри себя проверяет if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return; (строка 264 в changeReviewSlice), поэтому дублирования запросов не будет. Но для чистоты проверяем и на стороне вызова.

    Замечание: fetchFileContent принимает (teamName, memberName | undefined, filePath). В mode='task' memberName может быть undefined — это ОК, store обработает.

  7. onSelectFile в FileTree теперь вызывает scrollToFile:

    const handleTreeFileClick = useCallback(
      (filePath: string) => {
        scrollToFile(filePath);
      },
      [scrollToFile]
    );
    
  8. handleAcceptAll / handleRejectAll — работают с activeFilePath:

    const handleAcceptAll = useCallback(() => {
      const targetPath = activeFilePath;
      if (!targetPath) return;
    
      const view = editorViewMapRef.current.get(targetPath);
      if (view) acceptAllChunks(view);
      acceptAllFile(targetPath);
    }, [activeFilePath, acceptAllFile]);
    
    const handleRejectAll = useCallback(() => {
      const targetPath = activeFilePath;
      if (!targetPath) return;
    
      const view = editorViewMapRef.current.get(targetPath);
      if (view) rejectAllChunks(view);
      rejectAllFile(targetPath);
    }, [activeFilePath, rejectAllFile]);
    
  9. handleSaveCurrentFile / handleDiscardCurrentFile — теперь принимают filePath как аргумент:

    Важно: FileSectionHeader вызывает onDiscard(file.filePath) и onSave(file.filePath), передавая filePath. Поэтому callbacks должны принимать filePath:

    const handleSaveFile = useCallback(
      (filePath: string) => {
        void saveEditedFile(filePath);
      },
      [saveEditedFile]
    );
    
    const handleDiscardFile = useCallback(
      (filePath: string) => {
        discardFileEdits(filePath);
        setDiscardCounter((c) => c + 1);
      },
      [discardFileEdits]
    );
    

    Замечание о discardCounter: В текущем single-file mode discardCounter инкрементируется глобально и используется в key. В continuous mode counter глобальный — при discard одного файла ВСЕ editors пересоздадутся. Это неоптимально, но допустимо для фазы 1. Оптимизация (per-file counter) — в будущем.

  10. handleFullyViewed — теперь принимает filePath:

    const handleFullyViewed = useCallback(
      (filePath: string) => {
        if (autoViewed && !isViewed(filePath)) {
          markViewed(filePath);
        }
      },
      [autoViewed, isViewed, markViewed]
    );
    
  11. getTargetEditorView helper:

    const getTargetEditorView = useCallback((): EditorView | null => {
      for (const view of editorViewMapRef.current.values()) {
        if (view.hasFocus) return view;
      }
      if (activeFilePath) {
        return editorViewMapRef.current.get(activeFilePath) ?? null;
      }
      const first = editorViewMapRef.current.values().next();
      return first.done ? null : first.value;
    }, [activeFilePath]);
    
  12. Cmd+N IPC listener — использует getTargetEditorView:

    useEffect(() => {
      if (!open) return;
      const cleanup = window.electronAPI?.review.onCmdN?.(() => {
        const view = getTargetEditorView();
        if (view) {
          rejectChunk(view);
          requestAnimationFrame(() => goToNextChunk(view));
        }
      });
      return cleanup ?? undefined;
    }, [open, getTargetEditorView]);
    
  13. useDiffNavigation — адаптация:

    Текущая сигнатура:

    useDiffNavigation(
      files: FileChangeSummary[],
      selectedFilePath: string | null,
      onSelectFile: (path: string) => void,
      editorViewRef: React.RefObject<EditorView | null>,
      isDialogOpen: boolean,
      onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
      onHunkRejected?: (filePath: string, hunkIndex: number) => void,
      onClose?: () => void,
      onSaveFile?: () => void
    )
    

    В continuous mode:

    • selectedFilePath -> activeFilePath
    • onSelectFile -> scrollToFile
    • editorViewRef -> нужен прокси ref, или рефакторинг хука

    Что используется из useDiffNavigation:

    • diffNav.showShortcutsHelp / diffNav.setShowShortcutsHelp — для KeyboardShortcutsHelp (строка 337-339)
    • diffNav.goToHunk(idx) — для FileEditTimeline.onEventClick (строка 424)
    • diffNav.currentHunkIndex — для FileEditTimeline.activeSnippetIndex (строка 425)
    • Keyboard handlers (Cmd+Y, Alt+J, Cmd+Enter) — дублируют CM keymap + useContinuousScrollNav

    Решение для фазы 1:

    • Создать прокси ref activeEditorViewRef что всегда указывает на getTargetEditorView():
      const activeEditorViewRef = useRef<EditorView | null>(null);
      // Sync при смене activeFilePath
      useEffect(() => {
        activeEditorViewRef.current = editorViewMapRef.current.get(activeFilePath ?? '') ?? null;
      }, [activeFilePath]);
      
    • Передать в useDiffNavigation:
      const diffNav = useDiffNavigation(
        activeChangeSet?.files ?? [],
        activeFilePath,
        scrollToFile,
        activeEditorViewRef,
        open,
        (filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
        (filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
        () => onOpenChange(false),
        handleSaveFile.bind(null, activeFilePath ?? '')
      );
      
    • Проблема: handleSaveFile ожидает filePath, а onSaveFile в useDiffNavigation — () => void. Нужен wrapper:
      const handleSaveActiveFile = useCallback(() => {
        if (activeFilePath) void saveEditedFile(activeFilePath);
      }, [activeFilePath, saveEditedFile]);
      

    Keyboard handlers в useDiffNavigation (Cmd+Y, Alt+J) будут работать через activeEditorViewRef — они проверяют event.defaultPrevented (строка 123), поэтому если CM keymap уже обработал — пропустят.

Diff content area — замена

Было (строки 432-568):

<div className="flex-1 overflow-y-auto">
  {selectedFile ? ( /* single file diff */ ) : ( /* "Select a file" placeholder */ )}
</div>

Стало:

<ContinuousScrollView
  files={activeChangeSet.files}
  fileContents={fileContents}
  fileContentsLoading={fileContentsLoading}
  viewedSet={viewedSet}
  editedContents={editedContents}
  fileDecisions={fileDecisions}
  collapseUnchanged={collapseUnchanged}
  applying={applying}
  autoViewed={autoViewed}
  discardCounter={discardCounter}
  onHunkAccepted={(fp, idx) => setHunkDecision(fp, idx, 'accepted')}
  onHunkRejected={(fp, idx) => setHunkDecision(fp, idx, 'rejected')}
  onFullyViewed={handleFullyViewed}
  onContentChanged={updateEditedContent}
  onDiscard={handleDiscardFile}
  onSave={handleSaveFile}
  onVisibleFileChange={setActiveFilePath}
  scrollContainerRef={scrollContainerRef}
  editorViewMapRef={editorViewMapRef}
  isProgrammaticScroll={isProgrammaticScroll}
/>

Edit Timeline sidebar section

Timeline привязана к activeFilePath вместо selectedReviewFilePath. Нужно оставить selectedFile useMemo, но привязать к activeFilePath:

const activeFile = useMemo(() => {
  if (!activeChangeSet || !activeFilePath) return null;
  return activeChangeSet.files.find((f) => f.filePath === activeFilePath) ?? null;
}, [activeChangeSet, activeFilePath]);

Sidebar секция (строки 406-429):

{activeFile?.timeline && activeFile.timeline.events.length > 0 && (
  <div className="border-t border-border">
    <button
      onClick={() => setTimelineOpen(!timelineOpen)}
      className="flex w-full items-center gap-1.5 px-3 py-2 text-xs text-text-secondary hover:text-text"
    >
      <Clock className="size-3.5" />
      <span>Edit Timeline ({activeFile.timeline.events.length})</span>
      <ChevronDown className={cn('ml-auto size-3 transition-transform', timelineOpen && 'rotate-180')} />
    </button>
    {timelineOpen && (
      <FileEditTimeline
        timeline={activeFile.timeline}
        onEventClick={(idx) => diffNav.goToHunk(idx)}
        activeSnippetIndex={diffNav.currentHunkIndex}
      />
    )}
  </div>
)}

File tree — передать activeFilePath

<ReviewFileTree
  files={activeChangeSet.files}
  selectedFilePath={null}  // В continuous mode нет "selected" — только active
  activeFilePath={activeFilePath}
  onSelectFile={handleTreeFileClick}
  viewedSet={viewedSet}
  onMarkViewed={markViewed}
  onUnmarkViewed={unmarkViewed}
/>

Замечание: selectedFilePath={null} — в continuous mode нет отдельного "selected" state. Подсветка только через activeFilePath.

Что делать с selectReviewFile из store

selectReviewFile(filePath) из changeReviewSlice (строка 148-150) устанавливает selectedReviewFilePath в store. В continuous mode это больше не используется для переключения контента.

Рекомендация: не трогать store — просто не вызывать selectReviewFile из ChangeReviewDialog. Store action останется для обратной совместимости. selectedReviewFilePath по-прежнему инициализируется при fetchAgentChanges/fetchTaskChanges (строки 121, 138) — это ОК, просто не используется в UI.

Удалить неиспользуемые импорты

После рефакторинга убрать:

  • import type { EditorState } from '@codemirror/state'
  • Если CONTENT_SOURCE_LABELS вынесен в FileSectionHeader — убрать из ChangeReviewDialog
  • CodeMirrorDiffView (рендерится в FileSectionDiff)
  • DiffErrorBoundary (рендерится в FileSectionDiff)
  • ReviewDiffContent (рендерится в FileSectionDiff)

Оставить: acceptAllChunks, rejectAllChunks из CodeMirrorDiffUtils — используются в handleAcceptAll/handleRejectAll. Оставить: rejectChunk из @codemirror/merge — используется в Cmd+N handler. Оставить: goToNextChunk из @codemirror/merge — используется в Cmd+N handler.


4. Критические детали

4.1. Scroll-spy + Programmatic scroll race

Последовательность при клике на файл в tree:

  1. User кликает файл B в tree
  2. handleTreeFileClick('B') -> scrollToFile('B')
  3. isProgrammaticScroll.current = true
  4. section.scrollIntoView({ behavior: 'smooth' })
  5. Scroll анимация: файл A проскакивает мимо viewport, файл B появляется
  6. IntersectionObserver вызывает callback для файлов A, B, C...
  7. useVisibleFileSection.updateTopmostVisible() проверяет isProgrammaticScroll -> молчит
  8. waitForScrollEnd() resolve через ~300-400ms
  9. isProgrammaticScroll.current = false
  10. Debounced update (100ms) запустится при следующем IO callback — обновит activeFilePath на B

Edge case: Если пользователь кликает на другой файл пока предыдущий scroll ещё идёт:

  • isProgrammaticScroll останется true
  • Новый scrollIntoView перезаписывает scroll target
  • Старый waitForScrollEnd promise resolve (scrollTop стабилизируется) — сбросит flag
  • Новый waitForScrollEnd заменит промис — потенциальный race: flag может сброситься преждевременно

Решение: Использовать counter или AbortController:

const scrollGeneration = useRef(0);

const scrollToFile = useCallback((filePath: string) => {
  // ...
  const gen = ++scrollGeneration.current;
  isProgrammaticScroll.current = true;
  section.scrollIntoView({ behavior: 'smooth', block: 'start' });
  void waitForScrollEnd(container, 500).then(() => {
    if (scrollGeneration.current === gen) {
      isProgrammaticScroll.current = false;
    }
  });
}, [scrollContainerRef]);

4.2. EditorView Map vs один ref

Было: Один editorViewRef = useRef<EditorView | null>(null) — переписывался при каждом переключении файла.

Стало: editorViewMapRef = useRef(new Map<string, EditorView>()) — все editors хранятся одновременно.

Memory: Каждый EditorView ~ 50-100KB. Для 50 файлов = 2.5-5MB. Приемлемо.

Lifecycle:

  • Mount: FileSectionDiff создаёт CodeMirrorDiffView -> EditorView, регистрирует в Map
  • Unmount: FileSectionDiff cleanup -> удаляет из Map
  • В continuous mode все editors живут одновременно (пока все файлы в DOM)

4.3. editorStateCache не нужен

В continuous mode все editors живут одновременно. Нет "переключения файла" — нет необходимости сохранять/восстанавливать EditorState. Undo history живёт в самом EditorView.

Discard edits: Вместо сброса cache entry — key prop с discardCounter пересоздаёт CodeMirrorDiffView.

Замечание: Текущий handleDiscardCurrentFile (строка 153-160) также делает editorStateCache.current.delete() и setCachedInitialState(undefined). Оба удаляются.

4.4. Keyboard Cmd+Y/N

В continuous scroll фокус может быть:

  1. Внутри конкретного CodeMirror (user кликнул в diff) -> CM keymap обработает
  2. Вне CodeMirror (user скроллит мышью) -> document keydown handler -> getTargetEditorView()

Приоритет:

  1. CM keymap (если фокус в CM) — обработает и вернёт true, event не propagates
  2. useDiffNavigation keyboard handler проверяет event.defaultPrevented (строка 123 в useDiffNavigation.ts) — если CM уже обработал, пропускает
  3. Если CM не обработал — useDiffNavigation handler использует activeEditorViewRef.current
  4. Cmd+N IPC handler (через window.electronAPI.review.onCmdN) — работает через getTargetEditorView()

Конфликт Cmd+Y: useDiffNavigation вызывает acceptChunk(view) (строка 149-153), а CM keymap тоже содержит Mod-y handler. Если фокус в CM — CM обработает первым и event.defaultPrevented будет true. Если фокус вне CM — useDiffNavigation handler сработает. Нет конфликта.

4.5. Performance: много CodeMirror editors одновременно

Проблема: 30+ CodeMirror editors в DOM одновременно = нагрузка на рендеринг.

Mitigation (фаза 2): Lazy loading — контент загружается по мере scroll. Editors создаются только для загруженных файлов.

Mitigation (будущая фаза 3): Virtualized rendering — только видимые файлы + буфер рендерятся в DOM. Файлы за пределами viewport заменяются placeholder фиксированной высоты.

Для фазы 1: Не оптимизировать — загрузить контент всех файлов при открытии (bulk). 20-30 файлов — OK для начала.

4.6. data-file-path дублирование

Атрибут data-file-path устанавливается:

  1. registerFileSectionRef в useVisibleFileSection -> на section <div> (для IntersectionObserver)
  2. Документ ранее предлагал его на sticky header в FileSectionHeader

Решение: Оставить data-file-path только на section div (через registerFileSectionRef). scrollToFile найдёт section div через querySelector, scrollIntoView({ block: 'start' }) покажет начало секции. Scroll-spy observer тоже наблюдает section div. Один источник правды.

В FileSectionHeader data-file-path не добавлять.

4.7. Загрузка контента при mode='task'

fetchFileContent(teamName, memberName, filePath) — в mode='task' memberName может быть undefined. Текущая signature в store (строка 261): fetchFileContent(teamName: string, memberName: string | undefined, filePath: string). Это ОК.


5. Порядок реализации

Шаг 1: Создать FileSectionPlaceholder.tsx

  • Простой компонент, без зависимостей
  • Тестирование: визуально убедиться что skeleton выглядит ок

Шаг 2: Создать FileSectionHeader.tsx

  • Извлечь из ChangeReviewDialog строки 437-509
  • Вынести CONTENT_SOURCE_LABELS
  • Добавить sticky positioning
  • Импортировать Save, Undo2, Loader2 из lucide-react, Tooltip/TooltipTrigger/TooltipContent
  • НЕ добавлять data-file-path на header (только на section div в ContinuousScrollView)
  • Тестирование: рендерить standalone, проверить sticky поведение

Шаг 3: Создать FileSectionDiff.tsx

  • Извлечь из ChangeReviewDialog строки 511-561
  • Добавить проверку fileContent.modifiedFullContent !== null в условие рендеринга CodeMirror
  • Добавить sentinel для auto-viewed (threshold: 0.85)
  • Добавить editorView registration callback
  • Передать onFullyViewed={undefined} в CodeMirrorDiffView (отключить встроенный sentinel)
  • Тестирование: рендерить с mock data, проверить что CodeMirror создаётся

Шаг 4: Создать useVisibleFileSection.ts

  • По паттерну useVisibleAIGroup.ts
  • Добавить debounce и isProgrammaticScroll
  • Guard на scrollContainerRef.current is null
  • Тестирование: unit test с mock IntersectionObserver

Шаг 5: Создать useContinuousScrollNav.ts

  • scrollToFile с waitForScrollEnd(container, 500)
  • Keyboard listeners (Alt+Arrow)
  • Scroll generation counter для предотвращения race при быстрых кликах
  • Тестирование: unit test keyboard events

Шаг 6: Создать ContinuousScrollView.tsx

  • Собрать все компоненты вместе
  • files.map -> section (header + diff)
  • Интегрировать useVisibleFileSection
  • data-file-path только на section div (через registerFileSectionRef)
  • Тестирование: рендерить с 3-5 файлами, проверить scroll и sticky headers

Шаг 7: Модифицировать ReviewFileTree.tsx

  • Добавить activeFilePath prop в ReviewFileTreeProps
  • Добавить activeFilePath в inline props TreeItem и пробросить рекурсивно
  • Добавить data-tree-file attribute на button
  • Добавить auto-scroll useEffect
  • Добавить визуальную подсветку active файла (isActive condition + border-l-2 стиль)
  • Тестирование: проверить подсветку active vs selected

Шаг 8: Модифицировать ChangeReviewDialog.tsx

  • Заменить single-file area на ContinuousScrollView
  • Убрать: editorViewRef, editorStateCache, cachedInitialState, handleSelectFile, hasCurrentFileEdits, selectedFile (заменить на activeFile)
  • Добавить: activeFilePath, editorViewMapRef, scrollContainerRef, activeEditorViewRef
  • Заменить lazy-load на bulk-load useEffect
  • Интегрировать useContinuousScrollNav
  • Адаптировать: handleAcceptAll/handleRejectAll, handleSaveFile (принимает filePath), handleDiscardFile (принимает filePath), handleFullyViewed (принимает filePath)
  • Адаптировать useDiffNavigation: activeFilePath, scrollToFile, activeEditorViewRef
  • Адаптировать Cmd+N handler: getTargetEditorView
  • Timeline sidebar: selectedFile -> activeFile
  • FileTree: selectedFilePath={null}, activeFilePath, onSelectFile=handleTreeFileClick
  • Убрать неиспользуемые импорты
  • Тестирование: полный E2E flow

6. Проверка

Функциональная проверка

  • Открыть ChangeReviewDialog с 3+ файлами
  • Все файлы отображаются вертикально друг под другом
  • Sticky headers прилипают при скролле
  • File tree подсвечивает текущий файл при скролле (scroll-spy)
  • Клик по файлу в tree = smooth scroll к файлу в контенте
  • Нет "мигания" active файла при programmatic scroll
  • Alt+ArrowDown/Up переключает между файлами
  • Cmd+Y accept chunk работает (focused editor и fallback через getTargetEditorView)
  • Cmd+N reject chunk работает (через IPC и через useDiffNavigation fallback)
  • Accept All / Reject All применяются к active файлу
  • Save/Discard кнопки в header работают (каждый файл независимо)
  • Auto-viewed работает (скроллить до конца файла — sentinel 85%)
  • Viewed checkbox в tree работает
  • Edit timeline sidebar показывается для active файла
  • Escape закрывает диалог
  • Быстрый двойной клик по разным файлам в tree — нет race condition (scroll generation counter)

Edge cases

  • 0 файлов — показывает "No file changes detected"
  • 1 файл — scroll-spy стабильно, нет navigation issues
  • Файл с contentSource: 'unavailable' — показывает ReviewDiffContent fallback
  • Файл с modifiedFullContent === null — показывает ReviewDiffContent fallback
  • Файл загружается — показывает FileSectionPlaceholder
  • Файл с isNewFile — показывает NEW badge в sticky header
  • Очень длинный файл (1000+ строк) — smooth scroll работает
  • Collapse unchanged toggle — все editors обновляются (через collapseUnchanged prop -> CodeMirrorDiffView reconfigure)
  • Discard edits — editor пересоздаётся (key с discardCounter; глобальный counter, все editors rebuild — ОК для фазы 1)
  • Resize window — IntersectionObserver пересчитывает, scroll-spy корректен
  • mode='task' с memberName=undefined — bulk-load работает

Performance

  • 20 файлов — открытие < 2 секунд
  • Scroll не лагает с 10+ CodeMirror editors
  • Memory не утекает при close/reopen диалога (EditorView.destroy() через cleanup в CodeMirrorDiffView + editorViewMapRef cleanup)