- 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.
38 KiB
Phase 5: Polish + EditorView Map + Toolbar адаптация
1. Обзор
Финальная фаза Continuous Scroll Diff View. Задачи:
- EditorView Map -- централизованный реестр всех EditorView экземпляров в ContinuousScrollView, необходимый для глобальных действий (Accept All, Reject All) и keyboard navigation.
- Keyboard shortcuts координация -- Cmd+Y/N/Enter должны корректно определять, с каким EditorView работать, когда на экране отображаются десятки файлов одновременно.
- Auto-viewed для каждого файла -- каждый FileSectionDiff отслеживает свой viewed-статус через IntersectionObserver.
- ReviewToolbar адаптация -- кнопки "Accept All" и "Reject All" теперь оперируют ВСЕМИ файлами, а не текущим. Добавляется progress indicator.
- ChangeReviewDialog адаптация -- handlers переключаются на multi-file режим, per-file discard counters.
- Cleanup и edge-cases -- корректная очистка при unmount, batch-обновления для 50+ файлов.
Предусловия: Phase 1 (ContinuousScrollView), Phase 2 (lazy loading), Phase 3 (navigation), Phase 4 (portionCollapse) -- все завершены.
2. EditorView Map в ContinuousScrollView
2.1. Структура данных
// ContinuousScrollView.tsx (внутри компонента)
const editorViewMapRef = useRef<Map<string, EditorView>>(new Map());
Map хранит filePath -> EditorView для каждого смонтированного FileSectionDiff. Используется useRef, а не useState, потому что:
- EditorView-инстансы не являются React-состоянием
- Изменение Map не должно вызывать ре-рендер ContinuousScrollView
- Доступ к Map нужен синхронно из event handlers
2.2. Callback-интерфейс FileSectionDiff
FileSectionDiff использует единый callback для регистрации/дерегистрации EditorView, как определено в Phase 1:
// FileSectionDiff.tsx — props interface (из Phase 1, секция 2.2)
interface FileSectionDiffProps {
filePath: string;
original: string;
modified: string;
fileName: string;
readOnly: boolean;
showMergeControls: boolean;
collapseUnchanged: boolean;
discardCounter: number;
// ... другие props
/**
* Вызывается при создании EditorView (view !== null) и при уничтожении (view === null).
* Единый callback по паттерну Phase 1.
*/
onEditorViewReady: (filePath: string, view: EditorView | null) => void;
}
Важно: Используется ОДИН callback onEditorViewReady(filePath, view | null), а НЕ два отдельных (onEditorViewReady + onEditorViewDestroyed). Это соответствует дизайну Phase 1 (секция 2.2 FileSectionDiff), где view === null сигнализирует об уничтожении EditorView.
2.3. Реализация в FileSectionDiff
FileSectionDiff оборачивает CodeMirrorDiffView и управляет lifecycle:
// FileSectionDiff.tsx (из Phase 1, секция 2.2)
const localEditorViewRef = useRef<EditorView | null>(null);
// Sync to parent Map при mount/unmount
useEffect(() => {
return () => {
// При unmount сообщить parent что view уничтожен
onEditorViewReady(filePath, null);
};
}, [filePath, onEditorViewReady]);
// Нужен useEffect чтобы проверить ref после рендера CodeMirrorDiffView
useEffect(() => {
if (localEditorViewRef.current) {
onEditorViewReady(filePath, localEditorViewRef.current);
}
});
Важно: CodeMirrorDiffView устанавливает editorViewRef.current синхронно в своём useEffect. Наш вторичный useEffect (без deps) ловит это на следующем render cycle.
Альтернативная реализация с requestAnimationFrame (для гарантии синхронизации):
useEffect(() => {
const rafId = requestAnimationFrame(() => {
const view = localEditorViewRef.current;
if (view) {
onEditorViewReady(filePath, view);
}
});
return () => {
cancelAnimationFrame(rafId);
if (localEditorViewRef.current) {
onEditorViewReady(filePath, null);
localEditorViewRef.current = null;
}
};
}, [filePath, discardCounter]);
2.4. Регистрация в ContinuousScrollView
// ContinuousScrollView.tsx (единый handler по паттерну Phase 1)
const handleEditorViewReady = useCallback(
(filePath: string, view: EditorView | null) => {
if (view) {
editorViewMapRef.current.set(filePath, view);
} else {
editorViewMapRef.current.delete(filePath);
}
},
[]
);
Передаётся каждому FileSectionDiff:
<FileSectionDiff
filePath={file.filePath}
onEditorViewReady={handleEditorViewReady}
// ... другие props
/>
2.5. Передача Map наружу
ContinuousScrollView передает Map наружу через useImperativeHandle:
// ContinuousScrollView.tsx
export interface ContinuousScrollViewHandle {
getEditorViewMap: () => Map<string, EditorView>;
getActiveEditorView: () => EditorView | null;
}
const ContinuousScrollView = forwardRef<ContinuousScrollViewHandle, ContinuousScrollViewProps>(
(props, ref) => {
const editorViewMapRef = useRef<Map<string, EditorView>>(new Map());
useImperativeHandle(ref, () => ({
getEditorViewMap: () => editorViewMapRef.current,
getActiveEditorView: () => {
// Логика определения активного editor (см. секцию 3)
return resolveActiveEditorView(editorViewMapRef.current, props.activeFilePath);
},
}), [props.activeFilePath]);
// ...
}
);
В ChangeReviewDialog:
const continuousScrollRef = useRef<ContinuousScrollViewHandle>(null);
<ContinuousScrollView ref={continuousScrollRef} ... />
// Использование:
const map = continuousScrollRef.current?.getEditorViewMap();
const activeView = continuousScrollRef.current?.getActiveEditorView();
Решение: используем useImperativeHandle -- он инкапсулирует логику определения активного editor внутри ContinuousScrollView, где есть доступ к scroll-spy данным.
3. Keyboard shortcuts координация (Cmd+Y/N)
3.1. Проблема
В single-file режиме editorViewRef.current -- всегда один EditorView. В continuous scroll -- их может быть десятки. Нужно определить, какой EditorView является "активным" для команд accept/reject.
3.2. Алгоритм resolveActiveEditorView
function resolveActiveEditorView(
editorViewMap: Map<string, EditorView>,
activeFilePath: string
): EditorView | null {
// 1. Приоритет: EditorView, который имеет фокус
const activeEl = document.activeElement;
if (activeEl) {
for (const [, view] of editorViewMap) {
if (view.dom.contains(activeEl)) {
return view;
}
}
}
// 2. Fallback: EditorView для activeFilePath (из scroll-spy)
if (activeFilePath) {
return editorViewMap.get(activeFilePath) ?? null;
}
return null;
}
Логика приоритетов:
- Если пользователь кликнул в CodeMirror editor (ставит фокус) -- используем именно этот editor.
document.activeElementбудет внутри.cm-contentэлемента. - Если фокус вне editor (например, после скролла мышью) -- используем editor для файла, определенного scroll-spy как видимый (
activeFilePath).
3.3. Интеграция с useDiffNavigation (Phase 3)
Phase 3 уже определяет continuousOptions?: ContinuousNavigationOptions как 10-й параметр useDiffNavigation. Этот объект включает:
interface ContinuousNavigationOptions {
editorViewRefs: Map<string, EditorView>;
activeFilePath: string | null;
scrollToFile: (filePath: string) => void;
enabled: boolean;
}
Внутри useDiffNavigation Phase 3 реализует helper getActiveEditorView(), который определяет активный editor по приоритету: focused > activeFilePath > first editor.
Phase 5 НЕ добавляет новых параметров в useDiffNavigation. Вся логика определения активного editor уже заложена в Phase 3 через continuousOptions. Phase 5 лишь использует эту инфраструктуру:
// В ChangeReviewDialog.tsx — передача continuousOptions (определено Phase 3)
const continuousOptions = useMemo(
(): ContinuousNavigationOptions | undefined => {
if (!isContinuousMode) return undefined;
return {
editorViewRefs: continuousScrollRef.current?.getEditorViewMap() ?? new Map(),
activeFilePath: continuousScrollActiveFilePath,
scrollToFile: scrollToFile,
enabled: true,
};
},
[isContinuousMode, continuousScrollActiveFilePath, scrollToFile]
);
const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [],
selectedReviewFilePath,
handleSelectFile,
editorViewRef,
open,
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
() => onOpenChange(false),
handleSaveCurrentFile,
continuousOptions // <-- 10-й параметр из Phase 3
);
3.4. Cmd+Y: Accept + goToNextChunk
Поток действий:
resolveActiveEditorView()-> получаем EditorViewacceptChunk(view)-- принимает текущий chunk в этом editorrequestAnimationFrame(() => goToNextChunk(view))-- прокручивает к следующему chunk- Cross-file transition: если это был последний chunk в файле, Phase 3 обрабатывает cross-file navigation через
isLastChunkInFile()иscrollToFile().
3.5. Cmd+N: Reject + goToNextChunk
Аналогично Cmd+Y, но вызывает rejectChunk(view). Обработка через IPC-listener window.electronAPI.review.onCmdN:
// В ChangeReviewDialog.tsx — модификация IPC listener
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
const view = isContinuousMode
? continuousScrollRef.current?.getActiveEditorView() ?? null
: editorViewRef.current;
if (view) {
rejectChunk(view);
requestAnimationFrame(() => goToNextChunk(view));
}
});
return cleanup ?? undefined;
}, [open, isContinuousMode]);
3.6. Cmd+Enter: Save file
Сохраняет только activeFilePath, не все файлы:
// Cmd+Enter handler
if (isMeta && event.key === 'Enter') {
event.preventDefault();
if (activeFilePath) {
saveEditedFile(activeFilePath);
}
return;
}
Где activeFilePath -- из scroll-spy (ContinuousScrollView props).
3.7. Alt+J: Next change
// Alt+J handler (реализовано в Phase 3 keyboard handler)
if (event.altKey && event.key.toLowerCase() === 'j') {
event.preventDefault();
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (view) goToNextChunk(view);
return;
}
4. Auto-viewed для каждого файла
4.1. Текущий механизм (single-file mode)
В CodeMirrorDiffView.tsx:
endSentinelRef-- невидимый<div>после editor- IntersectionObserver с
threshold: 1.0 - При пересечении вызывается
onFullyViewed()callback - В ChangeReviewDialog:
handleFullyViewed->markViewed(selectedReviewFilePath)
4.2. Continuous mode: per-file sentinel
Каждый FileSectionDiff содержит свой sentinel для auto-viewed:
// FileSectionDiff.tsx
const endSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!endSentinelRef.current || !autoViewed) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
onFullyViewed(filePath);
}
}
},
{ threshold: 0.85 } // НЕ 1.0 — portionCollapse может компактить файл
);
observer.observe(endSentinelRef.current);
return () => observer.disconnect();
}, [filePath, autoViewed, onFullyViewed]);
return (
<div>
<CodeMirrorDiffView ... />
{/* Sentinel для auto-viewed detection */}
<div ref={endSentinelRef} className="h-px shrink-0" />
</div>
);
4.3. Threshold: 0.85 вместо 1.0
Обоснование:
threshold: 1.0означает "100% элемента видимо". Для sentinel в 1px это работает.- Но в continuous mode sentinel может быть в viewport из-за подскролла следующего файла, пока текущий файл ещё не полностью просмотрен.
- Решение: sentinel размещаем ПОСЛЕ CodeMirrorDiffView внутри FileSectionDiff. Threshold 0.85 дает некоторый margin для portionCollapse, который может сильно уменьшить высоту файла.
- Sentinel для 1px элемента с threshold 0.85 сработает, когда sentinel "почти полностью" видим -- это надежно.
4.4. onFullyViewed callback в ContinuousScrollView
// ContinuousScrollView.tsx
const handleFileFullyViewed = useCallback((filePath: string) => {
if (autoViewed && !isViewed(filePath)) {
markViewed(filePath);
}
}, [autoViewed, isViewed, markViewed]);
Передается каждому FileSectionDiff:
<FileSectionDiff
filePath={file.filePath}
autoViewed={autoViewed}
onFullyViewed={handleFileFullyViewed}
// ...
/>
4.5. Отличие от single-file mode
В single-file mode за один скролл пользователь видит один файл. В continuous mode несколько файлов могут быть "viewed" за один скролл. Это корректное поведение:
- Маленькие файлы (1-5 строк diff) мгновенно проскакивают viewport
- Их sentinel пересекается с viewport -> onFullyViewed срабатывает
markViewed()идемпотентен (useViewedFiles проверяет через Set)
4.6. autoViewed toggle
Toggle в toolbar контролирует глобальный autoViewed state. Когда выключен:
- IntersectionObserver все ещё работает, но
handleFileFullyViewedпроверяетautoViewedflag и делает early return - Альтернатива: не создавать IntersectionObserver при
autoViewed === false(более оптимально)
Предпочтительная реализация (оптимизированная):
// FileSectionDiff.tsx
useEffect(() => {
if (!endSentinelRef.current || !autoViewed) return;
// Observer создается только когда autoViewed=true
// ...
}, [filePath, autoViewed, onFullyViewed]);
5. Модификация ReviewToolbar.tsx
5.1. Accept All / Reject All -- все файлы
Текущие tooltip:
- "Accept all changes in current file"
- "Reject all changes in current file"
В continuous mode:
- "Accept all changes across all files"
- "Reject all changes across all files"
Реализация: ReviewToolbar получает новый prop isContinuousMode:
interface ReviewToolbarProps {
stats: { pending: number; accepted: number; rejected: number };
changeStats: ChangeStats;
collapseUnchanged: boolean;
applying: boolean;
autoViewed: boolean;
onAutoViewedChange: (auto: boolean) => void;
onAcceptAll: () => void;
onRejectAll: () => void;
onApply: () => void;
onCollapseUnchangedChange: (collapse: boolean) => void;
editedCount?: number;
/** Phase 5: continuous scroll mode -- changes tooltip text */
isContinuousMode?: boolean;
}
Tooltip:
<TooltipContent side="bottom">
{isContinuousMode
? 'Accept all changes across all files'
: 'Accept all changes in current file'}
</TooltipContent>
5.2. Progress indicator: "12 of 45 changes reviewed"
Новый UI элемент между change stats и action buttons.
// ReviewToolbar.tsx — новый prop
interface ReviewToolbarProps {
// ...
/** Total hunks reviewed (accepted + rejected) */
reviewedCount?: number;
/** Total hunks across all files */
totalHunks?: number;
}
Вычисление в ChangeReviewDialog:
const reviewProgress = useMemo(() => {
if (!activeChangeSet) return { reviewed: 0, total: 0 };
let total = 0;
let reviewed = 0;
for (const file of activeChangeSet.files) {
for (let i = 0; i < file.snippets.length; i++) {
total++;
const key = `${file.filePath}:${i}`;
const decision = hunkDecisions[key];
if (decision === 'accepted' || decision === 'rejected') {
reviewed++;
}
}
}
return { reviewed, total };
}, [activeChangeSet, hunkDecisions]);
Отображение в ReviewToolbar:
{/* Progress indicator */}
{totalHunks !== undefined && totalHunks > 0 && (
<div className="flex items-center gap-2 text-xs">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-zinc-700/50">
<div
className="h-full rounded-full bg-blue-500/70 transition-all duration-300"
style={{ width: `${totalHunks > 0 ? (reviewedCount! / totalHunks) * 100 : 0}%` }}
/>
</div>
<span className="text-text-muted">
{reviewedCount} of {totalHunks} reviewed
</span>
</div>
)}
Позиция в toolbar: после change stats (+N -M across K files), перед separator (<div className="h-4 w-px bg-border" />).
5.3. Итоговый layout toolbar (слева направо)
- Decision stats badges (pending, accepted, rejected)
- Change stats (+N -M across K files)
- Review progress bar ("12 of 45 reviewed")
flex-1spacer- Collapse toggle
- Auto-viewed toggle
- Separator
- Edited count badge (если есть)
- Separator (если есть edited)
- Accept All button
- Reject All button
- Apply button
6. Модификация ChangeReviewDialog.tsx
6.1. handleAcceptAll -- все файлы
Текущая реализация:
const handleAcceptAll = useCallback(() => {
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}, [selectedReviewFilePath, acceptAllFile]);
Continuous mode:
const handleAcceptAll = useCallback(() => {
if (isContinuousMode) {
// 1. Store: пометить все hunks во всех файлах как accepted
acceptAll(); // store action — уже помечает ВСЕ файлы
// 2. CM: применить acceptAllChunks к каждому EditorView
const map = continuousScrollRef.current?.getEditorViewMap();
if (map) {
const views = Array.from(map.values());
// Batch: используем requestAnimationFrame для предотвращения layout thrashing
requestAnimationFrame(() => {
for (const view of views) {
acceptAllChunks(view);
}
});
}
} else {
// Single-file mode (без изменений)
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}
}, [isContinuousMode, acceptAll, selectedReviewFilePath, acceptAllFile]);
6.2. handleRejectAll -- все файлы
const handleRejectAll = useCallback(() => {
if (isContinuousMode) {
// 1. Store: пометить все hunks во всех файлах как rejected
rejectAll(); // store action
// 2. CM: применить rejectAllChunks к каждому EditorView
const map = continuousScrollRef.current?.getEditorViewMap();
if (map) {
const views = Array.from(map.values());
requestAnimationFrame(() => {
for (const view of views) {
rejectAllChunks(view);
}
});
}
} else {
const view = editorViewRef.current;
if (view) rejectAllChunks(view);
if (selectedReviewFilePath) rejectAllFile(selectedReviewFilePath);
}
}, [isContinuousMode, rejectAll, selectedReviewFilePath, rejectAllFile]);
6.3. handleSaveFile -- по activeFilePath
const handleSaveFile = useCallback((filePath: string) => {
void saveEditedFile(filePath);
}, [saveEditedFile]);
// Для toolbar/keyboard: сохраняет activeFilePath
const handleSaveActiveFile = useCallback(() => {
if (isContinuousMode) {
// activeFilePath определяется scroll-spy в ContinuousScrollView
// Передается через state или callback
const activePath = continuousScrollActiveFilePath;
if (activePath) handleSaveFile(activePath);
} else {
if (selectedReviewFilePath) handleSaveFile(selectedReviewFilePath);
}
}, [isContinuousMode, continuousScrollActiveFilePath, selectedReviewFilePath, handleSaveFile]);
6.4. handleDiscardFile -- per-file
const handleDiscardFile = useCallback((filePath: string) => {
// В continuous mode editorStateCache НЕ используется
// (все editors живут одновременно — cache не нужен, см. Phase 1 секция 4.3)
discardFileEdits(filePath);
setDiscardCounters(prev => ({
...prev,
[filePath]: (prev[filePath] ?? 0) + 1
}));
}, [discardFileEdits]);
// Для keyboard/toolbar: discard activeFilePath
const handleDiscardActiveFile = useCallback(() => {
const activePath = isContinuousMode
? continuousScrollActiveFilePath
: selectedReviewFilePath;
if (activePath) handleDiscardFile(activePath);
}, [isContinuousMode, continuousScrollActiveFilePath, selectedReviewFilePath, handleDiscardFile]);
Важно: editorStateCache не используется в continuous mode. Phase 1 (секция 4.3) устанавливает, что в continuous mode все editors живут одновременно и нет необходимости в кеше EditorState. Discard реализуется через discardCounters (пересоздание через key).
6.5. isContinuousMode state
// ChangeReviewDialog.tsx
// Phase 5: continuous scroll mode
// Вычисляется, не является toggle:
const isContinuousMode = (activeChangeSet?.files.length ?? 0) > 1;
Решение: isContinuousMode вычисляется, не является toggle. Continuous mode включается когда файлов > 1. Для одного файла -- обычный single-file mode (без ContinuousScrollView).
6.6. activeFilePath из ContinuousScrollView
ContinuousScrollView определяет видимый файл через scroll-spy и сообщает родителю:
// ContinuousScrollView.tsx props
interface ContinuousScrollViewProps {
// ...
onActiveFileChange: (filePath: string) => void;
}
В ChangeReviewDialog:
const [continuousScrollActiveFilePath, setContinuousScrollActiveFilePath] = useState<string | null>(null);
<ContinuousScrollView
onActiveFileChange={setContinuousScrollActiveFilePath}
// ...
/>
7. Per-file discard counter
7.1. Проблема
Текущий discardCounter -- одно число для всего диалога. При discard оно инкрементируется, и CodeMirrorDiffView пересоздается через key={filePath}:${discardCounter}.
В continuous mode каждый файл имеет свой CodeMirrorDiffView. Инкремент общего counter пересоздаст ВСЕ EditorView -- это неэффективно и потеряет scroll position.
7.2. Решение: Record<string, number>
// ChangeReviewDialog.tsx
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
7.3. Использование в FileSectionDiff key
// ContinuousScrollView.tsx — передает counter каждому FileSectionDiff
{files.map(file => (
<FileSectionDiff
key={`${file.filePath}:${discardCounters[file.filePath] ?? 0}`}
filePath={file.filePath}
discardCounter={discardCounters[file.filePath] ?? 0}
// ...
/>
))}
Внутри FileSectionDiff, CodeMirrorDiffView:
<CodeMirrorDiffView
key={`${filePath}:${discardCounter}`}
// ...
/>
7.4. Discard action
const handleDiscardFile = useCallback((filePath: string) => {
// 1. Удаляем edited content из store
discardFileEdits(filePath);
// 2. Инкрементируем counter ТОЛЬКО для этого файла
setDiscardCounters(prev => ({
...prev,
[filePath]: (prev[filePath] ?? 0) + 1,
}));
}, [discardFileEdits]);
Результат: пересоздается ТОЛЬКО EditorView для конкретного файла. Все остальные EditorViews сохраняют состояние.
7.5. Обратная совместимость
Для single-file mode (когда ContinuousScrollView не используется) сохраняется существующий discardCounter: number без изменений. discardCounters: Record<string, number> используется только в continuous mode. Оба варианта сосуществуют в ChangeReviewDialog:
// Single-file mode: существующий counter
const [discardCounter, setDiscardCounter] = useState(0);
// Continuous mode: per-file counters
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
8. Cleanup при закрытии
8.1. EditorView Map
При unmount ContinuousScrollView:
- Каждый FileSectionDiff вызывает
onEditorViewReady(filePath, null)(единый callback) - Map автоматически очищается
- EditorView.destroy() вызывается внутри CodeMirrorDiffView cleanup
// ContinuousScrollView.tsx
useEffect(() => {
return () => {
// Safety: на случай если unmount происходит до cleanup дочерних
editorViewMapRef.current.clear();
};
}, []);
8.2. Store state
clearChangeReview() из changeReviewSlice уже сбрасывает:
activeChangeSethunkDecisionsfileDecisionsfileContentsfileContentsLoadingeditedContentsapplyingapplyError
Дополнительных действий не требуется.
8.3. Viewed state
viewedSet persistent через localStorage (useViewedFiles -> diffViewedStorage). НЕ очищается при закрытии диалога -- это намеренное поведение (пользователь может закрыть и открыть диалог, и viewed файлы останутся).
8.4. discardCounters
React state -- автоматически GC при unmount компонента. Не persistent.
9. Edge-cases
9.1. 50 EditorViews в памяти
Проблема: каждый EditorView -- DOM-элемент с syntax highlighting, diff computations, merge extensions.
Смягчение:
- portionCollapse (Phase 4) минимизирует видимый контент: свёрнутые regions не рендерят DOM-ноды
- Lazy loading (Phase 2) гарантирует, что контент загружается по мере необходимости, а не все сразу
Если профилирование покажет проблемы:
- Будущая оптимизация: destroy EditorView для файлов далеко за пределами viewport
onEditorViewReady(filePath, null)уже в интерфейсе -- переход на destroy/recreate модель не потребует изменения API- Placeholder вместо destroyed EditorView (высота сохраняется через cached
scrollHeight)
Реализация (не в Phase 5, на будущее):
// Идея: IntersectionObserver с rootMargin для pre-destroy
const DESTROY_MARGIN = '2000px'; // destroy если > 2000px от viewport
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
const filePath = entry.target.dataset.filePath!;
if (entry.isIntersecting) {
// Восстановить EditorView
} else {
// Destroy EditorView, сохранить высоту
}
}
},
{ rootMargin: DESTROY_MARGIN }
);
9.2. Accept All + 50 файлов
Проблема: acceptAllChunks на 50 EditorView может вызвать layout thrashing.
Решение:
// Batch: один rAF на все view updates
requestAnimationFrame(() => {
const map = continuousScrollRef.current?.getEditorViewMap();
if (!map) return;
for (const view of map.values()) {
acceptAllChunks(view);
}
});
Это группирует все DOM-мутации в один frame. CodeMirror batches DOM updates внутри dispatch(), так что 50 dispatches в одном rAF -- приемлемо.
Store: acceptAll() уже batched -- одна транзакция set() обновляет все hunkDecisions и fileDecisions.
9.3. Cmd+Y/N без видимых chunks
Сценарий: все chunks в текущем файле уже accepted/rejected. Пользователь нажимает Cmd+Y.
Поведение: acceptChunk(view) от @codemirror/merge не делает ничего, если нет chunk под cursor. goToNextChunk(view) аналогично -- no-op.
Это корректно. Не нужен дополнительный feedback (звук, toast и т.д.).
9.4. File save в continuous mode
Сценарий: пользователь нажимает Cmd+Enter. Сохраняется только activeFilePath, НЕ все отредактированные файлы.
Обоснование:
- Пользователь ожидает "save THIS file", не "save ALL files"
- Для массового save есть Apply All Changes
- Если добавить "Save All Edited" -- это отдельная фича (не в Phase 5)
9.5. Scroll position после Accept All / Reject All
Проблема: Accept All может значительно изменить высоту контента (deleted chunks исчезают). Scroll position может сместиться.
Решение: браузер автоматически корректирует scroll при изменении высоты элементов ВЫШЕ viewport. Для элементов В viewport -- пользователь увидит изменение, что ожидаемо.
Если нужно сохранить позицию:
// Перед Accept All
const scrollTop = scrollContainerRef.current?.scrollTop ?? 0;
// ... apply accept all ...
requestAnimationFrame(() => {
scrollContainerRef.current?.scrollTo({ top: scrollTop });
});
Но это может быть нежелательно (пользователь хочет видеть результат). Решение: не корректировать scroll.
9.6. Race condition: onEditorViewReady + component key change
Сценарий: discard file -> key меняется -> old FileSectionDiff unmount -> new mount.
Порядок:
- Old component: cleanup effect ->
onEditorViewReady(filePath, null)-> Map.delete - New component: effect ->
onEditorViewReady(filePath, newView)-> Map.set
React гарантирует cleanup effects ПЕРЕД mount effects. Race condition невозможна.
9.7. EditorView для файла с unavailable content
Если fileContent.contentSource === 'unavailable', FileSectionDiff рендерит fallback (ReviewDiffContent), не CodeMirrorDiffView. EditorView не создается -> не попадает в Map.
При Accept All/Reject All -- файлы без EditorView обрабатываются только через store (hunkDecisions). Это корректно.
10. Проверка
10.1. Автоматические тесты
Unit tests:
| Тест | Файл | Что проверяет |
|---|---|---|
| resolveActiveEditorView с focused editor | resolveActiveEditorView.test.ts |
Возвращает focused EditorView из Map |
| resolveActiveEditorView fallback на activeFilePath | resolveActiveEditorView.test.ts |
Возвращает EditorView для activeFilePath |
| resolveActiveEditorView пустая Map | resolveActiveEditorView.test.ts |
Возвращает null |
| discardCounters per-file increment | ChangeReviewDialog.test.ts |
Инкремент только для одного файла |
| reviewProgress computation | ChangeReviewDialog.test.ts |
Корректный подсчет reviewed/total |
| ReviewToolbar tooltip в continuous mode | ReviewToolbar.test.ts |
"across all files" текст |
Integration tests:
| Тест | Что проверяет |
|---|---|
| Accept All в continuous mode | Store + все EditorViews обновлены |
| Reject All в continuous mode | Store + все EditorViews обновлены |
| Discard one file | Только один EditorView пересоздан |
| Auto-viewed multiple files | Несколько файлов помечены viewed за один скролл |
| Keyboard Cmd+Y с focused editor | Accept в focused editor, не в activeFilePath |
10.2. Ручное тестирование
Чеклист:
- Открыть review dialog с 5+ файлами
- Проскроллить вниз — auto-viewed помечает файлы по мере скролла
- Выключить auto-viewed toggle — скролл не помечает файлы
- Cmd+Y в focused editor — принимает chunk в этом editor
- Cmd+Y без фокуса — принимает chunk в activeFilePath editor
- Cmd+N — отклоняет chunk + переходит к следующему
- Cmd+Enter — сохраняет только текущий файл
- "Accept All" кнопка — все chunks во всех файлах accepted
- "Reject All" кнопка — все chunks во всех файлах rejected
- Discard файла — только этот EditorView пересоздается
- Progress bar обновляется при accept/reject
- Закрытие и повторное открытие — viewed state сохранен
- 20+ файлов — scroll не лагает
- Accept All + 20 файлов — без видимого зависания
10.3. Performance профилирование
- Chrome DevTools Performance: rAF timing при Accept All с 20 файлов (должен быть < 100ms)
- Memory: heap snapshot с 20 EditorViews (ожидание: ~50-80MB total)
- Layout: no forced synchronous layouts при scroll
Приложение: Полный diff изменений по файлам
Новые файлы
Нет новых файлов в Phase 5 (все компоненты созданы в Phase 1-4).
Модифицируемые файлы
| Файл | Изменения |
|---|---|
ContinuousScrollView.tsx |
EditorView Map, useImperativeHandle, onActiveFileChange callback |
FileSectionDiff.tsx |
onEditorViewReady(filePath, view | null) единый callback, per-file sentinel, autoViewed |
ChangeReviewDialog.tsx |
isContinuousMode, handleAcceptAll/RejectAll multi-file, discardCounters, continuousScrollActiveFilePath state, EditorView Map через ref |
ReviewToolbar.tsx |
isContinuousMode tooltip, progress indicator, reviewedCount/totalHunks props |
useDiffNavigation.ts |
Без дополнительных изменений Phase 5 — вся continuous mode логика уже реализована в Phase 3 (continuousOptions, getActiveEditorView, cross-file navigation) |
Неизменяемые файлы
| Файл | Причина |
|---|---|
CodeMirrorDiffView.tsx |
Без изменений — все обертывается через FileSectionDiff |
CodeMirrorDiffUtils.ts |
acceptAllChunks/rejectAllChunks уже поддерживают per-view вызов |
changeReviewSlice.ts |
acceptAll()/rejectAll() уже работают со всеми файлами |
useViewedFiles.ts |
markViewed() уже поддерживает per-file вызовы |
ReviewFileTree.tsx |
Без изменений в Phase 5 (модифицирован в Phase 1) |
KeyboardShortcutsHelp.tsx |
Без изменений в Phase 5 (модифицирован в Phase 3) |