- 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.
67 KiB
Фаза 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;
}
Что рендерит
-
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
-
Имя файла:
file.relativePath—text-xs font-medium text-text -
NEW badge (условный):
{file.isNewFile && ( <span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400"> NEW </span> )} -
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', }; -
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-400rejected->bg-red-500/20 text-red-400pending->bg-zinc-500/20 text-zinc-400
-
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 - Discard:
-
Кнопки обёрнуты в
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;
}
Логика рендеринга
-
Loading state: Если
isLoading— показатьFileSectionPlaceholder(из соседнего файла) -
Unavailable fallback: Если
!fileContent || fileContent.contentSource === 'unavailable'— показать<ReviewDiffContent file={file} />Важно: Также проверить
fileContent.modifiedFullContent !== null. В текущем коде (строка 523) условие:fileContent.contentSource !== 'unavailable' && fileContent.modifiedFullContent !== null. ЕслиmodifiedFullContent === null— тоже fallback на ReviewDiffContent. -
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, только показ ошибки
-
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 вместо встроенногоCodeMirrorDiffViewsentinel (см. ниже). -
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. -
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, не fallbackReviewDiffContentkeyвключает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 вместо контейнера. Решение: добавить guardif (!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.
Решение:
isProgrammaticScrollref устанавливается вtrueпередscrollIntoViewuseVisibleFileSectionпроверяет этот ref вupdateTopmostVisibleи молчитwaitForScrollEnd()(изnavigation/utils.ts) ждёт стабилизацииscrollTop(3 стабильных кадраrequestAnimationFrameсMath.abs(currentScrollTop - lastScrollTop) < 1)- После стабилизации ref сбрасывается в
false - 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: Атрибут устанавливается в двух местах:
- На section div через
registerFileSectionRef(для scroll-spy IntersectionObserver) - На 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 применить действие. Логика:
- Если EditorView имеет фокус (
view.hasFocus) — применить к нему - Иначе — применить к EditorView
activeFilePath(от scroll-spy) - 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
Что УБРАТЬ
editorViewRef(строка 88:const editorViewRef = useRef<EditorView | null>(null)) — заменён наeditorViewMapRefeditorStateCache(строка 95:const editorStateCache = useRef(new Map<string, EditorState>())) — не нужен в continuous mode (editors живут одновременно)cachedInitialState/setCachedInitialState(строка 97) — не нуженhandleSelectFile(строки 125-135) — логика сохранения EditorState в cache больше не нужна- Single-file diff area (строки 432-568) — заменён на
ContinuousScrollView selectedFileuseMemo (строки 239-242) — не нужен для выбора файла для рендеринга (но нужен для timeline sidebar — см. ниже)fileContent/isFileContentLoadingderived values (строки 244-247) — загрузка теперь в bulk при открытии- Lazy-load useEffect (строки 224-237) — заменяется на bulk-load (см. "Что ДОБАВИТЬ")
hasCurrentFileEdits(строки 120-122) — больше не нужен на уровне dialog (managed per-file в FileSectionHeader)- Import
EditorState(строка 25) — больше не используется
Что ДОБАВИТЬ
-
activeFilePathstate:const [activeFilePath, setActiveFilePath] = useState<string | null>(null); -
editorViewMapRef:const editorViewMapRef = useRef(new Map<string, EditorView>()); -
scrollContainerRef:const scrollContainerRef = useRef<HTMLDivElement>(null); -
discardCounterstate — уже есть (строка 93), оставить -
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, }); -
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 обработает. -
onSelectFileв FileTree теперь вызываетscrollToFile:const handleTreeFileClick = useCallback( (filePath: string) => { scrollToFile(filePath); }, [scrollToFile] ); -
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]); -
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) — в будущем. -
handleFullyViewed— теперь принимает filePath:const handleFullyViewed = useCallback( (filePath: string) => { if (autoViewed && !isViewed(filePath)) { markViewed(filePath); } }, [autoViewed, isViewed, markViewed] ); -
getTargetEditorViewhelper: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]); -
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]); -
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->activeFilePathonSelectFile->scrollToFileeditorViewRef-> нужен прокси 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:
- User кликает файл B в tree
handleTreeFileClick('B')->scrollToFile('B')isProgrammaticScroll.current = truesection.scrollIntoView({ behavior: 'smooth' })- Scroll анимация: файл A проскакивает мимо viewport, файл B появляется
- IntersectionObserver вызывает callback для файлов A, B, C...
useVisibleFileSection.updateTopmostVisible()проверяетisProgrammaticScroll-> молчитwaitForScrollEnd()resolve через ~300-400msisProgrammaticScroll.current = false- Debounced update (100ms) запустится при следующем IO callback — обновит activeFilePath на B
Edge case: Если пользователь кликает на другой файл пока предыдущий scroll ещё идёт:
isProgrammaticScrollостанетсяtrue- Новый
scrollIntoViewперезаписывает scroll target - Старый
waitForScrollEndpromise 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:
FileSectionDiffcleanup -> удаляет из 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 фокус может быть:
- Внутри конкретного CodeMirror (user кликнул в diff) -> CM keymap обработает
- Вне CodeMirror (user скроллит мышью) -> document keydown handler ->
getTargetEditorView()
Приоритет:
- CM keymap (если фокус в CM) — обработает и вернёт
true, event не propagates useDiffNavigationkeyboard handler проверяетevent.defaultPrevented(строка 123 в useDiffNavigation.ts) — если CM уже обработал, пропускает- Если CM не обработал —
useDiffNavigationhandler используетactiveEditorViewRef.current - 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 устанавливается:
registerFileSectionRefвuseVisibleFileSection-> на section<div>(для IntersectionObserver)- Документ ранее предлагал его на 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.currentis 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
- Добавить
activeFilePathprop вReviewFileTreeProps - Добавить
activeFilePathв inline props TreeItem и пробросить рекурсивно - Добавить
data-tree-fileattribute на 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)