diff --git a/.claude/rules/react.md b/.claude/rules/react.md index ba893c65..7e07988e 100644 --- a/.claude/rules/react.md +++ b/.claude/rules/react.md @@ -46,5 +46,22 @@ components/ └── sidebar/ # Sidebar navigation ``` +## Data Access: Store over Props +When data is available in the Zustand store, child components should read it directly via `useStore()` instead of receiving it through props. This avoids unnecessary prop drilling and keeps parent components clean. + +```tsx +// Preferred — child reads from store +const ProcessesSection = () => { + const teamName = useStore((s) => s.selectedTeamName); + const data = useStore((s) => s.selectedTeamData); + // ... +}; + +// Avoid — parent drills store data as props + +``` + +Only pass props when the data is NOT in the store (e.g. local state, computed values, callbacks). + ## Contexts - `contexts/TabUIContext.tsx` - Per-tab UI state isolation diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 81ecb971..c92708e3 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -6,6 +6,7 @@ This project follows the Contributor Covenant Code of Conduct. - Be respectful and constructive. - Assume good intent and discuss ideas, not people. - Give actionable feedback and accept feedback gracefully. +- If a change significantly affects the UI, discuss the design approach with maintainers or the community first so we can align on the best direction. ## Unacceptable Behavior - Harassment, discrimination, or personal attacks. @@ -13,7 +14,7 @@ This project follows the Contributor Covenant Code of Conduct. - Publishing private information without explicit permission. ## Enforcement -Project maintainers are responsible for clarifying and enforcing this code of conduct and may take corrective action for unacceptable behavior. +Maintainers are here to help keep our community welcoming. They’ll clarify expectations when needed and, if necessary, take steps to address behavior that goes against these standards. ## Reporting Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md`. diff --git a/README.md b/README.md index 1aecd604..b8b8bfaa 100644 --- a/README.md +++ b/README.md @@ -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) --- diff --git a/docs/iterations/diff-view/continuous-scroll/overview.md b/docs/iterations/diff-view/continuous-scroll/overview.md new file mode 100644 index 00000000..69aa7338 --- /dev/null +++ b/docs/iterations/diff-view/continuous-scroll/overview.md @@ -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` +- Поддерживает 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`. 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) diff --git a/docs/iterations/diff-view/continuous-scroll/phase-1-continuous-scroll-and-scroll-spy.md b/docs/iterations/diff-view/continuous-scroll/phase-1-continuous-scroll-and-scroll-spy.md new file mode 100644 index 00000000..31bdd731 --- /dev/null +++ b/docs/iterations/diff-view/continuous-scroll/phase-1-continuous-scroll-and-scroll-spy.md @@ -0,0 +1,1543 @@ +# Фаза 1: Continuous Scroll + Scroll-Spy + +## 1. Обзор + +**Цель:** Заменить текущий single-file diff view на непрерывный scroll всех файлов (как на GitHub PR review). + +**Текущее поведение:** `ChangeReviewDialog` показывает один файл за раз. Пользователь кликает по файлу в `ReviewFileTree` — диалог переключает контент. Для каждого файла создаётся/уничтожается один `CodeMirrorDiffView`. При переключении undo history сохраняется в `editorStateCache`. Контент файла загружается lazy (useEffect при смене `selectedReviewFilePath`). + +**Новое поведение:** +- Все файлы рендерятся в одном scroll-контейнере вертикально, один за другом +- Каждый файл имеет sticky header (имя, badges, кнопки) — прилипает к верху при скролле +- File tree подсвечивает текущий видимый файл (scroll-spy) +- Клик по файлу в tree = smooth scroll к файлу в контенте +- Все CodeMirror editors живут одновременно — нет необходимости в editorStateCache +- Keyboard navigation: Alt+ArrowDown/Up для перехода между файлами +- Контент всех файлов загружается при открытии диалога (bulk load для фазы 1) + +--- + +## 2. Новые файлы + +### 2.1. `FileSectionHeader.tsx` + +**Путь:** `src/renderer/components/team/review/FileSectionHeader.tsx` + +**Назначение:** Sticky header для каждой file section в continuous scroll. Извлечён из `ChangeReviewDialog.tsx` (строки 437-509 — блок `{/* File header with content source badge and save/discard */}`). + +#### Props Interface + +```typescript +import type { FileChangeSummary, FileChangeWithContent, HunkDecision } from '@shared/types'; + +interface FileSectionHeaderProps { + /** Данные файла (relativePath, isNewFile, filePath и т.д.) */ + file: FileChangeSummary; + + /** Загруженный контент файла (для отображения contentSource badge). null = ещё не загружен */ + fileContent: FileChangeWithContent | null; + + /** Решение по файлу целиком ('accepted' | 'rejected' | 'pending' | undefined) */ + fileDecision: HunkDecision | undefined; + + /** Есть ли несохранённые ручные правки для этого файла */ + hasEdits: boolean; + + /** Идёт ли сейчас операция сохранения/применения (disabled state для кнопки Save) */ + applying: boolean; + + /** Callback: пользователь нажал "Discard" для отмены ручных правок */ + onDiscard: (filePath: string) => void; + + /** Callback: пользователь нажал "Save File" для записи на диск */ + onSave: (filePath: string) => void; +} +``` + +#### Что рендерит + +1. **Sticky container:** `
` + - `data-file-path={file.filePath}` — для scroll-spy (querySelector) + - `bg-surface-sidebar` фон (непрозрачный, чтобы контент под sticky не просвечивал) + - `border-b border-border` + +2. **Имя файла:** `file.relativePath` — `text-xs font-medium text-text` + +3. **NEW badge** (условный): + ```tsx + {file.isNewFile && ( + + NEW + + )} + ``` + +4. **Content source badge** (условный, только когда fileContent загружен): + ```tsx + {fileContent?.contentSource && ( + + {CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource} + + )} + ``` + `CONTENT_SOURCE_LABELS` определяется в этом же файле (вынесен из `ChangeReviewDialog.tsx` строка 38-44): + ```typescript + const CONTENT_SOURCE_LABELS: Record = { + 'file-history': 'File History', + 'snippet-reconstruction': 'Reconstructed', + 'disk-current': 'Current Disk', + 'git-fallback': 'Git Fallback', + unavailable: 'Unavailable', + }; + ``` + +5. **File decision indicator** (условный): + ```tsx + {fileDecision && ( + + {fileDecision} + + )} + ``` + Цвета: + - `accepted` -> `bg-green-500/20 text-green-400` + - `rejected` -> `bg-red-500/20 text-red-400` + - `pending` -> `bg-zinc-500/20 text-zinc-400` + +6. **Save/Discard кнопки** (условные, только когда `hasEdits === true`): + - Discard: `` + "Discard" — `bg-orange-500/15 text-orange-400 hover:bg-orange-500/25`, вызывает `onDiscard(file.filePath)` + - Save: `` + "Save File" — `bg-green-500/15 text-green-400 hover:bg-green-500/25`, вызывает `onSave(file.filePath)`, `disabled={applying}` + - Во время `applying` вместо `` показывается `` (спиннер) + - Кнопка Save имеет `disabled:opacity-50` для disabled state + - Обе кнопки обёрнуты в `` (из `@renderer/components/ui/tooltip`) + - Save tooltip показывает keyboard shortcut `Cmd+Enter` через ``: + ```tsx + + Save file to disk + + ⌘↵ + + + ``` + - Discard tooltip: "Discard all edits for this file" + + **Импорты иконок:** `Save`, `Undo2`, `Loader2` из `lucide-react` + +7. **Кнопки обёрнуты в `ml-auto` контейнер:** + ```tsx +
+ {hasEdits && ( /* Discard + Save */ )} +
+ ``` + +#### Sticky позиционирование + +```tsx +
+``` + +**Важно:** `z-10` гарантирует, что sticky header перекрывает контент CodeMirror. Фон ОБЯЗАТЕЛЬНО непрозрачный (`bg-surface-sidebar`), чтобы diff-строки не просвечивали через header. + +**Важно:** Когда несколько sticky headers "стопятся" (файл A scrolled out, файл B виден) — только один header виден сверху. Это нативное поведение CSS `position: sticky`: каждый header прилипает в рамках своего parent section div. + +--- + +### 2.2. `FileSectionDiff.tsx` + +**Путь:** `src/renderer/components/team/review/FileSectionDiff.tsx` + +**Назначение:** Diff-контент для одного файла в continuous scroll. Извлечён из `ChangeReviewDialog.tsx` (строки 511-561 — блоки loading state, CodeMirror diff view, fallback snippet view). + +#### Props Interface + +```typescript +import type { EditorView } from '@codemirror/view'; +import type { FileChangeSummary, FileChangeWithContent } from '@shared/types'; + +interface FileSectionDiffProps { + /** Данные файла */ + file: FileChangeSummary; + + /** Загруженный контент (null = ещё не загружен) */ + fileContent: FileChangeWithContent | null; + + /** Контент загружается */ + isLoading: boolean; + + /** Collapse unchanged regions в CodeMirror */ + collapseUnchanged: boolean; + + /** Callback при accept hunk */ + onHunkAccepted: (filePath: string, hunkIndex: number) => void; + + /** Callback при reject hunk */ + onHunkRejected: (filePath: string, hunkIndex: number) => void; + + /** Callback: файл полностью просмотрен (sentinel виден в viewport) */ + onFullyViewed: (filePath: string) => void; + + /** Callback: ручная правка контента (debounced из CodeMirror) */ + onContentChanged: (filePath: string, content: string) => void; + + /** Callback для регистрации EditorView в общий Map. Вызывается при создании/уничтожении */ + onEditorViewReady: (filePath: string, view: EditorView | null) => void; + + /** + * Counter для force-rebuild editor (инкрементируется при discard). + * Используется как часть key для CodeMirrorDiffView. + */ + discardCounter: number; + + /** Auto-viewed включён (для sentinel IntersectionObserver) */ + autoViewed: boolean; + + /** Файл уже помечен как viewed (не вызывать onFullyViewed повторно) */ + isViewed: boolean; +} +``` + +#### Логика рендеринга + +1. **Loading state:** Если `isLoading` — показать `FileSectionPlaceholder` (из соседнего файла) + +2. **Unavailable fallback:** Если `!fileContent || fileContent.contentSource === 'unavailable'` — показать `` + + **Важно:** Также проверить `fileContent.modifiedFullContent !== null`. В текущем коде (строка 523) условие: `fileContent.contentSource !== 'unavailable' && fileContent.modifiedFullContent !== null`. Если `modifiedFullContent === null` — тоже fallback на ReviewDiffContent. + +3. **CodeMirror diff:** Иначе — полноценный CodeMirror: + ```tsx + + onHunkAccepted(file.filePath, idx)} + onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)} + onFullyViewed={handleFullyViewed} + editorViewRef={localEditorViewRef} + onContentChanged={(content) => onContentChanged(file.filePath, content)} + /> + + ``` + + **Обрати внимание:** + - `initialState` **не передаётся** — в continuous mode нет cache, editors живут одновременно + - `onFullyViewed` передаётся как `handleFullyViewed` (локальный callback без аргументов, т.к. `CodeMirrorDiffView.onFullyViewed` имеет тип `() => void`) + - `DiffErrorBoundary.props`: `filePath` (string), `oldString` (optional string), `newString` (optional string), `onRetry` (optional callback). Без `onRetry` — нет кнопки retry, только показ ошибки + +4. **handleFullyViewed — bridge между sentinel и parent callback:** + + `CodeMirrorDiffView.onFullyViewed` имеет сигнатуру `() => void`. Наш `FileSectionDiff.onFullyViewed` принимает `(filePath: string) => void`. Нужен bridge: + + ```typescript + const handleFullyViewed = useCallback(() => { + onFullyViewed(file.filePath); + }, [file.filePath, onFullyViewed]); + ``` + + Этот `handleFullyViewed` передаётся и в `CodeMirrorDiffView.onFullyViewed`, и в sentinel observer (оба вызывают одну функцию). Однако в continuous mode мы используем **собственный sentinel** вместо встроенного `CodeMirrorDiffView` sentinel (см. ниже). + +5. **EditorView регистрация:** + ```typescript + const localEditorViewRef = useRef(null); + + // Sync to parent Map при mount/unmount + useEffect(() => { + return () => { + // При unmount сообщить parent что view уничтожен + onEditorViewReady(file.filePath, null); + }; + }, [file.filePath, onEditorViewReady]); + + // Нужен useEffect чтобы проверить ref после рендера CodeMirrorDiffView + useEffect(() => { + if (localEditorViewRef.current) { + onEditorViewReady(file.filePath, localEditorViewRef.current); + } + }); + ``` + + **Как CodeMirrorDiffView устанавливает ref:** + В `CodeMirrorDiffView.tsx` строки 685-688 — при создании EditorView он синхронно записывает view в `externalViewRef.current`: + ```typescript + const extRef = externalViewRefHolder.current; + if (extRef) { + (extRef as React.MutableRefObject).current = view; + } + ``` + Это происходит в useEffect (строка 666), после чего наш вторичный useEffect (без deps) на следующем render cycle ловит значение и вызывает `onEditorViewReady`. + +6. **Sentinel для auto-viewed:** + + В continuous mode встроенный sentinel `CodeMirrorDiffView` (`endSentinelRef` внутри компонента, строка 281) может некорректно работать, т.к. `CodeMirrorDiffView` рендерится внутри `
` с `maxHeight: '100%'`. В continuous scroll нет фиксированной высоты — CodeMirror занимает весь свой контент. Поэтому встроенный sentinel (`threshold: 1.0`, строка 755) может не сработать. + + **Решение:** Внешний sentinel в `FileSectionDiff`, с `threshold: 0.85` (а не 1.0). Причина: в continuous scroll с collapsed unchanged regions файл может не занимать 100% viewport, и sentinel может быть виден на 85-90%. + + ```tsx + const sentinelRef = useRef(null); + + useEffect(() => { + if (!sentinelRef.current || !autoViewed || isViewed) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + onFullyViewed(file.filePath); + } + } + }, + { threshold: 0.85 } + ); + + observer.observe(sentinelRef.current); + return () => observer.disconnect(); + }, [autoViewed, isViewed, file.filePath, onFullyViewed]); + ``` + + Sentinel div в конце секции: + ```tsx +
+ ``` + + **Edge case:** Для очень коротких файлов (3-5 строк) sentinel может быть сразу виден при mount. Это ОК — файл просмотрен. + + **Дублирование:** Встроенный `CodeMirrorDiffView.onFullyViewed` тоже вызовет `handleFullyViewed` при scroll end внутри CM. Можно передать `onFullyViewed={undefined}` в CodeMirrorDiffView чтобы отключить встроенный observer (проп optional). Или оставить оба — двойной вызов `markViewed` для уже viewed файла — no-op (проверяется через `isViewed`). + + **Рекомендация:** Передать `onFullyViewed={undefined}` в CodeMirrorDiffView и полагаться только на внешний sentinel. + +#### Важные замечания + +- `DiffErrorBoundary` оборачивает только CodeMirror, не fallback `ReviewDiffContent` +- `key` включает `discardCounter` для force-rebuild при discard edits +- Условие для CodeMirror рендеринга: `!isLoading && fileContent && fileContent.contentSource !== 'unavailable' && fileContent.modifiedFullContent !== null` + +--- + +### 2.3. `FileSectionPlaceholder.tsx` + +**Путь:** `src/renderer/components/team/review/FileSectionPlaceholder.tsx` + +**Назначение:** Skeleton placeholder для file section пока контент загружается. + +#### Props Interface + +```typescript +interface FileSectionPlaceholderProps { + /** Имя файла для отображения в заголовке skeleton */ + fileName: string; +} +``` + +#### Что рендерит + +```tsx +export const FileSectionPlaceholder = ({ fileName }: FileSectionPlaceholderProps) => ( +
+ {/* Header area */} +
+ {fileName} +
+
+ + {/* Content shimmer lines */} +
+
+
+
+
+
+
+); +``` + +**CSS:** `animate-pulse` — встроенная Tailwind анимация для skeleton loading. Пульсирует opacity между 1 и 0.5. + +**Высота:** Примерно 120-140px, достаточно чтобы placeholder не "прыгал" при загрузке контента. Но это не идеальное совпадение с финальной высотой diff — абсолютной точности не требуется. + +--- + +### 2.4. `useVisibleFileSection.ts` + +**Путь:** `src/renderer/hooks/useVisibleFileSection.ts` + +**Назначение:** Scroll-spy хук. Отслеживает какой файл сейчас виден в viewport. По паттерну `useVisibleAIGroup.ts`. + +#### Interface + +```typescript +import { type RefObject } from 'react'; + +interface UseVisibleFileSectionOptions { + /** Callback: вызывается при смене видимого файла */ + onVisibleFileChange: (filePath: string) => void; + + /** Scroll container ref (ContinuousScrollView outer div) */ + scrollContainerRef: RefObject; + + /** Подавление scroll-spy во время programmatic scroll */ + isProgrammaticScroll: RefObject; +} + +interface UseVisibleFileSectionReturn { + /** + * Регистрация file section элемента для наблюдения. + * Возвращает ref callback — передать в div section. + * Пример:
+ */ + registerFileSectionRef: (filePath: string) => (element: HTMLElement | null) => void; +} +``` + +#### Реализация (описание) + +```typescript +export function useVisibleFileSection( + options: UseVisibleFileSectionOptions +): UseVisibleFileSectionReturn { + const { onVisibleFileChange, scrollContainerRef, isProgrammaticScroll } = options; + + // Set видимых filePath + const visibleFilePaths = useRef>(new Set()); + + // Map: filePath -> HTMLElement + const elementRefs = useRef>(new Map()); + + // Observer ref + const observerRef = useRef(null); + + // Debounce timer + const debounceRef = useRef>(); + + // Определить topmost visible file + const updateTopmostVisible = useCallback(() => { + // Если programmatic scroll — не обновлять (иначе race condition) + if (isProgrammaticScroll.current) return; + + if (visibleFilePaths.current.size === 0) return; + + let topmostPath: string | null = null; + let minTop = Infinity; + + visibleFilePaths.current.forEach((filePath) => { + const element = elementRefs.current.get(filePath); + if (element) { + const rect = element.getBoundingClientRect(); + if (rect.top < minTop) { + minTop = rect.top; + topmostPath = filePath; + } + } + }); + + if (topmostPath) { + onVisibleFileChange(topmostPath); + } + }, [onVisibleFileChange, isProgrammaticScroll]); + + // Debounced версия + const debouncedUpdate = useCallback(() => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(updateTopmostVisible, 100); + }, [updateTopmostVisible]); + + // Создание IntersectionObserver + useEffect(() => { + observerRef.current = new IntersectionObserver( + (entries) => { + let changed = false; + + for (const entry of entries) { + const filePath = entry.target.getAttribute('data-file-path'); + if (!filePath) continue; + + if (entry.isIntersecting && entry.intersectionRatio >= 0.1) { + if (!visibleFilePaths.current.has(filePath)) { + visibleFilePaths.current.add(filePath); + changed = true; + } + } else { + if (visibleFilePaths.current.has(filePath)) { + visibleFilePaths.current.delete(filePath); + changed = true; + } + } + } + + if (changed) { + debouncedUpdate(); + } + }, + { + root: scrollContainerRef.current, + threshold: 0.1, + rootMargin: '0px', + } + ); + + return () => { + observerRef.current?.disconnect(); + clearTimeout(debounceRef.current); + }; + }, [scrollContainerRef, debouncedUpdate]); + + // Register ref callback + const registerFileSectionRef = useCallback((filePath: string) => { + return (element: HTMLElement | null) => { + const observer = observerRef.current; + if (!observer) return; + + // Cleanup previous + const prev = elementRefs.current.get(filePath); + if (prev) { + observer.unobserve(prev); + elementRefs.current.delete(filePath); + visibleFilePaths.current.delete(filePath); + } + + // Register new + if (element) { + element.setAttribute('data-file-path', filePath); + elementRefs.current.set(filePath, element); + observer.observe(element); + } + }; + }, []); + + return { registerFileSectionRef }; +} +``` + +#### Ключевые отличия от `useVisibleAIGroup` + +| Аспект | `useVisibleAIGroup` | `useVisibleFileSection` | +|--------|--------------------|-----------------------| +| threshold | `0.5` (default, configurable via `threshold?` option) | `0.1` (файлы длинные, 50% может быть за viewport) | +| debounce | нет (вызывает updateTopmostVisible синхронно) | 100ms (стабильность при быстром скролле) | +| programmatic scroll suppression | нет | да (`isProgrammaticScroll` ref) | +| data attribute | `data-aigroup-id` | `data-file-path` | +| root | опциональный `rootRef?.current ?? null` | обязательный `scrollContainerRef.current` | +| callback name | `onVisibleChange` | `onVisibleFileChange` | + +#### Edge Cases + +- **Пустой список файлов:** Observer создаётся, но никто не регистрируется — no-op +- **Один файл:** Всегда виден, `onVisibleFileChange` вызовется один раз при mount +- **Быстрый scroll:** Debounce 100ms группирует обновления +- **Resize окна:** IntersectionObserver автоматически пересчитывает intersections +- **scrollContainerRef.current is null при первом рендере:** Observer создаётся с `root: null` — будет наблюдать viewport вместо контейнера. Решение: добавить guard `if (!scrollContainerRef.current) return;` в useEffect, либо убедиться что ref установлен до mount дочерних компонентов (ref на тот же div что и scrollContainerRef) + +--- + +### 2.5. `useContinuousScrollNav.ts` + +**Путь:** `src/renderer/hooks/useContinuousScrollNav.ts` + +**Назначение:** Навигация в continuous scroll — scroll-to-file, keyboard shortcuts, подавление scroll-spy во время programmatic scroll. + +#### Interface + +```typescript +import type { RefObject } from 'react'; + +interface UseContinuousScrollNavOptions { + /** Ref на scroll container (ContinuousScrollView outer div) */ + scrollContainerRef: RefObject; + + /** Упорядоченный список filePath (порядок = порядок рендеринга) */ + filePaths: string[]; + + /** Текущий активный файл (от scroll-spy) */ + activeFilePath: string | null; + + /** Диалог открыт (для keyboard listeners) */ + isOpen: boolean; +} + +interface UseContinuousScrollNavReturn { + /** Scroll к файлу по filePath (smooth) */ + scrollToFile: (filePath: string) => void; + + /** + * Ref-flag: true пока идёт programmatic scroll. + * Передаётся в useVisibleFileSection для подавления scroll-spy. + */ + isProgrammaticScroll: RefObject; +} +``` + +#### Реализация (описание) + +```typescript +import { waitForScrollEnd } from '@renderer/hooks/navigation/utils'; + +export function useContinuousScrollNav( + options: UseContinuousScrollNavOptions +): UseContinuousScrollNavReturn { + const { scrollContainerRef, filePaths, activeFilePath, isOpen } = options; + + const isProgrammaticScroll = useRef(false); + + const scrollToFile = useCallback( + (filePath: string) => { + const container = scrollContainerRef.current; + if (!container) return; + + const section = container.querySelector( + `[data-file-path="${CSS.escape(filePath)}"]` + ); + if (!section) return; + + // Подавить scroll-spy + isProgrammaticScroll.current = true; + + section.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + // Дождаться окончания scroll и снять подавление + // waitForScrollEnd default timeout = 400ms, передаём 500ms для запаса + void waitForScrollEnd(container, 500).then(() => { + isProgrammaticScroll.current = false; + }); + }, + [scrollContainerRef] + ); + + // Keyboard: Alt+ArrowDown = next file, Alt+ArrowUp = prev file + useEffect(() => { + if (!isOpen) return; + + const handler = (e: KeyboardEvent) => { + if (!e.altKey) return; + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const currentIdx = filePaths.indexOf(activeFilePath ?? ''); + const nextIdx = currentIdx < filePaths.length - 1 ? currentIdx + 1 : 0; + scrollToFile(filePaths[nextIdx]); + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + const currentIdx = filePaths.indexOf(activeFilePath ?? ''); + const prevIdx = currentIdx > 0 ? currentIdx - 1 : filePaths.length - 1; + scrollToFile(filePaths[prevIdx]); + } + }; + + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [isOpen, filePaths, activeFilePath, scrollToFile]); + + return { + scrollToFile, + isProgrammaticScroll, + }; +} +``` + +#### Race condition: scroll-spy vs programmatic scroll + +**Проблема:** Когда пользователь кликает на файл в tree — мы вызываем `scrollToFile()`. Scroll-spy видит промежуточные файлы пролетающие мимо viewport и обновляет `activeFilePath`. Это "мигание" в tree. + +**Решение:** +1. `isProgrammaticScroll` ref устанавливается в `true` перед `scrollIntoView` +2. `useVisibleFileSection` проверяет этот ref в `updateTopmostVisible` и молчит +3. `waitForScrollEnd()` (из `navigation/utils.ts`) ждёт стабилизации `scrollTop` (3 стабильных кадра `requestAnimationFrame` с `Math.abs(currentScrollTop - lastScrollTop) < 1`) +4. После стабилизации ref сбрасывается в `false` +5. Scroll-spy продолжает работать нормально + +**Timeout:** `waitForScrollEnd` имеет дефолтный fallback timeout 400ms (строка 172 в `navigation/utils.ts`). Smooth scroll в Chromium занимает ~300-400ms. Передаём 500ms для запаса. + +#### `CSS.escape(filePath)` + +**Важно:** `filePath` может содержать спецсимволы (точки, слеши). `CSS.escape()` экранирует их для `querySelector`. Пример: `src/utils/path.ts` -> `src\/utils\/path\.ts` в селекторе. + +#### Edge case: `filePaths.indexOf(activeFilePath ?? '')` returns -1 + +Если `activeFilePath` нет в `filePaths` (или null), `indexOf` вернёт -1. Тогда: +- ArrowDown: `nextIdx = -1 < length - 1 ? 0 : 0` = 0 — переход к первому файлу. OK. +- ArrowUp: `prevIdx = -1 > 0 ? ... : length - 1` = last — переход к последнему файлу. OK. + +--- + +### 2.6. `ContinuousScrollView.tsx` + +**Путь:** `src/renderer/components/team/review/ContinuousScrollView.tsx` + +**Назначение:** Главный контейнер continuous scroll. Заменяет single-file diff area в `ChangeReviewDialog`. + +#### Props Interface + +```typescript +import type { EditorView } from '@codemirror/view'; +import type { + FileChangeSummary, + FileChangeWithContent, + HunkDecision, +} from '@shared/types'; + +interface ContinuousScrollViewProps { + /** Список файлов из activeChangeSet.files */ + files: FileChangeSummary[]; + + /** Загруженный контент: filePath -> FileChangeWithContent */ + fileContents: Record; + + /** Флаги загрузки контента: filePath -> boolean */ + fileContentsLoading: Record; + + /** Set просмотренных файлов */ + viewedSet: Set; + + /** Ручные правки: filePath -> content string */ + editedContents: Record; + + /** Решения по файлам: filePath -> HunkDecision */ + fileDecisions: Record; + + /** Collapse unchanged regions */ + collapseUnchanged: boolean; + + /** Applying in progress */ + applying: boolean; + + /** Auto-viewed включён */ + autoViewed: boolean; + + /** Counter для force rebuild editors при discard */ + discardCounter: number; + + // -- Callbacks -- + + /** Hunk accepted в CodeMirror */ + onHunkAccepted: (filePath: string, hunkIndex: number) => void; + + /** Hunk rejected в CodeMirror */ + onHunkRejected: (filePath: string, hunkIndex: number) => void; + + /** Файл полностью просмотрен (auto-viewed) */ + onFullyViewed: (filePath: string) => void; + + /** Ручная правка контента */ + onContentChanged: (filePath: string, content: string) => void; + + /** Discard edits для файла */ + onDiscard: (filePath: string) => void; + + /** Save файла на диск */ + onSave: (filePath: string) => void; + + /** Callback: видимый файл изменился (scroll-spy). Parent обновляет activeFilePath */ + onVisibleFileChange: (filePath: string) => void; + + // -- Exposed refs -- + + /** Ref для scroll container (передаётся из parent для scroll-to-file) */ + scrollContainerRef: React.RefObject; + + /** Map EditorView по filePath. Parent использует для keyboard shortcuts */ + editorViewMapRef: React.MutableRefObject>; + + /** Ref: подавление scroll-spy (от useContinuousScrollNav) */ + isProgrammaticScroll: React.RefObject; +} +``` + +#### Структура рендеринга + +```tsx +export const ContinuousScrollView = ({ + files, + fileContents, + fileContentsLoading, + viewedSet, + editedContents, + fileDecisions, + collapseUnchanged, + applying, + autoViewed, + discardCounter, + onHunkAccepted, + onHunkRejected, + onFullyViewed, + onContentChanged, + onDiscard, + onSave, + onVisibleFileChange, + scrollContainerRef, + editorViewMapRef, + isProgrammaticScroll, +}: ContinuousScrollViewProps) => { + // Scroll-spy + const { registerFileSectionRef } = useVisibleFileSection({ + onVisibleFileChange, + scrollContainerRef, + isProgrammaticScroll, + }); + + // EditorView registration callback + const handleEditorViewReady = useCallback( + (filePath: string, view: EditorView | null) => { + if (view) { + editorViewMapRef.current.set(filePath, view); + } else { + editorViewMapRef.current.delete(filePath); + } + }, + [editorViewMapRef] + ); + + return ( +
+ {files.map((file) => { + const filePath = file.filePath; + const content = fileContents[filePath] ?? null; + const isLoading = fileContentsLoading[filePath] ?? false; + const hasEdits = filePath in editedContents; + const isViewed = viewedSet.has(filePath); + const decision = fileDecisions[filePath]; + + return ( +
+ + + {isLoading ? ( + + ) : ( + + )} +
+ ); + })} + + {files.length === 0 && ( +
+ No file changes detected +
+ )} +
+ ); +}; +``` + +**Замечание по `isLoading`:** Когда loading=true, показывается placeholder вместо `FileSectionDiff`. Когда loading завершится (контент загружен) — перерисовка покажет diff. `FileSectionDiff` получает `isLoading={false}` потому что condition уже обработан выше. + +**Замечание по `data-file-path`:** Атрибут устанавливается в двух местах: +1. На section div через `registerFileSectionRef` (для scroll-spy IntersectionObserver) +2. На sticky header внутри `FileSectionHeader` (для `querySelector` в `scrollToFile`) + +`scrollToFile` использует `querySelector('[data-file-path="..."]')` — найдёт **первый** элемент, а это header (он вложен в section). Чтобы `scrollIntoView` скроллил к началу секции (а не к header внутри), нужно убедиться что selector находит section div. **Решение:** Убрать `data-file-path` из `FileSectionHeader` и оставить только на section div. Тогда `scrollToFile` найдёт section div, а `scrollIntoView({ block: 'start' })` покажет начало секции = sticky header. + +#### EditorView Map + +```typescript +// В parent (ChangeReviewDialog): +const editorViewMapRef = useRef(new Map()); +``` + +**Зачем:** Keyboard shortcuts (`Cmd+Y`, `Cmd+N`) теперь должны знать, к какому EditorView применить действие. Логика: +1. Если EditorView имеет фокус (`view.hasFocus`) — применить к нему +2. Иначе — применить к EditorView `activeFilePath` (от scroll-spy) +3. Fallback — первый EditorView в Map + +```typescript +// Helper в ChangeReviewDialog: +function getTargetEditorView(): EditorView | null { + // 1. Focused editor + for (const view of editorViewMapRef.current.values()) { + if (view.hasFocus) return view; + } + // 2. Active file's editor + if (activeFilePath) { + return editorViewMapRef.current.get(activeFilePath) ?? null; + } + // 3. First available + const first = editorViewMapRef.current.values().next(); + return first.done ? null : first.value; +} +``` + +--- + +## 3. Модификации существующих файлов + +### 3.1. `ReviewFileTree.tsx` + +#### Новые props + +```typescript +interface ReviewFileTreeProps { + files: FileChangeSummary[]; + selectedFilePath: string | null; + onSelectFile: (filePath: string) => void; + viewedSet?: Set; + onMarkViewed?: (filePath: string) => void; + onUnmarkViewed?: (filePath: string) => void; + + // === НОВЫЕ === + /** Активный файл от scroll-spy (мягкая подсветка) */ + activeFilePath?: string; +} +``` + +#### Отличие `selectedFilePath` vs `activeFilePath` + +| | `selectedFilePath` | `activeFilePath` | +|---|---|---| +| Источник | Клик по файлу в tree | Scroll-spy (IntersectionObserver) | +| Визуал | `bg-blue-500/20 text-blue-300` (сильная подсветка) | `border-l-2 border-blue-400` (мягкий индикатор) | +| Поведение | Клик -> scrollToFile | Автоматически обновляется при скролле | +| При клике | Совпадает с activeFilePath | Может отставать (debounce 100ms) | + +**Визуальная логика в TreeItem:** + +```typescript +const isSelected = node.file.filePath === selectedFilePath; +const isActive = node.file.filePath === activeFilePath && !isSelected; +``` + +Стили: +```typescript +className={cn( + 'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors', + isSelected + ? 'bg-blue-500/20 text-blue-300' // Клик + : isActive + ? 'border-l-2 border-blue-400 text-text' // Scroll-spy + : 'text-text-secondary hover:bg-surface-raised hover:text-text' +)} +``` + +#### Пробросить `activeFilePath` через TreeItem + +TreeItem в текущем коде принимает inline props (не interface, а destructured объект, строки 108-126): + +```typescript +const TreeItem = ({ + node, + selectedFilePath, + onSelectFile, + depth, + hunkDecisions, + viewedSet, + onMarkViewed, + onUnmarkViewed, +}: { ... }) => { ... } +``` + +Нужно добавить `activeFilePath?: string` в этот inline type и пробрасывать дальше в рекурсивные `` (строка 195-206). + +#### Auto-scroll в tree при смене `activeFilePath` + +```typescript +// В ReviewFileTree +useEffect(() => { + if (!activeFilePath) return; + + const btn = document.querySelector( + `[data-tree-file="${CSS.escape(activeFilePath)}"]` + ); + if (btn) { + btn.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } +}, [activeFilePath]); +``` + +#### data-tree-file attribute + +Добавить на ` + {timelineOpen && ( + diffNav.goToHunk(idx)} + activeSnippetIndex={diffNav.currentHunkIndex} + /> + )} +
+)} +``` + +#### File tree — передать `activeFilePath` + +```tsx + +``` + +**Замечание:** `selectedFilePath={null}` — в continuous mode нет отдельного "selected" state. Подсветка только через `activeFilePath`. + +#### Что делать с `selectReviewFile` из store + +`selectReviewFile(filePath)` из `changeReviewSlice` (строка 148-150) устанавливает `selectedReviewFilePath` в store. В continuous mode это больше не используется для переключения контента. + +Рекомендация: **не трогать store** — просто не вызывать `selectReviewFile` из ChangeReviewDialog. Store action останется для обратной совместимости. `selectedReviewFilePath` по-прежнему инициализируется при `fetchAgentChanges`/`fetchTaskChanges` (строки 121, 138) — это ОК, просто не используется в UI. + +#### Удалить неиспользуемые импорты + +После рефакторинга убрать: +- `import type { EditorState } from '@codemirror/state'` +- Если `CONTENT_SOURCE_LABELS` вынесен в `FileSectionHeader` — убрать из ChangeReviewDialog +- `CodeMirrorDiffView` (рендерится в `FileSectionDiff`) +- `DiffErrorBoundary` (рендерится в `FileSectionDiff`) +- `ReviewDiffContent` (рендерится в `FileSectionDiff`) + +**Оставить:** `acceptAllChunks`, `rejectAllChunks` из `CodeMirrorDiffUtils` — используются в `handleAcceptAll`/`handleRejectAll`. +**Оставить:** `rejectChunk` из `@codemirror/merge` — используется в Cmd+N handler. +**Оставить:** `goToNextChunk` из `@codemirror/merge` — используется в Cmd+N handler. + +--- + +## 4. Критические детали + +### 4.1. Scroll-spy + Programmatic scroll race + +**Последовательность при клике на файл в tree:** + +1. User кликает файл B в tree +2. `handleTreeFileClick('B')` -> `scrollToFile('B')` +3. `isProgrammaticScroll.current = true` +4. `section.scrollIntoView({ behavior: 'smooth' })` +5. Scroll анимация: файл A проскакивает мимо viewport, файл B появляется +6. IntersectionObserver вызывает callback для файлов A, B, C... +7. `useVisibleFileSection.updateTopmostVisible()` проверяет `isProgrammaticScroll` -> **молчит** +8. `waitForScrollEnd()` resolve через ~300-400ms +9. `isProgrammaticScroll.current = false` +10. Debounced update (100ms) запустится при следующем IO callback — обновит activeFilePath на B + +**Edge case:** Если пользователь кликает на другой файл пока предыдущий scroll ещё идёт: +- `isProgrammaticScroll` останется `true` +- Новый `scrollIntoView` перезаписывает scroll target +- Старый `waitForScrollEnd` promise resolve (scrollTop стабилизируется) — сбросит flag +- Новый `waitForScrollEnd` заменит промис — **потенциальный race**: flag может сброситься преждевременно + +**Решение:** Использовать counter или AbortController: +```typescript +const scrollGeneration = useRef(0); + +const scrollToFile = useCallback((filePath: string) => { + // ... + const gen = ++scrollGeneration.current; + isProgrammaticScroll.current = true; + section.scrollIntoView({ behavior: 'smooth', block: 'start' }); + void waitForScrollEnd(container, 500).then(() => { + if (scrollGeneration.current === gen) { + isProgrammaticScroll.current = false; + } + }); +}, [scrollContainerRef]); +``` + +### 4.2. EditorView Map vs один ref + +**Было:** Один `editorViewRef = useRef(null)` — переписывался при каждом переключении файла. + +**Стало:** `editorViewMapRef = useRef(new Map())` — все editors хранятся одновременно. + +**Memory:** Каждый EditorView ~ 50-100KB. Для 50 файлов = 2.5-5MB. Приемлемо. + +**Lifecycle:** +- Mount: `FileSectionDiff` создаёт CodeMirrorDiffView -> EditorView, регистрирует в Map +- Unmount: `FileSectionDiff` cleanup -> удаляет из Map +- В continuous mode **все** editors живут одновременно (пока все файлы в DOM) + +### 4.3. editorStateCache не нужен + +В continuous mode все editors живут одновременно. Нет "переключения файла" — нет необходимости сохранять/восстанавливать EditorState. Undo history живёт в самом EditorView. + +**Discard edits:** Вместо сброса cache entry — `key` prop с `discardCounter` пересоздаёт CodeMirrorDiffView. + +**Замечание:** Текущий `handleDiscardCurrentFile` (строка 153-160) также делает `editorStateCache.current.delete()` и `setCachedInitialState(undefined)`. Оба удаляются. + +### 4.4. Keyboard Cmd+Y/N + +В continuous scroll фокус может быть: +1. Внутри конкретного CodeMirror (user кликнул в diff) -> CM keymap обработает +2. Вне CodeMirror (user скроллит мышью) -> document keydown handler -> `getTargetEditorView()` + +**Приоритет:** +1. CM keymap (если фокус в CM) — обработает и вернёт `true`, event не propagates +2. `useDiffNavigation` keyboard handler проверяет `event.defaultPrevented` (строка 123 в useDiffNavigation.ts) — если CM уже обработал, пропускает +3. Если CM не обработал — `useDiffNavigation` handler использует `activeEditorViewRef.current` +4. Cmd+N IPC handler (через `window.electronAPI.review.onCmdN`) — работает через `getTargetEditorView()` + +**Конфликт Cmd+Y:** `useDiffNavigation` вызывает `acceptChunk(view)` (строка 149-153), а CM keymap тоже содержит `Mod-y` handler. Если фокус в CM — CM обработает первым и `event.defaultPrevented` будет true. Если фокус вне CM — useDiffNavigation handler сработает. Нет конфликта. + +### 4.5. Performance: много CodeMirror editors одновременно + +**Проблема:** 30+ CodeMirror editors в DOM одновременно = нагрузка на рендеринг. + +**Mitigation (фаза 2):** Lazy loading — контент загружается по мере scroll. Editors создаются только для загруженных файлов. + +**Mitigation (будущая фаза 3):** Virtualized rendering — только видимые файлы + буфер рендерятся в DOM. Файлы за пределами viewport заменяются placeholder фиксированной высоты. + +**Для фазы 1:** Не оптимизировать — загрузить контент всех файлов при открытии (bulk). 20-30 файлов — OK для начала. + +### 4.6. `data-file-path` дублирование + +Атрибут `data-file-path` устанавливается: +1. `registerFileSectionRef` в `useVisibleFileSection` -> на section `
` (для IntersectionObserver) +2. Документ ранее предлагал его на sticky header в `FileSectionHeader` + +**Решение:** Оставить `data-file-path` **только на section div** (через `registerFileSectionRef`). `scrollToFile` найдёт section div через `querySelector`, `scrollIntoView({ block: 'start' })` покажет начало секции. Scroll-spy observer тоже наблюдает section div. Один источник правды. + +В `FileSectionHeader` `data-file-path` **не добавлять**. + +### 4.7. Загрузка контента при mode='task' + +`fetchFileContent(teamName, memberName, filePath)` — в mode='task' `memberName` может быть `undefined`. Текущая signature в store (строка 261): `fetchFileContent(teamName: string, memberName: string | undefined, filePath: string)`. Это ОК. + +--- + +## 5. Порядок реализации + +### Шаг 1: Создать `FileSectionPlaceholder.tsx` +- Простой компонент, без зависимостей +- Тестирование: визуально убедиться что skeleton выглядит ок + +### Шаг 2: Создать `FileSectionHeader.tsx` +- Извлечь из ChangeReviewDialog строки 437-509 +- Вынести `CONTENT_SOURCE_LABELS` +- Добавить sticky positioning +- Импортировать `Save`, `Undo2`, `Loader2` из lucide-react, `Tooltip`/`TooltipTrigger`/`TooltipContent` +- **НЕ добавлять** `data-file-path` на header (только на section div в ContinuousScrollView) +- Тестирование: рендерить standalone, проверить sticky поведение + +### Шаг 3: Создать `FileSectionDiff.tsx` +- Извлечь из ChangeReviewDialog строки 511-561 +- Добавить проверку `fileContent.modifiedFullContent !== null` в условие рендеринга CodeMirror +- Добавить sentinel для auto-viewed (threshold: 0.85) +- Добавить editorView registration callback +- Передать `onFullyViewed={undefined}` в CodeMirrorDiffView (отключить встроенный sentinel) +- Тестирование: рендерить с mock data, проверить что CodeMirror создаётся + +### Шаг 4: Создать `useVisibleFileSection.ts` +- По паттерну `useVisibleAIGroup.ts` +- Добавить debounce и isProgrammaticScroll +- Guard на `scrollContainerRef.current` is null +- Тестирование: unit test с mock IntersectionObserver + +### Шаг 5: Создать `useContinuousScrollNav.ts` +- scrollToFile с waitForScrollEnd(container, 500) +- Keyboard listeners (Alt+Arrow) +- Scroll generation counter для предотвращения race при быстрых кликах +- Тестирование: unit test keyboard events + +### Шаг 6: Создать `ContinuousScrollView.tsx` +- Собрать все компоненты вместе +- files.map -> section (header + diff) +- Интегрировать useVisibleFileSection +- `data-file-path` только на section div (через registerFileSectionRef) +- Тестирование: рендерить с 3-5 файлами, проверить scroll и sticky headers + +### Шаг 7: Модифицировать `ReviewFileTree.tsx` +- Добавить `activeFilePath` prop в `ReviewFileTreeProps` +- Добавить `activeFilePath` в inline props TreeItem и пробросить рекурсивно +- Добавить `data-tree-file` attribute на button +- Добавить auto-scroll useEffect +- Добавить визуальную подсветку active файла (isActive condition + border-l-2 стиль) +- Тестирование: проверить подсветку active vs selected + +### Шаг 8: Модифицировать `ChangeReviewDialog.tsx` +- Заменить single-file area на ContinuousScrollView +- Убрать: editorViewRef, editorStateCache, cachedInitialState, handleSelectFile, hasCurrentFileEdits, selectedFile (заменить на activeFile) +- Добавить: activeFilePath, editorViewMapRef, scrollContainerRef, activeEditorViewRef +- Заменить lazy-load на bulk-load useEffect +- Интегрировать useContinuousScrollNav +- Адаптировать: handleAcceptAll/handleRejectAll, handleSaveFile (принимает filePath), handleDiscardFile (принимает filePath), handleFullyViewed (принимает filePath) +- Адаптировать useDiffNavigation: activeFilePath, scrollToFile, activeEditorViewRef +- Адаптировать Cmd+N handler: getTargetEditorView +- Timeline sidebar: selectedFile -> activeFile +- FileTree: selectedFilePath={null}, activeFilePath, onSelectFile=handleTreeFileClick +- Убрать неиспользуемые импорты +- Тестирование: полный E2E flow + +--- + +## 6. Проверка + +### Функциональная проверка + +- [ ] Открыть ChangeReviewDialog с 3+ файлами +- [ ] Все файлы отображаются вертикально друг под другом +- [ ] Sticky headers прилипают при скролле +- [ ] File tree подсвечивает текущий файл при скролле (scroll-spy) +- [ ] Клик по файлу в tree = smooth scroll к файлу в контенте +- [ ] Нет "мигания" active файла при programmatic scroll +- [ ] Alt+ArrowDown/Up переключает между файлами +- [ ] Cmd+Y accept chunk работает (focused editor и fallback через getTargetEditorView) +- [ ] Cmd+N reject chunk работает (через IPC и через useDiffNavigation fallback) +- [ ] Accept All / Reject All применяются к active файлу +- [ ] Save/Discard кнопки в header работают (каждый файл независимо) +- [ ] Auto-viewed работает (скроллить до конца файла — sentinel 85%) +- [ ] Viewed checkbox в tree работает +- [ ] Edit timeline sidebar показывается для active файла +- [ ] Escape закрывает диалог +- [ ] Быстрый двойной клик по разным файлам в tree — нет race condition (scroll generation counter) + +### Edge cases + +- [ ] 0 файлов — показывает "No file changes detected" +- [ ] 1 файл — scroll-spy стабильно, нет navigation issues +- [ ] Файл с `contentSource: 'unavailable'` — показывает ReviewDiffContent fallback +- [ ] Файл с `modifiedFullContent === null` — показывает ReviewDiffContent fallback +- [ ] Файл загружается — показывает FileSectionPlaceholder +- [ ] Файл с isNewFile — показывает NEW badge в sticky header +- [ ] Очень длинный файл (1000+ строк) — smooth scroll работает +- [ ] Collapse unchanged toggle — все editors обновляются (через collapseUnchanged prop -> CodeMirrorDiffView reconfigure) +- [ ] Discard edits — editor пересоздаётся (key с discardCounter; глобальный counter, все editors rebuild — ОК для фазы 1) +- [ ] Resize window — IntersectionObserver пересчитывает, scroll-spy корректен +- [ ] mode='task' с memberName=undefined — bulk-load работает + +### Performance + +- [ ] 20 файлов — открытие < 2 секунд +- [ ] Scroll не лагает с 10+ CodeMirror editors +- [ ] Memory не утекает при close/reopen диалога (EditorView.destroy() через cleanup в CodeMirrorDiffView + editorViewMapRef cleanup) diff --git a/docs/iterations/diff-view/continuous-scroll/phase-2-lazy-loading.md b/docs/iterations/diff-view/continuous-scroll/phase-2-lazy-loading.md new file mode 100644 index 00000000..34b785aa --- /dev/null +++ b/docs/iterations/diff-view/continuous-scroll/phase-2-lazy-loading.md @@ -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; + + /** + * Загруженный контент из store (для проверки: уже загружен?). + * Тип: Record из changeReviewSlice. + */ + fileContents: Record; + + /** Флаги загрузки из store (для проверки: уже грузится?) */ + fileContentsLoading: Record; + + /** + * Функция загрузки контента из store. + * Сигнатура точно как в changeReviewSlice.fetchFileContent: + * (teamName: string, memberName: string | undefined, filePath: string) => Promise + * + * Внутри store уже есть guard от дубликатов (строка 264): + * if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return; + * Поэтому двойной вызов безопасен. + */ + fetchFileContent: ( + teamName: string, + memberName: string | undefined, + filePath: string + ) => Promise; + + /** Lazy loading включён (false = загрузить всё сразу, для fallback) */ + enabled: boolean; +} + +interface UseLazyFileContentReturn { + /** + * Регистрация file section для lazy-load наблюдения. + * Возвращает ref callback — передать в div section. + * Пример:
+ */ + 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()); + + // Queue: filePath ожидающих загрузки (FIFO, но с приоритетом) + const pendingQueue = useRef([]); + + // Max параллельных загрузок + const MAX_CONCURRENT = 3; + + // Observer ref + const observerRef = useRef(null); + + // Element refs + const elementRefs = useRef(new Map()); + + // 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 => { + 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 + * Из changeReviewSlice.fetchFileContent + */ + fetchFileContent: ( + teamName: string, + memberName: string | undefined, + filePath: string + ) => Promise; +} +``` + +**Интеграция в компоненте:** + +```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 ( +
+ {files.map((file) => { + const filePath = file.filePath; + const content = fileContents[filePath] ?? null; + const isLoading = fileContentsLoading[filePath] ?? false; + const hasContent = content !== null; + + return ( +
+ + + {/* Контент ещё не загружен — placeholder */} + {!hasContent && isLoading && ( + + )} + + {/* Контент ещё не начал грузиться — тоже placeholder */} + {!hasContent && !isLoading && ( + + )} + + {/* Контент загружен — diff */} + {hasContent && ( + + )} +
+ ); + })} + + {files.length === 0 && ( +
+ No file changes detected +
+ )} +
+ ); +}; +``` + +**Отличие от 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 + +``` + +**Примечание:** `teamName` берётся из props `ChangeReviewDialogProps`, `memberName` оттуда же (optional prop). `fetchFileContent` берётся из `useStore()` (строка 77 текущего файла). + +--- + +## 4. Throttle реализация: детали + +### Структура данных + +``` +┌─────────────────┐ +│ activeLoads │ Set -- 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` (value из `.current`), а Phase 2 работает с `MutableRefObject`. Нет конфликта -- 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 diff --git a/docs/iterations/diff-view/continuous-scroll/phase-3-navigation.md b/docs/iterations/diff-view/continuous-scroll/phase-3-navigation.md new file mode 100644 index 00000000..ebe7804d --- /dev/null +++ b/docs/iterations/diff-view/continuous-scroll/phase-3-navigation.md @@ -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` вместо одного `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, + 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; + + /** + * Текущий видимый файл из 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, + 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 | 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; + + /** Диалог открыт (для cleanup) */ + isOpen: boolean; +} + +interface UseContinuousScrollNavReturn { + /** Scroll к файлу по filePath (smooth) */ + scrollToFile: (filePath: string) => void; + + /** Ref-flag: true пока идёт programmatic scroll */ + isProgrammaticScroll: RefObject; +} +``` + +3. **scrollToFile -- без `setActiveFilePath`:** + +```typescript +const scrollToFile = useCallback( + (filePath: string) => { + const container = scrollContainerRef.current; + if (!container) return; + + const section = container.querySelector( + `[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 +``` +- `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()); +``` + +#### Получение данных из 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 */} + +``` + +**Примечание:** 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`. 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 | diff --git a/docs/iterations/diff-view/continuous-scroll/phase-4-portion-collapse.md b/docs/iterations/diff-view/continuous-scroll/phase-4-portion-collapse.md new file mode 100644 index 00000000..2ccedd7b --- /dev/null +++ b/docs/iterations/diff-view/continuous-scroll/phase-4-portion-collapse.md @@ -0,0 +1,1264 @@ +# Phase 4: Portion Collapse + +## Обзор + +**Проблема:** Стандартный `collapseUnchanged` из `@codemirror/merge` при клике на collapsed region разворачивает ВСЮ зону целиком. Для файлов с 500+ неизменённых строк между изменениями это создаёт резкий скачок контента и потерю контекста. GitHub решает это кнопками "Expand 20 lines" / "Expand all", позволяя раскрывать порциями. + +**Решение:** Кастомный CodeMirror StateField (`portionCollapseExtension`) который создаёт `Decoration.replace` с виджетами, содержащими кнопки "Expand N" и "Expand All". При клике на "Expand N" виджет разворачивает только указанное количество строк, оставляя остаток свёрнутым. + +**Зависимости:** Независим от Phase 1-3 (continuous scroll). Может использоваться как в single-file mode, так и в continuous mode. + +--- + +## Почему кастомный StateField + +### Ограничения CM's collapseUnchanged + +`@codemirror/merge` реализует `collapseUnchanged` через приватный StateField `CollapsedRanges` + `Decoration.replace` с внутренним `CollapseWidget`. При клике на collapsed widget используется StateEffect `uncollapseUnchanged` (экспортируется из `@codemirror/merge`), который ПОЛНОСТЬЮ удаляет decoration для зоны через `deco.update({ filter: from => from != e.value })`. + +```typescript +// Экспорт из @codemirror/merge +declare const uncollapseUnchanged: StateEffectType; +``` + +**Ключевая деталь реализации CM:** `CollapsedRanges` использует паттерн `StateField.define` + `StateField.init()`: +- `create()` возвращает `Decoration.none` (пустые decorations) +- `collapseUnchanged()` возвращает `CollapsedRanges.init(state => buildCollapsedRanges(state, margin, minSize))` — init переопределяет create при инициализации state +- `update()` делает ТОЛЬКО `deco.map(tr.changes)` + filter по `uncollapseUnchanged` effect +- `update()` НЕ делает rebuild при `docChanged` или `updateOriginalDoc` — CM пересоздаёт collapse decorations через reconfigure compartment при изменении chunks + +Проблемы: +1. **Нет partial expand** — `uncollapseUnchanged` принимает только `pos: number` и разворачивает всю зону +2. **Нет public API** для получения списка collapsed зон или модификации отдельных зон +3. **WidgetType** внутренний (CollapseWidget) — нет возможности заменить DOM widget без форка +4. **CollapsedRanges StateField** — приватный, недоступен для расширения + +### Почему не обёртка + +Теоретически можно было бы: +- Перехватить `uncollapseUnchanged` effect в транзакции +- Вместо полного uncollapse — создать два новых collapsed regionа +- Но `uncollapseUnchanged` привязан к внутреннему `CollapsedRanges` StateField, который фильтрует decorations по `from` position + +Это хрупко и сломается при обновлении @codemirror/merge. Надёжнее написать свой StateField. + +--- + +## Новый файл: portionCollapse.ts + +**Путь:** `src/renderer/components/team/review/portionCollapse.ts` + +### Exports + +```typescript +import { + Decoration, + type DecorationSet, + EditorView, + WidgetType, +} from '@codemirror/view'; +import { + type ChangeDesc, + type EditorState, + type Extension, + RangeSetBuilder, + StateEffect, + type StateEffectType, + StateField, + type Transaction, +} from '@codemirror/state'; + +import { getChunks, type updateOriginalDoc } from './CodeMirrorDiffUtils'; +// updateOriginalDoc используется только для .is() проверки в update(), +// поэтому импортируем его напрямую: +import { updateOriginalDoc } from '@codemirror/merge'; + +// ─── Configuration ─── + +interface PortionCollapseConfig { + /** + * Количество строк контекста, оставляемых видимыми до/после изменения. + * Default: 3 + * Соответствует поведению CM's collapseUnchanged.margin. + */ + margin?: number; + + /** + * Минимальное количество строк в unchanged зоне для создания collapse. + * Зоны короче этого значения остаются видимыми целиком. + * Default: 4 + * Соответствует CM's collapseUnchanged.minSize. + */ + minSize?: number; + + /** + * Количество строк, раскрываемых за одно нажатие "Expand N". + * Default: 100 + * При меньшем остатке кнопка показывает "Expand <остаток>". + */ + portionSize?: number; +} + +// ─── State Effects ─── + +/** + * Раскрыть portionSize строк из collapsed зоны. + * pos — позиция начала текущей collapsed decoration. + * count — количество строк для раскрытия (обычно = portionSize). + * + * StateEffect.define с map() для корректного ремаппинга при изменениях документа. + * map callback: (value, mapping: ChangeDesc) => Value | undefined. + * Возврат undefined удаляет effect (не наш случай — always remap). + */ +export const expandPortion: StateEffectType<{ + pos: number; + count: number; +}> = StateEffect.define<{ pos: number; count: number }>({ + map: (value, mapping: ChangeDesc) => ({ + pos: mapping.mapPos(value.pos), + count: value.count, + }), +}); + +/** + * Полностью раскрыть collapsed зону по позиции. + * pos — позиция начала collapsed decoration. + */ +export const expandAllAtPos: StateEffectType = StateEffect.define({ + map: (pos, mapping: ChangeDesc) => mapping.mapPos(pos), +}); + +// ─── Public API ─── + +/** + * Создаёт Extension для порционного collapse неизменённых зон. + * + * ВАЖНО: Эта extension НЕ совместима с collapseUnchanged из unifiedMergeView. + * Если portionCollapse включён — collapseUnchanged в mergeConfig НЕ должен быть задан. + * + * @param config — опциональная конфигурация + * @returns Extension для добавления в EditorView + */ +export function portionCollapseExtension(config?: PortionCollapseConfig): Extension; +``` + +### Внутренняя структура + +#### PortionCollapseWidget + +```typescript +/** + * Widget для отображения collapsed зоны с кнопками "Expand N" / "Expand All". + * + * Визуально повторяет стиль .cm-collapsedLines из CM's collapseUnchanged, + * но с двумя кнопками вместо одной кликабельной полосы. + * + * Сравнение с CM's CollapseWidget: + * - CM: `ignoreEvent(e) { return e instanceof MouseEvent; }` (игнорирует ВСЕ MouseEvent'ы) + * - Мы: `ignoreEvent(e) { return e.type === 'mousedown'; }` (игнорируем только mousedown) + * - CM: `estimatedHeight` = 27 (фиксированная высота виджета) + * - Мы: `estimatedHeight` = 28 (наш виджет чуть выше из-за кнопок) + */ +class PortionCollapseWidget extends WidgetType { + /** + * @param lineCount — количество скрытых строк в этой зоне + * @param pos — позиция начала decoration в документе (для dispatch effects) + * @param portionSize — количество строк для "Expand N" кнопки + */ + constructor( + readonly lineCount: number, + readonly pos: number, + readonly portionSize: number + ) { + super(); + } + + /** + * Создаёт DOM для collapsed зоны. + * + * Структура DOM: + * ```html + *
+ * + * ··· 247 unchanged lines ··· + * + *
+ * + * + *
+ *
+ * ``` + * + * Кнопка "Expand N": + * - Если lineCount <= portionSize: скрывается (остаётся только "Expand All") + * - Если lineCount > portionSize: показывает "Expand {portionSize}" + * - При клике: dispatch expandPortion.of({ pos: this.pos, count: this.portionSize }) + * + * Кнопка "Expand All": + * - Всегда видна + * - При клике: dispatch expandAllAtPos.of(this.pos) + * + * ВАЖНО: Обе кнопки используют onmousedown (не onclick) с preventDefault() + * чтобы предотвратить потерю фокуса CM editor. + * Паттерн аналогичен CM's CollapseWidget (addEventListener("click")), + * но mousedown + preventDefault надёжнее предотвращает перемещение selection. + */ + toDOM(view: EditorView): HTMLElement { + const container = document.createElement('div'); + container.className = 'cm-portion-collapse'; + + // Текст: "··· N unchanged lines ···" + 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); + + // Actions container + const actions = document.createElement('div'); + actions.className = 'cm-portion-collapse-actions'; + + // "Expand N" button (только если lineCount > portionSize) + 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(); + view.dispatch({ + effects: expandPortion.of({ + pos: this.pos, + count: this.portionSize, + }), + }); + }; + actions.appendChild(expandBtn); + } + + // "Expand All" button (всегда) + 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(); + view.dispatch({ + effects: expandAllAtPos.of(this.pos), + }); + }; + actions.appendChild(expandAllBtn); + + container.appendChild(actions); + return container; + } + + /** + * Сравнение виджетов для оптимизации рендеринга. + * CM вызывает eq() при обновлении decorations — если true, DOM не пересоздаётся. + * + * ВАЖНО: pos НЕ нужно сравнивать в eq(). CM вызывает eq() только для decorations + * на ОДИНАКОВЫХ позициях. Если позиция decoration изменилась — это уже другой range, + * и CM не вызовет eq(). Сравниваем только визуально-значимые параметры. + */ + eq(other: PortionCollapseWidget): boolean { + return ( + this.lineCount === other.lineCount && + this.portionSize === other.portionSize + ); + } + + /** + * Оценка высоты widget для scrollbar. + * + * CM использует estimatedHeight для замещающих (replace) decorations + * чтобы скорректировать scrollbar. Возвращаем высоту самого widget (28px), + * а НЕ высоту скрытого контента. CM's CollapseWidget возвращает 27. + * + * Это корректно: scrollbar должен отражать ВИДИМУЮ высоту документа. + * Скрытые строки не занимают места — вместо них виден widget. + */ + get estimatedHeight(): number { + return 28; + } + + /** + * Ignore events: позволяет кнопкам внутри widget обрабатывать клики. + * Без этого CM перехватит mousedown и поставит курсор. + * + * CM's CollapseWidget использует `e instanceof MouseEvent` (блокирует все mouse events). + * Мы используем проверку по type для большей точности. + */ + ignoreEvent(event: Event): boolean { + return event instanceof MouseEvent; + } +} +``` + +#### buildPortionRanges() + +```typescript +/** + * Вычисляет ranges для collapsed зон на основе текущих chunks. + * + * Алгоритм повторяет CM's buildCollapsedRanges() из @codemirror/merge, + * но с добавлением portionSize для PortionCollapseWidget. + * + * Оригинальный алгоритм CM (для справки): + * ```javascript + * function buildCollapsedRanges(state, margin, minLines) { + * let builder = new RangeSetBuilder(); + * let isA = state.facet(mergeConfig).side == "a"; + * let chunks = state.field(ChunkField); + * let prevLine = 1; + * for (let i = 0;; i++) { + * let chunk = i < chunks.length ? chunks[i] : null; + * let collapseFrom = i ? prevLine + margin : 1; + * let collapseTo = chunk + * ? state.doc.lineAt(isA ? chunk.fromA : chunk.fromB).number - 1 - margin + * : state.doc.lines; + * let lines = collapseTo - collapseFrom + 1; + * if (lines >= minLines) { + * builder.add( + * state.doc.line(collapseFrom).from, + * state.doc.line(collapseTo).to, + * Decoration.replace({ widget: new CollapseWidget(lines), block: true }) + * ); + * } + * if (!chunk) break; + * prevLine = state.doc.lineAt(Math.min(state.doc.length, isA ? chunk.toA : chunk.toB)).number; + * } + * return builder.finish(); + * } + * ``` + * + * Ключевые отличия от CM: + * 1. Используем getChunks(state) вместо state.field(ChunkField) — public API + * 2. Unified view → side="b", поэтому всегда используем fromB/toB + * 3. Первая зона: CM начинает с line 1 без margin (collapseFrom = 1 при i=0), + * мы делаем то же самое для совместимости + * 4. Добавляем portionSize в PortionCollapseWidget + * + * @param state — текущее состояние EditorState + * @param margin — количество строк контекста (default 3) + * @param minSize — минимум строк для collapse (default 4) + * @param portionSize — строк за "Expand N" (default 100) + * @returns DecorationSet с collapsed зонами + */ +function buildPortionRanges( + state: EditorState, + margin: number, + minSize: number, + portionSize: number +): DecorationSet { + const result = getChunks(state); + const doc = state.doc; + + // Если merge view ещё не инициализирован — пустые decorations + if (!result) return Decoration.none; + + const chunks = result.chunks; + const builder = new RangeSetBuilder(); + + // Повторяем алгоритм CM's buildCollapsedRanges для unified view (side="b") + let prevLine = 1; + + for (let i = 0; ; i++) { + const chunk = i < chunks.length ? chunks[i] : null; + + // Для первой зоны (i=0): начинаем с line 1 БЕЗ margin (как CM) + // Для последующих: prevLine + margin + const collapseFrom = i ? prevLine + margin : 1; + + // Конец зоны: строка перед началом следующего chunk - margin + // Или последняя строка документа (если chunk=null = зона после последнего chunk) + 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, from, portionSize); + + builder.add( + from, + to, + Decoration.replace({ + widget, + block: true, + }) + ); + } + + if (!chunk) break; + + // prevLine = номер строки конца текущего chunk (для вычисления следующей зоны) + // Math.min(doc.length, chunk.toB) — защита от toB за пределами документа + // (CM Chunk: toB может быть "1 past the end of the last line") + prevLine = doc.lineAt(Math.min(doc.length, chunk.toB)).number; + } + + return builder.finish(); +} +``` + +**Важно про chunks и позиции:** + +Chunks из `getChunks(state)` содержат: +- `fromA / toA` — позиции в original документе (A) +- `fromB / toB` — позиции в текущем документе (B = EditorView's doc) + +Для unified merge view `side` = `"b"` (или `null`), поэтому decorations в документе B. Используем `fromB / toB`. + +Из типов `@codemirror/merge`: +```typescript +class Chunk { + readonly fromA: number; // Start в original doc (character offset, 0-based) + readonly toA: number; // End в original doc (1 past end of last line, or = fromA if empty) + readonly fromB: number; // Start в current doc + readonly toB: number; // End в current doc (1 past end of last line, or = fromB if empty) + readonly changes: readonly Change[]; + readonly precise: boolean; + get endA(): number; // fromA if empty, else end of last line (valid doc position) + get endB(): number; // fromB if empty, else end of last line (valid doc position) +} +``` + +**ВАЖНО про toA/toB:** Документация CM явно указывает: +> "Note that `to` positions may point past the end of the document. Use `endA`/`endB` if you need an end position that is certain to be a valid document position." + +Поэтому `Math.min(doc.length, chunk.toB)` обязателен при использовании `toB` для `doc.lineAt()`. + +Позиции — это OFFSETS в документе (0-based character positions), НЕ номера строк. Конвертация: +```typescript +const lineNumber = doc.lineAt(chunk.fromB).number; // 1-based line number +const lineStart = doc.line(lineNumber).from; // character offset +``` + +#### PortionCollapsedField — StateField + +```typescript +/** + * StateField хранящий текущие collapsed decorations. + * + * Обновляется при: + * 1. Изменении документа (docChanged) — ремаппинг позиций через map() + * 2. expandPortion effect — частичное раскрытие зоны + * 3. expandAllAtPos effect — полное раскрытие зоны + * 4. updateOriginalDoc effect (accept chunk) — полный rebuild + * 5. Lazy init: если create() вернул Decoration.none (chunks не готовы) + * + * Отличие от CM's CollapsedRanges: + * - CM использует .init() для начального build и map+filter в update + * - CM НЕ делает rebuild в update (полагается на reconfigure через compartment) + * - Мы делаем rebuild при accept/reject потому что portion expand state теряется + */ +const PortionCollapsedField = StateField.define({ + create(state: EditorState): DecorationSet { + // getChunks(state) может вернуть null здесь если ChunkField ещё не инициализирован. + // Это нормально — buildPortionRanges обработает null и вернёт Decoration.none. + // Decorations будут построены при первом update (lazy init). + return buildPortionRanges(state, margin, minSize, portionSize); + }, + + update(value: DecorationSet, tr: Transaction): DecorationSet { + // === 1. Expand effects === + let hasExpandEffect = false; + + for (const effect of tr.effects) { + if (effect.is(expandPortion)) { + hasExpandEffect = true; + value = handleExpandPortion(value, effect.value, tr.state, minSize, portionSize); + } + + if (effect.is(expandAllAtPos)) { + hasExpandEffect = true; + value = handleExpandAll(value, effect.value); + } + } + + if (hasExpandEffect) { + return value; + } + + // === 2. Accept chunk (updateOriginalDoc) → полный rebuild === + // acceptChunk() dispatch'ит updateOriginalDoc effect БЕЗ docChanged. + // Это меняет original doc → chunks пересчитываются → наши decorations невалидны. + const hasUpdateOriginalDoc = tr.effects.some(e => e.is(updateOriginalDoc)); + if (hasUpdateOriginalDoc) { + return buildPortionRanges(tr.state, margin, minSize, portionSize); + } + + // === 3. Document changed (reject chunk, user editing) === + if (tr.docChanged) { + // rejectChunk() делает docChanged (вставляет original текст). + // Chunks пересчитываются CM автоматически. + // Полный rebuild — корректнее чем map, т.к. chunks изменились. + return buildPortionRanges(tr.state, margin, minSize, portionSize); + } + + // === 4. Lazy init: create() вернул Decoration.none === + // Это происходит если getChunks() вернул null при create(). + // После первой транзакции ChunkField уже инициализирован. + if (value === Decoration.none) { + const chunks = getChunks(tr.state); + if (chunks) { + return buildPortionRanges(tr.state, margin, minSize, portionSize); + } + } + + return value; + }, + + provide(field): Extension { + return EditorView.decorations.from(field); + }, +}); +``` + +**Импорт updateOriginalDoc:** +```typescript +import { updateOriginalDoc } from '@codemirror/merge'; +``` + +Этот effect dispatch'ится при `acceptChunk()` — он обновляет original doc, что меняет chunks. Нужен полный rebuild decorations. + +**ВАЖНО:** `updateOriginalDoc` уже импортируется в `CodeMirrorDiffUtils.ts` (строка 7), но НЕ реэкспортируется. Два варианта: +1. Добавить реэкспорт в CodeMirrorDiffUtils.ts: `export { acceptChunk, getChunks, rejectChunk, updateOriginalDoc };` +2. Импортировать напрямую из `@codemirror/merge` (рекомендуется — updateOriginalDoc это низкоуровневый effect, а не utility) + +#### handleExpandPortion() + +```typescript +/** + * Обрабатывает частичное раскрытие collapsed зоны. + * + * Алгоритм: + * 1. Найти decoration range, содержащий pos (через DecorationSet.between) + * 2. Вычислить новые границы: сдвинуть from на count строк вниз + * 3. Если оставшихся строк < minSize — удалить decoration (= expand all) + * 4. Иначе — заменить decoration на новый с уменьшенным lineCount и обновлённым pos/from + * + * Использует DecorationSet.update({ filter, add }) вместо ручной итерации + * через RangeSetBuilder — это идиоматичнее и безопаснее. + * + * @param decorations — текущий DecorationSet + * @param value — { pos, count } из expandPortion effect + * @param state — текущий EditorState (после transaction) + * @param minSize — минимум строк для collapse + * @param portionSize — строк для "Expand N" кнопки + * @returns обновлённый DecorationSet + */ +function handleExpandPortion( + decorations: DecorationSet, + value: { pos: number; count: number }, + state: EditorState, + minSize: number, + portionSize: number +): DecorationSet { + const { pos, count } = value; + const doc = state.doc; + + // Поиск decoration, содержащей pos + let targetFrom = -1; + let targetTo = -1; + + decorations.between(0, doc.length, (from, to) => { + if (from <= pos && pos <= to) { + targetFrom = from; + targetTo = to; + return false; // stop iteration + } + }); + + // pos не найден — возвращаем без изменений + if (targetFrom < 0) return decorations; + + // Вычисляем строки + const fromLine = doc.lineAt(targetFrom).number; + const toLine = doc.lineAt(targetTo).number; + + // Новый from = старый from + count строк + const newFromLine = fromLine + count; + const remainingLines = toLine - newFromLine + 1; + + if (remainingLines < minSize) { + // Слишком мало строк осталось — убираем decoration целиком + return decorations.update({ + filter: (from) => from !== targetFrom, + }); + } + + // Убираем старую decoration и добавляем новую с уменьшенным range + const newFrom = doc.line(newFromLine).from; + const widget = new PortionCollapseWidget(remainingLines, newFrom, portionSize); + + return decorations.update({ + filter: (from) => from !== targetFrom, + add: [ + Decoration.replace({ widget, block: true }).range(newFrom, targetTo), + ], + }); +} +``` + +#### handleExpandAll() + +```typescript +/** + * Обрабатывает полное раскрытие collapsed зоны. + * + * Использует DecorationSet.update({ filter }) — идиоматичный CM подход. + * Аналогично тому, как CM's CollapsedRanges обрабатывает uncollapseUnchanged: + * deco.update({ filter: from => from != e.value }) + * + * @param decorations — текущий DecorationSet + * @param pos — позиция из expandAllAtPos effect + * @returns обновлённый DecorationSet (без удалённой decoration) + */ +function handleExpandAll( + decorations: DecorationSet, + pos: number +): DecorationSet { + return decorations.update({ + filter: (from, to) => !(from <= pos && pos <= to), + }); +} +``` + +#### portionCollapseExtension() — реализация + +```typescript +export function portionCollapseExtension(config?: PortionCollapseConfig): Extension { + const resolvedMargin = config?.margin ?? 3; + const resolvedMinSize = config?.minSize ?? 4; + const resolvedPortionSize = config?.portionSize ?? 100; + + // Validate + if (resolvedMargin < 0) throw new Error('portionCollapse: margin must be >= 0'); + if (resolvedMinSize < 1) throw new Error('portionCollapse: minSize must be >= 1'); + if (resolvedPortionSize < 1) throw new Error('portionCollapse: portionSize must be >= 1'); + + // Замыкаем config значения для StateField + const margin = resolvedMargin; + const minSize = resolvedMinSize; + const portionSize = resolvedPortionSize; + + // StateField с замыканием на config + const field = StateField.define({ + create(state) { + return buildPortionRanges(state, margin, minSize, portionSize); + }, + update(value, tr) { + // Полная реализация PortionCollapsedField (см. выше) + // с замыканием на margin, minSize, portionSize + + // 1. Expand effects + let hasExpandEffect = false; + for (const effect of tr.effects) { + if (effect.is(expandPortion)) { + hasExpandEffect = true; + value = handleExpandPortion(value, effect.value, tr.state, minSize, portionSize); + } + if (effect.is(expandAllAtPos)) { + hasExpandEffect = true; + value = handleExpandAll(value, effect.value); + } + } + if (hasExpandEffect) return value; + + // 2. Accept (updateOriginalDoc) → rebuild + if (tr.effects.some(e => e.is(updateOriginalDoc))) { + return buildPortionRanges(tr.state, margin, minSize, portionSize); + } + + // 3. docChanged (reject, user edit) → rebuild + if (tr.docChanged) { + return buildPortionRanges(tr.state, margin, minSize, portionSize); + } + + // 4. Lazy init + if (value === Decoration.none) { + const chunks = getChunks(tr.state); + if (chunks) { + return buildPortionRanges(tr.state, margin, minSize, portionSize); + } + } + + return value; + }, + provide(f) { + return EditorView.decorations.from(f); + }, + }); + + return [field, portionCollapseTheme]; +} +``` + +**Дизайн-решение: closure vs Facet.** + +Config передаётся через closure в `portionCollapseExtension()`, а не через Facet. Причина: config не меняется после создания editor (только при dynamic reconfigure через Compartment). При reconfigure extension пересоздаётся целиком с новым config. + +--- + +## Модификация CodeMirrorDiffView.tsx + +**Файл:** `src/renderer/components/team/review/CodeMirrorDiffView.tsx` + +### Новый prop + +```typescript +interface CodeMirrorDiffViewProps { + // ... существующие props ... + + /** + * Использовать порционный collapse вместо CM's collapseUnchanged. + * Когда true: collapseUnchanged НЕ передаётся в mergeConfig. + * Вместо этого portionCollapseExtension добавляется отдельно. + * Default: false (обратная совместимость). + */ + usePortionCollapse?: boolean; + + /** + * Количество строк за одно нажатие "Expand N". + * Используется только когда usePortionCollapse=true. + * Default: 100 + */ + portionSize?: number; +} +``` + +### Новый Compartment для portionCollapse + +```typescript +// Существующий: +const mergeCompartment = useRef(new Compartment()); + +// НОВЫЙ: +const portionCompartment = useRef(new Compartment()); +``` + +### buildMergeExtension: условное исключение collapseUnchanged + +```typescript +const buildMergeExtension = useCallback( + (collapse: boolean, margin: number): Extension => { + const mergeConfig: Parameters[0] = { + original, + highlightChanges: false, + gutter: true, + syntaxHighlightDeletions: true, + }; + + // ИЗМЕНЕНИЕ: collapseUnchanged добавляется ТОЛЬКО если portionCollapse выключен + if (collapse && !usePortionCollapse) { + mergeConfig.collapseUnchanged = { + margin, + minSize: 4, + }; + } + + // ... mergeControls logic без изменений ... + + return unifiedMergeView(mergeConfig); + }, + [original, showMergeControls, scrollToNextChunk, usePortionCollapse] +); +``` + +### buildExtensions: добавление portionCollapseExtension + +```typescript +const buildExtensions = useCallback(() => { + const extensions: Extension[] = [ + diffTheme, + lineNumbers(), + syntaxHighlighting(oneDarkHighlightStyle), + EditorView.editable.of(!readOnly), + EditorState.readOnly.of(readOnly), + ]; + + // ... существующие extensions (history, keymap, language, merge controls) ... + + // Unified merge view (compartment) — ОБЯЗАТЕЛЬНО ПЕРВЫМ + // portionCollapse зависит от ChunkField из merge view + extensions.push( + mergeCompartment.current.of( + buildMergeExtension(collapseRef.current.enabled, collapseRef.current.margin) + ) + ); + + // НОВОЕ: Portion collapse (отдельный compartment для dynamic reconfigure) + // ОБЯЗАТЕЛЬНО ПОСЛЕ merge view чтобы ChunkField был доступен в create() + extensions.push( + portionCompartment.current.of( + usePortionCollapse && collapseRef.current.enabled + ? portionCollapseExtension({ + margin: collapseRef.current.margin, + minSize: 4, + portionSize: portionSize ?? 100, + }) + : [] + ) + ); + + return extensions; +}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize]); +``` + +### Dynamic reconfigure: portionCollapse toggle + +```typescript +// Существующий effect для collapse toggle: +useEffect(() => { + const view = viewRef.current; + if (!view) return; + + // Merge view reconfigure (без collapseUnchanged если portionCollapse включён) + view.dispatch({ + effects: mergeCompartment.current.reconfigure( + buildMergeExtension(collapseUnchangedProp, collapseMargin) + ), + }); + + // НОВОЕ: portionCollapse reconfigure + if (usePortionCollapse) { + view.dispatch({ + effects: portionCompartment.current.reconfigure( + collapseUnchangedProp + ? portionCollapseExtension({ + margin: collapseMargin, + minSize: 4, + portionSize: portionSize ?? 100, + }) + : [] // Collapse выключен — убираем portionCollapse decorations + ), + }); + } +}, [collapseUnchangedProp, collapseMargin, buildMergeExtension, usePortionCollapse, portionSize]); +``` + +**Оптимизация:** Два dispatch можно объединить в один: +```typescript +view.dispatch({ + effects: [ + mergeCompartment.current.reconfigure( + buildMergeExtension(collapseUnchangedProp, collapseMargin) + ), + ...(usePortionCollapse + ? [portionCompartment.current.reconfigure( + collapseUnchangedProp + ? portionCollapseExtension({ margin: collapseMargin, minSize: 4, portionSize: portionSize ?? 100 }) + : [] + )] + : []), + ], +}); +``` +Один dispatch = одна транзакция = один update всех StateField. Это важно для consistency. + +**Поведение toggle:** +- `collapseUnchanged: true` + `usePortionCollapse: true` = portion collapse ВКЛЮЧЁН +- `collapseUnchanged: false` + `usePortionCollapse: true` = все зоны развёрнуты (portionCollapse off) +- `collapseUnchanged: true` + `usePortionCollapse: false` = CM's стандартный collapse +- `collapseUnchanged: false` + `usePortionCollapse: false` = все зоны развёрнуты + +### Import + +```typescript +import { + portionCollapseExtension, +} from './portionCollapse'; +``` + +--- + +## Стили + +### Размещение: отдельная тема в portionCollapse.ts + +Стили включаются как часть extension через `portionCollapseTheme` — инкапсулированы рядом с логикой, автоматически включаются/выключаются вместе с extension. + +```typescript +// В portionCollapse.ts +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?): Extension { + return [field, portionCollapseTheme]; +} +``` + +**Почему не в diffTheme:** +1. Инкапсулирует стили рядом с логикой +2. Тема автоматически включается/выключается с extension +3. Не загрязняет diffTheme стилями для feature, который может быть отключён +4. CM dedup'ит тему если extension добавлена несколько раз + +--- + +## Как portionCollapse получает changed ranges + +### Доступные варианты + +#### Вариант A: getChunks из @codemirror/merge (public API) + +```typescript +import { getChunks } from '@codemirror/merge'; +``` + +`getChunks(state)` возвращает `{ chunks: readonly Chunk[], side: "a" | "b" | null } | null`. + +- **Плюс:** Официальный public API +- **Плюс:** Всегда актуальные chunks (обновляются при accept/reject) +- **Минус:** Может вернуть `null` если merge view ещё не инициализирован +- **Минус:** `side` в unified view = `"b"` (не `null` — в документации CM: unified = side "b") + +#### Вариант B: getChunks() из CodeMirrorDiffUtils.ts + +```typescript +import { getChunks } from './CodeMirrorDiffUtils'; +``` + +Это реэкспорт `getChunks` из `@codemirror/merge`: +```typescript +// CodeMirrorDiffUtils.ts, line 75 +export { acceptChunk, getChunks, rejectChunk }; +``` + +- **Плюс:** Уже используется в CodeMirrorDiffView.tsx +- **Плюс:** Единая точка импорта для всех merge utilities +- **Минус:** Тот же API что вариант A (просто реэкспорт) + +#### Вариант C: самостоятельное вычисление через diff + +```typescript +import { Chunk, getOriginalDoc } from '@codemirror/merge'; + +function computeChangedRanges(state: EditorState): readonly Chunk[] { + const original = getOriginalDoc(state); + return Chunk.build(original, state.doc); +} +``` + +- **Плюс:** Не зависит от внутреннего ChunkField merge view +- **Минус:** Дублирование вычислений (chunks считаются дважды) +- **Минус:** `Chunk.build` может быть дорогим на больших файлах + +### Рекомендация: Вариант B + +Используем `getChunks()` из `CodeMirrorDiffUtils.ts` — уже проверенный и используемый в проекте. Это реэкспорт official API, но через единую точку проекта. + +```typescript +// portionCollapse.ts +import { getChunks } from './CodeMirrorDiffUtils'; +``` + +**Обработка null:** +```typescript +const result = getChunks(state); +if (!result) return Decoration.none; // Merge view ещё не готов +``` + +### Порядок extensions и lazy init + +`getChunks` вернёт `null` в `create()` если ChunkField ещё не инициализирован. CM вызывает `create()` для всех StateField при создании EditorState. Порядок вызова `create()` определяется порядком extensions. + +**Но** даже если merge extension идёт первой в списке, `ChunkField.init()` может ещё не быть applied в момент `create()` нашего field, потому что `init()` работает как override для `create()` и применяется к тому же проходу инициализации. + +Поэтому `buildPortionRanges` обрабатывает `null` от `getChunks` и возвращает `Decoration.none`. Lazy init в `update()` StateField исправляет это при первой же транзакции: + +```typescript +update(value, tr) { + // Lazy init: если create() вернул Decoration.none потому что chunks были null + if (value === Decoration.none) { + const chunks = getChunks(tr.state); + if (chunks) { + return buildPortionRanges(tr.state, margin, minSize, portionSize); + } + } + // ... +} +``` + +**Когда вызовется первый update?** При первом dispatch в EditorView. В CodeMirrorDiffView.tsx после создания view сразу идёт reconfigure для language (`langCompartment.current.reconfigure(syncLang)`) — это transaction, которая триггерит update всех StateField. Поэтому lazy init сработает практически мгновенно. + +--- + +## Edge-cases + +### 1. portionCollapse + accept/reject + +**Проблема:** При `acceptChunk(view)` CM dispatch'ит `updateOriginalDoc` effect (обновляет original doc). Это меняет ChunkField — collapsed зоны могут стать невалидными. При `rejectChunk(view)` CM dispatch'ит `docChanged` (заменяет текст в document B). + +**Решение:** В `update()` StateField: +- `updateOriginalDoc` effect detected → полный rebuild через `buildPortionRanges()` +- `tr.docChanged` → полный rebuild через `buildPortionRanges()` + +Существующие expanded зоны (пользователь уже нажал "Expand 100") теряются — все collapsed зоны пересоздаются. + +**Обоснование:** Accept/reject — редкая операция. Потеря расширенных зон допустима (пользователь expand'ил чтобы посмотреть контекст, а после accept/reject контекст изменился). GitHub ведёт себя аналогично. + +**Отличие от CM:** CM's `CollapsedRanges` при accept/reject НЕ делает rebuild в `update()`. CM полагается на reconfigure compartment для пересоздания decorations. Наш подход с rebuild в `update()` проще и не требует внешнего координатора. + +### 2. Expand на границе файла + +**Проблема:** Collapsed зона в начале файла (строки 1-100). "Expand 100" сдвинет from на 100 строк — зона исчезнет (0 строк). Это нормальное поведение. + +**Проблема:** Collapsed зона в конце файла (строки 450-500). "Expand 100" запросит 100 строк, но доступно только 50. + +**Решение:** `handleExpandPortion` вычисляет `remainingLines`. Если `remainingLines < minSize` — зона удаляется целиком (эквивалент "Expand All"). Widget показывает `Expand {min(portionSize, lineCount)}` если lineCount < portionSize. + +Проверка в PortionCollapseWidget.toDOM(): +```typescript +if (this.lineCount > this.portionSize) { + // Кнопка "Expand {portionSize}" +} else { + // lineCount <= portionSize — показываем только "Expand All" + // (кнопка "Expand N" не создаётся — она бы expand'ила всё равно всё) +} +``` + +### 3. Файл целиком новый (isNewFile: true) + +**Проблема:** Новый файл = весь контент "inserted". `getChunks` вернёт один chunk покрывающий весь файл. Unchanged зон НЕТ. + +**Решение:** `buildPortionRanges` не создаёт decorations если нет промежутков между chunks. Цикл `for (let i = 0; ; i++)` проверяет `lines >= minSize` для каждой зоны — зон нет → `builder.finish()` возвращает пустой `DecorationSet`. + +### 4. Reconfigure при toggle collapseUnchanged + +**Проблема:** Пользователь включает/выключает collapse через ReviewToolbar toggle. + +**Решение:** Dynamic reconfigure через portionCompartment: +- Toggle ON: `portionCompartment.reconfigure(portionCollapseExtension(config))` — создаётся новый StateField с новыми decorations +- Toggle OFF: `portionCompartment.reconfigure([])` — StateField удаляется, decorations пропадают + +**Важно:** При reconfigure ВСЕ expanded зоны сбрасываются (новый StateField = новые decorations). Это ожидаемо — toggle collapse = "пересоздать все collapsed зоны". + +### 5. Конфликт с CM's collapseUnchanged + +**Проблема:** Если случайно включены оба (collapseUnchanged в mergeConfig И portionCollapseExtension) — двойные `Decoration.replace` на одних и тех же зонах. Это приведёт к невалидным overlapping replace decorations. + +**Решение:** `buildMergeExtension` проверяет `usePortionCollapse` и НЕ добавляет collapseUnchanged если portionCollapse включён. Дополнительно, в документации portionCollapseExtension явно указано: "НЕ совместима с collapseUnchanged". + +**Дополнительная защита:** Можно добавить runtime check в `buildPortionRanges`: +```typescript +// Если CM's collapseUnchanged уже создал decorations — пропускаем +// (проверка через наличие .cm-collapsedLines элементов в DOM) +``` +Но это over-engineering — достаточно документации и проверки в `buildMergeExtension`. + +### 6. Очень длинные файлы (10000+ строк) + +**Проблема:** Много collapsed зон может замедлить DecorationSet operations. + +**Решение:** +1. `RangeSetBuilder` создаёт balanced B-tree RangeSet эффективно (O(n)) +2. `DecorationSet.update({ filter })` = O(n) для фильтрации +3. Количество collapsed зон = chunks.length + 1 (максимум) +4. Типичное количество chunks < 100, поэтому collapsed зон < 101 +5. CM RangeSet оптимизирован для тысяч ranges + +### 7. Expand + docChanged одновременно (concurrent editing) + +**Проблема:** Пользователь нажимает "Expand 100" в момент когда CM обрабатывает typing transaction. + +**Решение:** CM гарантирует атомарность transactions. `expandPortion` effect будет в отдельной transaction. `StateEffect.define({ map })` обеспечивает корректный ремаппинг позиций если document изменился между dispatch и apply. + +Сигнатура map callback: `(value: Value, mapping: ChangeDesc) => Value | undefined`. Если map вернёт `undefined` — effect удаляется из transaction. Наш `expandPortion.map` всегда возвращает объект (mapPos не может вернуть undefined), поэтому effect всегда сохраняется. + +### 8. updateDOM vs toDOM в PortionCollapseWidget + +**Проблема:** CM вызывает `updateDOM(dom, view)` когда widget с `eq() = false` но того же типа. Можно обновить DOM вместо пересоздания. + +**Решение:** НЕ реализуем `updateDOM()`. Причина: +1. Widget пересоздаётся только при expand (нечасто) +2. `eq()` возвращает true если lineCount/portionSize не изменились — DOM не пересоздаётся +3. При rebuild decorations (accept/reject) все widgets пересоздаются в любом случае +4. Сложность updateDOM (изменение текста + показ/скрытие кнопок) не оправдана для редкой операции + +### 9. scrollbar высота при collapsed зонах + +**Проблема:** CM вычисляет высоту scrollbar на основе видимого контента. Collapsed зоны скрывают строки — scrollbar может стать неточным. + +**Решение:** `Decoration.replace({ block: true })` корректно обрабатывается CM для расчёта scrollbar. CM использует `estimatedHeight` widget'а для приблизительной высоты. Наш widget возвращает фиксированную высоту (28px) — это высота видимого widget, не скрытого контента. CM's CollapseWidget возвращает 27px. Scrollbar корректно отражает видимую высоту документа. + +### 10. eq() и pos — когда decoration перемещается + +**Проблема:** После `map(tr.changes)` позиция decoration может сдвинуться. Виджет хранит `pos` — он станет stale. + +**Решение:** `eq()` НЕ сравнивает `pos`. CM вызывает `eq()` только для decorations на ОДНОЙ позиции. Если позиция decoration изменилась после map — это другой range, CM пересоздаёт widget через `toDOM()`. Однако `pos` внутри виджета всё равно может быть stale если decoration map'нулась но eq() вернул true. + +**Но:** `pos` используется только в onmousedown для dispatch effect. К моменту клика пользователя — document уже stable, и `pos` совпадает с `from` decoration (потому что виджет создавался с `pos = from`). Если map изменил from — CM пересоздаст виджет (eq = false из-за lineCount изменения или нового toDOM). + +Для дополнительной надёжности можно использовать `view.posAtDOM(container)` вместо сохранённого `this.pos`: +```typescript +expandBtn.onmousedown = (e) => { + e.preventDefault(); + const pos = view.posAtDOM(container); + view.dispatch({ effects: expandPortion.of({ pos, count: this.portionSize }) }); +}; +``` +Это паттерн из CM's CollapseWidget (`view.posAtDOM(e.target)`). **Рекомендуется** для robustness. + +--- + +## Проверка + +### Unit тесты + +``` +test/renderer/components/team/review/portionCollapse.test.ts +``` + +**Тест-кейсы для buildPortionRanges:** + +1. **Нет chunks** — getChunks returns null → Decoration.none +2. **Один chunk в середине** — создаёт 2 collapsed зоны (до и после) +3. **Chunk в начале файла** — одна collapsed зона после chunk +4. **Chunk в конце файла** — одна collapsed зона до chunk +5. **Два chunks рядом** — зона между ними < minSize → не collapse +6. **Два chunks далеко** — зона между ними >= minSize → collapse с margin +7. **Весь файл — новый (1 chunk на весь файл)** — пустой DecorationSet +8. **margin = 0** — collapse начинается сразу после chunk +9. **minSize = 1** — даже 1 строка сворачивается +10. **Первая зона (до первого chunk)** — начинается с line 1 без margin (как CM) + +**Тест-кейсы для handleExpandPortion:** + +11. **Expand 100 строк из 247** — новая decoration с 147 строками, смещённый from +12. **Expand 100 строк из 103** — 3 строки осталось < minSize(4) → decoration удалена +13. **Expand 100 строк из 100** — lineCount == portionSize → decoration удалена (< minSize) +14. **pos не найден в decorations** — decorations без изменений + +**Тест-кейсы для handleExpandAll:** + +15. **Expand all — decoration удалена** — DecorationSet без этой decoration +16. **pos не найден** — decorations без изменений + +**Тест-кейсы для PortionCollapseWidget:** + +17. **toDOM: lineCount > portionSize** — 2 кнопки (Expand N + Expand All) +18. **toDOM: lineCount <= portionSize** — 1 кнопка (только Expand All) +19. **toDOM: lineCount = 1** — "1 unchanged line" (singular) +20. **eq: same lineCount + portionSize** — true +21. **eq: different lineCount** — false +22. **ignoreEvent: MouseEvent** — true +23. **ignoreEvent: KeyboardEvent** — false + +**Тест-кейсы для StateField update:** + +24. **updateOriginalDoc effect (accept)** — полный rebuild +25. **docChanged (reject)** — полный rebuild +26. **expandPortion effect** — partial expand +27. **expandAllAtPos effect** — полное удаление +28. **Lazy init: Decoration.none → chunks available** — rebuild +29. **No-op transaction** — value unchanged + +### Ручная проверка + +1. Открыть файл с 500+ строк между изменениями +2. Видна collapsed зона: "... 247 unchanged lines ..." +3. Кнопка "Expand 100" → зона уменьшается до "... 147 unchanged lines ..." +4. Повторный "Expand 100" → "... 47 unchanged lines ..." (только Expand All если <= 100) +5. "Expand All" → зона полностью развёрнута +6. Accept chunk → collapsed зоны пересчитаны +7. Toggle collapse off/on → все зоны пересозданы (expanded зоны сброшены) +8. Файл целиком новый → нет collapsed зон +9. Маленький файл (< minSize между chunks) → нет collapsed зон + +### Визуальная проверка стилей + +1. Collapsed зона визуально совпадает с CM's `.cm-collapsedLines` (bg, border, font) +2. Кнопки: hover → subtle highlight +3. Кнопки: active → darker highlight +4. Кнопки не ломают layout при resize окна +5. Текст "N unchanged lines" корректно обновляется при expand + +--- + +## Файлы + +| Файл | Тип | ~LOC | +|------|-----|---:| +| `src/renderer/components/team/review/portionCollapse.ts` | NEW | ~300 (StateField + Widget + helpers + theme) | +| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | MODIFY | ~40 (usePortionCollapse prop, compartment, buildExtensions) | +| `test/renderer/components/team/review/portionCollapse.test.ts` | NEW | ~300 (29 тест-кейсов) | +| **Итого** | 2 NEW + 1 MODIFY | ~640 | diff --git a/docs/iterations/diff-view/continuous-scroll/phase-5-polish.md b/docs/iterations/diff-view/continuous-scroll/phase-5-polish.md new file mode 100644 index 00000000..ef39aab8 --- /dev/null +++ b/docs/iterations/diff-view/continuous-scroll/phase-5-polish.md @@ -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>(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(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 + +``` + +### 2.5. Передача Map наружу + +ContinuousScrollView передает Map наружу через `useImperativeHandle`: + +```typescript +// ContinuousScrollView.tsx +export interface ContinuousScrollViewHandle { + getEditorViewMap: () => Map; + getActiveEditorView: () => EditorView | null; +} + +const ContinuousScrollView = forwardRef( + (props, ref) => { + const editorViewMapRef = useRef>(new Map()); + + useImperativeHandle(ref, () => ({ + getEditorViewMap: () => editorViewMapRef.current, + getActiveEditorView: () => { + // Логика определения активного editor (см. секцию 3) + return resolveActiveEditorView(editorViewMapRef.current, props.activeFilePath); + }, + }), [props.activeFilePath]); + + // ... + } +); +``` + +В ChangeReviewDialog: + +```typescript +const continuousScrollRef = useRef(null); + + + +// Использование: +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, + 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; + 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` -- невидимый `
` после 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(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 ( +
+ + {/* Sentinel для auto-viewed detection */} +
+
+); +``` + +### 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 + +``` + +### 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 + + {isContinuousMode + ? 'Accept all changes across all files' + : 'Accept all changes in current file'} + +``` + +### 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 && ( +
+
+
0 ? (reviewedCount! / totalHunks) * 100 : 0}%` }} + /> +
+ + {reviewedCount} of {totalHunks} reviewed + +
+)} +``` + +**Позиция в toolbar:** после change stats (`+N -M across K files`), перед separator (`
`). + +### 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(null); + + +``` + +--- + +## 7. Per-file discard counter + +### 7.1. Проблема + +Текущий `discardCounter` -- одно число для всего диалога. При discard оно инкрементируется, и CodeMirrorDiffView пересоздается через `key={filePath}:${discardCounter}`. + +В continuous mode каждый файл имеет свой `CodeMirrorDiffView`. Инкремент общего counter пересоздаст ВСЕ EditorView -- это неэффективно и потеряет scroll position. + +### 7.2. Решение: Record + +```typescript +// ChangeReviewDialog.tsx +const [discardCounters, setDiscardCounters] = useState>({}); +``` + +### 7.3. Использование в FileSectionDiff key + +```tsx +// ContinuousScrollView.tsx — передает counter каждому FileSectionDiff +{files.map(file => ( + +))} +``` + +Внутри FileSectionDiff, CodeMirrorDiffView: + +```tsx + +``` + +### 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` используется только в continuous mode. Оба варианта сосуществуют в ChangeReviewDialog: + +```typescript +// Single-file mode: существующий counter +const [discardCounter, setDiscardCounter] = useState(0); + +// Continuous mode: per-file counters +const [discardCounters, setDiscardCounters] = useState>({}); +``` + +--- + +## 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) | diff --git a/docs/research/diff-view-audit.md b/docs/research/diff-view-audit.md new file mode 100644 index 00000000..543d4551 --- /dev/null +++ b/docs/research/diff-view-audit.md @@ -0,0 +1,536 @@ +# Diff View Feature — Full Audit Report + +Date: 2026-02-26 +Verified: 2026-02-26 (4 parallel agents cross-checked every bug against actual source code) + +Comprehensive audit of the changes/diff viewing feature covering line count reliability, +hunk parsing, stat-to-hunk consistency, and UI rendering. + +--- + +## Architecture Overview + +The app uses **two distinct diff strategies**: + +1. **Chat Viewer Diffs** (read-only, simple display): + - `DiffViewer.tsx` — custom LCS-based line-by-line diff + - Pure string comparison, NO hunk structure + +2. **Team Review Diffs** (interactive, hunk-aware): + - CodeMirror Merge plugin (`@codemirror/merge`) + - `ReviewDiffContent.tsx` — uses `diffLines` from `diff` package + - `CodeMirrorDiffView.tsx` — full merge view with hunk navigation + - `ReviewApplierService.ts` — applies/rejects hunks to disk + +### Three independent stat computation paths: + +| Service | Algorithm | Used For | +|---------|-----------|----------| +| `ChangeExtractorService` | `diffLines()` from npm `diff` | Team member changes (file badges) | +| `MemberStatsComputer` | `split('\n').length` arithmetic | Session analytics | +| `FileContentResolver` | `diffLines()` from npm `diff` | Full file content diffs (CodeMirror) | + +--- + +## Evaluation Summary + +| # | Bug | Real? | Confidence | Status | +|---|-----|-------|------------|--------| +| 1 | Two conflicting line-counting methods | **YES** | 9/10 | Open | +| 2 | Write never counts removals | **YES** | 10/10 | Open | +| 3 | Trailing newline off-by-one | **PARTIAL** | 6/10 | Resolves with #1 | +| 4 | FileContentResolver overwrites stats | **PARTIAL** | 5/10 | Design issue | +| 5 | computeHunkIndexAtPos → 0 fallback | **YES** | 10/10 | Open | +| 6 | Hunk ≠ snippet mapping | **YES** | 9/10 | Open | +| 7 | indexOf duplicates in rejection | **YES** | 10/10 | Open | +| 8 | Skeleton flash after save | **FIXED** | 9/10 | Done | +| 9 | CRLF → false diffs | **YES** | 9/10 | Open | +| 10 | OOM on large files (LCS) | **YES** | 8/10 | Open | +| 11 | Race condition disk vs cache | **YES** | 8/10 | Open | +| 12 | Empty string inconsistency | **YES** | 7/10 | Resolves with #1 | +| 13 | Bash estimation ~30-40% | **PARTIAL** | 6/10 | Design limitation | +| 14 | Echo escape handling wrong | **YES** | 8/10 | Open | +| 15 | portionCollapse edge case | **PARTIAL** | 5/10 | Needs testing | +| 16 | No-newline-at-EOF hidden | **YES** | 8/10 | Open | +| 17 | Three-way merge labels | **NO** | 9/10 | False positive | +| 18 | Zero-change files invisible | **YES** | 8/10 | Open | +| 19 | Viewed threshold mismatch | **YES** | 9/10 | Open | +| 20 | useEffect no deps array | **YES** | 10/10 | Open | +| 21 | Hunk count ≠ snippet count | **YES** | 8/10 | Open | +| 22 | Toolbar off-screen narrow viewport | **YES** | 7/10 | Open | +| 23 | Deleted files not marked | **YES** | 8/10 | Open | +| 24 | write-update reconstruction null | **YES** | 9/10 | Open | +| 25 | Bash relative paths | **YES** | 9/10 | Open | +| 26 | Empty line → space | **YES** | 7/10 | Open | +| 27 | No keyboard nav in tree | **YES** | 8/10 | Feature | +| 28 | Collapse state not persisted | **YES** | 9/10 | Open | +| 29 | No stats summary | **NO** | 3/10 | Feature request | +| 30 | Binary files not detected | **YES** | 8/10 | Open | +| 31 | No max file size | **YES** | 7/10 | Open | +| 32 | Whitespace changes not distinguished | **PARTIAL** | 6/10 | Optional feature | + +**Totals**: 24 real bugs, 3 false positives (#4, #17, #29), 5 partial/design (#3, #13, #15, #32, #4), 1 fixed (#8) + +--- + +## CRITICAL BUGS + +### 1. Two Conflicting Line-Counting Methods + +**Real bug: YES — Confidence: 9/10** + +**Impact**: Line count badges in file tree may not match actual hunks in editor. + +**Details**: +- `ChangeExtractorService` (`src/main/services/team/ChangeExtractorService.ts:463-473`) uses `diffLines()` — semantic line diffing +- `MemberStatsComputer` (`src/main/services/team/MemberStatsComputer.ts:193-196`) uses naive `split('\n').length` — `newLines - oldLines` + +Example divergence: +``` +File: 10 lines rewritten completely (same line count) +diffLines(): added=10, removed=10 (correct — all lines changed) +split arithmetic: added=0, removed=0 (wrong — same line count) +``` + +**Best fix**: Create unified line-counting utility using `diffLines()` as source of truth. Replace `MemberStatsComputer`'s arithmetic with shared utility. +**Risk**: Line count numbers will change post-fix; must test edge cases. + +### 2. Write Operations Never Count Removals + +**Real bug: YES — Confidence: 10/10** + +**Location**: `MemberStatsComputer.ts:204-214` + +```typescript +if (toolName === 'Write') { + const writeContent = typeof input.content === 'string' ? input.content : ''; + if (writeContent) { + const fileAdded = writeContent.split('\n').length; + linesAdded += fileAdded; + addFileLines(input.file_path, fileAdded, 0); // Always 0 removals! + } +} +``` + +**Impact**: Write replacing 100-line file with 50-line file shows `+50 / -0` instead of accurate counts. + +**Best fix**: Use `FileContentResolver` to access original state before Write, calculate true delta. +**Risk**: Requires coordination with FileContentResolver; may introduce coupling. + +### 3. Trailing Newline Off-by-One + +**Real bug: PARTIAL — Confidence: 6/10** + +Resolves automatically when #1 is fixed (migration to `diffLines()`). Within MemberStatsComputer's own logic, the delta arithmetic is roughly self-consistent (both old and new over-count by 1, so the difference is correct). The issue is only visible when comparing MemberStatsComputer output against ChangeExtractorService output. + +### 4. FileContentResolver Overwrites Stats + +**Real bug: PARTIAL (design issue) — Confidence: 5/10** + +`FileContentResolver.getFileContent()` recalculates stats from full content using `diffLines()`, overwriting input stats. This is actually MORE ACCURATE than snippet-based counts. The "overwrite" is intentional improvement, not a bug. The inconsistency is that file tree badges (pre-CM load) use snippet counts, while CodeMirror view uses recalculated counts. + +**Verdict**: Not a code bug. Design choice with minor visual inconsistency during loading. + +### 5. `computeHunkIndexAtPos` Returns 0 as Fallback + +**Real bug: YES — Confidence: 10/10** + +**Location**: `CodeMirrorDiffView.tsx:129-143` + +```typescript +function computeHunkIndexAtPos(state: EditorState, pos: number): number { + const chunks = getChunks(state); + if (!chunks) return 0; + let index = 0; + for (const chunk of chunks.chunks) { + if (pos >= chunk.fromB && pos <= chunk.toB) { + return index; + } + index++; + } + return 0; // ← Always returns first hunk if no match! +} +``` + +**Impact**: Clicking Accept/Reject when cursor is between hunks applies action to the FIRST hunk, not the nearest one. Confirmed: callers at lines 416-420 and 432-435 trust this value unconditionally. + +**Best fix**: Find nearest chunk by minimum distance: `Math.min(|pos - chunk.fromB|, |pos - chunk.toB|)` for each chunk, return index of nearest. +**Alternative**: Return -1 for "no match" and require caller handling. +**Risk**: Need tie-breaking rule when cursor is equidistant from two chunks. + +### 6. Hunk Index ≠ Snippet Index (False 1:1 Assumption) + +**Real bug: YES — Confidence: 9/10** + +**Location**: `ReviewApplierService.ts:337-342` + +```typescript +const snippetsToReject = hunkIndices + .filter((idx) => idx >= 0 && idx < validSnippets.length) + .map((idx) => validSnippets[idx]); +``` + +**Problem**: Assumes hunk #N corresponds to snippet #N. But: +- Multiple Edit calls can merge into one hunk in structuredPatch +- One Write call can produce multiple hunks +- MultiEdit creates 1 snippet with multiple logical changes + +**Best fix**: Build hunk-to-snippet mapping using position matching. For each hunk, find snippets whose newString appears in that hunk region. Store `hunkIndex -> Set`. +**Risk**: Complex implementation, requires re-running diff analysis. + +### 7. Snippet Rejection via indexOf — Duplicate Content Bug + +**Real bug: YES — Confidence: 10/10** + +**Location**: `ReviewApplierService.ts:353` + +```typescript +const pos = content.indexOf(snippet.newString); +``` + +`indexOf()` finds FIRST occurrence only. If identical code patterns exist elsewhere in the file, rejection corrupts the wrong section. + +**Best fix**: Position-aware matching: calculate approximate line/column of original edit, search for newString near that position (±5 lines tolerance), require context match. +**Risk**: More complex logic, false negatives if context too strict. + +### 8. Skeleton Flash After File Save — FIXED + +**Location**: `changeReviewSlice.ts:649-666` + +After saving, `fileContents[filePath]` was deleted from cache, causing `hasContent = false` → skeleton placeholder shown until lazy re-fetch completes. + +**Fix applied**: Instead of deleting, update `modifiedFullContent` with saved content in-place. `contentSource` set to `'disk-current'`. + +--- + +## HIGH PRIORITY BUGS + +### 9. CRLF Line Endings → False Diffs + +**Real bug: YES — Confidence: 9/10** + +**Location**: `DiffViewer.tsx:297` + +```typescript +const oldLines = oldString.split('\n'); +``` + +Windows files with `\r\n` leave trailing `\r` on each line. Every line shows as "changed" even if content is identical. + +**Fix**: Use `split(/\r?\n/)` or normalize before diffing. +**Risk**: Very low. Standard regex, no side effects. + +### 10. OOM on Large Files (DiffViewer LCS) + +**Real bug: YES — Confidence: 8/10** + +**Location**: `DiffViewer.tsx:50-68` + +LCS algorithm is O(m×n) space. Two 5000-line files = 25M matrix entries ≈ 100MB RAM. +No safeguards, no fallback for large files. + +**Best fix**: Add size check: if `m * n > MAX_CELLS` (e.g., 1M), fallback to `diffLines()` from npm `diff` package. +**Risk**: Fallback produces different visual output (semantic vs LCS). Need to test. + +### 11. Race Condition: File Disk State vs Cache + +**Real bug: YES — Confidence: 8/10** + +Stats and hunks come from SEPARATE sources: +- Stats: JSONL tool_use blocks (snapshot at parse time) +- Hunks: current file on disk (read at view time) + +3-minute cache TTL on both `ChangeExtractorService` and `FileContentResolver`. +If file changes on disk between fetches, stats and hunks desynchronize. + +**No cache invalidation** on FileWatcher events → caches stay stale until TTL expires. + +**Best fix**: Hook FileWatcher to evict caches when files change. Or reduce TTL to 30s. +**Risk**: Must ensure invalidation doesn't create new race conditions. + +### 12. Empty String Handling Inconsistency + +**Real bug: YES — Confidence: 7/10** + +**Location**: `MemberStatsComputer.ts:193` + +Empty string `''` is falsy → returns 0. But `''.split('\n').length === 1`. + +**Resolves with #1** — migrating to `diffLines()` handles this correctly. + +### 13. Bash Line Estimation Covers ~30-40% of Patterns + +**Real bug: PARTIAL — Confidence: 6/10** + +**Location**: `MemberStatsComputer.ts:314-416` + +**Verdict**: Fundamental limitation, not a code bug. The JSONL only stores command strings, not execution output. Without running the shell, accurate counting is impossible. Code comments acknowledge this. Best approach: document limitation in UI with tooltip. + +### 14. Echo Escape Sequence Handling Wrong + +**Real bug: YES — Confidence: 8/10** + +**Location**: `MemberStatsComputer.ts:371` + +```typescript +added += content.split('\\n').length; +``` + +Splits on literal `\\n` in quoted string. But `echo "line1\nline2"` does NOT expand `\n` without `-e` flag. Counter is wrong for standard echo. + +**Best fix**: Check for `-e` flag before splitting on `\\n`. Without `-e`, treat as single line. +**Risk**: Hacky logic, any change may break other cases. Conservative: don't count echo lines at all. + +--- + +## MEDIUM PRIORITY BUGS + +### 15. portionCollapse Line Position Edge Case + +**Real bug: PARTIAL — Confidence: 5/10** + +**Location**: `portionCollapse.ts:140` + +Rare edge case at exact line boundaries. Needs unit tests with edge cases to confirm. +**Verdict**: Low probability, needs testing before fixing. + +### 16. No-Newline-At-End-Of-File Not Shown + +**Real bug: YES — Confidence: 8/10** + +**Location**: `ReviewDiffContent.tsx:46-48` + +```typescript +const lines = part.value.replace(/\n$/, '').split('\n'); +``` + +Strips trailing newline. If original has no final newline but modified adds one, diff shows them as identical. + +**Best fix**: Add visual indicator for no-newline-at-EOF. +**Risk**: Need to update rendering without breaking existing layout. + +### 17. Three-Way Merge Labels Confusing — FALSE POSITIVE + +**Confidence: 9/10 that this is NOT a bug** + +Labels correctly follow diff3 semantics: `<<<<<<< current` = disk state, `>>>>>>> original` = pre-change state. This is standard and correct. The audit was wrong. + +### 18. Zero-Change Files Invisible + +**Real bug: YES — Confidence: 8/10** + +**Location**: `ChangeStatsBadge.tsx` + +```typescript +if (linesAdded === 0 && linesRemoved === 0) return null; +``` + +Files modified with equal adds/removes (e.g., 5 lines rewritten) show no badge. Missing `modified` boolean flag. + +**Best fix**: Add `modified: boolean` flag to `FileChangeSummary`. Show neutral badge for zero-net-change files. +**Risk**: Requires data structure change, but straightforward. + +### 19. Viewed File Threshold Mismatch + +**Real bug: YES — Confidence: 9/10** + +- `FileSectionDiff.tsx`: `threshold: 0.85` +- `CodeMirrorDiffView.tsx`: `threshold: 1.0` + +**Best fix**: Standardize on 0.85 everywhere. One-line change. +**Risk**: None. + +### 20. Missing Dependency Array in useEffect + +**Real bug: YES — Confidence: 10/10** + +**Location**: `FileSectionDiff.tsx:50-56` + +```typescript +useEffect(() => { + if (localEditorViewRef.current) { + onEditorViewReady(file.filePath, localEditorViewRef.current); + } +}); // ← No dependency array! Runs EVERY render. +``` + +**Best fix**: Add `[file.filePath, onEditorViewReady]` dependency array. +**Risk**: Low. Need to ensure `onEditorViewReady` is memoized with useCallback. + +### 21. Hunk Counts Mismatch in UI + +**Real bug: YES — Confidence: 8/10** + +`snippets.length` used as fallback before CodeMirror loads, then replaced by `chunks.length`. User sees progress jump (e.g., "1 of 1" → "1 of 5"). + +**Best fix**: Pre-compute chunk count from `diffLines()` at load time, not from snippet count. +**Risk**: Medium — adds computation step, but improves correctness. + +### 22. Merge Toolbar Off-Screen in Narrow Viewport + +**Real bug: YES — Confidence: 7/10** + +Buttons at `insetInlineEnd: '8px'` get clipped in narrow viewports. + +**Best fix**: CSS-only: use `insetInlineStart` or add `maxWidth: '100%'` overflow handling. +**Risk**: None. CSS-only change. + +### 23. Deleted Files Not Marked + +**Real bug: YES — Confidence: 8/10** + +No `isDeleted` flag in `FileChangeSummary`. Deleted files show as regular changes. + +**Best fix**: Add `isDeleted: boolean` field. Set when original has content and modified is empty. +**Risk**: Medium — requires data structure change. + +### 24. Snippet Reconstruction Returns null for Write-Update + +**Real bug: YES — Confidence: 9/10** + +**Location**: `FileContentResolver.ts:392-394` + +`write-update` can't be reconstructed because JSONL doesn't include `oldString`. Falls back to disk-current which may differ from actual original. + +**Best fix**: Store original content when detecting write-update during extraction. +**Risk**: Medium — requires data structure enrichment. + +### 25. Bash Relative Paths Not Captured + +**Real bug: YES — Confidence: 9/10** + +**Location**: `MemberStatsComputer.ts:373-376` + +Only captures absolute paths (`startsWith('/')`). Misses relative paths. + +**Best fix**: Remove `startsWith('/')` check, just validate non-empty string. +**Risk**: Very low. 1-line fix. + +--- + +## LOW PRIORITY + +### 26. DiffViewer Empty Line Rendering + +**Real bug: YES — Confidence: 7/10** + +```typescript +{line.content || ' '} +``` + +Empty lines show as single space. Can't distinguish from space-only lines. + +**Best fix**: `{line.content ?? ' '}` (only use space if truly undefined). +**Risk**: None. 1-line fix. + +### 27. No Keyboard Navigation in File Tree + +**Real bug: YES — Confidence: 8/10** + +Mouse-only navigation. No WAI-ARIA tree widget support. + +**Verdict**: UX feature, ~150 LOC. Not a correctness bug. + +### 28. Folder Collapse State Not Persisted + +**Real bug: YES — Confidence: 9/10** + +State lost on dialog close. Uses `useState` with fresh `Set()`. + +**Best fix**: Persist to localStorage or Zustand store. +**Risk**: Low-medium. localStorage has size limits but file tree data is tiny. + +### 29. No Diff Stats Summary at Top — FALSE POSITIVE + +**Confidence: 3/10 that this is a bug** + +Missing feature, not a bug. File-by-file stats are visible. Aggregate summary would be nice UX but isn't a correctness issue. + +### 30. Binary Files Not Detected + +**Real bug: YES — Confidence: 8/10** + +`diffLines()` called on binary content without check. Can cause corrupted display. + +**Best fix**: Check for null bytes (`content.includes('\0')`) before diffing. Skip binary files. +**Risk**: Very low. Simple guard. + +### 31. No Maximum File Size Handling + +**Real bug: YES — Confidence: 7/10** + +No progress indicator for large files. Browser freezes for 2-3s on 1MB+ files. + +**Best fix**: Add size check, show "file too large" fallback for >5MB. +**Risk**: Low. Graceful degradation. + +### 32. Whitespace-Only Changes Not Distinguished + +**Real bug: PARTIAL — Confidence: 6/10** + +No visual distinction between content vs whitespace-only changes. + +**Verdict**: Optional enhancement. Add `ignoreWhitespace` toggle. Not a correctness bug. + +--- + +## Race Condition Severity Matrix + +| Scenario | Probability | Severity | +|----------|-------------|----------| +| Disk modification between stat cache and hunk fetch | Medium | High | +| File deletion after stats cached | Low-Medium | High | +| JSONL appended while parsing | Low | Medium | +| Git repo state change (checkout, rebase) | Low | Medium | +| Snippet reconstruction chain broken | Medium | High | + +--- + +## Fix Categories + +### Safe to Fix Now (isolated, low-risk, clear approach) +- **#5**: computeHunkIndexAtPos → nearest hunk (local function change) +- **#9**: CRLF normalization `split(/\r?\n/)` (1-line regex change) +- **#19**: Threshold 0.85 vs 1.0 → standardize (1-line constant) +- **#20**: useEffect dependency array (1-line addition) +- **#25**: Bash relative paths (remove `startsWith('/')` check) +- **#26**: Empty line rendering (`||` → `??`) + +### Need More Research Before Fixing (multi-file, complex, risky) +- **#1**: Unify line counting (affects 3 services, all consumers) +- **#2**: Write removals (needs original file content during stats) +- **#6**: Hunk↔snippet mapping (algorithm redesign) +- **#7**: indexOf → position-aware matching (could break existing logic) +- **#10**: OOM safeguard (need to test fallback visual consistency) +- **#11**: Cache invalidation (FileWatcher integration complexity) +- **#23**: Deleted files flag (data structure change, migration) +- **#24**: write-update reconstruction (data enrichment needed) + +### Feature Requests / Won't Fix +- **#13**: Bash estimation — fundamental limitation +- **#17**: Three-way labels — NOT a bug +- **#27**: Keyboard nav — UX feature +- **#29**: Stats summary — UX feature +- **#32**: Whitespace toggle — optional enhancement + +--- + +## Key Files Reference + +| File | Role | +|------|------| +| `src/main/services/team/ChangeExtractorService.ts` | Parses JSONL, aggregates per-file changes | +| `src/main/services/team/MemberStatsComputer.ts` | Session analytics, line counting | +| `src/main/services/team/FileContentResolver.ts` | Full file content resolution | +| `src/main/services/team/ReviewApplierService.ts` | Apply/reject hunks, save files | +| `src/renderer/components/chat/viewers/DiffViewer.tsx` | Simple LCS diff viewer | +| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Advanced CodeMirror merge view | +| `src/renderer/components/team/review/ReviewDiffContent.tsx` | Fallback snippet-based diff | +| `src/renderer/components/team/review/FileSectionDiff.tsx` | Diff section wrapper | +| `src/renderer/components/team/review/FileSectionHeader.tsx` | File header with Save button | +| `src/renderer/components/team/review/ContinuousScrollView.tsx` | Scroll container, skeleton logic | +| `src/renderer/components/team/review/portionCollapse.ts` | Smart collapse of unchanged regions | +| `src/renderer/components/team/review/ReviewFileTree.tsx` | File tree with stats badges | +| `src/renderer/store/slices/changeReviewSlice.ts` | Store: file contents, save, decisions | +| `src/renderer/hooks/useDiffNavigation.ts` | Keyboard navigation between hunks | +| `src/shared/types/review.ts` | Shared types: FileChangeSummary, FileChangeWithContent | diff --git a/docs/research/diff-view-fix-plans.md b/docs/research/diff-view-fix-plans.md new file mode 100644 index 00000000..861c100a --- /dev/null +++ b/docs/research/diff-view-fix-plans.md @@ -0,0 +1,814 @@ +# Diff View — Detailed Fix Plans from Deep Research + +Date: 2026-02-26 +Source: 4 parallel research agents + 3 deep research agents (Round 2) +Last updated: 2026-02-26 (Round 2 deep research — 3 agents, 280k+ tokens total) + +--- + +## Fix #11: Cache TTL 3min → 30sec + +**Confidence: 10/10** +**Effort: 2 lines** + +### Files to Change + +1. `src/main/services/team/ChangeExtractorService.ts:40` +```typescript +// OLD +private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин + +// NEW +private readonly CACHE_TTL = 30 * 1000; // 30 sec +``` + +2. `src/main/services/team/FileContentResolver.ts:32` +```typescript +// OLD +private readonly cacheTtl = 3 * 60 * 1000; // 3 мин + +// NEW +private readonly cacheTtl = 30 * 1000; // 30 sec +``` + +### Cache Architecture Details + +Both services use `Map` with TTL: +- `ChangeExtractorService`: key = `${teamName}:${memberName}`, stores `AgentChangeSet` + `mtime` + `expiresAt` +- `FileContentResolver`: key = file path, stores `original | modified | source` + `expiresAt` +- `ChangeExtractorService` stores file `mtime` but NEVER uses it for validation +- `FileContentResolver` has `invalidateFile(filePath)` but only called in ONE place: `review.ts:265` after save + +### FileWatcher Coverage + +FileWatcher ALREADY watches the right directories: +- `~/.claude/projects/` (JSONL session files) +- `~/.claude/todos/` (todo JSON files) +- `~/.claude/teams/` (team config files) +- `~/.claude/tasks/` (task JSON files) + +But NO service-level cache invalidation hooks exist beyond the single `invalidateFile()` call. + +### Thundering Herd Risk: NONE + +- Each cache entry expires independently, staggered by client refresh timing +- 30sec = 120 cache refreshes/hour per user, negligible CPU +- Each team member has separate cache key; concurrent misses don't cascade + +### Future Phase (Optional): FileWatcher Integration + +Would require: +1. Add `ChangeExtractorService` and `FileContentResolver` to ServiceContext +2. Wire FileWatcher events to precise cache invalidation +3. Map `${teamName}:${memberName}` cache keys to affected files +4. Complex wiring, not worth it until TTL proves insufficient + +--- + +## Fix #10: OOM Safeguard for DiffViewer LCS + +**Confidence: 9/10** +**Effort: ~30 LOC** + +### Memory Analysis + +LCS matrix: `(m+1) × (n+1)` entries, each number = ~8 bytes in V8: +- 1000×1000 = 1M entries ≈ 8MB ✓ Safe +- 3000×3000 = 9M entries ≈ 72MB ⚠️ Manageable +- 5000×5000 = 25M entries ≈ 200MB ✗ Dangerous +- 10000×10000 = 100M entries ≈ 800MB ✗ OOM + +**Recommended threshold: `MAX_CELLS = 1_000_000`** (~1000×1000 lines) + +### `diffLines()` Return Format + +From npm `diff` package: +```typescript +Array<{ + value: string; // The actual line(s) + newline + count?: number; // Number of lines + added?: boolean; // true = new lines + removed?: boolean; // true = removed lines + // If neither added/removed: unchanged context lines +}> +``` + +### DiffLine Type (DiffViewer) + +```typescript +interface DiffLine { + type: 'removed' | 'added' | 'context'; + content: string; + lineNumber: number; +} +``` + +### Implementation + +**Import to add** (DiffViewer.tsx top): +```typescript +import { diffLines as semanticDiffLines } from 'diff'; +``` + +**New constant**: +```typescript +/** Max LCS matrix cells before falling back to semantic diff. + * 1M cells ≈ 8MB RAM — safe for all platforms. */ +const MAX_LCS_CELLS = 1_000_000; +``` + +**Fallback function**: +```typescript +/** + * Fallback diff using semantic line-diffing from npm `diff` package. + * Used when LCS matrix would exceed memory threshold. + * Output format matches LCS-based generateDiff(). + */ +function generateDiffFallback(oldLines: string[], newLines: string[]): DiffLine[] { + const oldText = oldLines.join('\n'); + const newText = newLines.join('\n'); + const changes = semanticDiffLines(oldText, newText); + + const result: DiffLine[] = []; + let lineNumber = 1; + + for (const change of changes) { + // Split change value into individual lines, removing trailing newline + const changeLines = change.value.replace(/\r?\n$/, '').split(/\r?\n/); + + for (const content of changeLines) { + if (change.added) { + result.push({ type: 'added', content, lineNumber: lineNumber++ }); + } else if (change.removed) { + result.push({ type: 'removed', content, lineNumber: lineNumber++ }); + } else { + result.push({ type: 'context', content, lineNumber: lineNumber++ }); + } + } + } + + return result; +} +``` + +**Modified `generateDiff()`**: +```typescript +function generateDiff(oldLines: string[], newLines: string[]): DiffLine[] { + // Fallback to semantic diffing for large files to prevent OOM + if (oldLines.length * newLines.length > MAX_LCS_CELLS) { + return generateDiffFallback(oldLines, newLines); + } + + // Original LCS-based algorithm + const matrix = computeLCSMatrix(oldLines, newLines); + // ... rest unchanged ... +} +``` + +### Visual Behavior Difference + +| File Pair | Strategy | Visual Quality | +|-----------|----------|---------------| +| < 1000×1000 | LCS | Precise character-level alignment | +| > 1000×1000 | Semantic | Groups consecutive changes differently, but correct | + +The fallback is semantically correct but may group consecutive changes differently. +For most real code diffs, the visual difference is negligible. + +### Precedent in Codebase + +`ReviewDiffContent.tsx` already uses `diffLines()` successfully: +```typescript +const diffResult = diffLines(original ?? '', modified ?? ''); +``` + +--- + +## Fix #1+#2: Unified Line Counting + +**Confidence: 7/10 → UPGRADED to 9.5/10 (after deep research)** +**Effort: ~4-6 hours** + +### Deep Research Upgrade (2026-02-26) + +Deep research agent (76.1k tokens, 12 tool uses) found a significantly more reliable approach: + +1. **UnifiedLineCounter** — единая утилита на `diffLines()` для ALL counting paths +2. **filesSeen tracking** — Set для отслеживания Write (новый файл vs перезапись) +3. **File-history backups** — использование `~/.claude/file-history/` для получения оригинального контента при Write-update (вместо приблизительной оценки "assume ~same amount removed") +4. **buildTimeline fix** — ChangeExtractorService.buildTimeline тоже должен использовать `diffLines()` вместо `split('\n').length` + +**Ключевое улучшение**: вместо `linesRemoved += added` (assume ~same) для Write-update, агент обнаружил что можно получить оригинальный контент через тот же FileContentResolver/file-history pipeline и сделать точный diff. + +### Current State: 3 Independent Algorithms + +#### MemberStatsComputer (`src/main/services/team/MemberStatsComputer.ts`) + +**Edit** (lines 189-202): +```typescript +const oldLines = oldStr ? oldStr.split('\n').length : 0; +const newLines = newStr ? newStr.split('\n').length : 0; +const fileAdded = newLines > oldLines ? newLines - oldLines : 0; +const fileRemoved = oldLines > newLines ? oldLines - newLines : 0; +``` +- WRONG when content changes but line count stays same + +**Write** (lines 204-214): +```typescript +const fileAdded = writeContent.split('\n').length; +linesAdded += fileAdded; +addFileLines(input.file_path, fileAdded, 0); // Always removals = 0! +``` +- NEVER counts removals + +**MultiEdit** (lines 216-229): +- Same pattern as Write: only additions, no removals + +**Bash** (lines 232-243): +- Heuristic `estimateBashLinesChanged()` (~30-40% coverage) + +#### ChangeExtractorService (`src/main/services/team/ChangeExtractorService.ts`) + +**countLines** (lines 463-473): Uses `diffLines()` — CORRECT +**buildTimeline** (lines 426-427): Uses `split('\n').length` — INCONSISTENT with own countLines! + +#### FileContentResolver (`src/main/services/team/FileContentResolver.ts`) + +Lines 141-156: Uses `diffLines()` — CORRECT + +### Who Consumes These Counts + +| Source | Consumer | UI | +|--------|----------|-----| +| MemberStatsComputer | MemberStatsTab.tsx | Session analytics "+X / -Y" | +| ChangeExtractorService | ChangeStatsBadge.tsx | File tree badges | +| ChangeExtractorService | ReviewApplierService.ts | Diff hunks | +| FileContentResolver | CodeMirrorDiffView.tsx | Full file diff display | + +### Proposed Fix + +**Phase 1: Create UnifiedLineCounter** + +```typescript +// src/main/services/team/UnifiedLineCounter.ts +import { diffLines } from 'diff'; + +export class UnifiedLineCounter { + static countLines(oldStr: string, newStr: string): { added: number; removed: number } { + if (!oldStr && !newStr) return { added: 0, removed: 0 }; + const changes = diffLines(oldStr, newStr); + let added = 0; + let removed = 0; + for (const c of changes) { + if (c.added) added += c.count ?? 0; + if (c.removed) removed += c.count ?? 0; + } + return { added, removed }; + } +} +``` + +**Phase 2: Migrate MemberStatsComputer Edit** + +```typescript +// Replace lines 189-202 +const { added: fileAdded, removed: fileRemoved } = UnifiedLineCounter.countLines(oldStr, newStr); +``` + +**Phase 3: Fix Write Operations (Bug #2)** + +Track file creation vs update during JSONL parse: + +```typescript +const filesSeen = new Set(); + +// In Write handler: +if (toolName === 'Write') { + const filePath = typeof input.file_path === 'string' ? input.file_path : ''; + const writeContent = typeof input.content === 'string' ? input.content : ''; + + const isNewFile = !filesSeen.has(filePath); + filesSeen.add(filePath); + + if (writeContent) { + if (isNewFile) { + // New file creation — all lines are additions + const { added } = UnifiedLineCounter.countLines('', writeContent); + linesAdded += added; + if (filePath) addFileLines(filePath, added, 0); + } else { + // File replacement — assume full rewrite (conservative estimate) + const { added } = UnifiedLineCounter.countLines('', writeContent); + linesAdded += added; + linesRemoved += added; // Assume ~same amount removed + if (filePath) addFileLines(filePath, added, added); + } + } +} +``` + +**Phase 4: Fix buildTimeline in ChangeExtractorService** + +```typescript +// Replace lines 426-427 +const { added, removed } = UnifiedLineCounter.countLines(s.oldString, s.newString); +// Use `added` and `removed` instead of split('\n').length arithmetic +``` + +**Phase 5: Keep Bash As-Is** + +Fundamental limitation — command string has no execution output. + +### Risk Assessment + +| Risk | Level | Mitigation | +|------|-------|-----------| +| Write removals estimation inaccurate | HIGH | `filesSeen` Set tracks if file existed before; conservative "full rewrite" estimate | +| Line count numbers change in UI | MEDIUM | Expected — numbers become MORE accurate | +| Historical data shows different numbers | LOW | Accept as one-time correction | +| Circular dependency (MemberStatsComputer → FileContentResolver) | NONE | UnifiedLineCounter is independent utility | + +### Open Questions + +1. **Write-update without oldString**: `filesSeen` approach assumes sequential JSONL parsing. If messages are out of order, may misclassify. Need to verify JSONL ordering. +2. **Performance**: `diffLines()` is heavier than `split().length`. For sessions with 1000+ Edit calls, could add latency. Need to benchmark. +3. **Bash estimation**: Keep as-is or drop entirely? Current tooltip says "Approximate" — may be enough. + +--- + +## Fix #6+#7: Hunk↔Snippet Mapping + indexOf + +**Confidence: 5/10 → UPGRADED to 9/10 (after deep research)** +**Effort: ~150 LOC** + +### Deep Research Upgrade (2026-02-26) + +Deep research agent (83.8k tokens, 24 tool uses) found a significantly more reliable approach: + +1. **HunkSnippetMatcher** — отдельный класс для маппинга с fallback chain: + - Level 1: `contextHash` — хеш ±3 строк контекста вокруг edit site, вычисляется при извлечении snippet в ChangeExtractorService + - Level 2: `structuredPatch()` content overlap — hunk added/removed lines vs snippet newString/oldString + - Level 3: `indexOf` с disambiguation через oldString proximity scoring +2. **contextHash на SnippetDiff** — новое поле, вычисляется один раз при создании snippet, используется для быстрого matching без повторного парсинга +3. **Position-aware rejection** — вместо `content.indexOf(snippet.newString)` (первое вхождение), ищет ВСЕ вхождения и выбирает ближайшее к hunk position +4. **Fallback chain** — если contextHash не матчит → content overlap → indexOf, каждый уровень менее точный но покрывает больше кейсов + +**Ключевое улучшение**: добавление `contextHash` поля в SnippetDiff при извлечении (в ChangeExtractorService) даёт O(1) matching вместо O(n×m) content scanning. + +### Current Architecture Flow + +``` +CodeMirrorDiffView.tsx (lines 427, 443) + ↓ +computeHunkIndexAtPos(state, pos) → hunkIndex: number + ↓ +onRejectRef.current?.(idx) // onHunkRejected callback + ↓ +IPC: team:applyReviewDecisions + ↓ +ReviewApplierService.rejectHunks(filePath, original, modified, hunkIndices, snippets) + ↓ +trySnippetLevelReject(modified, hunkIndices, snippets) + ↓ +snippetsToReject = hunkIndices.map(idx => validSnippets[idx]) // ← BUG: 1:1 assumption + ↓ +content.indexOf(snippet.newString) // ← BUG: first occurrence only +``` + +### Data Available in Snippets + +From `ChangeExtractorService` (SnippetDiff type): +- `oldString` / `newString` — actual content +- `toolName` — Edit, Write, MultiEdit +- `toolUseId` — unique ID +- `timestamp` — when it happened +- `type` — 'edit' | 'write-new' | 'write-update' | 'multi-edit' +- `isError` — whether tool errored +- `replaceAll` — for Edit with replace_all flag +- **NO line numbers** — this is the core problem + +### Data Available in Hunks + +From `structuredPatch()` (npm `diff` package): +```typescript +interface StructuredPatchHunk { + oldStart: number; // Line number in original (1-based) + oldLines: number; // Line count in original + newStart: number; // Line number in modified (1-based) + newLines: number; // Line count in modified + lines: string[]; // Actual diff lines (+, -, space context) +} +``` + +From CodeMirror's `getChunks()`: +```typescript +chunks: { + fromA: number // Original doc character position + toA: number // Original doc character position + fromB: number // Modified doc character position + toB: number // Modified doc character position +}[] +``` + +### Proposed Fix: 3 Phases + +#### Phase 1: `buildHunkToSnippetMapping()` + +Build explicit mapping using content overlap detection: + +```typescript +private buildHunkToSnippetMapping( + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] +): Map> { + const patch = structuredPatch('file', 'file', original, modified); + if (!patch.hunks || patch.hunks.length === 0) return new Map(); + + const mapping = new Map>(); + + for (const hunkIdx of hunkIndices) { + if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue; + const hunk = patch.hunks[hunkIdx]; + const snippetSet = new Set(); + + // Extract added/removed content from hunk + const addedLines = hunk.lines.filter(l => l.startsWith('+')).map(l => l.slice(1)); + const removedLines = hunk.lines.filter(l => l.startsWith('-')).map(l => l.slice(1)); + const addedContent = addedLines.join('\n'); + const removedContent = removedLines.join('\n'); + + for (let sIdx = 0; sIdx < snippets.length; sIdx++) { + const snippet = snippets[sIdx]; + if (snippet.isError) continue; + + const matchesNew = addedContent.includes(snippet.newString); + const matchesOld = removedContent.includes(snippet.oldString); + + if (snippet.type === 'write-new' || snippet.type === 'write-update') { + if (matchesNew) snippetSet.add(sIdx); + } else { + // For edits: require both old AND new match for higher confidence + if (matchesNew && matchesOld) { + snippetSet.add(sIdx); + } else if (matchesNew) { + snippetSet.add(sIdx); // Lower confidence fallback + } + } + } + + mapping.set(hunkIdx, snippetSet); + } + + return mapping; +} +``` + +#### Phase 2: Position-Aware `findSnippetPosition()` + +Replace indexOf with context-aware search: + +```typescript +private findSnippetPosition( + snippet: SnippetDiff, + content: string +): number { + const { newString, oldString } = snippet; + + // Fast path: newString is unique in content + const firstPos = content.indexOf(newString); + if (firstPos === -1) return -1; + + const lastPos = content.lastIndexOf(newString); + if (firstPos === lastPos) return firstPos; // Only one occurrence — safe + + // Multiple occurrences — use oldString context to disambiguate + // Search for each occurrence and check if surrounding context matches oldString + const positions: number[] = []; + let searchStart = 0; + while (true) { + const pos = content.indexOf(newString, searchStart); + if (pos === -1) break; + positions.push(pos); + searchStart = pos + 1; + } + + // For each candidate position, check if oldString context is nearby + if (oldString) { + for (const pos of positions) { + // Look for oldString within ±1000 chars of this position + // (in the original document, oldString would be at roughly the same position) + const nearbyStart = Math.max(0, pos - 1000); + const nearbyEnd = Math.min(content.length, pos + newString.length + 1000); + const nearby = content.substring(nearbyStart, nearbyEnd); + + // If any unique token from oldString appears nearby, this is likely correct + const oldTokens = oldString.split(/\s+/).filter(t => t.length > 3); + const matchScore = oldTokens.filter(t => nearby.includes(t)).length; + + if (matchScore > oldTokens.length * 0.5) { + return pos; // >50% of oldString tokens found nearby + } + } + } + + // Last resort: return first position with warning + return firstPos; +} +``` + +#### Phase 3: Update `trySnippetLevelReject()` Signature + +```typescript +// Pass `original` through to enable mapping and context matching +private trySnippetLevelReject( + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[], + original: string // NEW parameter +): RejectResult | null { + const validSnippets = snippets.filter(s => !s.isError); + if (validSnippets.length === 0) return null; + + // NEW: Build mapping instead of assuming 1:1 + const hunkToSnippets = this.buildHunkToSnippetMapping( + original, modified, hunkIndices, validSnippets + ); + + // Collect all snippets to reject + const snippetIndices = new Set(); + for (const indices of hunkToSnippets.values()) { + indices.forEach(idx => snippetIndices.add(idx)); + } + + const snippetsToReject = Array.from(snippetIndices) + .map(idx => validSnippets[idx]) + .filter(Boolean); + + // NEW: Position-aware matching + const positioned = snippetsToReject + .map(snippet => ({ + snippet, + pos: this.findSnippetPosition(snippet, modified) + })) + .filter(item => item.pos !== -1) + .sort((a, b) => b.pos - a.pos); // Descending for safe replacement + + if (positioned.length !== snippetsToReject.length) { + return null; // Fallback to hunk-level + } + + let content = modified; + for (const { snippet, pos } of positioned) { + if (snippet.type === 'write-new') continue; + if (snippet.replaceAll) { + content = content.split(snippet.newString).join(snippet.oldString); + } else { + content = content.substring(0, pos) + snippet.oldString + + content.substring(pos + snippet.newString.length); + } + } + + return { success: true, newContent: content, hadConflicts: false }; +} +``` + +### Edge Cases & Concerns + +| Case | Current Behavior | After Fix | +|------|------------------|-----------| +| Multiple Edit → 1 Hunk | Assumes hunkIdx = snippetIdx (WRONG) | Content overlap mapping (CORRECT) | +| 1 Write → 2 Hunks | Maps to wrong snippet | Maps via content match | +| Duplicate code in file | Corrupts first occurrence | Context-aware disambiguation | +| Short snippet (1 line) | indexOf works | May still match wrong occurrence if context is ambiguous | +| No oldString context | N/A | Falls back to first indexOf match (same as before) | +| replaceAll snippets | Works (replaces all) | Still works (replaceAll logic unchanged) | + +### Open Questions + +1. **Short snippets**: If `newString` is `"return true;"` and appears 10 times — context matching may fail. Need hunk line range to narrow search. +2. **Performance**: `structuredPatch()` is called again in `buildHunkToSnippetMapping` (already called in `rejectHunks`). Should cache the patch result. +3. **MultiEdit**: Creates 1 snippet but may affect multiple non-contiguous regions. `buildHunkToSnippetMapping` should handle this but needs testing. +4. **Overlapping snippets**: Two snippets touching the same line range. Position-aware replacement from end (descending sort) handles this, but still fragile. +5. **`original` parameter**: Need to thread it through from all callers: `rejectHunks()`, `previewReject()`, `acceptHunks()`. + +### Why Confidence is Only 5/10 + +The core issue is that **snippets have NO line numbers**. All matching is content-based (heuristic). For short/common snippets, disambiguation may fail. A truly robust fix would require: +1. Adding line number tracking to `SnippetDiff` during extraction +2. Or using `structuredPatch` bidirectionally to map hunks to file regions + +Both are larger architectural changes that go beyond the current fix scope. + +--- + +## Summary: Implementation Priority + +| Fix | Confidence | Effort | Status | +|-----|-----------|--------|--------| +| #11 Cache TTL | 10/10 | 2 lines | DONE (commit d97a757) | +| #10 OOM safeguard | 9/10 | ~30 LOC | DONE (commit d97a757) | +| #1+#2 Line counting | 9.5/10 (was 7) | ~4-6h | Deep research done, pending implementation | +| #6+#7 Hunk mapping | 9/10 (was 5) | ~150 LOC | Deep research done, pending implementation | + +### Also Fixed in d97a757 +- #5: computeHunkIndexAtPos → nearest hunk (CodeMirrorDiffView.tsx) +- #8: Skeleton flash after save (changeReviewSlice.ts) +- #9: CRLF normalization (DiffViewer.tsx) +- #19: threshold 1.0 → 0.85 (CodeMirrorDiffView.tsx) +- #20: useEffect dependency array (FileSectionDiff.tsx) +- #25: bash relative paths (MemberStatsComputer.ts) +- #26: empty line ?? fix (DiffViewer.tsx) + +--- + +# Round 2: Deep Research (3 parallel agents, 280k+ tokens) + +## Agent 1: UnifiedLineCounter — Exact Line Numbers & Code Paths + +### 6 точек с неправильным подсчётом строк + +| # | Файл | Метод | Строки | Алгоритм | Проблема | Влияет на | +|---|------|-------|--------|----------|----------|-----------| +| 1 | MemberStatsComputer.ts | parseFile (Edit) | 193-196 | `split('\n').length` diff | Не считает реальные diff операции | MemberFullStats → Team stats | +| 2 | MemberStatsComputer.ts | parseFile (Write) | 208 | `split('\n').length` абсолют | `removed` всегда 0 | MemberFullStats → Team stats | +| 3 | MemberStatsComputer.ts | parseFile (NotebookEdit) | 220 | `split('\n').length` абсолют | Аналогично Write | MemberFullStats → Team stats | +| 4 | ChangeExtractorService.ts | buildTimeline() | 426-427 | `split('\n').length` diff | Не использует собственный `countLines()`! | FileEditTimeline → UI | +| 5 | ChangeExtractorService.ts | generateEditSummary() | 445, 449-450 | `split('\n').length` | Дублирует логику подсчёта | FileEditEvent.summary | +| 6 | ChangeExtractorService.ts | aggregateByFile() | 391 | `countLines()` → `diffLines()` | **ПРАВИЛЬНО** | FileChangeSummary | + +### Точные строки для замены + +**MemberStatsComputer.ts:193-196 (Edit):** +```typescript +// ТЕКУЩЕЕ (НЕПРАВИЛЬНО): +const oldLines = oldStr ? oldStr.split('\n').length : 0; +const newLines = newStr ? newStr.split('\n').length : 0; +const fileAdded = newLines > oldLines ? newLines - oldLines : 0; +const fileRemoved = oldLines > newLines ? oldLines - newLines : 0; +// ЗАМЕНА: const { added: fileAdded, removed: fileRemoved } = UnifiedLineCounter.countLines(oldStr, newStr); +``` + +**MemberStatsComputer.ts:208 (Write):** +```typescript +// ТЕКУЩЕЕ (НЕПРАВИЛЬНО): +const fileAdded = writeContent.split('\n').length; +linesAdded += fileAdded; +addFileLines(input.file_path, fileAdded, 0); // removed всегда 0! +// ЗАМЕНА: использовать diffLines('', writeContent) для write-new, отслеживать через filesSeen +``` + +**MemberStatsComputer.ts:220 (NotebookEdit):** +```typescript +// ТЕКУЩЕЕ (НЕПРАВИЛЬНО): +const fileAdded = src.split('\n').length; +// ЗАМЕНА: аналогично Write +``` + +**ChangeExtractorService.ts:426-427 (buildTimeline):** +```typescript +// ТЕКУЩЕЕ (НЕПРАВИЛЬНО): +linesAdded: Math.max(0, s.newString.split('\n').length - s.oldString.split('\n').length), +linesRemoved: Math.max(0, s.oldString.split('\n').length - s.newString.split('\n').length), +// ЗАМЕНА: const { added, removed } = this.countLines(s.oldString, s.newString); +``` + +**ChangeExtractorService.ts:445,449-450 (generateEditSummary):** +```typescript +// ТЕКУЩЕЕ (НЕПРАВИЛЬНО): +const lines = snippet.oldString.split('\n').length; +const added = snippet.newString.split('\n').length; +const removed = snippet.oldString.split('\n').length; +// ЗАМЕНА: использовать this.countLines() +``` + +### Критичные находки +- `diffLines` НЕ импортирован в MemberStatsComputer — нужно добавить +- `seenFiles` в ChangeExtractorService (строка 207-265) определяет write-new vs write-update, но НЕ учитывает файлы существовавшие ДО сессии +- JSONL парсится строго последовательно — filesSeen паттерн безопасен +- Performance: `diffLines()` для типичных snippets (<50 строк) = микросекунды, no risk +- **НЕТ ТЕСТОВ** для countLines, buildTimeline, generateEditSummary! + +--- + +## Agent 2: HunkSnippetMatcher — Exact Bug Chain + +### Полная цепочка бага (от UI до backend) + +``` +1. UI: CodeMirrorDiffView.tsx → computeHunkIndexAtPos(state, pos) → chunk index +2. UI: FileSectionDiff.tsx:123-124 → onHunkRejected(file.filePath, idx) +3. Store: changeReviewSlice.ts:610 → + for (let i = 0; i < file.snippets.length; i++) { + hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending'; + } + // ← СТРОИТ hunkDecs по SNIPPET INDICES, но hunk indices != snippet indices! +4. IPC: review.ts:191-203 → handleRejectHunks(teamName, filePath, original, modified, hunkIndices, snippets) +5. Backend: ReviewApplierService.ts:71 → trySnippetLevelReject(modified, hunkIndices, snippets) +6. Bug #1: строка 340-342 → hunkIndices.map(idx => validSnippets[idx]) // 1:1 ASSUMPTION +7. Bug #2: строка 353 → content.indexOf(snippet.newString) // ПЕРВОЕ ВХОЖДЕНИЕ +8. Fallback: строка 87 → tryHunkLevelReject() (structuredPatch + inverse) +``` + +### SnippetDiff — полное определение (shared/types/review.ts:1-12) + +```typescript +export interface SnippetDiff { + toolUseId: string; + filePath: string; + toolName: 'Edit' | 'Write' | 'MultiEdit'; + type: 'edit' | 'write-new' | 'write-update' | 'multi-edit'; + oldString: string; // ← ДО изменения (пусто для write-new) + newString: string; // ← ПОСЛЕ изменения + replaceAll: boolean; + timestamp: string; + isError: boolean; + // НЕТ: contextHash, lineNumber, position — core problem! +} +``` + +### Как создаются snippets (ChangeExtractorService.ts:176-308) + +- **Edit** (строки 239-258): берёт `input.old_string`, `input.new_string` из JSONL. НЕТ доступа к полному файлу. +- **Write** (строки 259-277): `newString = input.content` (весь файл!). 1 Write → 1 snippet → N hunks при patch. +- **MultiEdit** (строки 278-302): `for (const edit of edits)` → N snippets с ОДНИМ toolUseId. + +### Edge cases + +| Case | Текущее поведение | Опасность | +|------|-------------------|-----------| +| `snippet.newString === ""` (deletion) | `indexOf("") = 0` | Всегда находит позицию 0! | +| `newString` встречается 5+ раз | `indexOf` = первое | Неправильная позиция | +| `replaceAll = true` | `content.split(new).join(old)` | OK, но конфликт с другими snippets | +| MultiEdit | N snippets с одним toolUseId | Могут слиться в 1 hunk | +| 1 Write → N hunks | hunkIdx > snippets.length | Out of bounds! | + +### Proposed HunkSnippetMatcher Architecture + +```typescript +class HunkSnippetMatcher { + // Fallback chain (от самого точного к менее точному): + // 1. contextHash match (если добавлено к SnippetDiff) + // 2. structuredPatch content overlap (hunk lines vs snippet strings) + // 3. indexOf с disambiguation через oldString proximity + + matchAll(original, modified, hunks, snippets): Map + // Один hunk может соответствовать нескольким snippets! + // Один snippet может создать несколько hunks (Write)! +} +``` + +--- + +## Agent 3: Integration Points & Conflicts + +### 3 критичных конфликта между #1+#2 и #6+#7 + +#### КОНФЛИКТ #1: countLines() в ChangeExtractorService +- #1+#2 исправляет countLines() (строки 463-473) +- #6+#7 зависит от результатов countLines() в aggregateByFile() +- **Решение:** Реализовать #1+#2 ПЕРВЫМ + +#### КОНФЛИКТ #2: FileContentResolver переопределяет numbers +- FileContentResolver.getFileContent() (строки 144-156) ПЕРЕСЧИТЫВАЕТ linesAdded/linesRemoved из full content +- Может перезаписать значения из ChangeExtractorService +- **Решение:** Это OK — FileContentResolver использует более точный метод (full content diff) + +#### КОНФЛИКТ #3: Порядок snippets +- Если #1+#2 изменит фильтрацию/порядок snippets → hunk indices в #6+#7 сломаются +- **Решение:** #1+#2 НЕ меняет порядок snippets, только алгоритм подсчёта + +### Порядок реализации: #1+#2 ПЕРВЫМ, потом #6+#7 + +### Карта зависимостей SnippetDiff (13 файлов): + +``` +Создание: + ChangeExtractorService.parseJSONLFile() [строки 177-308] + ↓ +Агрегация: + ChangeExtractorService.aggregateByFile() [строки 368-415] + ↓ +IPC передача (5 каналов): + REVIEW_GET_AGENT_CHANGES, REVIEW_GET_TASK_CHANGES, + REVIEW_GET_FILE_CONTENT, REVIEW_REJECT_HUNKS, REVIEW_PREVIEW_REJECT + ↓ +Потребители: + FileContentResolver.resolveFileContent() — реконструкция + ReviewApplierService.trySnippetLevelReject() — reject/accept + ReviewDiffContent → SnippetDiffView — UI рендеринг + ChangeStatsBadge — отображает +/- +``` + +### Если добавить contextHash к SnippetDiff: +- shared/types/review.ts — ОБЯЗАТЕЛЬНО (тип) +- ChangeExtractorService.ts — ОБЯЗАТЕЛЬНО (вычисление при создании) +- Остальные 11 файлов — НЕ ТРЕБУЮТ изменений (optional field, JSON-safe) +- Preload/IPC — не требуют изменений (JSON сериализация OK) + +### Тестовая инфраструктура: +- MemberStatsComputer.test.ts — ЕСТЬ (75 строк, только Bash эвристика) +- ChangeExtractorService.test.ts — **НЕ СУЩЕСТВУЕТ!** +- ReviewApplierService.test.ts — **НЕ СУЩЕСТВУЕТ!** +- **Нужно создать оба перед реализацией** diff --git a/docs/research/diff-view-round3-research.md b/docs/research/diff-view-round3-research.md new file mode 100644 index 00000000..c43ed206 --- /dev/null +++ b/docs/research/diff-view-round3-research.md @@ -0,0 +1,239 @@ +# Diff View — Round 3: Deep Research (Remaining Limitations) + +Date: 2026-02-26 +Source: 3 parallel research agents (~260k tokens total) + +--- + +## Исследуемые проблемы + +После реализации UnifiedLineCounter (#1+#2) и HunkSnippetMatcher (#6+#7) осталось 5 ограничений. +Исследованы 3 из них (самые критичные): + +| # | Проблема | Уверенность до ресёрча | После ресёрча | +|---|----------|----------------------|---------------| +| A | Content overlap false positives + false negatives | 6/10 | 9/10 — root cause найден | +| B | changeReviewSlice hunk index mismatch | 4/10 | 9.5/10 — полная трассировка | +| C | fileLastContent для Edit (дубли oldStr) | 7/10 | 8.5/10 — JSONL подтверждение | + +--- + +## A. Content Overlap: False Positives + False Negatives + +### A1. FALSE NEGATIVES (критичнее) + +**Root cause (уточнён Round 3.1)**: НЕ whitespace (предыдущий анализ был неверен — Edit tool хранит точный текст с indentation). Реальная причина: **context lines** в hunk отбрасываются при matching. + +**Механизм**: +- `HunkSnippetMatcher` берёт только `+` и `-` строки из хунка, отбрасывая context (` ` prefix) +- `removedContent` = join только `-` строк → контекстные строки МЕЖДУ изменёнными строками теряются +- Snippet `oldString` содержит ВСЕ строки (включая context), т.к. это точная подстрока файла +- `includes()` в обе стороны фейлится: ни `removedContent ⊂ oldString`, ни наоборот + +**Concrete proof** (из ресёрча): +```typescript +// Claude's Edit: +// old_string = "interface UserConfig {\n name: string;\n age: number;\n email: string;\n active: boolean;\n premium: boolean;\n}" +// new_string = "interface UserSettings {\n name: string;\n age: number;\n email: string;\n active: boolean;\n isPremium: boolean;\n}" + +// structuredPatch() hunk: +// -interface UserConfig { ← removed +// name: string; ← CONTEXT (discarded!) +// age: number; ← CONTEXT (discarded!) +// email: string; ← CONTEXT (discarded!) +// active: boolean; ← CONTEXT (discarded!) +// - premium: boolean; ← removed +// +interface UserSettings { ← added +// + isPremium: boolean; ← added + +// removedContent = "interface UserConfig {\n premium: boolean;" +// oldString = "interface UserConfig {\n name: string;\n age: number;\n email: string;\n active: boolean;\n premium: boolean;\n}" +// removedContent.includes(oldString) → NO +// oldString.includes(removedContent) → NO (context lines break contiguity) +// ❌ FALSE NEGATIVE — snippet не матчится к своему хунку +``` + +**`structuredPatch()` merge threshold**: `context * 2` = 8 строк (default context=4). Хунки мержатся если gap ≤ 8 строк. + +**Частота**: ВЫСОКАЯ. Любой Edit где Claude захватывает блок с неизменёнными строками внутри: +- Переименование interface/class + изменение полей +- Смена параметров функции + изменение body +- Конфигурационные объекты (часть полей меняется, часть нет) + +**Решение**: Реконструировать "old side" и "new side" хунка включая context lines: +```typescript +// Вместо только +/- строк: +const oldSideContent = hunk.lines + .filter(l => l.startsWith(' ') || l.startsWith('-')) + .map(l => l.slice(1)).join('\n'); +const newSideContent = hunk.lines + .filter(l => l.startsWith(' ') || l.startsWith('+')) + .map(l => l.slice(1)).join('\n'); +// oldSideContent.includes(snippet.oldString) → TRUE ✓ +// newSideContent.includes(snippet.newString) → TRUE ✓ +``` + +**Уверенность**: 9.5/10 что реальный баг. 9/10 что fix через old/new side reconstruction работает. + +### A2. FALSE POSITIVES + +**Root cause**: Два сниппета с одинаковым `oldString`/`newString` оба матчатся к одному хунку. + +**Пример**: Два Edit-а меняют одинаковую строку import в разных местах файла: +``` +Snippet 0: oldString="import { X }", newString="import { X, Y }" (line 5) +Snippet 1: oldString="import { X }", newString="import { X, Y }" (line 50) +``` + +Оба матчатся к хунку, который содержит added line `"import { X, Y }"`. +При reject оба сниппета попадают в rejection set → откатываются ОБА вместо одного. + +**Решение**: Confidence scoring + одноразовое присвоение: +- После матча snippet→hunk, убрать snippet из пула кандидатов +- Приоритизация: snippet с ОБОИМИ `matchesNew && matchesOld` > только с одним +- При равных — первый по порядку (сохраняет хронологию Edit-ов) + +### A3. Производительность O(n×m) + +**Текущее**: H хунков × S сниппетов × `includes()` (O(L) каждый). + +**Реальный масштаб**: типичный review — 5-15 файлов, 3-10 хунков × 3-10 сниппетов на файл = 9-100 сравнений. Для `includes()` на строках <1KB это **микросекунды**. + +**Вердикт**: НЕ нужно оптимизировать. Проблема может возникнуть при 200+ хунках, но такие файлы нереалистичны для code review. + +--- + +## B. changeReviewSlice: Hunk Index Mismatch + +### B1. Суть бага + +`hunkDecisions` — это `Record`, но ключи имеют **двойную семантику**: +- До mount CodeMirror: индекс = `snippets.length` (из API) +- После mount CodeMirror: индекс = `getChunks().length` (из diff алгоритма) +- Это **РАЗНЫЕ числа**. + +### B2. Три точки разлома + +**Точка 1: Accept All до mount CodeMirror** (`changeReviewSlice.ts:385-399`) +```typescript +const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); +// fileChunkCounts[filePath] ещё undefined → count = snippets.length (3) +for (let i = 0; i < 3; i++) { + newHunkDecisions[`${filePath}:${i}`] = 'accepted'; // Только 0,1,2 +} +``` +CodeMirror позже покажет 5 чанков → чанки 3,4 навсегда `pending`. + +**Точка 2: Replay после mount** (`CodeMirrorDiffUtils.ts:108-114`) +```typescript +for (let i = 0; i < result.chunks.length; i++) { // 0..4 + const key = `${filePath}:${i}`; + const d = hunkDecisions[key]; // Находит только 0,1,2 +} +``` + +**Точка 3: Backend application** (`ReviewApplierService.ts:278-280`) +```typescript +const rejectedHunkIndices = Object.entries(decision.hunkDecisions) + .filter(([, d]) => d === 'rejected') + .map(([idx]) => parseInt(idx, 10)); +// Индексы [0,1,4,5] → но snippets.length = 3! +``` + +### B3. Полная трассировка + +``` +User → "Accept All" + → acceptAllFile() loops snippets.length (3) → stores decisions {0,1,2} + → CodeMirror mounts → getChunks() returns 5 chunks + → replayHunkDecisions() loops 0..4 → only finds 0,1,2 → chunks 3,4 = "pending" + → User sees mixed state (3 accepted, 2 pending) + → User clicks "Apply Review" + → Backend gets hunkDecisions {0,1,2} → indices 3,4 NOT rejected → partial application +``` + +### B4. Таблица расхождений + +| Точка | Источник индексов | Семантика | Пример | +|-------|-------------------|-----------|--------| +| `file.snippets.length` | API | Кол-во сниппетов | 3 | +| `hunkDecisions` (initial) | snippets.length | Snippet-based | {0,1,2} | +| CodeMirror `getChunks()` | Diff algorithm | Structural hunks | 5 chunks | +| UI click handler | CM state | CM chunk index | 0..4 | +| Backend `rejectedHunkIndices` | decisions object | Смешанные! | [0,1,4,5] | + +### B5. Решение + +**Единый источник правды**: hunkDecisions ВСЕГДА должны индексироваться по CM chunk index. + +1. **При первом mount CodeMirror**: записать `fileChunkCounts[filePath]` = chunks.length +2. **Accept All / Reject All**: ЖДАТЬ пока fileChunkCounts доступен (lazy init) +3. **Fallback** если CM ещё не mounted: вычислить `structuredPatch()` на frontend и использовать `patch.hunks.length` как count +4. **Backend**: `rejectedHunkIndices` — это ВСЕГДА индексы в `structuredPatch().hunks`, не в snippets + +--- + +## C. fileLastContent: Дубли oldStr при Edit + +### C1. Данные из JSONL + +Проверено 29 реальных Edit tool_use блоков: +- **0** содержат line_number или position +- Доступны ТОЛЬКО: `file_path`, `old_string`, `new_string`, `replace_all` +- **Нет способа** узнать какое именно вхождение oldStr редактировалось + +### C2. Частота проблемы + +- ~3% Edit-ов имеют `oldString` с точными дубликатами (markdown `---`, одинаковые import-ы) +- ~100% содержат **строки**, которые повторяются в файле (но не весь `oldString` целиком) +- **Реальная частота бага**: 5-10% multi-edit сессий где Claude последовательно редактирует разные вхождения одного паттерна + +### C3. Пример + +```json +// Turn 1: Edit file.ts +{ "old_string": "import { A } from './a';\nimport { B } from './b';", + "new_string": "import { A } from './a';\nimport { B } from './b';\nimport { C } from './c';" } + +// Turn 2: Edit file.ts (хочет изменить 2-й import) +{ "old_string": "import { B } from './b';", + "new_string": "import { B as UsedB } from './b';" } +``` + +Turn 2: `indexOf("import { B } from './b';")` найдёт ПЕРВОЕ вхождение — возможно не то, которое Claude хотел изменить (после изменений Turn 1 есть два вхождения). + +### C4. Что НЕЛЬЗЯ сделать + +- Нет line number в JSONL → нельзя точно определить вхождение +- Нет tool_result content (не всегда) → нельзя проверить результат +- Нельзя модифицировать формат JSONL → работаем с тем что есть + +### C5. Решение + +**Прагматичный фикс**: вместо `indexOf()` → sequential application. + +Ключевое наблюдение: Claude Code's Edit tool **сам** использует `indexOf()` при `replace_all: false` — т.е. он тоже заменяет ПЕРВОЕ вхождение. Значит наш `indexOf()` **корректен** для однократных Edit-ов. + +Проблема возникает только когда предыдущий Edit СОЗДАЛ дубликат (добавил строку, идентичную существующей). Это edge case edge case. + +**Вывод**: текущая реализация `indexOf()` — **правильная** для подавляющего большинства случаев, т.к. она зеркалит поведение самого Edit tool. Фикс не нужен. + +Единственный реальный improvement: после Edit, если `oldStr` НЕ найден в `prev` → `fileLastContent.delete(editPath)` (invalidate, чтобы не накапливать ошибку). + +--- + +## Приоритеты реализации + +| # | Фикс | Сложность | Влияние | Приоритет | +|---|------|-----------|---------|-----------| +| A1 | Whitespace normalization в hasContentOverlap | Низкая (5 строк) | Высокое — фиксит false negatives | **P0** | +| A2 | Confidence scoring + one-shot matching | Средняя (~30 строк) | Среднее — фиксит false positives | **P1** | +| B | changeReviewSlice → CM chunk indices | Высокая (~100 строк) | Критичное — UI показывает неверное состояние | **P0** | +| C | fileLastContent invalidation при miss | Низкая (3 строки) | Низкое — edge case edge case | **P2** | + +### Рекомендуемый порядок + +1. **A1** (whitespace normalization) — быстрый win, минимальный риск +2. **A2** (confidence scoring) — укрепляет матчинг +3. **B** (changeReviewSlice) — самый сложный, но самый критичный для UX +4. **C** (fileLastContent) — текущая реализация уже корректна, добавить только safeguard diff --git a/docs/research/git.md b/docs/research/git.md new file mode 100644 index 00000000..390afd4b --- /dev/null +++ b/docs/research/git.md @@ -0,0 +1,466 @@ +# Исследование: встраивание Git UI в Electron + React приложение + +> Дата: 2026-02-25 + +## TL;DR + +**Готового `` компонента не существует.** Все Git GUI (GitHub Desktop, GitButler, Ungit) — монолитные приложения с тесно связанными компонентами. Реалистичный путь — собрать из кирпичиков или встроить терминал с lazygit. + +--- + +## Оглавление + +1. [Git Backend библиотеки](#1-git-backend-библиотеки) +2. [UI-компоненты (npm-пакеты)](#2-ui-компоненты-npm-пакеты) +3. [Open-source Git GUI приложения (референсы)](#3-open-source-git-gui-приложения) +4. [IDE-embedded Git UI (не извлекаемые)](#4-ide-embedded-git-ui) +5. [Подходы к интеграции](#5-подходы-к-интеграции) +6. [Итоговая сравнительная таблица](#6-итоговая-сравнительная-таблица) +7. [Рекомендация](#7-рекомендация) + +--- + +## 1. Git Backend библиотеки + +Обеспечивают программный доступ к Git-операциям из Node.js/Electron. + +### simple-git ⭐ РЕКОМЕНДУЕТСЯ + +- **GitHub**: [simple-git-js/simple-git](https://github.com/simple-git-js/simple-git) +- **Stars**: ~3,550 +- **npm downloads**: ~5.8M/week +- **Версия**: 3.32.2 (февраль 2026) +- **Лицензия**: MIT +- **Тип**: CLI wrapper (требует git binary) +- **Особенности**: + - Легковесная обертка вокруг `git` CLI + - ES Modules, CommonJS, TypeScript + - Async/await, promise chaining + - Progress monitoring для clone/checkout + - Concurrency control (`maxConcurrentProcesses`) +- **Плюсы**: Простейший API, самые высокие downloads, отличная TS поддержка, активно поддерживается +- **Минусы**: Требует установленный Git на машине; спавнит shell-процессы + +```typescript +import simpleGit, { SimpleGit } from 'simple-git'; + +const git: SimpleGit = simpleGit('/path/to/repo'); + +const status = await git.status(); // modified, staged, not_added +const log = await git.log({ maxCount: 50 }); // hash, date, message, author +const diff = await git.diff(['--staged']); // staged diff +const branches = await git.branch(); // all branches +await git.add(['src/file.ts']); +await git.commit('fix: resolve issue'); +await git.stash(['push', '-m', 'WIP']); +``` + +### isomorphic-git + +- **GitHub**: [isomorphic-git/isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) +- **Stars**: ~8,100 +- **npm downloads**: ~300-600K/week +- **Версия**: 1.37.1 (февраль 2026) +- **Лицензия**: MIT +- **Тип**: Pure JavaScript Git implementation +- **Особенности**: + - Pure JS — zero native dependencies + - Работает в Node.js И в browser/renderer + - Clone, commit, push, pull, fetch, branch, merge, checkout + - 100% совместимость с canonical git + - Читает/пишет `.git` директорию напрямую +- **Плюсы**: Нет нативных зависимостей, работает везде +- **Минусы**: Медленнее нативных реализаций на больших репо; некоторые продвинутые git-фичи отсутствуют + +### dugite + +- **GitHub**: [desktop/dugite](https://github.com/desktop/dugite) +- **Stars**: ~495 +- **npm downloads**: ~3-6K/week +- **Версия**: 2.7.1 +- **Лицензия**: MIT +- **Тип**: Бандлит git binary (свой Git в пакете) +- **Особенности**: + - Поставляет скомпилированный Git binary — пользователю НЕ нужен установленный Git + - TypeScript + - Используется GitHub Desktop (проверено в production) + - Создан командой GitHub Desktop +- **Плюсы**: Гарантия наличия Git; battle-tested +- **Минусы**: Увеличивает размер бандла; возможные проблемы с corporate proxy + +### nodegit ❌ НЕ РЕКОМЕНДУЕТСЯ + +- **GitHub**: [nodegit/nodegit](https://github.com/nodegit/nodegit) +- **Stars**: ~5,750 +- **Тип**: Native C++ bindings к libgit2 +- **Проблемы**: Плохо поддерживается (последний stable-релиз много лет назад); нативный C++ build ломается; persistent проблемы совместимости с Electron +- **Вердикт**: Не использовать для новых проектов + +--- + +## 2. UI-компоненты (npm-пакеты) + +### Diff Viewers + +#### @git-diff-view/react ⭐ РЕКОМЕНДУЕТСЯ + +- **GitHub**: [MrWangJustToDo/git-diff-view](https://github.com/MrWangJustToDo/git-diff-view) +- **Версия**: 0.0.36 (февраль 2026, активно обновляется) +- **Лицензия**: MIT +- **Особенности**: + - GitHub-parity UI (выглядит как GitHub diff) + - Web Worker для 60fps рендеринга + - Split и unified views + - Zero dependencies, pure CSS + - SSR/RSC support + - **Virtual scrolling** — ~280ms рендер 10K+ строк + - Multi-framework (React, Vue, Solid, Svelte) +- **Плюсы**: Самый активно поддерживаемый; лучшая производительность; GitHub-quality UI +- **Минусы**: Pre-1.0 (v0.0.x) + +#### react-diff-view + +- **GitHub**: [otakustay/react-diff-view](https://github.com/otakustay/react-diff-view) +- **Stars**: ~977 | **Downloads**: ~140K/week +- **Версия**: 3.3.2 +- **Лицензия**: MIT +- **Особенности**: + - Принимает `git diff -U1` output напрямую (самый git-native) + - Split и unified views + - Collapsed code expansion + - Code comments support + - Large diff lazy loading + - Гибкая система decoration/widget +- **Плюсы**: Самый Git-native; хорошая производительность; extensible + +#### react-diff-viewer-continued + +- **GitHub**: [ralzinov/react-diff-viewer-continued](https://github.com/ralzinov/react-diff-viewer-continued) +- **Версия**: 3.4.0 +- **Лицензия**: MIT +- **Описание**: Maintained форк заброшенного react-diff-viewer. Split/inline view, word diff, GitHub-style + +#### Monaco DiffEditor (@monaco-editor/react) + +- **GitHub**: [suren-atoyan/monaco-react](https://github.com/suren-atoyan/monaco-react) +- **Описание**: VS Code Monaco Editor с встроенным DiffEditor +- **Плюсы**: Production-grade (тот же движок что в VS Code); отличная подсветка синтаксиса +- **Минусы**: Тяжелый бандл; overkill если нужен только просмотр diff + +### Commit Graph Visualization + +#### @dolthub/gitgraph-react + +- **npm**: [@dolthub/gitgraph-react](https://www.npmjs.com/package/@dolthub/gitgraph-react) +- **Описание**: Живой форк архивированного @gitgraph/react, поддерживается DoltHub +- **Плюсы**: Активный форк; декларативный API +- **Минусы**: Кастомизирован под нужды DoltHub + +#### @gitgraph/react ❌ АРХИВИРОВАН + +- **GitHub**: [nicoespeon/gitgraph.js](https://github.com/nicoespeon/gitgraph.js) +- **Downloads**: ~4,300/week +- **Статус**: Архивирован с 2019. Автор рекомендует Mermaid.js + +#### Mermaid.js + @mermaid-js/react-wrapper + +- **GitHub**: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid) +- **Stars**: ~60,000+ +- **Лицензия**: MIT +- **Описание**: Нативный `gitGraph` тип диаграмм. Text-based DSL +- **Плюсы**: Огромное сообщество, активно поддерживается +- **Минусы**: Text-based input; больше для документации/иллюстраций, чем для интерактивных графов + +#### commit-graph (CommitGraph) + +- **GitHub**: [liuliu-dev/CommitGraph](https://github.com/liuliu-dev/CommitGraph) +- **Описание**: Interactive commit graph с infinite scrolling и pagination +- **Особенности**: `commitSpacing`, `branchSpacing`, `nodeRadius`, `branchColors`, `onCommitClick` +- **Плюсы**: Построен для реальных данных; пагинация +- **Минусы**: Новый, мало adoption + +#### @gitkraken/gitkraken-components + +- **npm**: v11.0.7 (февраль 2026) +- **Описание**: Shared React-компоненты между GitKraken Desktop и GitLens. Включает `GraphContainer` для commit graph +- **Плюсы**: Production-proven (GitKraken), активно обновляется +- **Минусы**: **Без документации**, требует React 17, undocumented API + +### File Tree + +#### react-arborist + +- **GitHub**: [brimdata/react-arborist](https://github.com/brimdata/react-arborist) +- **Stars**: ~3,542 | **Downloads**: ~225K/week +- **Версия**: 3.4.3 (февраль 2025) +- **Лицензия**: MIT +- **Описание**: Полное tree view (как VS Code sidebar). Selection, multi-select, drag-and-drop, виртуализация, кастомный рендеринг нод +- **Использование**: Для git staging panel с file tree + status indicators + +### Terminal Emulator + +#### xterm.js (@xterm/xterm) + +- **GitHub**: [xtermjs/xterm.js](https://github.com/xtermjs/xterm.js) +- **Описание**: Полный терминальный эмулятор в браузере/Electron. Используется VS Code, Hyper, Wave Terminal +- **React wrapper**: [Qovery/react-xtermjs](https://github.com/Qovery/react-xtermjs) +- **Использование**: Для встраивания lazygit/tig как терминальной панели + +--- + +## 3. Open-source Git GUI приложения + +### GitHub Desktop ⭐ ЛУЧШИЙ РЕФЕРЕНС + +- **GitHub**: [desktop/desktop](https://github.com/desktop/desktop) +- **Stars**: ~21,000 +- **Стек**: Electron + React + TypeScript +- **Git backend**: dugite +- **Лицензия**: MIT +- **Статус**: Активно поддерживается (февраль 2026) +- **Извлекаемость**: Монолитное приложение. Компоненты тесно связаны с внутренним `git-store.ts`. Нельзя npm install, но можно изучить архитектуру и адаптировать паттерны +- **Ключевые файлы для изучения**: `src/ui/diff/`, `src/ui/history/`, `src/lib/stores/git-store.ts` + +### Ungit + +- **GitHub**: [FredrikNoren/ungit](https://github.com/FredrikNoren/ungit) +- **Stars**: ~10,456 +- **Стек**: Node.js web server (Knockout.js) +- **Лицензия**: MIT +- **Описание**: Web-based Git GUI. Запускает HTTP-сервер на localhost. Есть pre-built Electron-пакеты +- **Встраивание**: Можно через iframe/webview, но свой UI (Knockout.js), невозможно стилизовать + +### GitButler + +- **GitHub**: [gitbutlerapp/gitbutler](https://github.com/gitbutlerapp/gitbutler) +- **Stars**: ~14,000 +- **Стек**: Tauri + Svelte + TypeScript + Rust +- **Лицензия**: Fair Source (→ MIT через 2 года) +- **Извлекаемость**: Не React, не Electron. Есть `@gitbutler/ui` но на Svelte + +### Sapling ISL (Facebook) — интересная находка + +- **GitHub**: [facebook/sapling](https://github.com/facebook/sapling) → `addons/isl/` +- **Стек**: React 18 + Jotai + StyleX + Vite +- **Лицензия**: MIT +- **Описание**: Interactive Smartlog — web GUI для Sapling SCM +- **Компоненты**: Commit tree visualization, drag-and-drop rebase, commit details panel, PR integration +- **Проблемы**: Заточен под Sapling SCM (не Git напрямую); требует isl-server backend +- **Ценность**: Отличный референс React-архитектуры для Git UI + +### Другие + +| Проект | Стек | Stars | Статус | +|--------|------|-------|--------| +| Thermal | Electron + Vue | - | Не React | +| Gitamine | Electron + React + NodeGit | 142 | Неактивен (2019), GPL v3 | +| LithiumGit | Electron + TypeScript | 20 | Активен, MIT | +| NeatGit | Electron + React + Tailwind + Vite | 3 | Ранняя разработка | + +--- + +## 4. IDE-embedded Git UI + +Все **не извлекаемые** для standalone использования. + +### Eclipse Theia (@theia/git) + +- **Статус**: **DEPRECATED** — рекомендуют использовать VS Code Git extension +- **Проблемы**: InversifyJS DI-контейнер, PhosphorJS/Lumino виджеты (не React), нужна полная Theia среда +- [Обсуждение Copia Automation](https://github.com/eclipse-theia/theia/discussions/15151) — вывод: проще написать свой view + +### VS Code Git Extension + +- **Архитектура**: Extension Host + webview API. Глубоко интегрирован в workbench. Не React. С VS Code 1.93 Git Graph встроен +- **Извлекаемость**: Невозможна без переписывания workbench + +### Другие IDE + +| IDE | Вердикт | +|-----|---------| +| Gitpod / OpenVSCode Server | Форк VS Code, не экспортирует компоненты | +| JetBrains Fleet | Proprietary, Kotlin/Skia рендеринг | +| Sourcegraph | Нет git management компонентов, фокус на code search | + +--- + +## 5. Подходы к интеграции + +### Подход A: xterm.js + lazygit (~200 LOC) + +Быстрейший путь к полному Git UI. + +``` +Electron Main Process + └── node-pty.spawn('lazygit', [], { cwd: repoPath }) + ├── stdout → xterm.js (renderer) + └── stdin ← xterm.js keyboard events +``` + +```typescript +// Main process +import * as pty from 'node-pty'; + +const ptyProcess = pty.spawn('lazygit', [], { + name: 'xterm-256color', + cols: 120, rows: 40, + cwd: '/path/to/repo', + env: { ...process.env, TERM: 'xterm-256color' } +}); + +ptyProcess.onData((data) => mainWindow.webContents.send('terminal:data', data)); +ipcMain.on('terminal:input', (_, data) => ptyProcess.write(data)); +ipcMain.on('terminal:resize', (_, { cols, rows }) => ptyProcess.resize(cols, rows)); +``` + +```tsx +// Renderer (React) +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; + +function LazyGitTerminal({ repoPath }: { repoPath: string }) { + const termRef = useRef(null); + + useEffect(() => { + const term = new Terminal({ + theme: { background: '#141416' }, + fontSize: 13 + }); + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(termRef.current!); + fitAddon.fit(); + + term.onData((data) => window.api.terminalInput(data)); + window.api.onTerminalData((data: string) => term.write(data)); + + return () => term.dispose(); + }, []); + + return
; +} +``` + +| Критерий | Оценка | +|---|---| +| Сложность | **Низкая** (~200 LOC) | +| React-совместимость | Хорошая (xterm.js оборачивается в компонент) | +| Git-полнота | **Отличная** (lazygit покрывает всё) | +| Кастомизация UI | **Никакая** (черный ящик) | +| Зависимости | `node-pty` (нативный модуль), lazygit должен быть установлен | + +### Подход B: Кастомный React UI (из кирпичиков) + +``` +Electron Main Process + └── GitService (simple-git) + ├── IPC: git:status + ├── IPC: git:log + ├── IPC: git:diff + ├── IPC: git:commit + ├── IPC: git:branch + ├── IPC: git:checkout + ├── IPC: git:stash + └── IPC: git:merge + +Electron Renderer (React + Zustand) + └── gitSlice (status, log, branches, diff) + ├── GitStatusPanel (кастомный) + ├── GitLogView + CommitGraph (@dolthub/gitgraph-react) + ├── GitDiffViewer (@git-diff-view/react) + ├── CommitForm (кастомный) + ├── BranchSelector (кастомный) + └── StashPanel (кастомный) +``` + +| Критерий | Оценка | +|---|---| +| Сложность | **Высокая** (полная реализация), **Средняя** (базовые функции) | +| React-совместимость | **Идеальная** (нативные React-компоненты, Zustand, Tailwind) | +| Git-полнота | Настраиваемая — от status/commit/diff до полного | +| Кастомизация UI | **Полная** | +| Объем работ | ~500-1000 LOC для базового функционала | + +### Подход C: Embed Ungit (iframe) + +``` +Electron Main Process + └── spawn('ungit', ['--port', '9001']) + +Renderer + └──