- 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.
35 KiB
Фаза 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 продолжает наблюдать все элементы, даже загруженные. Это ОК:
- Callback вызовется для уже загруженного файла
shouldLoad()проверяетfileContentsRef.current[filePath]-> файл есть ->return falseenqueueLoadничего не делает
Альтернатива observer.unobserve() после загрузки добавляет сложности (нужен callback из store, race conditions). Текущий подход проще и не имеет performance penalty (observer callback -- O(1) проверка).
3. Модификации существующих файлов
3.1. changeReviewSlice.ts -- НЕ требует изменений
Решение: prefetchFileContents НЕ НУЖЕН.
Изначально предполагался convenience-метод prefetchFileContents для batch-вызова. Однако при ревью обнаружено:
useLazyFileContentуже реализует preload первых 5 файлов черезenqueueLoadв useEffect при mount -- это полностью покрывает потребность в batch preload.fetchFileContentуже имеет внутренний guard от дубликатов (строка 262-264 вchangeReviewSlice.ts):const state = get(); // Skip if already loaded or loading if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;useLazyFileContent.enqueueLoadдобавляет поверх store guard ещёactiveLoadsref-трекинг для 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-pathregisterLazyRef->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 поверх. Зачем два уровня защиты:
- Store guard предотвращает повторный IPC-вызов для загружаемого/загруженного файла -- но работает через
get()(синхронный snapshot). Между двумя вызовамиfetchFileContentв одном event loop tickfileContentsLoadingещё не обновлён (Zustand batch). activeLoadsref покрывает этот 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. Если пользователь закроет диалог пока идёт загрузка:
ContinuousScrollViewunmounts ->useLazyFileContentcleanup- IntersectionObserver disconnect
- Но
fetchFileContentвсё ещё in-flight в store - Store обновит
fileContents/fileContentsLoading-- ОК, store не зависит от компонента clearChangeReview()вызывается в useEffect cleanupChangeReviewDialog(строка 189) -- сбросит all state
Вывод: Нет утечек и race conditions. Store корректно очищается.
Edge case: файл уже загружен при re-open
При повторном открытии того же review:
clearChangeReview()сбрасываетfileContents = {}(строка 160)fetchAgentChanges()/fetchTaskChanges()загружает свежий changeSetuseLazyFileContentpreload + observer начинают с нуля- Все файлы загружаются заново (свежие данные)
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 в следующих аспектах:
-
EditorView Map -- Phase 2 использует
editorViewMapRefиз Phase 1. Phase 3 использует тот же Map черезContinuousNavigationOptions.editorViewRefs. Важно: Phase 3editorViewRefsэтоMap<string, EditorView>(value из.current), а Phase 2 работает сMutableRefObject<Map>. Нет конфликта -- Phase 3 читает из.currentнапрямую. -
Lazy loading + cross-file navigation -- когда Phase 3
goToNextFile()делаетscrollToFile(nextFilePath), файл может быть ещё не загружен. IntersectionObserver с rootMargin 200% должен сработать до того как scroll доедет до файла. Если файл далеко -- placeholder покажется на ~100-200ms, потом контент подгрузится. Это приемлемый UX. -
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