agent-ecosystem/docs/iterations/diff-view/continuous-scroll/phase-2-lazy-loading.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

35 KiB
Raw Permalink Blame History

Фаза 2: Lazy Loading контента

1. Обзор

Предпосылка: В фазе 1 continuous scroll рендерит все файлы одновременно. Но контент файлов (FileChangeWithContent) загружается через IPC-вызов fetchFileContent(teamName, memberName, filePath) — это сетевой запрос к main process, который читает файл с диска, строит diff и возвращает originalFullContent + modifiedFullContent.

Проблема: При открытии review с 30+ файлами загрузка всех сразу:

  • Блокирует main process 30 последовательными IPC-вызовами
  • UI показывает 30 skeleton placeholders одновременно
  • Пользователь видит контент только после загрузки всех файлов
  • Для больших файлов (>10K строк) задержка ощутима

Решение: Lazy loading — контент загружается по мере приближения файла к viewport:

  • Первые 5 файлов предзагружаются при mount (без ожидания scroll)
  • Остальные файлы загружаются при пересечении rootMargin "200% 0px" (2 viewport-высоты до видимости)
  • Максимум 3 параллельных загрузки (throttle) — не перегружать main process
  • Приоритет: файлы ближе к viewport загружаются раньше

Результат: Пользователь видит первые файлы через ~200ms, остальные подгружаются бесшовно при скролле.


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

2.1. useLazyFileContent.ts

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

Назначение: IntersectionObserver-based lazy loading контента файлов через fetchFileContent из changeReviewSlice.

Interface

import type { RefObject } from 'react';
import type { FileChangeWithContent } from '@shared/types';

interface UseLazyFileContentOptions {
  /** Имя команды (для fetchFileContent) */
  teamName: string;

  /** Имя участника (для fetchFileContent) */
  memberName: string | undefined;

  /** Список всех filePath в порядке рендеринга */
  filePaths: string[];

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

  /**
   * Загруженный контент из store (для проверки: уже загружен?).
   * Тип: Record<string, FileChangeWithContent> из changeReviewSlice.
   */
  fileContents: Record<string, FileChangeWithContent>;

  /** Флаги загрузки из store (для проверки: уже грузится?) */
  fileContentsLoading: Record<string, boolean>;

  /**
   * Функция загрузки контента из store.
   * Сигнатура точно как в changeReviewSlice.fetchFileContent:
   * (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>
   *
   * Внутри store уже есть guard от дубликатов (строка 264):
   *   if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
   * Поэтому двойной вызов безопасен.
   */
  fetchFileContent: (
    teamName: string,
    memberName: string | undefined,
    filePath: string
  ) => Promise<void>;

  /** Lazy loading включён (false = загрузить всё сразу, для fallback) */
  enabled: boolean;
}

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

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

export function useLazyFileContent(
  options: UseLazyFileContentOptions
): UseLazyFileContentReturn {
  const {
    teamName,
    memberName,
    filePaths,
    scrollContainerRef,
    fileContents,
    fileContentsLoading,
    fetchFileContent,
    enabled,
  } = options;

  // === Throttle State ===

  // Set: filePath текущих in-flight загрузок
  const activeLoads = useRef(new Set<string>());

  // Queue: filePath ожидающих загрузки (FIFO, но с приоритетом)
  const pendingQueue = useRef<string[]>([]);

  // Max параллельных загрузок
  const MAX_CONCURRENT = 3;

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

  // Element refs
  const elementRefs = useRef(new Map<string, HTMLElement>());

  // Stable refs для текущих значений (избежание stale closures)
  const fileContentsRef = useRef(fileContents);
  const fileContentsLoadingRef = useRef(fileContentsLoading);

  useEffect(() => {
    fileContentsRef.current = fileContents;
    fileContentsLoadingRef.current = fileContentsLoading;
  }, [fileContents, fileContentsLoading]);

  // === Throttled Loader ===

  /**
   * Проверяет, нужно ли загружать filePath:
   * - Не загружен (нет в fileContents)
   * - Не грузится (нет в fileContentsLoading или false)
   * - Не в activeLoads (не in-flight)
   *
   * ВАЖНО: проверяем fileContentsRef (ref), а не fileContents (prop) —
   * чтобы callback IntersectionObserver видел актуальное состояние.
   */
  const shouldLoad = useCallback((filePath: string): boolean => {
    if (fileContentsRef.current[filePath]) return false;
    if (fileContentsLoadingRef.current[filePath]) return false;
    if (activeLoads.current.has(filePath)) return false;
    return true;
  }, []);

  /**
   * Запустить загрузку одного файла.
   * Возвращает Promise (для chaining).
   */
  const loadFile = useCallback(
    async (filePath: string): Promise<void> => {
      if (!shouldLoad(filePath)) return;

      activeLoads.current.add(filePath);
      try {
        await fetchFileContent(teamName, memberName, filePath);
      } finally {
        activeLoads.current.delete(filePath);
        // После завершения — попробовать следующий из очереди
        processQueue();
      }
    },
    [teamName, memberName, fetchFileContent, shouldLoad]
  );

  /**
   * Обработать очередь: запустить загрузки пока slots < MAX_CONCURRENT.
   */
  const processQueue = useCallback(() => {
    while (
      activeLoads.current.size < MAX_CONCURRENT &&
      pendingQueue.current.length > 0
    ) {
      const nextPath = pendingQueue.current.shift()!;
      if (shouldLoad(nextPath)) {
        void loadFile(nextPath);
      }
      // Если nextPath уже не нужен (загружен за время ожидания) — пропускаем, берём следующий
    }
  }, [shouldLoad, loadFile]);

  /**
   * Добавить filePath в очередь загрузки.
   * Если есть свободные слоты — загрузить сразу.
   * Если нет — добавить в pending queue.
   */
  const enqueueLoad = useCallback(
    (filePath: string) => {
      if (!shouldLoad(filePath)) return;

      if (activeLoads.current.size < MAX_CONCURRENT) {
        // Есть свободный слот — загружаем сразу
        void loadFile(filePath);
      } else {
        // Очередь заполнена — добавить в pending (если ещё нет)
        if (!pendingQueue.current.includes(filePath)) {
          pendingQueue.current.push(filePath);
        }
      }
    },
    [shouldLoad, loadFile]
  );

  // === Preload первых N файлов при mount ===

  const PRELOAD_COUNT = 5;

  useEffect(() => {
    if (!enabled) return;

    // Загрузить первые 5 файлов сразу
    const toPreload = filePaths.slice(0, PRELOAD_COUNT);
    for (const fp of toPreload) {
      enqueueLoad(fp);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [enabled]); // Намеренно: только при mount (enabled = true)

  // === IntersectionObserver ===

  useEffect(() => {
    if (!enabled) return;

    observerRef.current = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (!entry.isIntersecting) continue;

          const filePath = entry.target.getAttribute('data-lazy-file');
          if (!filePath) continue;

          enqueueLoad(filePath);

          // После загрузки — перестать наблюдать (загружается один раз)
          // Но мы не можем unobserve сразу (загрузка async) — unobserve когда контент загружен
          // Проще: observer продолжает наблюдать, shouldLoad() вернёт false для загруженных
        }
      },
      {
        root: scrollContainerRef.current,
        // 200% от viewport сверху и снизу — предзагрузка за 2 экрана
        rootMargin: '200% 0px 200% 0px',
        threshold: 0,
      }
    );

    // Зарегистрировать все уже mounted элементы
    for (const [, element] of elementRefs.current) {
      observerRef.current.observe(element);
    }

    return () => {
      observerRef.current?.disconnect();
      observerRef.current = null;
    };
  }, [enabled, scrollContainerRef, enqueueLoad]);

  // === Register ref callback ===

  const registerLazyRef = useCallback((filePath: string) => {
    return (element: HTMLElement | null) => {
      const observer = observerRef.current;

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

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

  return { registerLazyRef };
}

Ключевые аспекты

rootMargin "200% 0px 200% 0px"

IntersectionObserver rootMargin расширяет область наблюдения за пределы видимого viewport. 200% означает 2x viewport-высоты сверху и снизу.

Пример: Viewport = 800px. rootMargin = 200% -> +1600px сверху и снизу. Файл начнёт загружаться когда его section находится в 1600px от видимой области.

Почему 200%: Smooth scroll на Chromium покрывает ~300-400px/сек. При viewport 800px пользователь доскроллит до следующей "зоны" за 2-4 секунды. Загрузка файла через IPC занимает ~50-200ms. 200% даёт достаточный запас для предзагрузки.

MAX_CONCURRENT = 3

Почему 3, а не больше:

  • Electron main process обрабатывает IPC последовательно (single thread)
  • Каждый fetchFileContent читает файл, парсит diff, возвращает контент
  • 3 параллельных запроса = main process занят ~100% на файловых операциях
  • Больше 3 = запросы встают в очередь IPC, но main process не ускоряется
  • Бонус: оставляет "дышать" main process для других IPC (file watcher, config)
Preload первых 5 файлов

При mount (открытие диалога) загружаем первые 5 файлов немедленно (без ожидания IntersectionObserver).

Почему 5:

  • Viewport обычно вмещает 2-3 file sections
  • 5 = 2-3 видимых + 2 "за кадром" для плавного scroll
  • Preload занимает ~200-500ms (3 параллельно + 2 в очереди)

Timing: Preload запускается одновременно с рендерингом DOM. К моменту первого paint IntersectionObserver ещё не успел сработать, но preload уже отправил запросы.

Приоритет в очереди

В текущей реализации очередь FIFO (first-in, first-out). Файлы добавляются в порядке пересечения rootMargin — ближайшие к viewport первыми.

Возможное улучшение (если потребуется): Реордеринг очереди при scroll event. Но FIFO достаточно для типичного use case (скролл сверху вниз).

Repeated observations

IntersectionObserver продолжает наблюдать все элементы, даже загруженные. Это ОК:

  1. Callback вызовется для уже загруженного файла
  2. shouldLoad() проверяет fileContentsRef.current[filePath] -> файл есть -> return false
  3. enqueueLoad ничего не делает

Альтернатива observer.unobserve() после загрузки добавляет сложности (нужен callback из store, race conditions). Текущий подход проще и не имеет performance penalty (observer callback -- O(1) проверка).


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

3.1. changeReviewSlice.ts -- НЕ требует изменений

Решение: prefetchFileContents НЕ НУЖЕН.

Изначально предполагался convenience-метод prefetchFileContents для batch-вызова. Однако при ревью обнаружено:

  1. useLazyFileContent уже реализует preload первых 5 файлов через enqueueLoad в useEffect при mount -- это полностью покрывает потребность в batch preload.
  2. fetchFileContent уже имеет внутренний guard от дубликатов (строка 262-264 в changeReviewSlice.ts):
    const state = get();
    // Skip if already loaded or loading
    if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
    
  3. useLazyFileContent.enqueueLoad добавляет поверх store guard ещё activeLoads ref-трекинг для throttle -- т.е. тройная защита от дубликатов.

Добавление prefetchFileContents в store создаст дублирование с useLazyFileContent preload и не даст throttle (все запросы уйдут параллельно). Оставляем store без изменений.


3.2. ContinuousScrollView.tsx

Интеграция useLazyFileContent

Новые props (добавляются к существующим props фазы 1):

interface ContinuousScrollViewProps {
  // ... все существующие props из фазы 1 (см. phase-1 документ) ...

  // === НОВЫЕ для фазы 2 ===
  /** Имя команды */
  teamName: string;

  /** Имя участника */
  memberName: string | undefined;

  /**
   * Функция загрузки контента из store.
   * Сигнатура: (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>
   * Из changeReviewSlice.fetchFileContent
   */
  fetchFileContent: (
    teamName: string,
    memberName: string | undefined,
    filePath: string
  ) => Promise<void>;
}

Интеграция в компоненте:

export const ContinuousScrollView = (props: ContinuousScrollViewProps) => {
  const {
    files,
    fileContents,
    fileContentsLoading,
    teamName,
    memberName,
    fetchFileContent,
    scrollContainerRef,
    isProgrammaticScroll,
    // ... rest из Phase 1
  } = props;

  const filePaths = useMemo(() => files.map((f) => f.filePath), [files]);

  // Scroll-spy (фаза 1)
  const { registerFileSectionRef } = useVisibleFileSection({
    onVisibleFileChange: props.onVisibleFileChange,
    scrollContainerRef,
    isProgrammaticScroll,
  });

  // Lazy loading (фаза 2)
  const { registerLazyRef } = useLazyFileContent({
    teamName,
    memberName,
    filePaths,
    scrollContainerRef,
    fileContents,
    fileContentsLoading,
    fetchFileContent,
    enabled: true,
  });

  // Комбинированный ref callback: регистрация в обоих observers
  const combinedRef = useCallback(
    (filePath: string) => {
      const sectionRef = registerFileSectionRef(filePath);
      const lazyRef = registerLazyRef(filePath);

      return (element: HTMLElement | null) => {
        sectionRef(element);
        lazyRef(element);
      };
    },
    [registerFileSectionRef, registerLazyRef]
  );

  // EditorView registration callback (Phase 1, без изменений)
  const handleEditorViewReady = useCallback(
    (filePath: string, view: EditorView | null) => {
      if (view) {
        props.editorViewMapRef.current.set(filePath, view);
      } else {
        props.editorViewMapRef.current.delete(filePath);
      }
    },
    [props.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 hasContent = content !== null;

        return (
          <div
            key={filePath}
            ref={combinedRef(filePath)}    // <-- Комбинированный ref (Phase 1 scroll-spy + Phase 2 lazy)
            className="border-b border-border"
          >
            <FileSectionHeader
              file={file}
              fileContent={content}
              fileDecision={props.fileDecisions[filePath]}
              hasEdits={filePath in props.editedContents}
              applying={props.applying}
              onDiscard={props.onDiscard}
              onSave={props.onSave}
            />

            {/* Контент ещё не загружен — placeholder */}
            {!hasContent && isLoading && (
              <FileSectionPlaceholder fileName={file.relativePath} />
            )}

            {/* Контент ещё не начал грузиться — тоже placeholder */}
            {!hasContent && !isLoading && (
              <FileSectionPlaceholder fileName={file.relativePath} />
            )}

            {/* Контент загружен — diff */}
            {hasContent && (
              <FileSectionDiff
                file={file}
                fileContent={content}
                isLoading={false}
                collapseUnchanged={props.collapseUnchanged}
                onHunkAccepted={props.onHunkAccepted}
                onHunkRejected={props.onHunkRejected}
                onFullyViewed={props.onFullyViewed}
                onContentChanged={props.onContentChanged}
                onEditorViewReady={handleEditorViewReady}
                discardCounter={props.discardCounter}
                autoViewed={props.autoViewed}
                isViewed={props.viewedSet.has(filePath)}
              />
            )}
          </div>
        );
      })}

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

Отличие от Phase 1 ContinuousScrollView: В Phase 1 ref={registerFileSectionRef(filePath)} использовался напрямую. В Phase 2 заменён на ref={combinedRef(filePath)}, который вызывает оба ref callback (scroll-spy + lazy). Phase 1 рендерил FileSectionDiff / FileSectionPlaceholder по условию isLoading (ternary). Phase 2 добавляет промежуточное состояние "не начал грузиться" (!hasContent && !isLoading).

Два placeholder состояния

Состояние hasContent isLoading Что показывать
Не начал грузиться false false FileSectionPlaceholder
Грузится false true FileSectionPlaceholder
Загружен true - FileSectionDiff
Ошибка загрузки false false FileSectionPlaceholder (потом retry)

Замечание: "Не начал грузиться" -- файл ещё не попал в rootMargin IntersectionObserver. Placeholder показывается, но без индикатора загрузки. Визуально идентичен "грузится" -- это ОК, пользователь не различает.

Ошибка загрузки: fetchFileContent в store ставит fileContentsLoading[fp] = false (строка 279) и НЕ записывает в fileContents (строка 278 -- catch блок). Результат: hasContent = false, isLoading = false -- снова placeholder. IntersectionObserver при следующем пересечении вызовет enqueueLoad -- retry произойдёт автоматически (при re-scroll).

Важно: shouldLoad() в useLazyFileContent проверяет fileContentsRef.current[filePath] -- после ошибки этого ключа нет, поэтому повторный вызов пройдёт. Также fileContentsLoadingRef.current[filePath] будет false (store сбросил loading). Таким образом retry корректно сработает.

Если нужен явный retry без scroll: добавить кнопку "Retry" в placeholder. Но для фазы 2 автоматический retry через scroll достаточен.

combinedRef -- объединение двух ref callbacks

const combinedRef = useCallback(
  (filePath: string) => {
    const sectionRef = registerFileSectionRef(filePath);
    const lazyRef = registerLazyRef(filePath);

    return (element: HTMLElement | null) => {
      sectionRef(element);
      lazyRef(element);
    };
  },
  [registerFileSectionRef, registerLazyRef]
);

Зачем: Оба хука (useVisibleFileSection, useLazyFileContent) используют IntersectionObserver на одном и том же элементе (file section div). Вместо двух отдельных ref -- один объединённый.

data attributes: Каждый callback ставит свой атрибут:

  • registerFileSectionRef -> data-file-path
  • registerLazyRef -> data-lazy-file

Оба атрибута на одном элементе -- ОК, они используются разными observers.


3.3. ChangeReviewDialog.tsx

Убрать lazy-load useEffect

Было (строки 224-237 текущего файла):

// Lazy-load file content when file selected
useEffect(() => {
  if (!open || !selectedReviewFilePath) return;
  if (fileContents[selectedReviewFilePath] || fileContentsLoading[selectedReviewFilePath]) return;
  void fetchFileContent(teamName, memberName, selectedReviewFilePath);
}, [
  open,
  selectedReviewFilePath,
  teamName,
  memberName,
  fileContents,
  fileContentsLoading,
  fetchFileContent,
]);

Стало: Удалить этот useEffect целиком. Загрузка контента теперь полностью делегирована useLazyFileContent внутри ContinuousScrollView:

  • Preload первых 5 файлов при mount
  • Остальные подгружаются по IntersectionObserver

Передать новые props в ContinuousScrollView

<ContinuousScrollView
  // ... props из фазы 1 ...
  teamName={teamName}
  memberName={memberName}
  fetchFileContent={fetchFileContent}
/>

Примечание: teamName берётся из props ChangeReviewDialogProps, memberName оттуда же (optional prop). fetchFileContent берётся из useStore() (строка 77 текущего файла).


4. Throttle реализация: детали

Структура данных

┌─────────────────┐
│  activeLoads     │ Set<string>  -- max 3 элемента
│  (in-flight)     │
├─────────────────┤
│  pendingQueue    │ string[]     -- FIFO очередь
│  (waiting)       │
└─────────────────┘

Жизненный цикл загрузки

1. IntersectionObserver fires -> enqueueLoad(filePath)
2. shouldLoad() checks:
   - fileContentsRef.current[fp]? -> skip (already loaded)
   - fileContentsLoadingRef.current[fp]? -> skip (store knows about it)
   - activeLoads.has(fp)? -> skip (our local tracking)
3. activeLoads.size < MAX_CONCURRENT?
   -> YES: loadFile(fp) immediately
   -> NO: pendingQueue.push(fp)
4. loadFile(fp):
   - activeLoads.add(fp)
   - await fetchFileContent(teamName, memberName, fp)
   - activeLoads.delete(fp)
   - processQueue() <-- проверить, есть ли ожидающие
5. processQueue():
   - while (activeLoads.size < MAX_CONCURRENT && pendingQueue.length > 0)
   - shift from queue, check shouldLoad, loadFile

Диаграмма состояний

                  ┌──────────┐
  IO trigger ──> │ enqueue  │
                  └────┬─────┘
                       │
              ┌────────v────────┐
              │ slots available? │
              └──┬─────────┬────┘
                 │ YES     │ NO
          ┌──────v──┐   ┌──v───────┐
          │ loadFile │   │ add to   │
          │ (async)  │   │ pending  │
          └──────┬───┘   │ queue    │
                 │       └──────────┘
          ┌──────v───┐         ^
          │ complete  │         │
          └──────┬───┘         │
                 │             │
          ┌──────v────────┐    │
          │ processQueue  ├────┘
          └───────────────┘

Взаимодействие throttle с store guard

fetchFileContent в store (строки 261-282) имеет собственный guard:

const state = get();
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;

useLazyFileContent добавляет activeLoads ref поверх. Зачем два уровня защиты:

  1. Store guard предотвращает повторный IPC-вызов для загружаемого/загруженного файла -- но работает через get() (синхронный snapshot). Между двумя вызовами fetchFileContent в одном event loop tick fileContentsLoading ещё не обновлён (Zustand batch).
  2. activeLoads ref покрывает этот micro-timing gap -- activeLoads.add(fp) происходит синхронно ДО await, а shouldLoad() проверяет ref мгновенно.

Таким образом:

  • Store guard: macro-level (между renders)
  • activeLoads ref: micro-level (между тиками в одном frame)
  • Оба нужны для надёжности

Приоритет загрузки

Текущий подход: FIFO. IntersectionObserver вызывает callbacks в порядке пересечения rootMargin. Для типичного скролла сверху вниз это означает: верхние файлы раньше нижних.

Потенциальное улучшение (не для фазы 2):

Если пользователь быстро скроллит вниз (skip middle files), можно реализовать priority queue:

// Вместо string[] использовать priority queue:
interface PendingItem {
  filePath: string;
  priority: number; // расстояние от viewport center
}

// При каждом scroll event -- пересчитать priority для pending items
// Ближайшие к viewport -- выше приоритет

Но это оверинжиниринг для фазы 2. FIFO достаточно:

  • IntersectionObserver с rootMargin 200% ловит файлы рано
  • 3 параллельных загрузки покрывают типичную скорость скролла
  • Даже при быстром скролле -- placeholder на 100-200ms, потом контент

Refs для stale closure prevention

const fileContentsRef = useRef(fileContents);
const fileContentsLoadingRef = useRef(fileContentsLoading);

useEffect(() => {
  fileContentsRef.current = fileContents;
  fileContentsLoadingRef.current = fileContentsLoading;
}, [fileContents, fileContentsLoading]);

Зачем: shouldLoad() замыкает fileContentsRef и fileContentsLoadingRef. Без ref-трюка callback IntersectionObserver "видит" stale fileContents из момента создания observer.

Альтернатива: Пересоздавать IntersectionObserver при каждом изменении fileContents. Но это = disconnect + observe all elements заново = bad performance.

Edge case: диалог закрыт во время загрузки

fetchFileContent -- async. Если пользователь закроет диалог пока идёт загрузка:

  1. ContinuousScrollView unmounts -> useLazyFileContent cleanup
  2. IntersectionObserver disconnect
  3. Но fetchFileContent всё ещё in-flight в store
  4. Store обновит fileContents / fileContentsLoading -- ОК, store не зависит от компонента
  5. clearChangeReview() вызывается в useEffect cleanup ChangeReviewDialog (строка 189) -- сбросит all state

Вывод: Нет утечек и race conditions. Store корректно очищается.

Edge case: файл уже загружен при re-open

При повторном открытии того же review:

  1. clearChangeReview() сбрасывает fileContents = {} (строка 160)
  2. fetchAgentChanges() / fetchTaskChanges() загружает свежий changeSet
  3. useLazyFileContent preload + observer начинают с нуля
  4. Все файлы загружаются заново (свежие данные)

Edge case: circular dependency loadFile <-> processQueue

loadFile вызывает processQueue в finally. processQueue вызывает loadFile. Потенциальный бесконечный цикл?

Нет -- loadFile начинается с if (!shouldLoad(filePath)) return;, а activeLoads.add(fp) происходит синхронно. processQueue берёт из очереди (shift), проверяет shouldLoad, и вызывает loadFile через void (fire-and-forget). Каждый loadFile -- это новый async task, не рекурсия в call stack. Queue конечна (max = количество файлов). Цикла нет.


5. Консистентность с Phase 3

Phase 3 (Navigation) зависит от Phase 2 в следующих аспектах:

  1. EditorView Map -- Phase 2 использует editorViewMapRef из Phase 1. Phase 3 использует тот же Map через ContinuousNavigationOptions.editorViewRefs. Важно: Phase 3 editorViewRefs это Map<string, EditorView> (value из .current), а Phase 2 работает с MutableRefObject<Map>. Нет конфликта -- Phase 3 читает из .current напрямую.

  2. Lazy loading + cross-file navigation -- когда Phase 3 goToNextFile() делает scrollToFile(nextFilePath), файл может быть ещё не загружен. IntersectionObserver с rootMargin 200% должен сработать до того как scroll доедет до файла. Если файл далеко -- placeholder покажется на ~100-200ms, потом контент подгрузится. Это приемлемый UX.

  3. activeFilePath -- Phase 2 НЕ управляет activeFilePath. Scroll-spy из Phase 1 (useVisibleFileSection) определяет activeFilePath. Phase 2 только загружает контент. Phase 3 использует activeFilePath для определения "текущего" файла в навигации.


6. Проверка

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

  • Открыть review с 10+ файлами
  • Первые 5 файлов показывают контент в первые ~500ms
  • Файлы 6-10 показывают placeholder, потом контент при подскролле
  • Scroll вниз -- файлы подгружаются бесшовно (placeholder -> diff)
  • Scroll быстро вниз -- плейсхолдеры видны на ~200ms, потом контент
  • Scroll обратно вверх -- уже загруженные файлы показывают diff мгновенно
  • Кликнуть на файл 15 в tree -> smooth scroll + контент загружается

Throttle проверка

  • Открыть DevTools Network tab (или console log)
  • Убедиться: максимум 3 одновременных IPC-вызова getFileContent
  • Остальные ждут в очереди и выполняются последовательно по 3

Edge cases

  • 0 файлов -- нет ошибок в console
  • 1 файл -- загружается мгновенно (preload)
  • Файл с ошибкой загрузки (main process throw) -- placeholder остаётся, scroll retry работает
  • Закрыть диалог во время загрузки -- нет ошибок, store очищен
  • Переоткрыть диалог -- все файлы загружаются заново

Performance

  • 30 файлов -- UI не зависает при открытии
  • Main process responsive (file watcher работает) во время загрузки 30 файлов
  • Memory: placeholder -> diff transition не утекает (EditorView create/destroy)
  • Scroll FPS > 30 при 20+ загруженных CodeMirror editors