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.
This commit is contained in:
iliya 2026-02-25 15:39:14 +02:00
parent e4aa544f57
commit 0df816bba6
31 changed files with 7588 additions and 342 deletions

View file

@ -108,6 +108,7 @@ pnpm dist # macOS + Windows + Linux
## TODO
- [ ] Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
---

View file

@ -0,0 +1,255 @@
# Continuous Scroll Diff View -- Overview
## 1. Цель
Текущий Review Dialog показывает diff для одного файла за раз. Пользователь переключает файлы через дерево слева. Это создает трение при ревью: нужно кликать каждый файл, терять контекст между файлами, невозможно быстро пролистать все изменения.
**Continuous Scroll Diff View** -- это режим, в котором все файлы changeset-а отображаются в одном непрерывном скролле, аналогично GitHub PR diff view. Каждый файл начинается с заголовка (sticky header), за которым идет diff. Пользователь скроллит вниз и видит все изменения последовательно. File tree слева синхронизируется с текущей видимой позицией (scroll-spy), клик на файл в дереве плавно прокручивает к нему.
---
## 2. Целевой UX
### Что видит пользователь
1. Открывает Review Dialog с несколькими файлами
2. Слева -- file tree (как сейчас), справа -- непрерывный скролл всех файлов
3. Каждый файл начинается со **sticky header** (имя файла, badges, +/-) -- при скролле header "прилипает" к верху
4. Под header -- CodeMirror diff view для этого файла
5. Неизменённые регионы свёрнуты (portionCollapse), с возможностью развернуть порциями ("Expand 100" / "Expand All")
6. Файлы, контент которых ещё не загружен, показывают placeholder с skeleton
7. File tree подсвечивает текущий видимый файл (scroll-spy)
8. Клик по файлу в дереве -> плавный скролл к этому файлу
9. Cmd+Y/N accept/reject работают для видимого файла (или focused editor)
10. "Accept All" / "Reject All" применяются ко ВСЕМ файлам
11. Progress bar показывает "12 of 45 changes reviewed"
12. Auto-viewed помечает файлы по мере скролла
### Когда включается continuous mode
- Когда файлов > 1 в changeset -- continuous mode автоматически
- Когда файл один -- обычный single-file mode (без изменений)
---
## 3. Архитектурные решения
### 3.1. Почему НЕ @tanstack/react-virtual
Виртуализация (react-virtual, react-window и т.д.) работает по принципу: рендерить только элементы в viewport, остальные -- placeholder с фиксированной высотой.
**Проблема для CodeMirror:**
- CodeMirror EditorView требует реального DOM-узла для создания editor instance
- EditorView рассчитывает layout, позиции строк, viewport -- всё завязано на реальный DOM
- При "виртуализации" EditorView нужно destroy/create при входе/выходе из viewport
- destroy теряет undo history, scroll position внутри editor, cursor position
- create -- тяжёлая операция (парсинг, syntax highlighting, merge computation)
**Альтернатива: lazy loading + portionCollapse:**
- Все файлы существуют в DOM одновременно
- Но их контент загружается lazy (Phase 2)
- Неизменённые регионы свёрнуты через portionCollapse (Phase 4)
- Итог: 50 файлов в DOM, но каждый занимает минимум строк (только changed lines + margin)
### 3.2. Почему кастомный portionCollapse
CodeMirror из коробки поддерживает `collapseUnchanged` в `unifiedMergeView`:
```typescript
unifiedMergeView({
collapseUnchanged: { margin: 3, minSize: 4 }
});
```
**Проблема:** встроенный collapse -- monolithic. Кнопка "expand" раскрывает ВСЮ свёрнутую область, без возможности:
- Раскрыть порцию строк (например, 100 строк за одно нажатие)
- Раскрыть полностью по отдельной кнопке
- Показать контекст постепенно
**Решение:** кастомный `portionCollapse.ts` -- StateField + Decoration, который:
- Управляет свёрнутыми регионами как `RangeSet<Decoration>`
- Поддерживает partial expand (portionSize=100 строк за нажатие)
- Полностью заменяет встроенный collapseUnchanged
### 3.3. Lazy loading вместо виртуализации
Файлы загружают контент по мере приближения к viewport:
- IntersectionObserver с `rootMargin: '200% 0px 200% 0px'` на placeholder каждого файла
- Когда placeholder входит в расширенный viewport -- `fetchFileContent()` запускается
- Пока контент грузится -- placeholder показывает skeleton
- После загрузки -- CodeMirrorDiffView рендерится
Это даёт:
- Быстрый первичный рендер (только заголовки + placeholders)
- Предварительная загрузка за 2 viewport-высоты до видимости
- Нет потери undo history (EditorView живёт, пока диалог открыт)
---
## 4. Карта файлов
### 4.1. Новые файлы (8)
| Файл | Путь | Фаза | Ответственность |
|------|------|------|-----------------|
| `FileSectionHeader.tsx` | `src/renderer/components/team/review/FileSectionHeader.tsx` | Phase 1 | Sticky header для каждого файла: имя, badges (+/-), content source, viewed checkbox, file-level decision indicator. Использует `position: sticky; top: 0; z-index: 10`. |
| `FileSectionDiff.tsx` | `src/renderer/components/team/review/FileSectionDiff.tsx` | Phase 1 | Обёртка над CodeMirrorDiffView для одного файла в continuous scroll. Управляет lifecycle EditorView (onEditorViewReady(filePath, view \| null) единый callback), содержит sentinel для auto-viewed, передаёт все props в CodeMirrorDiffView. |
| `FileSectionPlaceholder.tsx` | `src/renderer/components/team/review/FileSectionPlaceholder.tsx` | Phase 1 | Placeholder-скелетон для файла, пока контент не загружен. Фиксированная высота (~200px). Содержит IntersectionObserver trigger для lazy loading (Phase 2). |
| `ContinuousScrollView.tsx` | `src/renderer/components/team/review/ContinuousScrollView.tsx` | Phase 1 | Главный контейнер: рендерит файлы последовательно (FileSectionHeader + FileSectionDiff/Placeholder). Хранит EditorView Map (Phase 5). useImperativeHandle для доступа к Map из родителя. Обрабатывает scroll events для scroll-spy. |
| `useVisibleFileSection.ts` | `src/renderer/hooks/useVisibleFileSection.ts` | Phase 1 | Hook для scroll-spy: IntersectionObserver определяет, какой file section сейчас виден в viewport. Возвращает `activeFilePath`. Учитывает programmatic scroll (flag `isProgrammaticScroll`). |
| `useContinuousScrollNav.ts` | `src/renderer/hooks/useContinuousScrollNav.ts` | Phase 1 | Hook для programmatic navigation: `scrollToFile(filePath)` -- плавный скролл к конкретному файлу. Использует `Element.scrollIntoView({ behavior: 'smooth' })`. Устанавливает `isProgrammaticScroll` flag для подавления scroll-spy. |
| `useLazyFileContent.ts` | `src/renderer/hooks/useLazyFileContent.ts` | Phase 2 | Hook для lazy loading контента файлов: IntersectionObserver с rootMargin для prefetch. Вызывает `fetchFileContent()` из store. Отслеживает loaded/loading state per file. |
| `portionCollapse.ts` | `src/renderer/components/team/review/portionCollapse.ts` | Phase 4 | CodeMirror StateField + Decoration для partial collapse неизменённых regions. Кнопки "Expand 100" (portionSize=100) и "Expand All". Rebuilds decorations после accept/reject. Включает `portionCollapseTheme` со стилями. |
### 4.2. Модифицируемые файлы (8)
| Файл | Путь | Фазы | Изменения |
|------|------|------|-----------|
| `ChangeReviewDialog.tsx` | `src/renderer/components/team/review/ChangeReviewDialog.tsx` | Phase 1, 3, 5 | **Phase 1:** условный рендер ContinuousScrollView vs single-file mode, убирается file header из content area. **Phase 3:** continuousOptions передаётся в useDiffNavigation (10-й параметр). **Phase 5:** handleAcceptAll/RejectAll multi-file, per-file discardCounters, continuousScrollActiveFilePath state, isContinuousMode computed, EditorView Map через ref. |
| `ReviewFileTree.tsx` | `src/renderer/components/team/review/ReviewFileTree.tsx` | Phase 1 | Highlight active file из scroll-spy (не только selected), новый prop `activeFilePath` для visual indicator (отличается от `selectedFilePath`). В continuous mode `activeFilePath` определяется scroll-spy, `selectedFilePath` не используется. |
| `CodeMirrorDiffView.tsx` | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Phase 4 | Замена встроенного `collapseUnchanged` на кастомный portionCollapse extension. Новый prop `usePortionCollapse` (boolean). Добавление portionCollapse StateField в buildExtensions() через отдельный Compartment. |
| `changeReviewSlice.ts` | `src/renderer/store/slices/changeReviewSlice.ts` | Phase 2 | Новый action `prefetchFileContents(teamName, memberName, filePaths)` -- batch-загрузка контента нескольких файлов. Вызывается из useLazyFileContent при пересечении IntersectionObserver. |
| `useDiffNavigation.ts` | `src/renderer/hooks/useDiffNavigation.ts` | Phase 3 | Новый optional param `continuousOptions?: ContinuousNavigationOptions` (10-й параметр). Внутри keyboard handler: `getActiveEditorView()` проверяет focused editor первым, затем activeFilePath, затем первый editor. Cross-file chunk navigation при достижении последнего chunk в файле. Helpers: `isLastChunkInFile()`, `isFirstChunkInFile()`. |
| `ReviewToolbar.tsx` | `src/renderer/components/team/review/ReviewToolbar.tsx` | Phase 5 | Новые props: `isContinuousMode`, `reviewedCount`, `totalHunks`. Tooltip "Accept all changes across all files" в continuous mode. Progress bar компонент. |
| `KeyboardShortcutsHelp.tsx` | `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` | Phase 3 | Новые shortcuts: Alt+K (prev change), Alt+ArrowDown/Up (next/prev file), ? (toggle help). |
| `useContinuousScrollNav.ts` | `src/renderer/hooks/useContinuousScrollNav.ts` | Phase 3 | Уточнение scrollToFile: принудительный setActiveFilePath после стабилизации scroll. |
---
## 5. Зависимости между фазами
```
Phase 4 (portionCollapse) ─────────────────────────────────────┐
(изолированный CM extension, можно параллельно с 2/3) │
Phase 1 (Continuous Scroll + Scroll-Spy) ──┬──> Phase 2 ───────┼──> Phase 5
(базовая инфраструктура) │ (Lazy Loading) │ (Polish)
│ │
├──> Phase 3 ────────┘
│ (Navigation)
└──> Phase 5
(EditorView Map + Toolbar)
```
**Детали:**
| Зависимость | Причина |
|-------------|---------|
| Phase 1 -> Phase 2 | useLazyFileContent использует IntersectionObserver на placeholder, созданном в ContinuousScrollView |
| Phase 1 -> Phase 3 | Keyboard navigation в continuous mode требует scroll infrastructure (scrollToFile) и scroll-spy (activeFilePath) |
| Phase 1 -> Phase 5 | EditorView Map живёт в ContinuousScrollView. Accept All/Reject All итерируют по Map. |
| Phase 4 (параллельно) | portionCollapse.ts -- изолированный CM StateField/Extension. Не зависит от ContinuousScrollView. Может разрабатываться и тестироваться отдельно на обычном CodeMirrorDiffView. |
| Phase 5 -> после 1-4 | Финальная полировка, интеграция всех компонентов. Требует: ContinuousScrollView (Phase 1), lazy loading (Phase 2), navigation (Phase 3), portionCollapse (Phase 4). |
**Рекомендованный порядок:**
```
Неделя 1: Phase 1 + Phase 4 (параллельно)
Неделя 2: Phase 2 + Phase 3 (параллельно, после Phase 1)
Неделя 3: Phase 5 (после всех)
```
---
## 6. Критические edge-cases
| # | Кейс | Решение | Фаза |
|---|------|---------|------|
| 1 | **Scroll-spy + programmatic scroll race:** scroll-spy определяет "не тот" файл во время programmatic scroll (scrollToFile) | `isProgrammaticScroll` ref flag. scrollToFile устанавливает flag=true. Scroll-spy игнорирует IntersectionObserver events пока flag=true. `waitForScrollEnd()` (через `scrollend` event или debounced timeout 150ms) сбрасывает flag и берёт финальный видимый файл. | Phase 1 |
| 2 | **50 EditorViews в памяти:** потенциальная проблема с памятью и производительностью при большом количестве файлов | portionCollapse минимизирует DOM-контент каждого editor (свёрнутые regions = 0 DOM-нод). Lazy loading (Phase 2) гарантирует постепенную загрузку. Если профилирование покажет проблемы -- destroy EditorViews далеко за viewport (будущая оптимизация, не в Phase 5). | Phase 5 |
| 3 | **Keyboard Cmd+Y/N -- какой editor:** несколько EditorView на экране, нужно определить целевой | Приоритет: (1) EditorView, содержащий `document.activeElement` (user clicked into it), (2) EditorView для `activeFilePath` из scroll-spy. Реализовано в `resolveActiveEditorView()`. | Phase 5 |
| 4 | **Cross-file hunk navigation:** goToNextChunk в последнем chunk файла -> нужно перейти к следующему файлу | goToNextChunk не выходит за пределы одного EditorView. Для cross-file: определить, что cursor на последнем chunk (`isLastChunkInFile()`), -> scrollToFile(nextFile) + goToNextChunk(nextView). Реализуется в useDiffNavigation Phase 3 рефакторинге. | Phase 3 |
| 5 | **portionCollapse + accept/reject:** после accept chunk-а, неизменённые regions меняются | portionCollapse rebuilds decorations через `EditorView.updateListener`. При изменении doc или original (updateOriginalDoc effect) -- декорации пересчитываются. | Phase 4 |
| 6 | **Auto-viewed threshold 0.85:** sentinel при threshold 1.0 может не срабатывать из-за collapse | Threshold 0.85 для 1px sentinel элемента. portionCollapse может значительно уменьшить высоту файла, из-за чего sentinel может быть "видим" до полного просмотра. 0.85 дает margin. Sentinel размещается ПОСЛЕ CodeMirrorDiffView. | Phase 1, 5 |
| 7 | **Lazy loading race: файл не загружен при scrollToFile** | scrollToFile прокручивает к placeholder. useLazyFileContent автоматически запустит загрузку через IntersectionObserver. Placeholder -> skeleton -> loaded diff. Пользователь видит transition. | Phase 2 |
| 8 | **Sticky header z-index stacking:** несколько sticky headers при быстром скролле | Каждый header имеет `z-index: 10`. Только один виден как sticky (ближайший к top). Следующий header "выталкивает" предыдущий. CSS `position: sticky; top: 0` с корректным stacking context. | Phase 1 |
| 9 | **Discard one file в continuous mode:** пересоздание одного EditorView не должно сломать остальные | Per-file `discardCounters: Record<string, number>`. Key FileSectionDiff: `${filePath}:${discardCounters[filePath]}`. Инкремент counter только для одного файла -> React пересоздает только этот компонент. | Phase 5 |
| 10 | **Accept All + scroll position:** Accept All меняет высоту всех editors, scroll может "прыгнуть" | Браузер корректирует scroll для элементов выше viewport автоматически. Для элементов в viewport -- пользователь видит изменения, что ожидаемо. Не корректируем scroll искусственно. | Phase 5 |
| 11 | **File с unavailable content в continuous mode** | FileSectionDiff проверяет `contentSource`. Если `unavailable` -- рендерит fallback ReviewDiffContent вместо CodeMirrorDiffView. EditorView не создается -> не попадает в Map. Accept All/Reject All для таких файлов -- только store update. | Phase 1 |
---
## 7. Чеклист верификации
Полный чеклист для тестирования после реализации всех 5 фаз.
### Phase 1: Continuous Scroll + Scroll-Spy
- [ ] ContinuousScrollView рендерит все файлы последовательно
- [ ] Sticky headers "прилипают" при скролле и корректно сменяют друг друга
- [ ] Scroll-spy определяет текущий видимый файл
- [ ] ReviewFileTree подсвечивает видимый файл (не только selected)
- [ ] Клик по файлу в tree -> плавный scroll к этому файлу
- [ ] Programmatic scroll не вызывает "мерцание" в file tree (isProgrammaticScroll flag)
- [ ] Single-file mode (1 файл) -- работает как раньше, без ContinuousScrollView
- [ ] Файлы с `unavailable` content -- показывают fallback
- [ ] Пустой changeset (0 файлов) -- сообщение "No file changes detected"
### Phase 2: Lazy Loading
- [ ] При открытии dialog загружается контент только видимых файлов (1-3 штуки)
- [ ] При скролле вниз -- файлы загружаются за 2 viewport-высоты до видимости
- [ ] Placeholder с skeleton виден пока контент грузится
- [ ] После загрузки -- placeholder заменяется CodeMirrorDiffView
- [ ] Быстрый скролл через много файлов -- не спамит запросы (MAX_CONCURRENT=3 throttle)
- [ ] Повторное посещение файла -- контент уже в кэше (store), нет повторного запроса
### Phase 3: Navigation
- [ ] Alt+J -- переход к следующему change в текущем editor
- [ ] Alt+K -- переход к предыдущему change
- [ ] Alt+ArrowDown -- переход к следующему файлу (smooth scroll)
- [ ] Alt+ArrowUp -- переход к предыдущему файлу (smooth scroll)
- [ ] Cmd+Y -- accept chunk + next chunk
- [ ] Cmd+N -- reject chunk + next chunk
- [ ] Cross-file navigation: после последнего chunk в файле -> переход к первому chunk следующего файла
- [ ] Keyboard shortcuts работают и с focused editor, и без фокуса (fallback на activeFilePath)
- [ ] ? -- toggle shortcuts help dialog
### Phase 4: Portion Collapse
- [ ] Неизменённые regions >= 10 строк свёрнуты по умолчанию (minSize=4 + margin=3 с обеих сторон = 10 строк минимум для создания collapse)
- [ ] Widget "N unchanged lines" виден на месте свёрнутого региона
- [ ] Клик "Expand 100" -- раскрывает 100 строк (portionSize=100)
- [ ] Если строк меньше portionSize -- только кнопка "Expand All" (без "Expand N")
- [ ] Клик "Expand All" -- раскрывает свёрнутый регион полностью
- [ ] Accept chunk -> decorations пересчитываются (новые неизменённые areas корректно collapse)
- [ ] Reject chunk -> decorations пересчитываются
- [ ] Работает в single-file mode (без ContinuousScrollView)
### Phase 5: Polish
- [ ] "Accept All" -> все hunks во всех файлах accepted (store + CM)
- [ ] "Reject All" -> все hunks во всех файлах rejected (store + CM)
- [ ] Tooltip "Accept all changes across all files" (не "in current file")
- [ ] Progress bar "12 of 45 reviewed" обновляется при accept/reject
- [ ] Cmd+Y с focused editor -> accept в этом editor
- [ ] Cmd+Y без фокуса -> accept в activeFilePath editor
- [ ] Cmd+Enter -> save только activeFilePath
- [ ] Discard файла -> только этот EditorView пересоздается
- [ ] Auto-viewed помечает файлы по мере скролла (multiple files per scroll)
- [ ] Auto-viewed toggle off -> скролл не помечает файлы
- [ ] Закрытие dialog -> viewed state сохранён (persistent localStorage)
- [ ] 20+ файлов -- нет видимых лагов при scroll/accept all
### Cross-cutting
- [ ] Escape закрывает dialog
- [ ] Typecheck: `pnpm typecheck` проходит без ошибок
- [ ] Lint: `pnpm lint:fix` без warnings
- [ ] Тесты: `pnpm test` все проходят
- [ ] Нет регрессий в single-file mode
- [ ] macOS: traffic light padding корректен
- [ ] Dark/light theme: все CSS variables работают
---
## 8. Ссылки на файлы фаз
- [Phase 1: Continuous Scroll + Scroll-Spy](./phase-1-continuous-scroll-and-scroll-spy.md)
- [Phase 2: Lazy Loading](./phase-2-lazy-loading.md)
- [Phase 3: Navigation](./phase-3-navigation.md)
- [Phase 4: Portion Collapse](./phase-4-portion-collapse.md)
- [Phase 5: Polish + EditorView Map + Toolbar](./phase-5-polish.md)

View file

@ -0,0 +1,790 @@
# Фаза 2: Lazy Loading контента
## 1. Обзор
**Предпосылка:** В фазе 1 continuous scroll рендерит все файлы одновременно. Но контент файлов (`FileChangeWithContent`) загружается через IPC-вызов `fetchFileContent(teamName, memberName, filePath)` — это сетевой запрос к main process, который читает файл с диска, строит diff и возвращает `originalFullContent` + `modifiedFullContent`.
**Проблема:** При открытии review с 30+ файлами загрузка всех сразу:
- Блокирует main process 30 последовательными IPC-вызовами
- UI показывает 30 skeleton placeholders одновременно
- Пользователь видит контент только после загрузки всех файлов
- Для больших файлов (>10K строк) задержка ощутима
**Решение:** Lazy loading — контент загружается по мере приближения файла к viewport:
- Первые 5 файлов предзагружаются при mount (без ожидания scroll)
- Остальные файлы загружаются при пересечении rootMargin "200% 0px" (2 viewport-высоты до видимости)
- Максимум 3 параллельных загрузки (throttle) — не перегружать main process
- Приоритет: файлы ближе к viewport загружаются раньше
**Результат:** Пользователь видит первые файлы через ~200ms, остальные подгружаются бесшовно при скролле.
---
## 2. Новые файлы
### 2.1. `useLazyFileContent.ts`
**Путь:** `src/renderer/hooks/useLazyFileContent.ts`
**Назначение:** IntersectionObserver-based lazy loading контента файлов через `fetchFileContent` из changeReviewSlice.
#### Interface
```typescript
import type { RefObject } from 'react';
import type { FileChangeWithContent } from '@shared/types';
interface UseLazyFileContentOptions {
/** Имя команды (для fetchFileContent) */
teamName: string;
/** Имя участника (для fetchFileContent) */
memberName: string | undefined;
/** Список всех filePath в порядке рендеринга */
filePaths: string[];
/** Scroll container ref (ContinuousScrollView outer div) */
scrollContainerRef: RefObject<HTMLElement>;
/**
* Загруженный контент из store (для проверки: уже загружен?).
* Тип: Record<string, FileChangeWithContent> из changeReviewSlice.
*/
fileContents: Record<string, FileChangeWithContent>;
/** Флаги загрузки из store (для проверки: уже грузится?) */
fileContentsLoading: Record<string, boolean>;
/**
* Функция загрузки контента из store.
* Сигнатура точно как в changeReviewSlice.fetchFileContent:
* (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>
*
* Внутри store уже есть guard от дубликатов (строка 264):
* if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
* Поэтому двойной вызов безопасен.
*/
fetchFileContent: (
teamName: string,
memberName: string | undefined,
filePath: string
) => Promise<void>;
/** Lazy loading включён (false = загрузить всё сразу, для fallback) */
enabled: boolean;
}
interface UseLazyFileContentReturn {
/**
* Регистрация file section для lazy-load наблюдения.
* Возвращает ref callback — передать в div section.
* Пример: <div ref={registerLazyRef(file.filePath)}>
*/
registerLazyRef: (filePath: string) => (element: HTMLElement | null) => void;
}
```
#### Полная реализация (описание)
```typescript
export function useLazyFileContent(
options: UseLazyFileContentOptions
): UseLazyFileContentReturn {
const {
teamName,
memberName,
filePaths,
scrollContainerRef,
fileContents,
fileContentsLoading,
fetchFileContent,
enabled,
} = options;
// === Throttle State ===
// Set: filePath текущих in-flight загрузок
const activeLoads = useRef(new Set<string>());
// Queue: filePath ожидающих загрузки (FIFO, но с приоритетом)
const pendingQueue = useRef<string[]>([]);
// Max параллельных загрузок
const MAX_CONCURRENT = 3;
// Observer ref
const observerRef = useRef<IntersectionObserver | null>(null);
// Element refs
const elementRefs = useRef(new Map<string, HTMLElement>());
// Stable refs для текущих значений (избежание stale closures)
const fileContentsRef = useRef(fileContents);
const fileContentsLoadingRef = useRef(fileContentsLoading);
useEffect(() => {
fileContentsRef.current = fileContents;
fileContentsLoadingRef.current = fileContentsLoading;
}, [fileContents, fileContentsLoading]);
// === Throttled Loader ===
/**
* Проверяет, нужно ли загружать filePath:
* - Не загружен (нет в fileContents)
* - Не грузится (нет в fileContentsLoading или false)
* - Не в activeLoads (не in-flight)
*
* ВАЖНО: проверяем fileContentsRef (ref), а не fileContents (prop) —
* чтобы callback IntersectionObserver видел актуальное состояние.
*/
const shouldLoad = useCallback((filePath: string): boolean => {
if (fileContentsRef.current[filePath]) return false;
if (fileContentsLoadingRef.current[filePath]) return false;
if (activeLoads.current.has(filePath)) return false;
return true;
}, []);
/**
* Запустить загрузку одного файла.
* Возвращает Promise (для chaining).
*/
const loadFile = useCallback(
async (filePath: string): Promise<void> => {
if (!shouldLoad(filePath)) return;
activeLoads.current.add(filePath);
try {
await fetchFileContent(teamName, memberName, filePath);
} finally {
activeLoads.current.delete(filePath);
// После завершения — попробовать следующий из очереди
processQueue();
}
},
[teamName, memberName, fetchFileContent, shouldLoad]
);
/**
* Обработать очередь: запустить загрузки пока slots < MAX_CONCURRENT.
*/
const processQueue = useCallback(() => {
while (
activeLoads.current.size < MAX_CONCURRENT &&
pendingQueue.current.length > 0
) {
const nextPath = pendingQueue.current.shift()!;
if (shouldLoad(nextPath)) {
void loadFile(nextPath);
}
// Если nextPath уже не нужен (загружен за время ожидания) — пропускаем, берём следующий
}
}, [shouldLoad, loadFile]);
/**
* Добавить filePath в очередь загрузки.
* Если есть свободные слоты — загрузить сразу.
* Если нет — добавить в pending queue.
*/
const enqueueLoad = useCallback(
(filePath: string) => {
if (!shouldLoad(filePath)) return;
if (activeLoads.current.size < MAX_CONCURRENT) {
// Есть свободный слот — загружаем сразу
void loadFile(filePath);
} else {
// Очередь заполнена — добавить в pending (если ещё нет)
if (!pendingQueue.current.includes(filePath)) {
pendingQueue.current.push(filePath);
}
}
},
[shouldLoad, loadFile]
);
// === Preload первых N файлов при mount ===
const PRELOAD_COUNT = 5;
useEffect(() => {
if (!enabled) return;
// Загрузить первые 5 файлов сразу
const toPreload = filePaths.slice(0, PRELOAD_COUNT);
for (const fp of toPreload) {
enqueueLoad(fp);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]); // Намеренно: только при mount (enabled = true)
// === IntersectionObserver ===
useEffect(() => {
if (!enabled) return;
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const filePath = entry.target.getAttribute('data-lazy-file');
if (!filePath) continue;
enqueueLoad(filePath);
// После загрузки — перестать наблюдать (загружается один раз)
// Но мы не можем unobserve сразу (загрузка async) — unobserve когда контент загружен
// Проще: observer продолжает наблюдать, shouldLoad() вернёт false для загруженных
}
},
{
root: scrollContainerRef.current,
// 200% от viewport сверху и снизу — предзагрузка за 2 экрана
rootMargin: '200% 0px 200% 0px',
threshold: 0,
}
);
// Зарегистрировать все уже mounted элементы
for (const [, element] of elementRefs.current) {
observerRef.current.observe(element);
}
return () => {
observerRef.current?.disconnect();
observerRef.current = null;
};
}, [enabled, scrollContainerRef, enqueueLoad]);
// === Register ref callback ===
const registerLazyRef = useCallback((filePath: string) => {
return (element: HTMLElement | null) => {
const observer = observerRef.current;
// Cleanup previous
const prev = elementRefs.current.get(filePath);
if (prev && observer) {
observer.unobserve(prev);
}
elementRefs.current.delete(filePath);
// Register new
if (element) {
element.setAttribute('data-lazy-file', filePath);
elementRefs.current.set(filePath, element);
if (observer) {
observer.observe(element);
}
}
};
}, []);
return { registerLazyRef };
}
```
#### Ключевые аспекты
##### rootMargin "200% 0px 200% 0px"
IntersectionObserver `rootMargin` расширяет область наблюдения за пределы видимого viewport. `200%` означает 2x viewport-высоты сверху и снизу.
**Пример:** Viewport = 800px. rootMargin = 200% -> +1600px сверху и снизу. Файл начнёт загружаться когда его section находится в 1600px от видимой области.
**Почему 200%:** Smooth scroll на Chromium покрывает ~300-400px/сек. При viewport 800px пользователь доскроллит до следующей "зоны" за 2-4 секунды. Загрузка файла через IPC занимает ~50-200ms. 200% даёт достаточный запас для предзагрузки.
##### MAX_CONCURRENT = 3
**Почему 3, а не больше:**
- Electron main process обрабатывает IPC последовательно (single thread)
- Каждый `fetchFileContent` читает файл, парсит diff, возвращает контент
- 3 параллельных запроса = main process занят ~100% на файловых операциях
- Больше 3 = запросы встают в очередь IPC, но main process не ускоряется
- Бонус: оставляет "дышать" main process для других IPC (file watcher, config)
##### Preload первых 5 файлов
При mount (открытие диалога) загружаем первые 5 файлов немедленно (без ожидания IntersectionObserver).
**Почему 5:**
- Viewport обычно вмещает 2-3 file sections
- 5 = 2-3 видимых + 2 "за кадром" для плавного scroll
- Preload занимает ~200-500ms (3 параллельно + 2 в очереди)
**Timing:** Preload запускается одновременно с рендерингом DOM. К моменту первого paint IntersectionObserver ещё не успел сработать, но preload уже отправил запросы.
##### Приоритет в очереди
В текущей реализации очередь FIFO (first-in, first-out). Файлы добавляются в порядке пересечения rootMargin — ближайшие к viewport первыми.
**Возможное улучшение (если потребуется):** Реордеринг очереди при scroll event. Но FIFO достаточно для типичного use case (скролл сверху вниз).
##### Repeated observations
IntersectionObserver продолжает наблюдать все элементы, даже загруженные. Это ОК:
1. Callback вызовется для уже загруженного файла
2. `shouldLoad()` проверяет `fileContentsRef.current[filePath]` -> файл есть -> `return false`
3. `enqueueLoad` ничего не делает
Альтернатива `observer.unobserve()` после загрузки добавляет сложности (нужен callback из store, race conditions). Текущий подход проще и не имеет performance penalty (observer callback -- O(1) проверка).
---
## 3. Модификации существующих файлов
### 3.1. `changeReviewSlice.ts` -- НЕ требует изменений
**Решение: `prefetchFileContents` НЕ НУЖЕН.**
Изначально предполагался convenience-метод `prefetchFileContents` для batch-вызова. Однако при ревью обнаружено:
1. `useLazyFileContent` уже реализует preload первых 5 файлов через `enqueueLoad` в useEffect при mount -- это полностью покрывает потребность в batch preload.
2. `fetchFileContent` уже имеет внутренний guard от дубликатов (строка 262-264 в `changeReviewSlice.ts`):
```typescript
const state = get();
// Skip if already loaded or loading
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
```
3. `useLazyFileContent.enqueueLoad` добавляет поверх store guard ещё `activeLoads` ref-трекинг для throttle -- т.е. тройная защита от дубликатов.
Добавление `prefetchFileContents` в store создаст дублирование с `useLazyFileContent` preload и не даст throttle (все запросы уйдут параллельно). **Оставляем store без изменений.**
---
### 3.2. `ContinuousScrollView.tsx`
#### Интеграция `useLazyFileContent`
**Новые props (добавляются к существующим props фазы 1):**
```typescript
interface ContinuousScrollViewProps {
// ... все существующие props из фазы 1 (см. phase-1 документ) ...
// === НОВЫЕ для фазы 2 ===
/** Имя команды */
teamName: string;
/** Имя участника */
memberName: string | undefined;
/**
* Функция загрузки контента из store.
* Сигнатура: (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>
* Из changeReviewSlice.fetchFileContent
*/
fetchFileContent: (
teamName: string,
memberName: string | undefined,
filePath: string
) => Promise<void>;
}
```
**Интеграция в компоненте:**
```typescript
export const ContinuousScrollView = (props: ContinuousScrollViewProps) => {
const {
files,
fileContents,
fileContentsLoading,
teamName,
memberName,
fetchFileContent,
scrollContainerRef,
isProgrammaticScroll,
// ... rest из Phase 1
} = props;
const filePaths = useMemo(() => files.map((f) => f.filePath), [files]);
// Scroll-spy (фаза 1)
const { registerFileSectionRef } = useVisibleFileSection({
onVisibleFileChange: props.onVisibleFileChange,
scrollContainerRef,
isProgrammaticScroll,
});
// Lazy loading (фаза 2)
const { registerLazyRef } = useLazyFileContent({
teamName,
memberName,
filePaths,
scrollContainerRef,
fileContents,
fileContentsLoading,
fetchFileContent,
enabled: true,
});
// Комбинированный ref callback: регистрация в обоих observers
const combinedRef = useCallback(
(filePath: string) => {
const sectionRef = registerFileSectionRef(filePath);
const lazyRef = registerLazyRef(filePath);
return (element: HTMLElement | null) => {
sectionRef(element);
lazyRef(element);
};
},
[registerFileSectionRef, registerLazyRef]
);
// EditorView registration callback (Phase 1, без изменений)
const handleEditorViewReady = useCallback(
(filePath: string, view: EditorView | null) => {
if (view) {
props.editorViewMapRef.current.set(filePath, view);
} else {
props.editorViewMapRef.current.delete(filePath);
}
},
[props.editorViewMapRef]
);
return (
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
{files.map((file) => {
const filePath = file.filePath;
const content = fileContents[filePath] ?? null;
const isLoading = fileContentsLoading[filePath] ?? false;
const hasContent = content !== null;
return (
<div
key={filePath}
ref={combinedRef(filePath)} // <-- Комбинированный ref (Phase 1 scroll-spy + Phase 2 lazy)
className="border-b border-border"
>
<FileSectionHeader
file={file}
fileContent={content}
fileDecision={props.fileDecisions[filePath]}
hasEdits={filePath in props.editedContents}
applying={props.applying}
onDiscard={props.onDiscard}
onSave={props.onSave}
/>
{/* Контент ещё не загружен — placeholder */}
{!hasContent && isLoading && (
<FileSectionPlaceholder fileName={file.relativePath} />
)}
{/* Контент ещё не начал грузиться — тоже placeholder */}
{!hasContent && !isLoading && (
<FileSectionPlaceholder fileName={file.relativePath} />
)}
{/* Контент загружен — diff */}
{hasContent && (
<FileSectionDiff
file={file}
fileContent={content}
isLoading={false}
collapseUnchanged={props.collapseUnchanged}
onHunkAccepted={props.onHunkAccepted}
onHunkRejected={props.onHunkRejected}
onFullyViewed={props.onFullyViewed}
onContentChanged={props.onContentChanged}
onEditorViewReady={handleEditorViewReady}
discardCounter={props.discardCounter}
autoViewed={props.autoViewed}
isViewed={props.viewedSet.has(filePath)}
/>
)}
</div>
);
})}
{files.length === 0 && (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
No file changes detected
</div>
)}
</div>
);
};
```
**Отличие от Phase 1 ContinuousScrollView:** В Phase 1 `ref={registerFileSectionRef(filePath)}` использовался напрямую. В Phase 2 заменён на `ref={combinedRef(filePath)}`, который вызывает оба ref callback (scroll-spy + lazy). Phase 1 рендерил `FileSectionDiff` / `FileSectionPlaceholder` по условию `isLoading` (ternary). Phase 2 добавляет промежуточное состояние "не начал грузиться" (`!hasContent && !isLoading`).
#### Два placeholder состояния
| Состояние | `hasContent` | `isLoading` | Что показывать |
|-----------|-------------|-------------|----------------|
| Не начал грузиться | false | false | `FileSectionPlaceholder` |
| Грузится | false | true | `FileSectionPlaceholder` |
| Загружен | true | - | `FileSectionDiff` |
| Ошибка загрузки | false | false | `FileSectionPlaceholder` (потом retry) |
**Замечание:** "Не начал грузиться" -- файл ещё не попал в rootMargin IntersectionObserver. Placeholder показывается, но без индикатора загрузки. Визуально идентичен "грузится" -- это ОК, пользователь не различает.
**Ошибка загрузки:** `fetchFileContent` в store ставит `fileContentsLoading[fp] = false` (строка 279) и НЕ записывает в `fileContents` (строка 278 -- catch блок). Результат: `hasContent = false, isLoading = false` -- снова placeholder. IntersectionObserver при следующем пересечении вызовет `enqueueLoad` -- retry произойдёт автоматически (при re-scroll).
**Важно:** `shouldLoad()` в `useLazyFileContent` проверяет `fileContentsRef.current[filePath]` -- после ошибки этого ключа нет, поэтому повторный вызов пройдёт. Также `fileContentsLoadingRef.current[filePath]` будет `false` (store сбросил loading). Таким образом retry корректно сработает.
Если нужен явный retry без scroll: добавить кнопку "Retry" в placeholder. Но для фазы 2 автоматический retry через scroll достаточен.
#### `combinedRef` -- объединение двух ref callbacks
```typescript
const combinedRef = useCallback(
(filePath: string) => {
const sectionRef = registerFileSectionRef(filePath);
const lazyRef = registerLazyRef(filePath);
return (element: HTMLElement | null) => {
sectionRef(element);
lazyRef(element);
};
},
[registerFileSectionRef, registerLazyRef]
);
```
**Зачем:** Оба хука (`useVisibleFileSection`, `useLazyFileContent`) используют IntersectionObserver на одном и том же элементе (file section div). Вместо двух отдельных ref -- один объединённый.
**data attributes:** Каждый callback ставит свой атрибут:
- `registerFileSectionRef` -> `data-file-path`
- `registerLazyRef` -> `data-lazy-file`
Оба атрибута на одном элементе -- ОК, они используются разными observers.
---
### 3.3. `ChangeReviewDialog.tsx`
#### Убрать lazy-load useEffect
**Было** (строки 224-237 текущего файла):
```typescript
// Lazy-load file content when file selected
useEffect(() => {
if (!open || !selectedReviewFilePath) return;
if (fileContents[selectedReviewFilePath] || fileContentsLoading[selectedReviewFilePath]) return;
void fetchFileContent(teamName, memberName, selectedReviewFilePath);
}, [
open,
selectedReviewFilePath,
teamName,
memberName,
fileContents,
fileContentsLoading,
fetchFileContent,
]);
```
**Стало:** Удалить этот useEffect целиком. Загрузка контента теперь полностью делегирована `useLazyFileContent` внутри `ContinuousScrollView`:
- Preload первых 5 файлов при mount
- Остальные подгружаются по IntersectionObserver
#### Передать новые props в ContinuousScrollView
```tsx
<ContinuousScrollView
// ... props из фазы 1 ...
teamName={teamName}
memberName={memberName}
fetchFileContent={fetchFileContent}
/>
```
**Примечание:** `teamName` берётся из props `ChangeReviewDialogProps`, `memberName` оттуда же (optional prop). `fetchFileContent` берётся из `useStore()` (строка 77 текущего файла).
---
## 4. Throttle реализация: детали
### Структура данных
```
┌─────────────────┐
│ activeLoads │ Set<string> -- max 3 элемента
│ (in-flight) │
├─────────────────┤
│ pendingQueue │ string[] -- FIFO очередь
│ (waiting) │
└─────────────────┘
```
### Жизненный цикл загрузки
```
1. IntersectionObserver fires -> enqueueLoad(filePath)
2. shouldLoad() checks:
- fileContentsRef.current[fp]? -> skip (already loaded)
- fileContentsLoadingRef.current[fp]? -> skip (store knows about it)
- activeLoads.has(fp)? -> skip (our local tracking)
3. activeLoads.size < MAX_CONCURRENT?
-> YES: loadFile(fp) immediately
-> NO: pendingQueue.push(fp)
4. loadFile(fp):
- activeLoads.add(fp)
- await fetchFileContent(teamName, memberName, fp)
- activeLoads.delete(fp)
- processQueue() <-- проверить, есть ли ожидающие
5. processQueue():
- while (activeLoads.size < MAX_CONCURRENT && pendingQueue.length > 0)
- shift from queue, check shouldLoad, loadFile
```
### Диаграмма состояний
```
┌──────────┐
IO trigger ──> │ enqueue │
└────┬─────┘
┌────────v────────┐
│ slots available? │
└──┬─────────┬────┘
│ YES │ NO
┌──────v──┐ ┌──v───────┐
│ loadFile │ │ add to │
│ (async) │ │ pending │
└──────┬───┘ │ queue │
│ └──────────┘
┌──────v───┐ ^
│ complete │ │
└──────┬───┘ │
│ │
┌──────v────────┐ │
│ processQueue ├────┘
└───────────────┘
```
### Взаимодействие throttle с store guard
`fetchFileContent` в store (строки 261-282) имеет собственный guard:
```typescript
const state = get();
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
```
`useLazyFileContent` добавляет `activeLoads` ref поверх. Зачем два уровня защиты:
1. **Store guard** предотвращает повторный IPC-вызов для загружаемого/загруженного файла -- но работает через `get()` (синхронный snapshot). Между двумя вызовами `fetchFileContent` в одном event loop tick `fileContentsLoading` ещё не обновлён (Zustand batch).
2. **`activeLoads` ref** покрывает этот micro-timing gap -- `activeLoads.add(fp)` происходит синхронно ДО await, а `shouldLoad()` проверяет ref мгновенно.
Таким образом:
- Store guard: macro-level (между renders)
- activeLoads ref: micro-level (между тиками в одном frame)
- Оба нужны для надёжности
### Приоритет загрузки
**Текущий подход:** FIFO. IntersectionObserver вызывает callbacks в порядке пересечения rootMargin. Для типичного скролла сверху вниз это означает: верхние файлы раньше нижних.
**Потенциальное улучшение (не для фазы 2):**
Если пользователь быстро скроллит вниз (skip middle files), можно реализовать priority queue:
```typescript
// Вместо string[] использовать priority queue:
interface PendingItem {
filePath: string;
priority: number; // расстояние от viewport center
}
// При каждом scroll event -- пересчитать priority для pending items
// Ближайшие к viewport -- выше приоритет
```
Но это оверинжиниринг для фазы 2. FIFO достаточно:
- IntersectionObserver с rootMargin 200% ловит файлы рано
- 3 параллельных загрузки покрывают типичную скорость скролла
- Даже при быстром скролле -- placeholder на 100-200ms, потом контент
### Refs для stale closure prevention
```typescript
const fileContentsRef = useRef(fileContents);
const fileContentsLoadingRef = useRef(fileContentsLoading);
useEffect(() => {
fileContentsRef.current = fileContents;
fileContentsLoadingRef.current = fileContentsLoading;
}, [fileContents, fileContentsLoading]);
```
**Зачем:** `shouldLoad()` замыкает `fileContentsRef` и `fileContentsLoadingRef`. Без ref-трюка callback IntersectionObserver "видит" stale `fileContents` из момента создания observer.
**Альтернатива:** Пересоздавать IntersectionObserver при каждом изменении `fileContents`. Но это = disconnect + observe all elements заново = bad performance.
### Edge case: диалог закрыт во время загрузки
`fetchFileContent` -- async. Если пользователь закроет диалог пока идёт загрузка:
1. `ContinuousScrollView` unmounts -> `useLazyFileContent` cleanup
2. IntersectionObserver disconnect
3. Но `fetchFileContent` всё ещё in-flight в store
4. Store обновит `fileContents` / `fileContentsLoading` -- ОК, store не зависит от компонента
5. `clearChangeReview()` вызывается в useEffect cleanup `ChangeReviewDialog` (строка 189) -- сбросит all state
**Вывод:** Нет утечек и race conditions. Store корректно очищается.
### Edge case: файл уже загружен при re-open
При повторном открытии того же review:
1. `clearChangeReview()` сбрасывает `fileContents = {}` (строка 160)
2. `fetchAgentChanges()` / `fetchTaskChanges()` загружает свежий changeSet
3. `useLazyFileContent` preload + observer начинают с нуля
4. Все файлы загружаются заново (свежие данные)
### Edge case: circular dependency loadFile <-> processQueue
`loadFile` вызывает `processQueue` в finally. `processQueue` вызывает `loadFile`. Потенциальный бесконечный цикл?
Нет -- `loadFile` начинается с `if (!shouldLoad(filePath)) return;`, а `activeLoads.add(fp)` происходит синхронно. `processQueue` берёт из очереди (shift), проверяет `shouldLoad`, и вызывает `loadFile` через `void` (fire-and-forget). Каждый `loadFile` -- это новый async task, не рекурсия в call stack. Queue конечна (max = количество файлов). Цикла нет.
---
## 5. Консистентность с Phase 3
Phase 3 (Navigation) зависит от Phase 2 в следующих аспектах:
1. **EditorView Map** -- Phase 2 использует `editorViewMapRef` из Phase 1. Phase 3 использует тот же Map через `ContinuousNavigationOptions.editorViewRefs`. Важно: Phase 3 `editorViewRefs` это `Map<string, EditorView>` (value из `.current`), а Phase 2 работает с `MutableRefObject<Map>`. Нет конфликта -- Phase 3 читает из `.current` напрямую.
2. **Lazy loading + cross-file navigation** -- когда Phase 3 `goToNextFile()` делает `scrollToFile(nextFilePath)`, файл может быть ещё не загружен. IntersectionObserver с rootMargin 200% должен сработать до того как scroll доедет до файла. Если файл далеко -- placeholder покажется на ~100-200ms, потом контент подгрузится. Это приемлемый UX.
3. **activeFilePath** -- Phase 2 НЕ управляет `activeFilePath`. Scroll-spy из Phase 1 (`useVisibleFileSection`) определяет activeFilePath. Phase 2 только загружает контент. Phase 3 использует activeFilePath для определения "текущего" файла в навигации.
---
## 6. Проверка
### Функциональная проверка
- [ ] Открыть review с 10+ файлами
- [ ] Первые 5 файлов показывают контент в первые ~500ms
- [ ] Файлы 6-10 показывают placeholder, потом контент при подскролле
- [ ] Scroll вниз -- файлы подгружаются бесшовно (placeholder -> diff)
- [ ] Scroll быстро вниз -- плейсхолдеры видны на ~200ms, потом контент
- [ ] Scroll обратно вверх -- уже загруженные файлы показывают diff мгновенно
- [ ] Кликнуть на файл 15 в tree -> smooth scroll + контент загружается
### Throttle проверка
- [ ] Открыть DevTools Network tab (или console log)
- [ ] Убедиться: максимум 3 одновременных IPC-вызова `getFileContent`
- [ ] Остальные ждут в очереди и выполняются последовательно по 3
### Edge cases
- [ ] 0 файлов -- нет ошибок в console
- [ ] 1 файл -- загружается мгновенно (preload)
- [ ] Файл с ошибкой загрузки (main process throw) -- placeholder остаётся, scroll retry работает
- [ ] Закрыть диалог во время загрузки -- нет ошибок, store очищен
- [ ] Переоткрыть диалог -- все файлы загружаются заново
### Performance
- [ ] 30 файлов -- UI не зависает при открытии
- [ ] Main process responsive (file watcher работает) во время загрузки 30 файлов
- [ ] Memory: placeholder -> diff transition не утекает (EditorView create/destroy)
- [ ] Scroll FPS > 30 при 20+ загруженных CodeMirror editors

View file

@ -0,0 +1,996 @@
# Phase 3: Click-to-Scroll + Навигация
## Обзор
Фаза 3 адаптирует навигацию для continuous scroll mode. В текущей реализации (file-at-a-time) каждый файл показывается отдельно: `goToNextFile()` вызывает `onSelectFile()`, который уничтожает текущий EditorView и создаёт новый. В continuous mode все файлы видны одновременно в одном scroll container, поэтому навигация переключается на программный scroll.
**Ключевые изменения:**
- Клик по файлу в sidebar = smooth scroll к секции файла (вместо уничтожения/создания editor)
- Keyboard shortcuts (Alt+ArrowDown/Up) = scroll к следующему/предыдущему файлу
- Cross-file hunk navigation: при достижении последнего hunk файла -- автоматический scroll к следующему файлу
- `useDiffNavigation` работает с `Map<string, EditorView>` вместо одного `editorViewRef`
- Публичный интерфейс `DiffNavigationState` НЕ меняется -- изменяется только внутренняя реализация
**Зависимости:** Phase 1 (ContinuousScrollView, useVisibleFileSection, useContinuousScrollNav) и Phase 2 (lazy loading, EditorView Map из Phase 1).
---
## Модификации
### 1. useDiffNavigation.ts -- полная переработка для continuous mode
**Файл:** `src/renderer/hooks/useDiffNavigation.ts`
#### Текущая сигнатура (без изменений)
```typescript
import { useCallback, useEffect, useRef, useState } from 'react';
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
import type { EditorView } from '@codemirror/view';
import type { FileChangeSummary } from '@shared/types/review';
// --- Return interface НЕ МЕНЯЕТСЯ ---
interface DiffNavigationState {
currentHunkIndex: number;
totalHunks: number;
goToNextHunk: () => void;
goToPrevHunk: () => void;
goToNextFile: () => void;
goToPrevFile: () => void;
goToHunk: (index: number) => void;
acceptCurrentHunk: () => void;
rejectCurrentHunk: () => void;
showShortcutsHelp: boolean;
setShowShortcutsHelp: (show: boolean) => void;
}
```
#### Новый optional параметр continuousOptions
```typescript
// --- НОВАЯ сигнатура (расширение, backward compatible) ---
export function 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,
continuousOptions?: ContinuousNavigationOptions // <-- НОВЫЙ 10-й параметр
): DiffNavigationState;
```
**Важно:** НЕ используем overloads. Один вариант сигнатуры с optional 10-м параметром. Overloads здесь избыточны -- `continuousOptions` опционален, TypeScript корректно проверяет типы без overload.
#### Новый тип ContinuousNavigationOptions
```typescript
interface ContinuousNavigationOptions {
/**
* Map всех EditorView по filePath. Заполняется в ContinuousScrollView.
* Это НЕ ref -- передаётся сам Map (через .current снаружи).
* Передаётся как value, но мутируется извне (Map reference стабильна).
*/
editorViewRefs: Map<string, EditorView>;
/**
* Текущий видимый файл из scroll-spy (Phase 1 useVisibleFileSection).
* НЕ selectedFilePath -- это activeFilePath.
* Обновляется при скролле.
*/
activeFilePath: string | null;
/**
* Программный scroll к секции файла из useContinuousScrollNav (Phase 1).
* Вызывает scrollIntoView + подавление scroll-spy.
*/
scrollToFile: (filePath: string) => void;
/** Флаг continuous mode -- определяет какую логику использовать. */
enabled: boolean;
}
```
**Дизайн-решение:** Вместо создания отдельного хука (useContinuousDiffNavigation), расширяем существующий через optional 10-й параметр `continuousOptions`. Это позволяет:
1. Не дублировать keyboard handler логику
2. Постепенно мигрировать: `ChangeReviewDialog` просто передаёт `continuousOptions` когда continuous mode включён
3. Сохранить обратную совместимость -- без `continuousOptions` хук работает как раньше
#### Внутренняя реализация -- helper: getActiveEditorView()
```typescript
/**
* Определяет "активный" EditorView для навигации.
*
* Приоритет:
* 1. Focused editor -- если какой-то CM editor сейчас имеет фокус
* 2. activeFilePath editor -- editor файла, определённого scroll-spy как видимый
* 3. Fallback: первый editor в Map
*
* В legacy mode: просто возвращает editorViewRef.current.
*/
function getActiveEditorView(
editorViewRef: React.RefObject<EditorView | null>,
continuousOptions?: ContinuousNavigationOptions
): EditorView | null {
// Legacy mode
if (!continuousOptions?.enabled) {
return editorViewRef.current;
}
const { editorViewRefs, activeFilePath } = continuousOptions;
// 1. Focused editor -- используем view.hasFocus (CM API)
for (const [, view] of editorViewRefs) {
if (view.hasFocus) return view;
}
// 2. activeFilePath editor
if (activeFilePath) {
const view = editorViewRefs.get(activeFilePath);
if (view) return view;
}
// 3. Fallback: первый editor
const firstEntry = editorViewRefs.values().next();
return firstEntry.done ? null : firstEntry.value;
}
```
**ИСПРАВЛЕНИЕ:** Оригинальный вариант использовал `document.activeElement.closest('.cm-editor')` + сравнение с `view.dom`. Это ненадёжно -- CM editor может содержать nested elements, и `closest` не всегда корректно разрешает до внешнего `.cm-editor`. Используем встроенный `view.hasFocus` -- это официальный CM API для проверки фокуса.
#### Внутренняя реализация -- helper: getActiveFilePath()
```typescript
/**
* Определяет путь активного файла для контекста навигации.
*
* В continuous mode: activeFilePath из scroll-spy.
* В legacy mode: selectedFilePath.
*/
function getActiveFilePath(
selectedFilePath: string | null,
continuousOptions?: ContinuousNavigationOptions
): string | null {
if (continuousOptions?.enabled && continuousOptions.activeFilePath) {
return continuousOptions.activeFilePath;
}
return selectedFilePath;
}
```
#### Внутренняя реализация -- helper: getFilePathForView()
```typescript
/**
* Находит filePath для данного EditorView в Map.
* Нужно для определения "в каком файле мы сейчас" при focused editor.
*/
function getFilePathForView(
view: EditorView,
editorViewRefs: Map<string, EditorView>
): string | null {
for (const [filePath, v] of editorViewRefs) {
if (v === view) return filePath;
}
return null;
}
```
#### Внутренняя реализация -- helpers: isLastChunkInFile() / isFirstChunkInFile()
```typescript
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
```
**ВАЖНО: API `getChunks`.**
`getChunks` реэкспортируется из `@codemirror/merge`. Сигнатура:
```typescript
function getChunks(state: EditorState): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null;
```
Где `Chunk` имеет поля:
- `fromA`, `toA` -- диапазон в original document (side A)
- `fromB`, `toB` -- диапазон в modified document (side B)
- `changes` -- внутренние изменения
В `unifiedMergeView` (которую мы используем) side всегда `"b"`. Позиции курсора соответствуют side B.
```typescript
/**
* Проверяет, находится ли курсор на последнем chunk файла.
* Нужно для cross-file navigation: если на последнем chunk -- scroll к следующему файлу.
*
* Алгоритм:
* 1. Получаем chunks из CM state через getChunks()
* 2. Определяем текущую позицию курсора (view.state.selection.main.head)
* 3. Проверяем: курсор находится в или после последнего chunk
*
* ВАЖНО: goToNextChunk -- это StateCommand. Возвращает boolean:
* - true: перешёл к следующему chunk (dispatch вызван)
* - false: нет chunks в документе ИЛИ только один chunk и курсор уже в нём
*
* goToNextChunk возвращает false НЕ когда "нет больше chunks после текущего",
* а когда chunks.length === 0 или chunks.length === 1 && cursor уже в нём.
* При >1 chunks goToNextChunk ВСЕГДА возвращает true (циклическая навигация!).
*
* Поэтому мы НЕ можем полагаться на return value goToNextChunk для определения
* "последний ли это chunk". Нужна отдельная проверка через getChunks().
*/
function isLastChunkInFile(view: EditorView): boolean {
const result = getChunks(view.state);
if (!result || result.chunks.length === 0) return true;
const cursorPos = view.state.selection.main.head;
const chunks = result.chunks;
const lastChunk = chunks[chunks.length - 1];
// Курсор в пределах последнего chunk или после него
// fromB -- начало chunk в modified document
// toB -- конец chunk (1 past end of last line)
return cursorPos >= lastChunk.fromB;
}
/**
* Аналогично для первого chunk.
*/
function isFirstChunkInFile(view: EditorView): boolean {
const result = getChunks(view.state);
if (!result || result.chunks.length === 0) return true;
const cursorPos = view.state.selection.main.head;
const firstChunk = result.chunks[0];
// Курсор в пределах первого chunk или перед ним
return cursorPos <= firstChunk.toB;
}
```
**ИСПРАВЛЕНИЕ:** Уточнено поведение `goToNextChunk` -- это **циклическая** навигация (moveByChunk берёт `chunks[(pos + offset) % chunks.length]`). При >1 chunks всегда возвращает `true`. Поэтому:
- `const moved = goToNextChunk(view); if (!moved)` -- значит 0 или 1 chunk, а НЕ "последний chunk"
- Для определения "последний chunk" нужен `isLastChunkInFile()`
- В `goToNextHunk` правильная логика: **сначала** проверить `isLastChunkInFile`, **потом** решить -- переходить к следующему файлу или вызвать `goToNextChunk`
#### Изменения в goToNextFile()
```typescript
const goToNextFile = useCallback(() => {
if (files.length === 0) return;
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
const nextIdx = currentIdx < files.length - 1 ? currentIdx + 1 : 0;
const nextFilePath = files[nextIdx].filePath;
if (continuousOptions?.enabled) {
// Continuous mode: smooth scroll к следующему файлу
continuousOptions.scrollToFile(nextFilePath);
// НЕ вызываем onSelectFile -- scroll-spy обновит activeFilePath сам
} else {
// Legacy mode: переключение файла
onSelectFile(nextFilePath);
}
}, [files, selectedFilePath, onSelectFile, continuousOptions]);
```
**Важно:** В continuous mode `goToNextFile()` НЕ вызывает `onSelectFile()`. Вместо этого:
1. Вызывается `scrollToFile(nextFilePath)` из `useContinuousScrollNav`
2. `scrollToFile` выполняет `element.scrollIntoView({ behavior: 'smooth' })`
3. `isProgrammaticScroll` подавляет scroll-spy
4. `waitForScrollEnd()` ждёт стабилизации (timeout 500ms, из `navigation/utils.ts`)
5. `isProgrammaticScroll = false`, scroll-spy обнаруживает новый видимый файл
6. `activeFilePath` обновляется через `onVisibleFileChange` callback
#### Изменения в goToPrevFile()
```typescript
const goToPrevFile = useCallback(() => {
if (files.length === 0) return;
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
const prevIdx = currentIdx > 0 ? currentIdx - 1 : files.length - 1;
const prevFilePath = files[prevIdx].filePath;
if (continuousOptions?.enabled) {
continuousOptions.scrollToFile(prevFilePath);
} else {
onSelectFile(prevFilePath);
}
}, [files, selectedFilePath, onSelectFile, continuousOptions]);
```
#### Изменения в goToNextHunk()
```typescript
const goToNextHunk = useCallback(() => {
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (!view) return;
if (continuousOptions?.enabled) {
// Cross-file hunk navigation
if (isLastChunkInFile(view)) {
// Уже на последнем hunk файла -- переход к следующему файлу
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
if (currentIdx < files.length - 1) {
const nextFilePath = files[currentIdx + 1].filePath;
continuousOptions.scrollToFile(nextFilePath);
// После scroll -- перейти к первому hunk нового файла
// Используем requestAnimationFrame чтобы дождаться scroll + render
requestAnimationFrame(() => {
const nextView = continuousOptions.editorViewRefs.get(nextFilePath);
if (nextView) {
// Перемещаем курсор в начало файла, потом goToNextChunk
nextView.dispatch({
selection: { anchor: 0 },
});
goToNextChunk(nextView);
}
});
}
// Если это последний файл -- no-op (конец списка)
} else {
// Не последний chunk -- обычная навигация внутри файла
goToNextChunk(view);
}
} else {
// Legacy mode: навигация внутри текущего файла
goToNextChunk(view);
}
setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1));
}, [editorViewRef, totalHunks, setCurrentHunkIndex, files, selectedFilePath, continuousOptions]);
```
**ИСПРАВЛЕНИЕ (критическое):** Оригинальный вариант вызывал `goToNextChunk(view)` ПЕРЕД проверкой `isLastChunkInFile`. Проблема: `goToNextChunk` -- циклическая навигация. Если курсор на последнем chunk, `goToNextChunk` перейдёт к ПЕРВОМУ chunk (wrap-around), а потом `isLastChunkInFile` вернёт `false`. Результат: cross-file navigation никогда не сработает.
Правильная логика: **сначала** `isLastChunkInFile()`, **потом** решение -- переход к следующему файлу ИЛИ `goToNextChunk()` для навигации внутри файла.
#### Изменения в goToPrevHunk()
```typescript
const goToPrevHunk = useCallback(() => {
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (!view) return;
if (continuousOptions?.enabled) {
if (isFirstChunkInFile(view)) {
// Первый hunk файла -- переход к предыдущему файлу
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
if (currentIdx > 0) {
const prevFilePath = files[currentIdx - 1].filePath;
continuousOptions.scrollToFile(prevFilePath);
requestAnimationFrame(() => {
const prevView = continuousOptions.editorViewRefs.get(prevFilePath);
if (prevView) {
// Перемещаем курсор в конец файла, потом goToPreviousChunk
const docLength = prevView.state.doc.length;
prevView.dispatch({
selection: { anchor: docLength },
});
goToPreviousChunk(prevView);
}
});
}
} else {
// Не первый chunk -- обычная навигация назад
goToPreviousChunk(view);
}
} else {
goToPreviousChunk(view);
}
setCurrentHunkIndex((prev) => Math.max(prev - 1, 0));
}, [editorViewRef, setCurrentHunkIndex, files, selectedFilePath, continuousOptions]);
```
#### Изменения в acceptCurrentHunk()
```typescript
const acceptCurrentHunk = useCallback(() => {
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
if (activePath && onHunkAccepted) {
onHunkAccepted(activePath, currentHunkIndex);
}
}, [selectedFilePath, currentHunkIndex, onHunkAccepted, continuousOptions]);
```
#### Изменения в rejectCurrentHunk()
```typescript
const rejectCurrentHunk = useCallback(() => {
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
if (activePath && onHunkRejected) {
onHunkRejected(activePath, currentHunkIndex);
}
}, [selectedFilePath, currentHunkIndex, onHunkRejected, continuousOptions]);
```
#### Keyboard handler -- адаптация
**ВАЖНО: Конфликт с useContinuousScrollNav (Phase 1).**
В Phase 1 `useContinuousScrollNav` регистрирует keyboard listener для Alt+ArrowDown/Up. В Phase 3 `useDiffNavigation` тоже хочет обрабатывать эти клавиши. Два обработчика на одно событие -- конфликт.
**Решение:** Удалить keyboard handler для Alt+Arrow из `useContinuousScrollNav` (Phase 1). Вся keyboard обработка навигации живёт в `useDiffNavigation`. Причина: `useDiffNavigation` уже обрабатывает все shortcuts и имеет доступ к `continuousOptions.scrollToFile`. Дублирование нарушает single-responsibility.
```typescript
useEffect(() => {
if (!isDialogOpen) return;
const handler = (event: KeyboardEvent) => {
// Skip if CM keymap already handled
if (event.defaultPrevented) return;
// Skip inputs/textareas
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
const isMeta = event.metaKey || event.ctrlKey;
// Alt+J -> next change (работает в обоих режимах)
if (event.altKey && event.key.toLowerCase() === 'j') {
event.preventDefault();
goToNextHunk();
return;
}
// Alt+K -> prev change (НОВЫЙ shortcut)
if (event.altKey && event.key.toLowerCase() === 'k') {
event.preventDefault();
goToPrevHunk();
return;
}
// Alt+ArrowDown -> next file (scroll в continuous mode, onSelectFile в legacy)
if (event.altKey && event.key === 'ArrowDown') {
event.preventDefault();
goToNextFile();
return;
}
// Alt+ArrowUp -> prev file
if (event.altKey && event.key === 'ArrowUp') {
event.preventDefault();
goToPrevFile();
return;
}
// Cmd+Enter -> save active file
if (isMeta && event.key === 'Enter') {
event.preventDefault();
onSaveFileRef.current?.();
return;
}
// Cmd+Y -> accept chunk + next (на active editor)
if (isMeta && event.key.toLowerCase() === 'y') {
event.preventDefault();
const view = getActiveEditorView(editorViewRef, continuousOptions);
if (view) {
acceptChunk(view);
requestAnimationFrame(() => {
if (continuousOptions?.enabled && isLastChunkInFile(view)) {
// Cross-file: scroll к следующему файлу после accept последнего chunk
goToNextFile();
} else {
goToNextChunk(view);
}
});
}
return;
}
// ? -> toggle shortcuts help
if (event.key === '?' && !isMeta && !event.altKey) {
event.preventDefault();
setShowShortcutsHelp((prev) => !prev);
return;
}
// Escape handling
if (event.key === 'Escape') {
if (showShortcutsHelp) {
event.preventDefault();
setShowShortcutsHelp(false);
}
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [
isDialogOpen,
showShortcutsHelp,
editorViewRef,
continuousOptions,
goToNextFile,
goToPrevFile,
goToNextHunk,
goToPrevHunk,
]);
```
**ИСПРАВЛЕНИЕ:** Alt+J/K теперь вызывают `goToNextHunk()` / `goToPrevHunk()` (callback из хука), а не напрямую `goToNextChunk(view)`. Это обеспечивает cross-file навигацию в continuous mode. В оригинале Alt+J вызывал `goToNextChunk` напрямую -- cross-file не работал бы.
#### Полная таблица keyboard shortcuts
| Shortcut | Action | Legacy mode | Continuous mode |
|----------|--------|:-----------:|:---------------:|
| `Alt+J` | Next change (hunk) | goToNextHunk (внутри файла) | goToNextHunk (cross-file) |
| `Alt+K` | Prev change (hunk) | goToPrevHunk (внутри файла) | goToPrevHunk (cross-file) |
| `Alt+ArrowDown` | Next file | goToNextFile (onSelectFile) | goToNextFile (scrollToFile) |
| `Alt+ArrowUp` | Prev file | goToPrevFile (onSelectFile) | goToPrevFile (scrollToFile) |
| `Cmd+Y` | Accept change + next | acceptChunk + goToNextChunk | acceptChunk + cross-file navigation |
| `Cmd+N` | Reject change + next | rejectChunk + goToNextChunk (IPC) | rejectChunk + cross-file navigation (IPC) |
| `Cmd+Enter` | Save file | save selectedFilePath | save activeFilePath |
| `?` | Toggle shortcuts help | toggle | toggle |
| `Escape` | Close help / dialog | close help или dialog | close help или dialog |
| `Ctrl+Alt+ArrowDown` | Next change (CM keymap) | goToNextChunk (built-in) | goToNextChunk (built-in per-editor) |
| `Ctrl+Alt+ArrowUp` | Prev change (CM keymap) | goToPreviousChunk (built-in) | goToPreviousChunk (built-in per-editor) |
**Примечание:** Ctrl+Alt+Arrow -- это встроенный CM keymap, не наш. Он работает per-editor (без cross-file). Это ОК -- пользователи, привыкшие к CM keymap, получают привычное поведение внутри файла. Alt+J/K -- наш shortcut с cross-file.
---
### 2. useContinuousScrollNav.ts -- изменения для Phase 3
**Файл:** `src/renderer/hooks/useContinuousScrollNav.ts`
Phase 1 реализует:
- `scrollToFile(filePath)` -- программный scroll к секции файла
- `isProgrammaticScroll` ref -- подавление scroll-spy при программном scroll
Phase 3 изменения:
1. **Убрать keyboard handler (Alt+Arrow) из useContinuousScrollNav.** Keyboard навигация теперь полностью в `useDiffNavigation`. Это устраняет конфликт двойной регистрации event listener.
2. **Убрать `activeFilePath` и `filePaths` из options** -- они больше не нужны хуку (keyboard handler убран). Упрощённый interface:
```typescript
interface UseContinuousScrollNavOptions {
/** Ref на scroll container */
scrollContainerRef: RefObject<HTMLElement>;
/** Диалог открыт (для cleanup) */
isOpen: boolean;
}
interface UseContinuousScrollNavReturn {
/** Scroll к файлу по filePath (smooth) */
scrollToFile: (filePath: string) => void;
/** Ref-flag: true пока идёт programmatic scroll */
isProgrammaticScroll: RefObject<boolean>;
}
```
3. **scrollToFile -- без `setActiveFilePath`:**
```typescript
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;
// Suppress scroll-spy during programmatic scroll
isProgrammaticScroll.current = true;
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Дождаться стабилизации scroll, потом разрешить scroll-spy
void waitForScrollEnd(container, 500).then(() => {
isProgrammaticScroll.current = false;
// scroll-spy сам обнаружит новый видимый файл и обновит activeFilePath
});
},
[scrollContainerRef]
);
```
**ИСПРАВЛЕНИЕ:** Оригинальный вариант вызывал `setActiveFilePath(filePath)` внутри `scrollToFile`. Проблема: `setActiveFilePath` не является частью hook state `useContinuousScrollNav` -- он живёт в parent (`ChangeReviewDialog` как `useState`). Передавать setter внутрь нарушает separation of concerns. Вместо этого: после `isProgrammaticScroll = false` scroll-spy (`useVisibleFileSection`) сам обнаружит видимый файл и вызовет `onVisibleFileChange`, который обновит `activeFilePath` в parent. Задержка ~100ms (debounce scroll-spy), но это ОК -- UI уже показывает правильный файл.
**waitForScrollEnd signature** (из `src/renderer/hooks/navigation/utils.ts`):
```typescript
function waitForScrollEnd(container: HTMLElement, timeoutMs?: number): Promise<void>
```
- `container` -- scroll container DOM element
- `timeoutMs` -- fallback timeout (default 400ms, мы передаём 500ms для запаса smooth scroll)
- Возвращает Promise, resolve когда scrollTop стабилизировался (3 consecutive frames без изменений)
---
### 3. ChangeReviewDialog.tsx -- интеграция
**Файл:** `src/renderer/components/team/review/ChangeReviewDialog.tsx`
#### Новый state: continuous mode toggle
```typescript
// Новый state для continuous mode (Phase 3)
const [isContinuousMode, setIsContinuousMode] = useState(false);
```
#### EditorView Map для continuous mode
```typescript
// Map всех EditorViews в continuous mode
// Заполняется через callback из ContinuousScrollView (Phase 1)
// Уже существует из Phase 1: editorViewMapRef
const editorViewMapRef = useRef(new Map<string, EditorView>());
```
#### Получение данных из useContinuousScrollNav
```typescript
// useContinuousScrollNav теперь принимает options object (Phase 1 interface,
// упрощённый в Phase 3):
const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
scrollContainerRef,
isOpen: open,
});
```
#### Передача continuousOptions в useDiffNavigation
```typescript
// Формируем continuousOptions только когда continuous mode включён.
//
// ВАЖНО: НЕ оборачивать editorViewMapRef.current в useMemo deps --
// .current не реактивен. Map reference стабильна (useRef), мутируется извне.
// useDiffNavigation обращается к Map.get() в момент вызова (не при создании options).
// activeFilePath и scrollToFile -- реактивны, они меняются.
const continuousOptions = useMemo(
(): ContinuousNavigationOptions | undefined => {
if (!isContinuousMode) return undefined;
return {
editorViewRefs: editorViewMapRef.current,
activeFilePath: continuousScrollNav.activeFilePath,
scrollToFile: continuousScrollNav.scrollToFile,
enabled: true,
};
},
[isContinuousMode, continuousScrollNav.activeFilePath, continuousScrollNav.scrollToFile]
);
const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [],
selectedReviewFilePath,
handleSelectFile,
editorViewRef, // Legacy ref (используется если continuousOptions undefined)
open,
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
() => onOpenChange(false),
handleSaveCurrentFile,
continuousOptions // <-- НОВЫЙ 10-й параметр
);
```
**Примечание:** `continuousScrollNav.activeFilePath` -- это state из `useContinuousScrollNav` или state из parent (`ChangeReviewDialog`). В Phase 1 `activeFilePath` управляется через `onVisibleFileChange` callback. Уточнение: `activeFilePath` -- это `useState` в `ChangeReviewDialog`, обновляется через `setActiveFilePath` callback, переданный в `ContinuousScrollView.onVisibleFileChange`.
#### handleSelectFile адаптация
```typescript
const handleSelectFile = useCallback(
(filePath: string | null) => {
if (isContinuousMode && filePath) {
// В continuous mode: scroll к секции вместо переключения
scrollToFile(filePath);
// НЕ вызываем selectReviewFile -- sidebar highlight управляется через activeFilePath
return;
}
// Legacy mode: старая логика
const view = editorViewRef.current;
if (view && selectedReviewFilePath) {
editorStateCache.current.set(selectedReviewFilePath, view.state);
}
setCachedInitialState(filePath ? editorStateCache.current.get(filePath) : undefined);
selectReviewFile(filePath);
},
[isContinuousMode, selectedReviewFilePath, selectReviewFile, scrollToFile]
);
```
#### handleSaveCurrentFile адаптация
```typescript
const handleSaveCurrentFile = useCallback(() => {
// В continuous mode сохраняем activeFilePath (видимый), не selectedReviewFilePath
const targetFile = isContinuousMode
? activeFilePath // из useState в ChangeReviewDialog
: selectedReviewFilePath;
if (targetFile) void saveEditedFile(targetFile);
}, [isContinuousMode, selectedReviewFilePath, activeFilePath, saveEditedFile]);
```
#### handleAcceptAll / handleRejectAll адаптация
```typescript
const handleAcceptAll = useCallback(() => {
if (isContinuousMode) {
// В continuous mode: accept all на ACTIVE file's editor
if (activeFilePath) {
const view = editorViewMapRef.current.get(activeFilePath);
if (view) acceptAllChunks(view);
acceptAllFile(activeFilePath);
}
} else {
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}
}, [isContinuousMode, selectedReviewFilePath, activeFilePath, acceptAllFile]);
```
#### Sidebar: подсветка activeFilePath в continuous mode
```typescript
{/* File tree -- selectedFilePath меняется на activeFilePath в continuous mode */}
<ReviewFileTree
files={activeChangeSet.files}
selectedFilePath={
isContinuousMode
? activeFilePath // из scroll-spy
: selectedReviewFilePath // из store
}
onSelectFile={handleSelectFile}
viewedSet={viewedSet}
onMarkViewed={markViewed}
onUnmarkViewed={unmarkViewed}
/>
```
**Примечание:** Phase 1 добавила `activeFilePath` prop в `ReviewFileTree` для мягкой подсветки (border-l). В continuous mode мы просто передаём `activeFilePath` как `selectedFilePath` -- полноценная подсветка (`bg-blue-500/20`). Это проще и визуально понятнее: один выделенный файл в tree.
#### Cmd+N IPC listener адаптация
```typescript
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
const view = isContinuousMode
? getActiveEditorView(editorViewRef, continuousOptions)
: editorViewRef.current;
if (view) {
rejectChunk(view);
requestAnimationFrame(() => {
if (isContinuousMode && isLastChunkInFile(view)) {
// Cross-file: scroll к следующему файлу
diffNav.goToNextFile();
} else {
goToNextChunk(view);
}
});
}
});
return cleanup ?? undefined;
}, [open, isContinuousMode, continuousOptions, diffNav]);
```
**Примечание:** `getActiveEditorView` и `isLastChunkInFile` -- helper функции из `useDiffNavigation`. Для использования в `ChangeReviewDialog` нужно:
- Либо экспортировать helpers из `useDiffNavigation.ts`
- Либо дублировать логику (нежелательно)
- Либо добавить метод в return interface: `diffNav.getActiveView()` / `diffNav.isOnLastChunk()`
**Рекомендация:** Экспортировать `getActiveEditorView` и `isLastChunkInFile` как named exports из `useDiffNavigation.ts`. Они чистые функции, не зависят от hook state.
---
### 4. KeyboardShortcutsHelp.tsx -- новые shortcuts
**Файл:** `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx`
Добавляются новые shortcuts. Текущий массив `shortcuts` (строки 10-18):
```typescript
const shortcuts = [
{ keys: ['\u2325+J'], action: 'Next change' },
{ keys: ['\u2325+K'], action: 'Previous change' }, // НОВЫЙ
{ keys: ['\u2325+\u2193'], action: 'Next file' }, // НОВЫЙ
{ keys: ['\u2325+\u2191'], action: 'Previous file' }, // НОВЫЙ
{ keys: ['\u2318+Y'], action: 'Accept change' },
{ keys: ['\u2318+N'], action: 'Reject change' },
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
{ keys: ['\u2318+Z'], action: 'Undo' },
{ keys: ['\u2318+\u21E7+Z'], action: 'Redo' },
{ keys: ['?'], action: 'Toggle this help' }, // НОВЫЙ
{ keys: ['Esc'], action: 'Close dialog' },
];
```
---
## Return Interface
```typescript
interface DiffNavigationState {
currentHunkIndex: number;
totalHunks: number;
goToNextHunk: () => void;
goToPrevHunk: () => void;
goToNextFile: () => void;
goToPrevFile: () => void;
goToHunk: (index: number) => void;
acceptCurrentHunk: () => void;
rejectCurrentHunk: () => void;
showShortcutsHelp: boolean;
setShowShortcutsHelp: (show: boolean) => void;
}
```
Интерфейс **НЕ меняется**. Все вызовы `diffNav.goToNextFile()`, `diffNav.goToNextHunk()` и т.д. в ChangeReviewDialog продолжают работать без изменений. Внутренняя реализация каждого метода проверяет `continuousOptions?.enabled` и выбирает стратегию.
---
## Edge-cases
### 1. scrollToFile + scroll-spy подавление
**Проблема:** При `scrollToFile(nextFile)` scroll-spy может обнаружить промежуточные файлы (мелькание activeFilePath).
**Решение:** `isProgrammaticScroll` ref в `useContinuousScrollNav`. При программном scroll:
1. `isProgrammaticScroll.current = true` устанавливается ДО `scrollIntoView`
2. Scroll-spy IntersectionObserver проверяет `isProgrammaticScroll.current` в `updateTopmostVisible()` и ИГНОРИРУЕТ обновления
3. После стабилизации scroll (через `waitForScrollEnd(container, 500)`) -- сбрасывается в `false`
4. Scroll-spy автоматически обнаруживает видимый файл на следующем intersection event
**Таймаут:** `waitForScrollEnd` имеет fallback timeout. Сигнатура: `waitForScrollEnd(container: HTMLElement, timeoutMs?: number): Promise<void>`. Default timeout 400ms. Мы передаём 500ms. Smooth scroll в Chromium занимает ~300-400ms. 500ms достаточно.
### 2. Cross-file hunk navigation: определение границы файла
**Проблема:** Как определить что мы на последнем/первом hunk файла?
**Решение:** Функции `isLastChunkInFile(view)` / `isFirstChunkInFile(view)` используют `getChunks(view.state)` для получения списка chunks, и сравнивают позицию курсора (`view.state.selection.main.head`) с позицией первого/последнего chunk.
**Критическая деталь `goToNextChunk`:**
- `goToNextChunk` -- это `StateCommand` (тип: `(target: { state, dispatch }) => boolean`)
- `EditorView` реализует этот интерфейс (имеет `.state` и `.dispatch()`)
- `goToNextChunk` реализует **циклическую** навигацию: `chunks[(pos + offset) % chunks.length]`
- При >1 chunks `goToNextChunk` **ВСЕГДА** возвращает `true` (перешёл к следующему chunk, даже если wrap-around к первому)
- `false` возвращается ТОЛЬКО когда: chunks.length === 0, или chunks.length === 1 && cursor уже в этом chunk
Поэтому использовать `const moved = goToNextChunk(view); if (!moved)` для определения "последний chunk" -- **некорректно**. Нужна явная проверка `isLastChunkInFile()`.
### 3. Multiple EditorViews: какой active?
**Проблема:** В continuous mode 10+ EditorView одновременно. Какой считать "активным" для keyboard shortcuts?
**Решение:** Приоритет в `getActiveEditorView()`:
1. **Focused editor** -- `view.hasFocus` (CM API). Пользователь кликнул в editor для редактирования.
2. **activeFilePath editor** -- editor файла, определённого scroll-spy как видимый. Пользователь скроллит, но не кликает в editor.
3. **Первый editor** -- fallback, если ни один не подходит.
**Нюанс:** Когда пользователь кликает в sidebar (ReviewFileTree), фокус уходит из CM editor. `view.hasFocus` становится `false` для всех. В этом случае activeFilePath editor используется корректно.
### 4. goToNextChunk на пустом файле (0 chunks)
**Проблема:** Файл целиком новый (`isNewFile: true`) -- весь контент является одним "inserted" chunk. Или файл без diff (identical). `goToNextChunk` возвращает `false` при 0 chunks.
**Решение:** `isLastChunkInFile` и `isFirstChunkInFile` возвращают `true` при 0 chunks. В `goToNextHunk` continuous mode: если `isLastChunkInFile` true и 0 chunks -- переходим к следующему файлу. Это корректно: файл без changes пропускается.
Для new file (1 chunk covering entire file): `isLastChunkInFile` вернёт `true` если курсор >= chunk.fromB. При первом заходе курсор в позиции 0 = chunk.fromB = 0, значит `isLastChunkInFile` true -- сразу переход к следующему файлу. Это может быть нежелательно для больших new files. **Решение:** Для файлов с 1 chunk можно добавить проверку `cursorPos >= lastChunk.toB - 1` (конец chunk, не начало). Но это edge case, оставляем для будущей итерации.
### 5. Cmd+Enter save: какой файл сохраняется?
**Проблема:** В continuous mode несколько файлов видны одновременно. `Cmd+Enter` должен сохранять конкретный файл.
**Решение:** Сохраняется файл из `handleSaveCurrentFile`:
- В continuous mode: `activeFilePath` из scroll-spy
- В legacy mode: `selectedReviewFilePath` из store
`onSaveFileRef.current` в keyboard handler вызывает `handleSaveCurrentFile`, который уже адаптирован.
### 6. Cross-file navigation + requestAnimationFrame timing
**Проблема:** При переходе к следующему файлу, `scrollToFile` триггерит smooth scroll. EditorView нового файла может быть не готов.
**Решение:**
1. В Phase 1/2 ВСЕ EditorView создаются при mount (lazy loading загружает контент, но DOM + EditorView создаются сразу для загруженных файлов)
2. `requestAnimationFrame` используется для задержки `goToNextChunk` после scroll
3. Если EditorView ещё не доступен (файл ещё не загружен через lazy loading) -- `continuousOptions.editorViewRefs.get(filePath)` вернёт `undefined`, navigation no-op
**Потенциальная проблема:** rAF может сработать до завершения smooth scroll. Но для `goToNextChunk` / `goToPreviousChunk` это ОК -- CM сам scrollIntoView к chunk. Визуально: scroll к файлу + мгновенный jump к первому chunk.
### 7. Wrap-around: конец/начало списка файлов
**Поведение:**
- `goToNextFile()` на последнем файле: wrap к первому файлу (index 0). Это текущее поведение legacy mode, сохраняем.
- `goToNextHunk()` на последнем hunk последнего файла: no-op (не wrap). Это отличается от goToNextFile -- hunk navigation останавливается на границе.
- `goToPrevHunk()` на первом hunk первого файла: no-op.
### 8. Editor state cache в continuous mode
**Проблема:** В legacy mode `editorStateCache` хранит EditorState для восстановления undo history при переключении файлов. В continuous mode все editors живут одновременно -- cache не нужен.
**Решение:** `editorStateCache` используется только в legacy mode (`handleSelectFile` проверяет `isContinuousMode`). В continuous mode undo history каждого EditorView сохраняется автоматически (editor не уничтожается при навигации).
### 9. goToNextChunk циклическая навигация vs наше поведение
**Ситуация:** `goToNextChunk` при >1 chunks делает wrap-around (с последнего chunk на первый). Наше cross-file поведение ожидает "стоп на последнем chunk -- перейти к следующему файлу".
**Решение:** Мы НЕ вызываем `goToNextChunk` когда `isLastChunkInFile` true. Поэтому wrap-around не происходит. `goToNextChunk` вызывается только когда мы знаем что есть следующий chunk в текущем файле.
---
## Проверка
### Unit тесты
```
test/renderer/hooks/useDiffNavigation.test.ts
```
**Тест-кейсы:**
1. **goToNextFile в continuous mode** -- вызывает scrollToFile, НЕ вызывает onSelectFile
2. **goToNextFile в legacy mode** -- вызывает onSelectFile, НЕ вызывает scrollToFile
3. **getActiveEditorView: focused editor приоритет** -- mock view.hasFocus
4. **getActiveEditorView: fallback на activeFilePath** -- когда hasFocus false для всех
5. **goToNextHunk: isLastChunkInFile true** -- вызывает scrollToFile для следующего файла, НЕ вызывает goToNextChunk
6. **goToNextHunk: isLastChunkInFile false** -- вызывает goToNextChunk, НЕ переходит к файлу
7. **goToPrevHunk cross-file** -- при isFirstChunkInFile=true, вызывает scrollToFile для предыдущего файла
8. **Keyboard: Alt+ArrowDown** -- вызывает goToNextFile
9. **Keyboard: Alt+ArrowUp** -- вызывает goToPrevFile
10. **Keyboard: Alt+J** -- вызывает goToNextHunk (с cross-file)
11. **Keyboard: Cmd+Y + cross-file** -- acceptChunk + goToNextFile если isLastChunkInFile
12. **handleSaveCurrentFile в continuous mode** -- сохраняет activeFilePath
13. **handleSelectFile в continuous mode** -- вызывает scrollToFile вместо selectReviewFile
14. **isLastChunkInFile: 0 chunks** -- returns true
15. **isLastChunkInFile: cursor before last chunk** -- returns false
16. **isLastChunkInFile: cursor at last chunk.fromB** -- returns true
### Ручная проверка
1. Открыть review dialog в continuous mode с 5+ файлами
2. Клик по файлу в sidebar -- плавный scroll к секции
3. Alt+ArrowDown/Up -- навигация между файлами
4. Alt+J -- переход к следующему hunk
5. На последнем hunk файла: Alt+J -- scroll к следующему файлу, первый hunk
6. Cmd+Y на последнем hunk -- accept + scroll к следующему файлу
7. Cmd+Enter -- сохраняет видимый файл (не первый в списке)
8. Переключить на legacy mode -- все shortcuts работают как раньше
### Интеграция с Phase 1/2
- scrollToFile корректно подавляет scroll-spy (isProgrammaticScroll)
- activeFilePath обновляется после программного scroll (через scroll-spy, не принудительно)
- EditorView Map содержит все созданные editors
- Sidebar highlight синхронизирован с activeFilePath в continuous mode
- Lazy loading не мешает навигации (placeholder для незагруженных файлов)
---
## Файлы
| Файл | Тип | ~LOC изменений |
|------|-----|---:|
| `src/renderer/hooks/useDiffNavigation.ts` | MODIFY | ~200 (helpers + goToNext/Prev переработка + keyboard) |
| `src/renderer/hooks/useContinuousScrollNav.ts` | MODIFY | ~-30 (удаление keyboard handler, упрощение interface) |
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | ~60 (continuousOptions, handleSelectFile, handleSave) |
| `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` | MODIFY | ~10 (новые shortcuts) |
| `test/renderer/hooks/useDiffNavigation.test.ts` | MODIFY | ~200 (новые тест-кейсы для continuous mode) |
| **Итого** | 0 NEW + 5 MODIFY | ~440 |

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,988 @@
# 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. Структура данных
```typescript
// 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:
```typescript
// 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:
```typescript
// 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** (для гарантии синхронизации):
```typescript
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
```typescript
// 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`:
```tsx
<FileSectionDiff
filePath={file.filePath}
onEditorViewReady={handleEditorViewReady}
// ... другие props
/>
```
### 2.5. Передача Map наружу
ContinuousScrollView передает Map наружу через `useImperativeHandle`:
```typescript
// 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:
```typescript
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
```typescript
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`. Этот объект включает:
```typescript
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 лишь использует эту инфраструктуру:
```typescript
// В 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`:
```typescript
// В 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`, не все файлы:
```typescript
// 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
```typescript
// 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:
```typescript
// 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
```typescript
// ContinuousScrollView.tsx
const handleFileFullyViewed = useCallback((filePath: string) => {
if (autoViewed && !isViewed(filePath)) {
markViewed(filePath);
}
}, [autoViewed, isViewed, markViewed]);
```
Передается каждому FileSectionDiff:
```tsx
<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` (более оптимально)
Предпочтительная реализация (оптимизированная):
```typescript
// 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`:
```typescript
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:
```tsx
<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.
```typescript
// ReviewToolbar.tsx — новый prop
interface ReviewToolbarProps {
// ...
/** Total hunks reviewed (accepted + rejected) */
reviewedCount?: number;
/** Total hunks across all files */
totalHunks?: number;
}
```
Вычисление в ChangeReviewDialog:
```typescript
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:
```tsx
{/* 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 -- все файлы
Текущая реализация:
```typescript
const handleAcceptAll = useCallback(() => {
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}, [selectedReviewFilePath, acceptAllFile]);
```
Continuous mode:
```typescript
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 -- все файлы
```typescript
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
```typescript
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
```typescript
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
```typescript
// 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 и сообщает родителю:
```typescript
// ContinuousScrollView.tsx props
interface ContinuousScrollViewProps {
// ...
onActiveFileChange: (filePath: string) => void;
}
```
В ChangeReviewDialog:
```typescript
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>
```typescript
// ChangeReviewDialog.tsx
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
```
### 7.3. Использование в FileSectionDiff key
```tsx
// 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:
```tsx
<CodeMirrorDiffView
key={`${filePath}:${discardCounter}`}
// ...
/>
```
### 7.4. Discard action
```typescript
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:
```typescript
// 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
```typescript
// 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, на будущее):**
```typescript
// Идея: 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.
**Решение:**
```typescript
// 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 -- пользователь увидит изменение, что ожидаемо.
Если нужно сохранить позицию:
```typescript
// Перед 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) |

View file

@ -4,6 +4,8 @@ import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import * as readline from 'readline';
import { TeamConfigReader } from './TeamConfigReader';
import type { TaskBoundaryParser } from './TaskBoundaryParser';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type {
@ -39,7 +41,8 @@ export class ChangeExtractorService {
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
private readonly boundaryParser: TaskBoundaryParser
private readonly boundaryParser: TaskBoundaryParser,
private readonly configReader: TeamConfigReader = new TeamConfigReader()
) {}
/** Получить все изменения агента */
@ -51,6 +54,7 @@ export class ChangeExtractorService {
}
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
const projectPath = await this.resolveProjectPath(teamName);
// Собираем все snippets из всех JSONL файлов
const allSnippets: SnippetDiff[] = [];
@ -70,7 +74,7 @@ export class ChangeExtractorService {
allSnippets.push(...snippets);
}
const files = this.aggregateByFile(allSnippets);
const files = this.aggregateByFile(allSnippets, projectPath);
let totalLinesAdded = 0;
let totalLinesRemoved = 0;
@ -106,6 +110,8 @@ export class ChangeExtractorService {
return this.emptyTaskChangeSet(teamName, taskId);
}
const projectPath = await this.resolveProjectPath(teamName);
// Парсим boundaries для каждого лог-файла и ищем scope данной задачи
const allScopes: TaskChangeScope[] = [];
for (const ref of logRefs) {
@ -118,12 +124,12 @@ export class ChangeExtractorService {
// Если scope не найден — fallback на весь файл
if (allScopes.length === 0) {
return this.fallbackSingleTaskScope(teamName, taskId, logRefs);
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath);
}
// Фильтруем snippets по tool_use IDs из scope
const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds));
const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds);
const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds, projectPath);
const worstTier = Math.max(...allScopes.map((s) => s.confidence.tier));
const warnings: string[] = [];
@ -157,6 +163,16 @@ export class ChangeExtractorService {
// ---- Private methods ----
/** Получить projectPath из конфига команды */
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
try {
const config = await this.configReader.getConfig(teamName);
return config?.projectPath?.trim() || undefined;
} catch {
return undefined;
}
}
/** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */
private async parseJSONLFile(filePath: string): Promise<SnippetDiff[]> {
// Сначала считываем все записи в память для двух проходов
@ -376,11 +392,19 @@ export class ChangeExtractorService {
totalAdded += added;
totalRemoved += removed;
}
// Normalize separators for cross-platform path stripping
const normalizedFp = fp.replace(/\\/g, '/');
const normalizedProject = projectPath?.replace(/\\/g, '/');
const relative = normalizedProject
? normalizedFp.startsWith(normalizedProject + '/')
? normalizedFp.slice(normalizedProject.length + 1)
: normalizedFp.startsWith(normalizedProject)
? normalizedFp.slice(normalizedProject.length)
: normalizedFp.split('/').slice(-3).join('/')
: normalizedFp.split('/').slice(-3).join('/');
return {
filePath: fp,
relativePath: projectPath
? fp.replace(projectPath + '/', '')
: fp.split('/').slice(-3).join('/'),
relativePath: relative,
snippets: data.snippets,
linesAdded: totalAdded,
linesRemoved: totalRemoved,
@ -487,7 +511,8 @@ export class ChangeExtractorService {
/** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */
private async extractFilteredChanges(
logRefs: LogFileRef[],
allowedToolUseIds: Set<string>
allowedToolUseIds: Set<string>,
projectPath?: string
): Promise<FileChangeSummary[]> {
const allSnippets: SnippetDiff[] = [];
for (const ref of logRefs) {
@ -503,27 +528,29 @@ export class ChangeExtractorService {
allSnippets.push(...snippets);
}
}
return this.aggregateByFile(allSnippets);
return this.aggregateByFile(allSnippets, projectPath);
}
/** Извлечь все изменения из одного файла */
private async extractAllChanges(
filePath: string,
_memberName: string
_memberName: string,
projectPath?: string
): Promise<FileChangeSummary[]> {
const snippets = await this.parseJSONLFile(filePath);
return this.aggregateByFile(snippets);
return this.aggregateByFile(snippets, projectPath);
}
/** Fallback: вернуть все изменения из лог-файлов как Tier 4 */
private async fallbackSingleTaskScope(
teamName: string,
taskId: string,
logRefs: LogFileRef[]
logRefs: LogFileRef[],
projectPath?: string
): Promise<TaskChangeSetV2> {
const allFiles: FileChangeSummary[] = [];
for (const ref of logRefs) {
const files = await this.extractAllChanges(ref.filePath, ref.memberName);
const files = await this.extractAllChanges(ref.filePath, ref.memberName, projectPath);
allFiles.push(...files);
}

View file

@ -335,7 +335,8 @@ function buildAgentBlockUsagePolicy(): string {
${AGENT_BLOCK_OPEN}
(internal instructions: commands, script usage, paths, etc.)
${AGENT_BLOCK_CLOSE}
- Put ONLY the internal instructions inside the agent-only block.`;
- Put ONLY the internal instructions inside the agent-only block.
- CRITICAL: Messages to "user" (the human) must NEVER contain agent-only blocks. Write them as plain readable text the human sees these messages directly in the UI. Agent-only blocks are stripped before display, so a message containing ONLY an agent-only block will appear completely empty.`;
}
function getSystemLocale(): string {

View file

@ -495,10 +495,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}
}, [teamName, refreshTeamData]);
const selectReviewFile = useStore((s) => s.selectReviewFile);
const handleViewChanges = useCallback((taskId: string) => {
setReviewDialogState({ open: true, mode: 'task', taskId });
}, []);
const handleViewChangesForFile = useCallback(
(taskId: string, filePath?: string) => {
setReviewDialogState({ open: true, mode: 'task', taskId });
if (filePath) {
selectReviewFile(filePath);
}
},
[selectReviewFile]
);
const handleDeleteTeam = useCallback((): void => {
setDeleteConfirmOpen(true);
}, []);
@ -1093,6 +1105,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setSendDialogOpen(true);
}}
onMessageVisible={handleMessageVisible}
onTaskIdClick={(taskId) => {
const task = taskMap.get(taskId);
if (task) setSelectedTask(task);
}}
/>
</CollapsibleTeamSection>
@ -1331,6 +1347,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onOwnerChange={(taskId, owner) => {
void updateTaskOwner(teamName, taskId, owner);
}}
onViewChanges={handleViewChangesForFile}
/>
<ChangeReviewDialog

View file

@ -41,6 +41,8 @@ interface ActivityItemProps {
onMemberNameClick?: (memberName: string) => void;
onCreateTask?: (subject: string, description: string) => void;
onReply?: (message: InboxMessage) => void;
/** Called when a task ID link (e.g. #10) is clicked in message text. */
onTaskIdClick?: (taskId: string) => void;
}
function getStringField(obj: StructuredMessage, key: string): string | null {
@ -125,6 +127,11 @@ function getSystemMessageLabel(text: string): string | null {
// Full message card — left colored border, name badge, collapsible content
// ---------------------------------------------------------------------------
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
function linkifyTaskIdsInMarkdown(text: string): string {
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
}
export const ActivityItem = ({
message,
teamName,
@ -135,6 +142,7 @@ export const ActivityItem = ({
onMemberNameClick,
onCreateTask,
onReply,
onTaskIdClick,
}: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const formattedRole = formatAgentRole(memberRole);
@ -153,11 +161,13 @@ export const ActivityItem = ({
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
const [isExpanded, setIsExpanded] = useState(!systemLabel);
// Strip agent-only blocks from displayed text
const displayText = useMemo(
() => (structured ? null : stripAgentBlocks(message.text)),
[structured, message.text]
);
// Strip agent-only blocks from displayed text + linkify task IDs
const displayText = useMemo(() => {
if (structured) return null;
const stripped = stripAgentBlocks(message.text).trim();
if (!stripped) return null; // All content was agent-only blocks → show summary instead
return onTaskIdClick ? linkifyTaskIdsInMarkdown(stripped) : stripped;
}, [structured, message.text, onTaskIdClick]);
// Check if this is a reply message
const parsedReply = useMemo(
@ -355,9 +365,31 @@ export const ActivityItem = ({
</div>
) : parsedReply ? (
<ReplyQuoteBlock reply={parsedReply} />
) : (
<MarkdownViewer content={displayText ?? ''} maxHeight="max-h-56" copyable bare />
)}
) : displayText ? (
<span
onClickCapture={
onTaskIdClick
? (e) => {
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
'a[href^="task://"]'
);
if (link) {
e.preventDefault();
e.stopPropagation();
const taskId = link.getAttribute('href')?.replace('task://', '');
if (taskId) onTaskIdClick(taskId);
}
}
: undefined
}
>
<MarkdownViewer content={displayText} maxHeight="max-h-56" copyable bare />
</span>
) : summaryText ? (
<p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}>
{summaryText}
</p>
) : null}
{message.attachments?.length && message.messageId ? (
<AttachmentDisplay
teamName={teamName}

View file

@ -20,6 +20,8 @@ interface ActivityTimelineProps {
onMemberClick?: (member: ResolvedTeamMember) => void;
/** Called when a message enters the viewport (for marking as read). */
onMessageVisible?: (message: InboxMessage) => void;
/** Called when a task ID link (e.g. #10) is clicked in message text. */
onTaskIdClick?: (taskId: string) => void;
}
const VIEWPORT_THRESHOLD = 0.15;
@ -35,6 +37,7 @@ const MessageRowWithObserver = ({
onCreateTask,
onReply,
onVisible,
onTaskIdClick,
}: {
message: InboxMessage;
teamName: string;
@ -46,6 +49,7 @@ const MessageRowWithObserver = ({
onCreateTask?: (subject: string, description: string) => void;
onReply?: (message: InboxMessage) => void;
onVisible?: (message: InboxMessage) => void;
onTaskIdClick?: (taskId: string) => void;
}): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false);
@ -89,6 +93,7 @@ const MessageRowWithObserver = ({
onMemberNameClick={onMemberNameClick}
onCreateTask={onCreateTask}
onReply={onReply}
onTaskIdClick={onTaskIdClick}
/>
</div>
);
@ -103,6 +108,7 @@ export const ActivityTimeline = ({
onReplyToMessage,
onMemberClick,
onMessageVisible,
onTaskIdClick,
}: ActivityTimelineProps): React.JSX.Element => {
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
const memberInfo = new Map<string, { role?: string; color?: string }>();
@ -167,6 +173,7 @@ export const ActivityTimeline = ({
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick}
/>
);
})}

View file

@ -23,6 +23,7 @@ import {
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { markAsRead } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
buildMemberColorMap,
@ -31,7 +32,15 @@ import {
TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers';
import { formatDistanceToNow } from 'date-fns';
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine } from 'lucide-react';
import {
ArrowLeftFromLine,
ArrowRightFromLine,
Clock,
FileCode,
Link2,
Loader2,
PenLine,
} from 'lucide-react';
import { TaskCommentsSection } from './TaskCommentsSection';
@ -47,6 +56,7 @@ interface TaskDetailDialogProps {
onClose: () => void;
onScrollToTask?: (taskId: string) => void;
onOwnerChange?: (taskId: string, owner: string | null) => void;
onViewChanges?: (taskId: string, filePath?: string) => void;
}
export const TaskDetailDialog = ({
@ -59,6 +69,7 @@ export const TaskDetailDialog = ({
onClose,
onScrollToTask,
onOwnerChange,
onViewChanges,
}: TaskDetailDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
@ -71,6 +82,35 @@ export const TaskDetailDialog = ({
if (latest > 0) markAsRead(teamName, currentTask.id, latest);
}, [open, teamName, currentTask]);
// Lazy-load task changes when dialog is open and task is completed
const isTaskCompleted = currentTask?.status === 'completed';
const activeChangeSet = useStore((s) => s.activeChangeSet);
const changeSetLoading = useStore((s) => s.changeSetLoading);
const fetchTaskChanges = useStore((s) => s.fetchTaskChanges);
const taskChangesFiles = useMemo(() => {
if (!activeChangeSet || !currentTask) return null;
if ('taskId' in activeChangeSet && activeChangeSet.taskId === currentTask.id) {
return activeChangeSet.files;
}
return null;
}, [activeChangeSet, currentTask]);
useEffect(() => {
if (!open || !currentTask || !isTaskCompleted || !onViewChanges) return;
// Only fetch if we don't already have data for this task
if (taskChangesFiles !== null) return;
void fetchTaskChanges(teamName, currentTask.id);
}, [
open,
currentTask,
isTaskCompleted,
teamName,
fetchTaskChanges,
taskChangesFiles,
onViewChanges,
]);
const handleDependencyClick = (taskId: string): void => {
onClose();
onScrollToTask?.(taskId);
@ -217,6 +257,51 @@ export const TaskDetailDialog = ({
)}
</CollapsibleTeamSection>
{/* Changes */}
{isTaskCompleted && onViewChanges ? (
<CollapsibleTeamSection
title="Changes"
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
defaultOpen
>
{changeSetLoading && !taskChangesFiles ? (
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
Loading changes...
</div>
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
{taskChangesFiles.map((file) => (
<button
key={file.filePath}
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={() => {
onClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
<FileCode size={14} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 flex-1 truncate font-mono text-[var(--color-text-secondary)]">
{file.relativePath}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{file.linesAdded > 0 ? (
<span className="text-emerald-400">+{file.linesAdded}</span>
) : null}
{file.linesRemoved > 0 ? (
<span className="text-red-400">-{file.linesRemoved}</span>
) : null}
</span>
</button>
))}
</div>
) : (
<p className="text-xs text-[var(--color-text-muted)]">No file changes detected</p>
)}
</CollapsibleTeamSection>
) : null}
<div className="mb-3 space-y-2">
{/* Dependencies */}
{blockedByIds.length > 0 ? (

View file

@ -2,26 +2,22 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { goToNextChunk, rejectChunk } from '@codemirror/merge';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav';
import { isLastChunkInFile, useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { ChevronDown, Clock, Loader2, Save, Undo2, X } from 'lucide-react';
import { ChevronDown, Clock, X } from 'lucide-react';
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
import { ConfidenceBadge } from './ConfidenceBadge';
import { DiffErrorBoundary } from './DiffErrorBoundary';
import { ContinuousScrollView } from './ContinuousScrollView';
import { FileEditTimeline } from './FileEditTimeline';
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
import { ReviewDiffContent } from './ReviewDiffContent';
import { ReviewFileTree } from './ReviewFileTree';
import { ReviewToolbar } from './ReviewToolbar';
import { ScopeWarningBanner } from './ScopeWarningBanner';
import { ViewedProgressBar } from './ViewedProgressBar';
import type { EditorState } from '@codemirror/state';
import type { EditorView } from '@codemirror/view';
import type { HunkDecision, TaskChangeSetV2 } from '@shared/types';
@ -34,14 +30,6 @@ interface ChangeReviewDialogProps {
taskId?: string;
}
const CONTENT_SOURCE_LABELS: Record<string, string> = {
'file-history': 'File History',
'snippet-reconstruction': 'Reconstructed',
'disk-current': 'Current Disk',
'git-fallback': 'Git Fallback',
unavailable: 'Unavailable',
};
function isTaskChangeSetV2(cs: { teamName: string }): cs is TaskChangeSetV2 {
return 'scope' in cs;
}
@ -58,12 +46,9 @@ export const ChangeReviewDialog = ({
activeChangeSet,
changeSetLoading,
changeSetError,
selectedReviewFilePath,
fetchAgentChanges,
fetchTaskChanges,
selectReviewFile,
clearChangeReview,
// Phase 2
hunkDecisions,
fileDecisions,
fileContents,
@ -77,22 +62,38 @@ export const ChangeReviewDialog = ({
acceptAllFile,
rejectAllFile,
applyReview,
// Editable diff
editedContents,
updateEditedContent,
discardFileEdits,
saveEditedFile,
} = useStore();
const editorViewRef = useRef<EditorView | null>(null);
// Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll)
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
const [autoViewed, setAutoViewed] = useState(true);
const [timelineOpen, setTimelineOpen] = useState(false);
// Counter to force editor rebuild on discard
const [discardCounter, setDiscardCounter] = useState(0);
// Cache EditorState per file to preserve undo history between file switches
const editorStateCache = useRef(new Map<string, EditorState>());
// Current file's cached initial state (derived outside render to avoid ref access during render)
const [cachedInitialState, setCachedInitialState] = useState<EditorState | undefined>(undefined);
// EditorView map for all visible file editors
const editorViewMapRef = useRef(new Map<string, EditorView>());
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Proxy ref for useDiffNavigation (points to active file's editor)
const activeEditorViewRef = useRef<EditorView | null>(null);
const activeFilePathRef = useRef<string | null>(null);
// Keep refs in sync with activeFilePath
useEffect(() => {
activeFilePathRef.current = activeFilePath;
activeEditorViewRef.current = activeFilePath
? (editorViewMapRef.current.get(activeFilePath) ?? null)
: null;
}, [activeFilePath]);
// Continuous scroll navigation
const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
scrollContainerRef,
});
// Build scope key for viewed storage
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
@ -113,69 +114,122 @@ export const ChangeReviewDialog = ({
progress: viewedProgress,
} = useViewedFiles(teamName, scopeKey, allFilePaths);
// Editable diff computed values
const editedCount = Object.keys(editedContents).length;
const hasCurrentFileEdits = !!(
selectedReviewFilePath && selectedReviewFilePath in editedContents
);
// Save current editor state to cache before switching files
const handleSelectFile = useCallback(
(filePath: string | null) => {
const view = editorViewRef.current;
if (view && selectedReviewFilePath) {
editorStateCache.current.set(selectedReviewFilePath, view.state);
}
setCachedInitialState(filePath ? editorStateCache.current.get(filePath) : undefined);
selectReviewFile(filePath);
// Scroll-spy handler
const handleVisibleFileChange = useCallback((filePath: string) => {
setActiveFilePath(filePath);
}, []);
// Tree click → scroll to file
const handleTreeFileClick = useCallback(
(filePath: string) => {
scrollToFile(filePath);
setActiveFilePath(filePath);
},
[selectedReviewFilePath, selectReviewFile]
[scrollToFile]
);
// Accept/Reject all across all files
const handleAcceptAll = useCallback(() => {
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
}, [selectedReviewFilePath, acceptAllFile]);
if (!activeChangeSet) return;
for (const file of activeChangeSet.files) {
acceptAllFile(file.filePath);
}
requestAnimationFrame(() => {
for (const view of editorViewMapRef.current.values()) {
acceptAllChunks(view);
}
});
}, [activeChangeSet, acceptAllFile]);
const handleRejectAll = useCallback(() => {
const view = editorViewRef.current;
if (view) rejectAllChunks(view);
if (selectedReviewFilePath) rejectAllFile(selectedReviewFilePath);
}, [selectedReviewFilePath, rejectAllFile]);
const handleSaveCurrentFile = useCallback(() => {
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
}, [selectedReviewFilePath, saveEditedFile]);
const handleDiscardCurrentFile = useCallback(() => {
if (selectedReviewFilePath) {
editorStateCache.current.delete(selectedReviewFilePath);
setCachedInitialState(undefined);
discardFileEdits(selectedReviewFilePath);
setDiscardCounter((c) => c + 1);
if (!activeChangeSet) return;
for (const file of activeChangeSet.files) {
rejectAllFile(file.filePath);
}
}, [selectedReviewFilePath, discardFileEdits]);
requestAnimationFrame(() => {
for (const view of editorViewMapRef.current.values()) {
rejectAllChunks(view);
}
});
}, [activeChangeSet, rejectAllFile]);
// Per-file callbacks for ContinuousScrollView
const handleHunkAccepted = useCallback(
(filePath: string, hunkIndex: number) => {
setHunkDecision(filePath, hunkIndex, 'accepted');
},
[setHunkDecision]
);
const handleHunkRejected = useCallback(
(filePath: string, hunkIndex: number) => {
setHunkDecision(filePath, hunkIndex, 'rejected');
},
[setHunkDecision]
);
const handleContentChanged = useCallback(
(filePath: string, content: string) => {
updateEditedContent(filePath, content);
},
[updateEditedContent]
);
const handleFullyViewed = useCallback(
(filePath: string) => {
if (autoViewed && !isViewed(filePath)) {
markViewed(filePath);
}
},
[autoViewed, isViewed, markViewed]
);
const handleSaveFile = useCallback(
(filePath: string) => {
void saveEditedFile(filePath);
},
[saveEditedFile]
);
const handleDiscardFile = useCallback(
(filePath: string) => {
discardFileEdits(filePath);
setDiscardCounter((c) => c + 1);
},
[discardFileEdits]
);
// Save active file (for Cmd+Enter keyboard shortcut)
const handleSaveActiveFile = useCallback(() => {
if (activeFilePath) void saveEditedFile(activeFilePath);
}, [activeFilePath, saveEditedFile]);
// Continuous navigation options for cross-file hunk navigation
const continuousOptions = useMemo(
() => ({
editorViewMapRef,
activeFilePath,
scrollToFile,
enabled: true,
}),
[activeFilePath, scrollToFile]
);
const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [],
selectedReviewFilePath,
handleSelectFile,
editorViewRef,
activeFilePath,
scrollToFile,
activeEditorViewRef,
open,
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
handleHunkAccepted,
handleHunkRejected,
() => onOpenChange(false),
handleSaveCurrentFile
handleSaveActiveFile,
continuousOptions
);
// Auto-viewed callback
const handleFullyViewed = useCallback(() => {
if (autoViewed && selectedReviewFilePath && !isViewed(selectedReviewFilePath)) {
markViewed(selectedReviewFilePath);
}
}, [autoViewed, selectedReviewFilePath, isViewed, markViewed]);
// Load data on open
useEffect(() => {
if (!open) return;
@ -210,39 +264,21 @@ export const ChangeReviewDialog = ({
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
const view = editorViewRef.current;
const fp = activeFilePathRef.current;
const view = fp ? editorViewMapRef.current.get(fp) : null;
if (view) {
rejectChunk(view);
requestAnimationFrame(() => goToNextChunk(view));
requestAnimationFrame(() => {
if (isLastChunkInFile(view)) {
diffNav.goToNextFile();
} else {
goToNextChunk(view);
}
});
}
});
return cleanup ?? undefined;
}, [open]);
// Lazy-load file content when file selected
useEffect(() => {
if (!open || !selectedReviewFilePath) return;
if (fileContents[selectedReviewFilePath] || fileContentsLoading[selectedReviewFilePath]) return;
void fetchFileContent(teamName, memberName, selectedReviewFilePath);
}, [
open,
selectedReviewFilePath,
teamName,
memberName,
fileContents,
fileContentsLoading,
fetchFileContent,
]);
const selectedFile = useMemo(() => {
if (!activeChangeSet || !selectedReviewFilePath) return null;
return activeChangeSet.files.find((f) => f.filePath === selectedReviewFilePath) ?? null;
}, [activeChangeSet, selectedReviewFilePath]);
const fileContent = selectedReviewFilePath ? fileContents[selectedReviewFilePath] : null;
const isFileContentLoading = selectedReviewFilePath
? (fileContentsLoading[selectedReviewFilePath] ?? false)
: false;
}, [open, diffNav]);
// Compute toolbar stats
const reviewStats = useMemo(() => {
@ -278,6 +314,12 @@ export const ChangeReviewDialog = ({
void applyReview(teamName, taskId, memberName);
}, [applyReview, teamName, taskId, memberName]);
// Active file for timeline (derived from scroll-spy)
const activeFile = useMemo(() => {
if (!activeChangeSet || !activeFilePath) return null;
return activeChangeSet.files.find((f) => f.filePath === activeFilePath) ?? null;
}, [activeChangeSet, activeFilePath]);
const title =
mode === 'agent'
? `Changes by ${memberName ?? 'unknown'}`
@ -310,9 +352,6 @@ export const ChangeReviewDialog = ({
{activeChangeSet.totalFiles} files, +{activeChangeSet.totalLinesAdded} -
{activeChangeSet.totalLinesRemoved}
</span>
{mode === 'task' && isTaskChangeSetV2(activeChangeSet) && (
<ConfidenceBadge confidence={activeChangeSet.scope.confidence} />
)}
<ViewedProgressBar
viewed={viewedCount}
total={viewedTotalCount}
@ -356,16 +395,13 @@ export const ChangeReviewDialog = ({
/>
)}
{/* Scope info / warnings */}
{mode === 'task' &&
activeChangeSet &&
isTaskChangeSetV2(activeChangeSet) &&
(activeChangeSet.warnings.length > 0 || activeChangeSet.scope.confidence.tier >= 2) && (
<ScopeWarningBanner
warnings={activeChangeSet.warnings}
confidence={activeChangeSet.scope.confidence}
/>
)}
{/* Scope info / warnings + confidence badge */}
{mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && (
<ScopeWarningBanner
warnings={activeChangeSet.warnings}
confidence={activeChangeSet.scope.confidence}
/>
)}
{/* Apply error */}
{applyError && (
@ -394,22 +430,23 @@ export const ChangeReviewDialog = ({
<div className="w-64 shrink-0 overflow-y-auto border-r border-border bg-surface-sidebar">
<ReviewFileTree
files={activeChangeSet.files}
selectedFilePath={selectedReviewFilePath}
onSelectFile={handleSelectFile}
selectedFilePath={null}
onSelectFile={handleTreeFileClick}
viewedSet={viewedSet}
onMarkViewed={markViewed}
onUnmarkViewed={unmarkViewed}
activeFilePath={activeFilePath ?? undefined}
/>
{/* Edit Timeline */}
{selectedFile?.timeline && selectedFile.timeline.events.length > 0 && (
{/* Edit Timeline for active file */}
{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 ({selectedFile.timeline.events.length})</span>
<span>Edit Timeline ({activeFile.timeline.events.length})</span>
<ChevronDown
className={cn(
'ml-auto size-3 transition-transform',
@ -419,7 +456,7 @@ export const ChangeReviewDialog = ({
</button>
{timelineOpen && (
<FileEditTimeline
timeline={selectedFile.timeline}
timeline={activeFile.timeline}
onEventClick={(idx) => diffNav.goToHunk(idx)}
activeSnippetIndex={diffNav.currentHunkIndex}
/>
@ -428,143 +465,32 @@ export const ChangeReviewDialog = ({
)}
</div>
{/* Diff content */}
<div className="flex-1 overflow-y-auto">
{selectedFile ? (
<div className="flex h-full flex-col">
{/* File header with content source badge and save/discard */}
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
<span className="text-xs font-medium text-text">
{selectedFile.relativePath}
</span>
{selectedFile.isNewFile && (
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
NEW
</span>
)}
{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>
)}
{/* File-level decision indicator */}
{fileDecisions[selectedFile.filePath] && (
<span
className={`rounded px-1.5 py-0.5 text-[10px] ${
fileDecisions[selectedFile.filePath] === 'accepted'
? 'bg-green-500/20 text-green-400'
: fileDecisions[selectedFile.filePath] === 'rejected'
? 'bg-red-500/20 text-red-400'
: 'bg-zinc-500/20 text-zinc-400'
}`}
>
{fileDecisions[selectedFile.filePath]}
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
{hasCurrentFileEdits && (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleDiscardCurrentFile}
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
>
<Undo2 className="size-3" />
Discard
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
Discard all edits for this file
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleSaveCurrentFile}
disabled={applying}
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
>
{applying ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Save className="size-3" />
)}
Save File
</button>
</TooltipTrigger>
<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>
</Tooltip>
</>
)}
</div>
</div>
{/* Loading state */}
{isFileContentLoading && (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-text-muted">
<Loader2 className="size-4 animate-spin" />
Loading file content...
</div>
)}
{/* CodeMirror diff view when file content is available */}
{!isFileContentLoading &&
fileContent &&
fileContent.contentSource !== 'unavailable' &&
fileContent.modifiedFullContent !== null && (
<div className="flex-1 overflow-auto">
<DiffErrorBoundary
filePath={selectedFile.filePath}
oldString={fileContent.originalFullContent ?? ''}
newString={fileContent.modifiedFullContent}
>
<CodeMirrorDiffView
key={`${selectedFile.filePath}:${discardCounter}`}
original={fileContent.originalFullContent ?? ''}
modified={fileContent.modifiedFullContent}
fileName={selectedFile.relativePath}
readOnly={false}
showMergeControls={true}
collapseUnchanged={collapseUnchanged}
initialState={cachedInitialState}
onHunkAccepted={(idx) =>
setHunkDecision(selectedFile.filePath, idx, 'accepted')
}
onHunkRejected={(idx) =>
setHunkDecision(selectedFile.filePath, idx, 'rejected')
}
onFullyViewed={handleFullyViewed}
editorViewRef={editorViewRef}
onContentChanged={(content) => {
updateEditedContent(selectedFile.filePath, content);
}}
/>
</DiffErrorBoundary>
</div>
)}
{/* Fallback: Phase 1 snippet view when content unavailable */}
{!isFileContentLoading &&
(!fileContent || fileContent.contentSource === 'unavailable') && (
<div className="flex-1 overflow-auto">
<ReviewDiffContent file={selectedFile} />
</div>
)}
</div>
) : (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Select a file to view changes
</div>
)}
</div>
{/* Continuous scroll diff content */}
<ContinuousScrollView
files={activeChangeSet.files}
fileContents={fileContents}
fileContentsLoading={fileContentsLoading}
viewedSet={viewedSet}
editedContents={editedContents}
fileDecisions={fileDecisions}
collapseUnchanged={collapseUnchanged}
applying={applying}
autoViewed={autoViewed}
discardCounter={discardCounter}
onHunkAccepted={handleHunkAccepted}
onHunkRejected={handleHunkRejected}
onFullyViewed={handleFullyViewed}
onContentChanged={handleContentChanged}
onDiscard={handleDiscardFile}
onSave={handleSaveFile}
onVisibleFileChange={handleVisibleFileChange}
scrollContainerRef={scrollContainerRef}
editorViewMapRef={editorViewMapRef}
isProgrammaticScroll={isProgrammaticScroll}
teamName={teamName}
memberName={memberName}
fetchFileContent={fetchFileContent}
/>
</>
)}

View file

@ -25,6 +25,7 @@ import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils';
import { portionCollapseExtension } from './portionCollapse';
interface CodeMirrorDiffViewProps {
original: string;
@ -45,6 +46,10 @@ interface CodeMirrorDiffViewProps {
onContentChanged?: (content: string) => void;
/** Cached EditorState to restore (preserves undo history between file switches) */
initialState?: EditorState;
/** Use portion collapse instead of CM's collapseUnchanged (Expand N / Expand All buttons) */
usePortionCollapse?: boolean;
/** Lines per "Expand N" click (only with usePortionCollapse). Default: 100 */
portionSize?: number;
}
/** Synchronous language extension for common file types (bundled by Vite) */
@ -275,6 +280,8 @@ export const CodeMirrorDiffView = ({
editorViewRef: externalViewRef,
onContentChanged,
initialState,
usePortionCollapse = false,
portionSize = 100,
}: CodeMirrorDiffViewProps): React.ReactElement => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
@ -305,6 +312,8 @@ export const CodeMirrorDiffView = ({
const langCompartment = useRef(new Compartment());
// Compartment for merge view — allows dynamic collapse reconfigure without editor recreation
const mergeCompartment = useRef(new Compartment());
// Compartment for portion collapse (separate from merge to allow independent reconfigure)
const portionCompartment = useRef(new Compartment());
// Collapse as ref — used in buildExtensions (initial value) without triggering full rebuild
const collapseRef = useRef({ enabled: collapseUnchangedProp, margin: collapseMargin });
@ -322,7 +331,7 @@ export const CodeMirrorDiffView = ({
syntaxHighlightDeletions: true,
};
if (collapse) {
if (collapse && !usePortionCollapse) {
mergeConfig.collapseUnchanged = {
margin,
minSize: 4,
@ -442,7 +451,7 @@ export const CodeMirrorDiffView = ({
return unifiedMergeView(mergeConfig);
},
[original, showMergeControls, scrollToNextChunk]
[original, showMergeControls, scrollToNextChunk, usePortionCollapse]
);
const buildExtensions = useCallback(() => {
@ -660,8 +669,21 @@ export const CodeMirrorDiffView = ({
)
);
// Portion collapse — must come AFTER merge view so ChunkField is available
extensions.push(
portionCompartment.current.of(
usePortionCollapse && collapseRef.current.enabled
? portionCollapseExtension({
margin: collapseRef.current.margin,
minSize: 4,
portionSize,
})
: []
)
);
return extensions;
}, [readOnly, showMergeControls, buildMergeExtension]);
}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize]);
useEffect(() => {
if (!containerRef.current) return;
@ -729,16 +751,27 @@ export const CodeMirrorDiffView = ({
};
}, [fileName, buildExtensions, initialState]);
// Dynamic collapse toggle — reconfigure compartment in-place, preserving undo history
// Dynamic collapse toggle — reconfigure compartments in-place, preserving undo history
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: mergeCompartment.current.reconfigure(
buildMergeExtension(collapseUnchangedProp, collapseMargin)
),
effects: [
mergeCompartment.current.reconfigure(
buildMergeExtension(collapseUnchangedProp, collapseMargin)
),
portionCompartment.current.reconfigure(
usePortionCollapse && collapseUnchangedProp
? portionCollapseExtension({
margin: collapseMargin,
minSize: 4,
portionSize,
})
: []
),
],
});
}, [collapseUnchangedProp, collapseMargin, buildMergeExtension]);
}, [collapseUnchangedProp, collapseMargin, buildMergeExtension, usePortionCollapse, portionSize]);
// Auto-viewed detection via IntersectionObserver
useEffect(() => {

View file

@ -0,0 +1,166 @@
import React, { useCallback, useMemo } from 'react';
import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent';
import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
import { FileSectionDiff } from './FileSectionDiff';
import { FileSectionHeader } from './FileSectionHeader';
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
import type { EditorView } from '@codemirror/view';
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
import type { FileChangeSummary } from '@shared/types/review';
interface ContinuousScrollViewProps {
files: FileChangeSummary[];
fileContents: Record<string, FileChangeWithContent>;
fileContentsLoading: Record<string, boolean>;
viewedSet: Set<string>;
editedContents: Record<string, string>;
fileDecisions: Record<string, HunkDecision>;
collapseUnchanged: boolean;
applying: boolean;
autoViewed: boolean;
discardCounter: number;
onHunkAccepted: (filePath: string, hunkIndex: number) => void;
onHunkRejected: (filePath: string, hunkIndex: number) => void;
onFullyViewed: (filePath: string) => void;
onContentChanged: (filePath: string, content: string) => void;
onDiscard: (filePath: string) => void;
onSave: (filePath: string) => void;
onVisibleFileChange: (filePath: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>;
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
isProgrammaticScroll: React.RefObject<boolean>;
teamName: string;
memberName: string | undefined;
fetchFileContent: (
teamName: string,
memberName: string | undefined,
filePath: string
) => Promise<void>;
}
export const ContinuousScrollView = ({
files,
fileContents,
fileContentsLoading,
viewedSet,
editedContents,
fileDecisions,
collapseUnchanged,
applying,
autoViewed,
discardCounter,
onHunkAccepted,
onHunkRejected,
onFullyViewed,
onContentChanged,
onDiscard,
onSave,
onVisibleFileChange,
scrollContainerRef,
editorViewMapRef,
isProgrammaticScroll,
teamName,
memberName,
fetchFileContent,
}: ContinuousScrollViewProps): React.ReactElement => {
const filePaths = useMemo(() => files.map((f) => f.filePath), [files]);
const { registerFileSectionRef } = useVisibleFileSection({
onVisibleFileChange,
scrollContainerRef,
isProgrammaticScroll,
});
const { registerLazyRef } = useLazyFileContent({
teamName,
memberName,
filePaths,
scrollContainerRef,
fileContents,
fileContentsLoading,
fetchFileContent,
enabled: true,
});
// Combined ref callback: registers element in both scroll-spy and lazy-load observers
const combinedRef = useCallback(
(filePath: string) => {
const sectionRef = registerFileSectionRef(filePath);
const lazyRef = registerLazyRef(filePath);
return (element: HTMLElement | null) => {
sectionRef(element);
lazyRef(element);
};
},
[registerFileSectionRef, registerLazyRef]
);
const handleEditorViewReady = useCallback(
(filePath: string, view: EditorView | null) => {
if (view) {
editorViewMapRef.current.set(filePath, view);
} else {
editorViewMapRef.current.delete(filePath);
}
},
[editorViewMapRef]
);
if (files.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
No file changes detected
</div>
);
}
return (
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
{files.map((file) => {
const filePath = file.filePath;
const content = fileContents[filePath] ?? null;
const hasContent = filePath in fileContents;
const hasEdits = filePath in editedContents;
const isViewed = viewedSet.has(filePath);
const decision = fileDecisions[filePath];
return (
<div key={filePath} ref={combinedRef(filePath)} className="border-b border-border">
<FileSectionHeader
file={file}
fileContent={content}
fileDecision={decision}
hasEdits={hasEdits}
applying={applying}
onDiscard={onDiscard}
onSave={onSave}
/>
{hasContent ? (
<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}
/>
) : (
<FileSectionPlaceholder fileName={file.relativePath} />
)}
</div>
);
})}
</div>
);
};

View file

@ -0,0 +1,122 @@
import React, { useEffect, useRef } from 'react';
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
import { DiffErrorBoundary } from './DiffErrorBoundary';
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
import { ReviewDiffContent } from './ReviewDiffContent';
import type { EditorView } from '@codemirror/view';
import type { FileChangeWithContent } from '@shared/types';
import type { FileChangeSummary } from '@shared/types/review';
interface FileSectionDiffProps {
file: FileChangeSummary;
fileContent: FileChangeWithContent | null;
isLoading: boolean;
collapseUnchanged: boolean;
onHunkAccepted: (filePath: string, hunkIndex: number) => void;
onHunkRejected: (filePath: string, hunkIndex: number) => void;
onFullyViewed: (filePath: string) => void;
onContentChanged: (filePath: string, content: string) => void;
onEditorViewReady: (filePath: string, view: EditorView | null) => void;
discardCounter: number;
autoViewed: boolean;
isViewed: boolean;
}
export const FileSectionDiff = ({
file,
fileContent,
isLoading,
collapseUnchanged,
onHunkAccepted,
onHunkRejected,
onFullyViewed,
onContentChanged,
onEditorViewReady,
discardCounter,
autoViewed,
isViewed,
}: FileSectionDiffProps): React.ReactElement => {
const localEditorViewRef = useRef<EditorView | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
// Register/unregister EditorView with parent Map
useEffect(() => {
return () => {
onEditorViewReady(file.filePath, null);
};
}, [file.filePath, onEditorViewReady]);
// Sync EditorView ref to parent after CM creates the view
useEffect(() => {
if (localEditorViewRef.current) {
onEditorViewReady(file.filePath, localEditorViewRef.current);
}
});
// Auto-viewed sentinel observer
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]);
// Loading state
if (isLoading) {
return <FileSectionPlaceholder fileName={file.relativePath} />;
}
// Unavailable / no content fallback
const hasCodeMirrorContent =
fileContent &&
fileContent.contentSource !== 'unavailable' &&
fileContent.modifiedFullContent !== null;
if (!hasCodeMirrorContent) {
return (
<div className="overflow-auto">
<ReviewDiffContent file={file} />
<div ref={sentinelRef} className="h-1 shrink-0" />
</div>
);
}
return (
<div className="overflow-auto">
<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}
usePortionCollapse={true}
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)}
onContentChanged={(content) => onContentChanged(file.filePath, content)}
editorViewRef={localEditorViewRef}
/>
</DiffErrorBoundary>
<div ref={sentinelRef} className="h-1 shrink-0" />
</div>
);
};

View file

@ -0,0 +1,108 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Loader2, Save, Undo2 } from 'lucide-react';
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
import type { FileChangeSummary } from '@shared/types/review';
const CONTENT_SOURCE_LABELS: Record<string, string> = {
'file-history': 'File History',
'snippet-reconstruction': 'Reconstructed',
'disk-current': 'Current Disk',
'git-fallback': 'Git Fallback',
unavailable: 'Unavailable',
};
interface FileSectionHeaderProps {
file: FileChangeSummary;
fileContent: FileChangeWithContent | null;
fileDecision: HunkDecision | undefined;
hasEdits: boolean;
applying: boolean;
onDiscard: (filePath: string) => void;
onSave: (filePath: string) => void;
}
export const FileSectionHeader = ({
file,
fileContent,
fileDecision,
hasEdits,
applying,
onDiscard,
onSave,
}: FileSectionHeaderProps): React.ReactElement => {
return (
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2">
<span className="text-xs font-medium text-text">{file.relativePath}</span>
{file.isNewFile && (
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
NEW
</span>
)}
{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>
)}
{fileDecision && (
<span
className={`rounded px-1.5 py-0.5 text-[10px] ${
fileDecision === 'accepted'
? 'bg-green-500/20 text-green-400'
: fileDecision === 'rejected'
? 'bg-red-500/20 text-red-400'
: 'bg-zinc-500/20 text-zinc-400'
}`}
>
{fileDecision}
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
{hasEdits && (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDiscard(file.filePath)}
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
>
<Undo2 className="size-3" />
Discard
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Discard all edits for this file</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onSave(file.filePath)}
disabled={applying}
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
>
{applying ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Save className="size-3" />
)}
Save File
</button>
</TooltipTrigger>
<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>
</Tooltip>
</>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,23 @@
import React from 'react';
interface FileSectionPlaceholderProps {
fileName: string;
}
export const FileSectionPlaceholder = ({
fileName,
}: FileSectionPlaceholderProps): React.ReactElement => (
<div className="animate-pulse">
<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>
<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>
);

View file

@ -9,11 +9,15 @@ interface KeyboardShortcutsHelpProps {
const shortcuts = [
{ keys: ['\u2325+J'], action: 'Next change' },
{ keys: ['\u2325+K'], action: 'Previous change' },
{ keys: ['\u2325+\u2193'], action: 'Next file' },
{ keys: ['\u2325+\u2191'], action: 'Previous file' },
{ keys: ['\u2318+Y'], action: 'Accept change' },
{ keys: ['\u2318+N'], action: 'Reject change' },
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
{ keys: ['\u2318+Z'], action: 'Undo' },
{ keys: ['\u2318+\u21E7+Z'], action: 'Redo' },
{ keys: ['?'], action: 'Toggle shortcuts' },
{ keys: ['Esc'], action: 'Close dialog' },
];

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
@ -14,6 +14,7 @@ interface ReviewFileTreeProps {
viewedSet?: Set<string>;
onMarkViewed?: (filePath: string) => void;
onUnmarkViewed?: (filePath: string) => void;
activeFilePath?: string;
}
interface TreeNode {
@ -108,6 +109,7 @@ const FileStatusIcon = ({ status }: { status: FileStatus }) => {
const TreeItem = ({
node,
selectedFilePath,
activeFilePath,
onSelectFile,
depth,
hunkDecisions,
@ -117,6 +119,7 @@ const TreeItem = ({
}: {
node: TreeNode;
selectedFilePath: string | null;
activeFilePath?: string;
onSelectFile: (filePath: string) => void;
depth: number;
hunkDecisions: Record<string, HunkDecision>;
@ -126,15 +129,19 @@ const TreeItem = ({
}) => {
if (node.isFile && node.file) {
const isSelected = node.file.filePath === selectedFilePath;
const isActive = node.file.filePath === activeFilePath && !isSelected;
const status = getFileStatus(node.file, hunkDecisions);
return (
<button
data-tree-file={node.file.filePath}
onClick={() => onSelectFile(node.file!.filePath)}
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'
: 'text-text-secondary hover:bg-surface-raised hover:text-text'
: isActive
? 'border-l-2 border-blue-400 text-text'
: 'text-text-secondary hover:bg-surface-raised hover:text-text'
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
@ -196,6 +203,7 @@ const TreeItem = ({
key={child.fullPath}
node={child}
selectedFilePath={selectedFilePath}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
depth={depth + 1}
hunkDecisions={hunkDecisions}
@ -215,10 +223,23 @@ export const ReviewFileTree = ({
viewedSet,
onMarkViewed,
onUnmarkViewed,
activeFilePath,
}: ReviewFileTreeProps) => {
const hunkDecisions = useStore((state) => state.hunkDecisions);
const tree = useMemo(() => buildTree(files), [files]);
// Auto-scroll tree to active file when scroll-spy updates
useEffect(() => {
if (!activeFilePath) return;
const btn = document.querySelector<HTMLElement>(
`[data-tree-file="${CSS.escape(activeFilePath)}"]`
);
if (btn) {
btn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, [activeFilePath]);
if (files.length === 0) {
return <div className="p-4 text-center text-xs text-text-muted">No changed files</div>;
}
@ -235,6 +256,7 @@ export const ReviewFileTree = ({
key={node.fullPath}
node={node}
selectedFilePath={selectedFilePath}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
depth={0}
hunkDecisions={hunkDecisions}

View file

@ -45,6 +45,8 @@ export const ReviewToolbar = ({
}: ReviewToolbarProps): React.ReactElement => {
const hasRejected = stats.rejected > 0;
const canApply = hasRejected && !applying;
const totalChanges = stats.pending + stats.accepted + stats.rejected;
const reviewedCount = stats.accepted + stats.rejected;
return (
<div className="flex items-center gap-3 border-b border-border bg-surface-sidebar px-4 py-2">
@ -76,6 +78,21 @@ export const ReviewToolbar = ({
<span className="ml-1">across {changeStats.filesChanged} files</span>
</div>
{/* Review progress */}
{totalChanges > 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: `${(reviewedCount / totalChanges) * 100}%` }}
/>
</div>
<span className="text-text-muted">
{reviewedCount}/{totalChanges}
</span>
</div>
)}
<div className="flex-1" />
<Tooltip>
@ -140,7 +157,7 @@ export const ReviewToolbar = ({
Accept All
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Accept all changes in current file</TooltipContent>
<TooltipContent side="bottom">Accept all changes across all files</TooltipContent>
</Tooltip>
<Tooltip>
@ -153,7 +170,7 @@ export const ReviewToolbar = ({
Reject All
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Reject all changes in current file</TooltipContent>
<TooltipContent side="bottom">Reject all changes across all files</TooltipContent>
</Tooltip>
<Tooltip>

View file

@ -3,6 +3,8 @@ import { useState } from 'react';
import { cn } from '@renderer/lib/utils';
import { AlertTriangle, ChevronRight, Info, ShieldCheck, X } from 'lucide-react';
import { ConfidenceBadge } from './ConfidenceBadge';
import type { TaskScopeConfidence } from '@shared/types';
import type { FC } from 'react';
@ -81,8 +83,13 @@ export const ScopeWarningBanner = ({
Read more
<ChevronRight className={cn('size-3 transition-transform', expanded && 'rotate-90')} />
</button>
<div className="flex-1" />
<ConfidenceBadge confidence={confidence} />
{onDismiss && (
<button onClick={onDismiss} className="ml-auto text-text-muted hover:text-text">
<button onClick={onDismiss} className="text-text-muted hover:text-text">
<X className="size-3.5" />
</button>
)}

View file

@ -0,0 +1,315 @@
import { updateOriginalDoc } from '@codemirror/merge';
import { type Extension, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
import { getChunks } from './CodeMirrorDiffUtils';
import type { ChangeDesc, EditorState, Transaction } from '@codemirror/state';
// ─── Configuration ───
interface PortionCollapseConfig {
margin?: number;
minSize?: number;
portionSize?: number;
}
// ─── State Effects ───
export const expandPortion = StateEffect.define<{ pos: number; count: number }>({
map: (value, mapping: ChangeDesc) => ({
pos: mapping.mapPos(value.pos),
count: value.count,
}),
});
export const expandAllAtPos = StateEffect.define<number>({
map: (pos, mapping: ChangeDesc) => mapping.mapPos(pos),
});
// ─── Widget ───
class PortionCollapseWidget extends WidgetType {
constructor(
readonly lineCount: number,
readonly portionSize: number
) {
super();
}
toDOM(view: EditorView): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-portion-collapse';
const text = document.createElement('span');
text.className = 'cm-portion-collapse-text';
text.textContent = `\u00B7\u00B7\u00B7 ${this.lineCount} unchanged line${this.lineCount !== 1 ? 's' : ''} \u00B7\u00B7\u00B7`;
container.appendChild(text);
const actions = document.createElement('div');
actions.className = 'cm-portion-collapse-actions';
if (this.lineCount > this.portionSize) {
const expandBtn = document.createElement('button');
expandBtn.className = 'cm-portion-expand-btn';
expandBtn.textContent = `Expand ${this.portionSize}`;
expandBtn.title = `Show next ${this.portionSize} lines`;
expandBtn.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
const pos = view.posAtDOM(container);
view.dispatch({
effects: expandPortion.of({ pos, count: this.portionSize }),
});
};
actions.appendChild(expandBtn);
}
const expandAllBtn = document.createElement('button');
expandAllBtn.className = 'cm-portion-expand-all-btn';
expandAllBtn.textContent = 'Expand All';
expandAllBtn.title = `Show all ${this.lineCount} unchanged lines`;
expandAllBtn.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
const pos = view.posAtDOM(container);
view.dispatch({
effects: expandAllAtPos.of(pos),
});
};
actions.appendChild(expandAllBtn);
container.appendChild(actions);
return container;
}
eq(other: PortionCollapseWidget): boolean {
return this.lineCount === other.lineCount && this.portionSize === other.portionSize;
}
// eslint-disable-next-line @typescript-eslint/class-literal-property-style -- WidgetType defines estimatedHeight as getter, cannot override with property
get estimatedHeight(): number {
return 28;
}
ignoreEvent(event: Event): boolean {
return event instanceof MouseEvent;
}
}
// ─── Helpers ───
function buildPortionRanges(
state: EditorState,
margin: number,
minSize: number,
portionSize: number
): DecorationSet {
const result = getChunks(state);
const doc = state.doc;
if (!result) return Decoration.none;
const chunks = result.chunks;
const builder = new RangeSetBuilder<Decoration>();
let prevLine = 1;
for (let i = 0; ; i++) {
const chunk = i < chunks.length ? chunks[i] : null;
const collapseFrom = i ? prevLine + margin : 1;
const collapseTo = chunk ? doc.lineAt(chunk.fromB).number - 1 - margin : doc.lines;
const lines = collapseTo - collapseFrom + 1;
if (lines >= minSize) {
const from = doc.line(collapseFrom).from;
const to = doc.line(collapseTo).to;
const widget = new PortionCollapseWidget(lines, portionSize);
builder.add(from, to, Decoration.replace({ widget, block: true }));
}
if (!chunk) break;
prevLine = doc.lineAt(Math.min(doc.length, chunk.toB)).number;
}
return builder.finish();
}
function handleExpandPortion(
decorations: DecorationSet,
value: { pos: number; count: number },
state: EditorState,
minSize: number,
portionSize: number
): DecorationSet {
const { pos, count } = value;
const doc = state.doc;
let targetFrom = -1;
let targetTo = -1;
decorations.between(0, doc.length, (from, to) => {
if (from <= pos && pos <= to) {
targetFrom = from;
targetTo = to;
return false;
}
});
if (targetFrom < 0) return decorations;
const fromLine = doc.lineAt(targetFrom).number;
const toLine = doc.lineAt(targetTo).number;
const newFromLine = fromLine + count;
const remainingLines = toLine - newFromLine + 1;
if (remainingLines < minSize) {
return decorations.update({
filter: (from) => from !== targetFrom,
});
}
const newFrom = doc.line(newFromLine).from;
const widget = new PortionCollapseWidget(remainingLines, portionSize);
return decorations.update({
filter: (from) => from !== targetFrom,
add: [Decoration.replace({ widget, block: true }).range(newFrom, targetTo)],
});
}
function handleExpandAll(decorations: DecorationSet, pos: number): DecorationSet {
return decorations.update({
filter: (from, to) => !(from <= pos && pos <= to),
});
}
// ─── Theme ───
const portionCollapseTheme = EditorView.theme({
'.cm-portion-collapse': {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 12px',
backgroundColor: 'var(--color-surface-raised)',
borderTop: '1px solid var(--color-border)',
borderBottom: '1px solid var(--color-border)',
minHeight: '28px',
cursor: 'default',
userSelect: 'none',
},
'.cm-portion-collapse-text': {
fontSize: '12px',
color: 'var(--color-text-muted)',
letterSpacing: '0.5px',
},
'.cm-portion-collapse-actions': {
display: 'flex',
alignItems: 'center',
gap: '6px',
},
'.cm-portion-expand-btn': {
padding: '2px 10px',
fontSize: '11px',
fontWeight: '500',
lineHeight: '18px',
color: 'var(--color-text-secondary)',
backgroundColor: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: '4px',
cursor: 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
color: 'var(--color-text)',
backgroundColor: 'rgba(255, 255, 255, 0.06)',
borderColor: 'var(--color-border-emphasis)',
},
'&:active': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
},
'.cm-portion-expand-all-btn': {
padding: '2px 10px',
fontSize: '11px',
fontWeight: '500',
lineHeight: '18px',
color: 'var(--color-text-muted)',
backgroundColor: 'transparent',
border: '1px solid transparent',
borderRadius: '4px',
cursor: 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
color: 'var(--color-text-secondary)',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
borderColor: 'var(--color-border)',
},
'&:active': {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
},
});
// ─── Extension ───
export function portionCollapseExtension(config?: PortionCollapseConfig): Extension {
const margin = config?.margin ?? 3;
const minSize = config?.minSize ?? 4;
const portionSize = config?.portionSize ?? 100;
const field = StateField.define<DecorationSet>({
create(state: EditorState): DecorationSet {
return buildPortionRanges(state, margin, minSize, portionSize);
},
update(deco: DecorationSet, tr: Transaction): DecorationSet {
// 1. Expand effects
let result = deco;
let hasExpandEffect = false;
for (const effect of tr.effects) {
if (effect.is(expandPortion)) {
hasExpandEffect = true;
result = handleExpandPortion(result, effect.value, tr.state, minSize, portionSize);
}
if (effect.is(expandAllAtPos)) {
hasExpandEffect = true;
result = handleExpandAll(result, effect.value);
}
}
if (hasExpandEffect) return result;
// 2. Accept chunk (updateOriginalDoc) → full rebuild
if (tr.effects.some((e) => e.is(updateOriginalDoc))) {
return buildPortionRanges(tr.state, margin, minSize, portionSize);
}
// 3. Document changed (reject, user edit) → full rebuild
if (tr.docChanged) {
return buildPortionRanges(tr.state, margin, minSize, portionSize);
}
// 4. Lazy init
if (deco === Decoration.none) {
const chunks = getChunks(tr.state);
if (chunks) {
return buildPortionRanges(tr.state, margin, minSize, portionSize);
}
}
return deco;
},
provide(f) {
return EditorView.decorations.from(f);
},
});
return [field, portionCollapseTheme];
}

View file

@ -23,6 +23,8 @@ const TEAMMATE_COLORS: Record<string, TeamColorSet> = {
cyan: { border: '#06b6d4', badge: 'rgba(6, 182, 212, 0.15)', text: '#22d3ee' },
orange: { border: '#f97316', badge: 'rgba(249, 115, 22, 0.15)', text: '#fb923c' },
pink: { border: '#ec4899', badge: 'rgba(236, 72, 153, 0.15)', text: '#f472b6' },
/** Reserved for the human user — never assigned to team members. */
user: { border: '#f5f5f4', badge: 'rgba(245, 245, 244, 0.12)', text: '#d6d3d1' },
};
const DEFAULT_COLOR: TeamColorSet = TEAMMATE_COLORS.blue;

View file

@ -0,0 +1,50 @@
import { type RefObject, useCallback, useRef } from 'react';
import { waitForScrollEnd } from '@renderer/hooks/navigation/utils';
interface UseContinuousScrollNavOptions {
scrollContainerRef: RefObject<HTMLElement | null>;
}
interface UseContinuousScrollNavReturn {
scrollToFile: (filePath: string) => void;
isProgrammaticScroll: RefObject<boolean>;
}
export function useContinuousScrollNav(
options: UseContinuousScrollNavOptions
): UseContinuousScrollNavReturn {
const { scrollContainerRef } = options;
const isProgrammaticScroll = useRef(false);
const scrollGeneration = useRef(0);
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;
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]
);
return {
scrollToFile,
isProgrammaticScroll,
};
}

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
import type { EditorView } from '@codemirror/view';
import type { FileChangeSummary } from '@shared/types/review';
@ -19,6 +20,74 @@ interface DiffNavigationState {
setShowShortcutsHelp: (show: boolean) => void;
}
export interface ContinuousNavigationOptions {
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
activeFilePath: string | null;
scrollToFile: (filePath: string) => void;
enabled: boolean;
}
function getEditorViewRefs(
continuousOptions?: ContinuousNavigationOptions
): Map<string, EditorView> | null {
return continuousOptions?.enabled ? continuousOptions.editorViewMapRef.current : null;
}
function getActiveEditorView(
editorViewRef: React.RefObject<EditorView | null>,
continuousOptions?: ContinuousNavigationOptions
): EditorView | null {
const editorViewRefs = getEditorViewRefs(continuousOptions);
if (!editorViewRefs) {
return editorViewRef.current;
}
const { activeFilePath } = continuousOptions!;
// 1. Focused editor
for (const [, view] of editorViewRefs) {
if (view.hasFocus) return view;
}
// 2. activeFilePath editor
if (activeFilePath) {
const view = editorViewRefs.get(activeFilePath);
if (view) return view;
}
// 3. Fallback: first editor
const firstEntry = editorViewRefs.values().next();
return firstEntry.done ? null : firstEntry.value;
}
function getActiveFilePath(
selectedFilePath: string | null,
continuousOptions?: ContinuousNavigationOptions
): string | null {
if (continuousOptions?.enabled && continuousOptions.activeFilePath) {
return continuousOptions.activeFilePath;
}
return selectedFilePath;
}
export function isLastChunkInFile(view: EditorView): boolean {
const result = getChunks(view.state);
if (!result || result.chunks.length === 0) return true;
const cursorPos = view.state.selection.main.head;
const lastChunk = result.chunks[result.chunks.length - 1];
return cursorPos >= lastChunk.fromB;
}
export function isFirstChunkInFile(view: EditorView): boolean {
const result = getChunks(view.state);
if (!result || result.chunks.length === 0) return true;
const cursorPos = view.state.selection.main.head;
const firstChunk = result.chunks[0];
return cursorPos <= firstChunk.toB;
}
export function useDiffNavigation(
files: FileChangeSummary[],
selectedFilePath: string | null,
@ -28,64 +97,135 @@ export function useDiffNavigation(
onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
onClose?: () => void,
onSaveFile?: () => void
onSaveFile?: () => void,
continuousOptions?: ContinuousNavigationOptions
): DiffNavigationState {
// Track hunk index keyed by file path to auto-reset on file change
const [hunkState, setHunkState] = useState<{ filePath: string | null; index: number }>({
filePath: selectedFilePath,
index: 0,
});
const [showShortcutsHelp, setShowShortcutsHelp] = useState(false);
const selectedFile = files.find((f) => f.filePath === selectedFilePath);
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
const selectedFile = files.find((f) => f.filePath === activePath);
const totalHunks = selectedFile?.snippets.length ?? 0;
// Derive currentHunkIndex: reset to 0 when selectedFilePath changes
const currentHunkIndex = hunkState.filePath === selectedFilePath ? hunkState.index : 0;
const currentHunkIndex = hunkState.filePath === activePath ? hunkState.index : 0;
const setCurrentHunkIndex = useCallback(
(updater: number | ((prev: number) => number)) => {
setHunkState((prev) => {
const newIndex =
typeof updater === 'function'
? updater(prev.filePath === selectedFilePath ? prev.index : 0)
? updater(prev.filePath === activePath ? prev.index : 0)
: updater;
return { filePath: selectedFilePath, index: newIndex };
return { filePath: activePath, index: newIndex };
});
},
[selectedFilePath]
[activePath]
);
const goToNextHunk = useCallback(() => {
const view = editorViewRef.current;
if (view) {
goToNextChunk(view);
}
setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1));
}, [editorViewRef, totalHunks, setCurrentHunkIndex]);
const goToPrevHunk = useCallback(() => {
const view = editorViewRef.current;
if (view) {
goToPreviousChunk(view);
}
setCurrentHunkIndex((prev) => Math.max(prev - 1, 0));
}, [editorViewRef, setCurrentHunkIndex]);
// Stable refs for continuousOptions to avoid stale closures
const continuousOptionsRef = useRef(continuousOptions);
useEffect(() => {
continuousOptionsRef.current = continuousOptions;
});
const goToNextFile = useCallback(() => {
if (files.length === 0) return;
const currentIdx = files.findIndex((f) => f.filePath === selectedFilePath);
const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
const nextIdx = currentIdx < files.length - 1 ? currentIdx + 1 : 0;
onSelectFile(files[nextIdx].filePath);
const nextFilePath = files[nextIdx].filePath;
if (continuousOptionsRef.current?.enabled) {
continuousOptionsRef.current.scrollToFile(nextFilePath);
} else {
onSelectFile(nextFilePath);
}
}, [files, selectedFilePath, onSelectFile]);
const goToPrevFile = useCallback(() => {
if (files.length === 0) return;
const currentIdx = files.findIndex((f) => f.filePath === selectedFilePath);
const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
const prevIdx = currentIdx > 0 ? currentIdx - 1 : files.length - 1;
onSelectFile(files[prevIdx].filePath);
const prevFilePath = files[prevIdx].filePath;
if (continuousOptionsRef.current?.enabled) {
continuousOptionsRef.current.scrollToFile(prevFilePath);
} else {
onSelectFile(prevFilePath);
}
}, [files, selectedFilePath, onSelectFile]);
const goToNextHunk = useCallback(() => {
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
if (!view) return;
if (continuousOptionsRef.current?.enabled) {
if (isLastChunkInFile(view)) {
const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
if (currentIdx < files.length - 1) {
const nextFilePath = files[currentIdx + 1].filePath;
continuousOptionsRef.current.scrollToFile(nextFilePath);
requestAnimationFrame(() => {
const opts = continuousOptionsRef.current;
const nextView = opts?.editorViewMapRef.current.get(nextFilePath);
if (nextView) {
nextView.dispatch({ selection: { anchor: 0 } });
goToNextChunk(nextView);
}
});
}
} else {
goToNextChunk(view);
}
} else {
goToNextChunk(view);
}
setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1));
}, [editorViewRef, totalHunks, setCurrentHunkIndex, files, selectedFilePath]);
const goToPrevHunk = useCallback(() => {
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
if (!view) return;
if (continuousOptionsRef.current?.enabled) {
if (isFirstChunkInFile(view)) {
const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
if (currentIdx > 0) {
const prevFilePath = files[currentIdx - 1].filePath;
continuousOptionsRef.current.scrollToFile(prevFilePath);
requestAnimationFrame(() => {
const opts = continuousOptionsRef.current;
const prevView = opts?.editorViewMapRef.current.get(prevFilePath);
if (prevView) {
const docLength = prevView.state.doc.length;
prevView.dispatch({ selection: { anchor: docLength } });
goToPreviousChunk(prevView);
}
});
}
} else {
goToPreviousChunk(view);
}
} else {
goToPreviousChunk(view);
}
setCurrentHunkIndex((prev) => Math.max(prev - 1, 0));
}, [editorViewRef, setCurrentHunkIndex, files, selectedFilePath]);
const goToHunk = useCallback(
(index: number) => {
setCurrentHunkIndex(Math.max(0, Math.min(index, totalHunks - 1)));
@ -94,14 +234,16 @@ export function useDiffNavigation(
);
const acceptCurrentHunk = useCallback(() => {
if (selectedFilePath && onHunkAccepted) {
onHunkAccepted(selectedFilePath, currentHunkIndex);
const path = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
if (path && onHunkAccepted) {
onHunkAccepted(path, currentHunkIndex);
}
}, [selectedFilePath, currentHunkIndex, onHunkAccepted]);
const rejectCurrentHunk = useCallback(() => {
if (selectedFilePath && onHunkRejected) {
onHunkRejected(selectedFilePath, currentHunkIndex);
const path = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
if (path && onHunkRejected) {
onHunkRejected(path, currentHunkIndex);
}
}, [selectedFilePath, currentHunkIndex, onHunkRejected]);
@ -114,25 +256,43 @@ export function useDiffNavigation(
onSaveFileRef.current = onSaveFile;
}, [onClose, onSaveFile]);
// Keyboard handler — new shortcuts for editable diff
// Keyboard handler
useEffect(() => {
if (!isDialogOpen) return;
const handler = (event: KeyboardEvent) => {
// Skip if CM keymap already handled this event
if (event.defaultPrevented) return;
// Skip inputs/textareas
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return;
}
const isMeta = event.metaKey || event.ctrlKey;
// Alt+J -> next change
// Alt+J -> next hunk (cross-file in continuous mode)
if (event.altKey && event.key.toLowerCase() === 'j') {
event.preventDefault();
const view = editorViewRef.current;
if (view) goToNextChunk(view);
goToNextHunk();
return;
}
// Alt+K -> prev hunk (cross-file in continuous mode)
if (event.altKey && event.key.toLowerCase() === 'k') {
event.preventDefault();
goToPrevHunk();
return;
}
// Alt+ArrowDown -> next file
if (event.altKey && event.key === 'ArrowDown') {
event.preventDefault();
goToNextFile();
return;
}
// Alt+ArrowUp -> prev file
if (event.altKey && event.key === 'ArrowUp') {
event.preventDefault();
goToPrevFile();
return;
}
@ -143,30 +303,50 @@ export function useDiffNavigation(
return;
}
// Cmd+Y -> accept + scroll (fallback when editor not focused)
// Cmd+Y -> accept chunk + next (cross-file aware)
if (isMeta && event.key.toLowerCase() === 'y') {
event.preventDefault();
const view = editorViewRef.current;
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
if (view) {
acceptChunk(view);
requestAnimationFrame(() => goToNextChunk(view));
requestAnimationFrame(() => {
if (continuousOptionsRef.current?.enabled && isLastChunkInFile(view)) {
goToNextFile();
} else {
goToNextChunk(view);
}
});
}
return;
}
// ? -> toggle shortcuts help
if (event.key === '?' && !isMeta && !event.altKey) {
event.preventDefault();
setShowShortcutsHelp((prev) => !prev);
return;
}
// Escape handling
if (event.key === 'Escape') {
if (showShortcutsHelp) {
event.preventDefault();
setShowShortcutsHelp(false);
}
// Note: main Escape handling for closing dialog is in ChangeReviewDialog itself
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isDialogOpen, showShortcutsHelp, editorViewRef]);
}, [
isDialogOpen,
showShortcutsHelp,
editorViewRef,
goToNextFile,
goToPrevFile,
goToNextHunk,
goToPrevHunk,
]);
return {
currentHunkIndex,

View file

@ -0,0 +1,150 @@
import { type RefObject, useCallback, useEffect, useRef } from 'react';
import type { FileChangeWithContent } from '@shared/types';
const MAX_CONCURRENT = 3;
const PRELOAD_COUNT = 5;
interface UseLazyFileContentOptions {
teamName: string;
memberName: string | undefined;
filePaths: string[];
scrollContainerRef: RefObject<HTMLElement | null>;
fileContents: Record<string, FileChangeWithContent>;
fileContentsLoading: Record<string, boolean>;
fetchFileContent: (
teamName: string,
memberName: string | undefined,
filePath: string
) => Promise<void>;
enabled: boolean;
}
interface UseLazyFileContentReturn {
registerLazyRef: (filePath: string) => (element: HTMLElement | null) => void;
}
export function useLazyFileContent(options: UseLazyFileContentOptions): UseLazyFileContentReturn {
const { enabled, scrollContainerRef } = options;
const activeLoads = useRef(new Set<string>());
const pendingQueue = useRef<string[]>([]);
const observerRef = useRef<IntersectionObserver | null>(null);
const elementRefs = useRef(new Map<string, HTMLElement>());
// Stable ref to avoid stale closures in observer/processQueue callbacks
const optionsRef = useRef(options);
useEffect(() => {
optionsRef.current = options;
});
const shouldLoad = useCallback((filePath: string): boolean => {
const opts = optionsRef.current;
if (opts.fileContents[filePath]) return false;
if (opts.fileContentsLoading[filePath]) return false;
if (activeLoads.current.has(filePath)) return false;
return true;
}, []);
// Refs for loadFile/processQueue to avoid circular useCallback deps
const loadFileRef = useRef<(fp: string) => Promise<void>>();
const processQueueRef = useRef<() => void>();
loadFileRef.current = async (filePath: string) => {
if (!shouldLoad(filePath)) return;
activeLoads.current.add(filePath);
try {
const opts = optionsRef.current;
await opts.fetchFileContent(opts.teamName, opts.memberName, filePath);
} finally {
activeLoads.current.delete(filePath);
processQueueRef.current?.();
}
};
processQueueRef.current = () => {
while (activeLoads.current.size < MAX_CONCURRENT && pendingQueue.current.length > 0) {
const nextPath = pendingQueue.current.shift()!;
if (shouldLoad(nextPath)) {
void loadFileRef.current?.(nextPath);
}
}
};
const enqueueLoad = useCallback(
(filePath: string) => {
if (!shouldLoad(filePath)) return;
if (activeLoads.current.size < MAX_CONCURRENT) {
void loadFileRef.current?.(filePath);
} else {
if (!pendingQueue.current.includes(filePath)) {
pendingQueue.current.push(filePath);
}
}
},
[shouldLoad]
);
// Preload first N files on mount
useEffect(() => {
if (!enabled) return;
const toPreload = optionsRef.current.filePaths.slice(0, PRELOAD_COUNT);
for (const fp of toPreload) {
enqueueLoad(fp);
}
}, [enabled, enqueueLoad]);
// IntersectionObserver for lazy loading
useEffect(() => {
if (!enabled || !scrollContainerRef.current) return;
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const filePath = entry.target.getAttribute('data-lazy-file');
if (!filePath) continue;
enqueueLoad(filePath);
}
},
{
root: scrollContainerRef.current,
rootMargin: '200% 0px 200% 0px',
threshold: 0,
}
);
// Observe already mounted elements
for (const [, element] of elementRefs.current) {
observerRef.current.observe(element);
}
return () => {
observerRef.current?.disconnect();
observerRef.current = null;
};
}, [enabled, scrollContainerRef, enqueueLoad]);
const registerLazyRef = useCallback((filePath: string) => {
return (element: HTMLElement | null) => {
const observer = observerRef.current;
const prev = elementRefs.current.get(filePath);
if (prev && observer) {
observer.unobserve(prev);
}
elementRefs.current.delete(filePath);
if (element) {
element.setAttribute('data-lazy-file', filePath);
elementRefs.current.set(filePath, element);
if (observer) {
observer.observe(element);
}
}
};
}, []);
return { registerLazyRef };
}

View file

@ -0,0 +1,114 @@
import { type RefObject, useCallback, useEffect, useRef } from 'react';
interface UseVisibleFileSectionOptions {
onVisibleFileChange: (filePath: string) => void;
scrollContainerRef: RefObject<HTMLElement | null>;
isProgrammaticScroll: RefObject<boolean>;
}
interface UseVisibleFileSectionReturn {
registerFileSectionRef: (filePath: string) => (element: HTMLElement | null) => void;
}
export function useVisibleFileSection(
options: UseVisibleFileSectionOptions
): UseVisibleFileSectionReturn {
const { onVisibleFileChange, scrollContainerRef, isProgrammaticScroll } = options;
const visibleFilePaths = useRef<Set<string>>(new Set());
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
const observerRef = useRef<IntersectionObserver | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const updateTopmostVisible = useCallback(() => {
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]);
const debouncedUpdate = useCallback(() => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(updateTopmostVisible, 100);
}, [updateTopmostVisible]);
useEffect(() => {
if (!scrollContainerRef.current) return;
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();
observerRef.current = null;
clearTimeout(debounceRef.current);
};
}, [scrollContainerRef, debouncedUpdate]);
const registerFileSectionRef = useCallback((filePath: string) => {
return (element: HTMLElement | null) => {
const observer = observerRef.current;
if (!observer) return;
const prev = elementRefs.current.get(filePath);
if (prev) {
observer.unobserve(prev);
elementRefs.current.delete(filePath);
visibleFilePaths.current.delete(filePath);
}
if (element) {
element.setAttribute('data-file-path', filePath);
elementRefs.current.set(filePath, element);
observer.observe(element);
}
};
}, []);
return { registerFileSectionRef };
}

View file

@ -492,6 +492,11 @@ body {
-webkit-app-region: no-drag;
}
/* Prevent drag region bleed-through on Windows: all fixed overlays must be no-drag */
.fixed {
-webkit-app-region: no-drag;
}
/* Hide horizontal scrollbar in tab bar */
.scrollbar-none {
scrollbar-width: none;

View file

@ -93,13 +93,9 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
map.set(removed[i].name, removed[i].color ?? getMemberColor(active.length + i));
}
// Map "user" to team-lead's resolved color
const lead = members.find(
(m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead')
);
if (lead) {
map.set('user', map.get(lead.name) ?? getMemberColor(0));
}
// "user" = the human operator; gets a unique reserved color
// that is never assigned to any team member.
map.set('user', 'user');
return map;
}