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

38 KiB
Raw Permalink Blame History

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;
}

Логика приоритетов:

  1. Если пользователь кликнул в CodeMirror editor (ставит фокус) -- используем именно этот editor. document.activeElement будет внутри .cm-content элемента.
  2. Если фокус вне 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

Поток действий:

  1. resolveActiveEditorView() -> получаем EditorView
  2. acceptChunk(view) -- принимает текущий chunk в этом editor
  3. requestAnimationFrame(() => goToNextChunk(view)) -- прокручивает к следующему chunk
  4. 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 проверяет autoViewed flag и делает 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 (слева направо)

  1. Decision stats badges (pending, accepted, rejected)
  2. Change stats (+N -M across K files)
  3. Review progress bar ("12 of 45 reviewed")
  4. flex-1 spacer
  5. Collapse toggle
  6. Auto-viewed toggle
  7. Separator
  8. Edited count badge (если есть)
  9. Separator (если есть edited)
  10. Accept All button
  11. Reject All button
  12. 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:

  1. Каждый FileSectionDiff вызывает onEditorViewReady(filePath, null) (единый callback)
  2. Map автоматически очищается
  3. EditorView.destroy() вызывается внутри CodeMirrorDiffView cleanup
// ContinuousScrollView.tsx
useEffect(() => {
  return () => {
    // Safety: на случай если unmount происходит до cleanup дочерних
    editorViewMapRef.current.clear();
  };
}, []);

8.2. Store state

clearChangeReview() из changeReviewSlice уже сбрасывает:

  • activeChangeSet
  • hunkDecisions
  • fileDecisions
  • fileContents
  • fileContentsLoading
  • editedContents
  • applying
  • applyError

Дополнительных действий не требуется.

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.

Порядок:

  1. Old component: cleanup effect -> onEditorViewReady(filePath, null) -> Map.delete
  2. 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)