From 0df816bba6ffec625ed567e49369e8f87706b33b Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Feb 2026 15:39:14 +0200 Subject: [PATCH] feat: enhance diff view with continuous scroll and lazy loading - Introduced a continuous scroll mode for the diff view, allowing users to review multiple files in a single scrollable container. - Added lazy loading functionality to improve performance by loading file content as it approaches the viewport. - Implemented a new portion collapse feature to allow users to expand unchanged regions incrementally, enhancing context retention during reviews. - Updated navigation to support smooth scrolling between files and improved keyboard shortcuts for file navigation. - Enhanced the review toolbar to manage actions across all files, including bulk accept/reject options. - Added new hooks and components to support the continuous scroll and lazy loading features, ensuring a seamless user experience. --- README.md | 1 + .../diff-view/continuous-scroll/overview.md | 255 +++ ...hase-1-continuous-scroll-and-scroll-spy.md | 1543 +++++++++++++++++ .../continuous-scroll/phase-2-lazy-loading.md | 790 +++++++++ .../continuous-scroll/phase-3-navigation.md | 996 +++++++++++ .../phase-4-portion-collapse.md | 1264 ++++++++++++++ .../continuous-scroll/phase-5-polish.md | 988 +++++++++++ .../services/team/ChangeExtractorService.ts | 53 +- .../services/team/TeamProvisioningService.ts | 3 +- .../components/team/TeamDetailView.tsx | 17 + .../components/team/activity/ActivityItem.tsx | 48 +- .../team/activity/ActivityTimeline.tsx | 7 + .../team/dialogs/TaskDetailDialog.tsx | 87 +- .../team/review/ChangeReviewDialog.tsx | 440 ++--- .../team/review/CodeMirrorDiffView.tsx | 49 +- .../team/review/ContinuousScrollView.tsx | 166 ++ .../team/review/FileSectionDiff.tsx | 122 ++ .../team/review/FileSectionHeader.tsx | 108 ++ .../team/review/FileSectionPlaceholder.tsx | 23 + .../team/review/KeyboardShortcutsHelp.tsx | 4 + .../components/team/review/ReviewFileTree.tsx | 26 +- .../components/team/review/ReviewToolbar.tsx | 21 +- .../team/review/ScopeWarningBanner.tsx | 9 +- .../components/team/review/portionCollapse.ts | 315 ++++ src/renderer/constants/teamColors.ts | 2 + src/renderer/hooks/useContinuousScrollNav.ts | 50 + src/renderer/hooks/useDiffNavigation.ts | 264 ++- src/renderer/hooks/useLazyFileContent.ts | 150 ++ src/renderer/hooks/useVisibleFileSection.ts | 114 ++ src/renderer/index.css | 5 + src/renderer/utils/memberHelpers.ts | 10 +- 31 files changed, 7588 insertions(+), 342 deletions(-) create mode 100644 docs/iterations/diff-view/continuous-scroll/overview.md create mode 100644 docs/iterations/diff-view/continuous-scroll/phase-1-continuous-scroll-and-scroll-spy.md create mode 100644 docs/iterations/diff-view/continuous-scroll/phase-2-lazy-loading.md create mode 100644 docs/iterations/diff-view/continuous-scroll/phase-3-navigation.md create mode 100644 docs/iterations/diff-view/continuous-scroll/phase-4-portion-collapse.md create mode 100644 docs/iterations/diff-view/continuous-scroll/phase-5-polish.md create mode 100644 src/renderer/components/team/review/ContinuousScrollView.tsx create mode 100644 src/renderer/components/team/review/FileSectionDiff.tsx create mode 100644 src/renderer/components/team/review/FileSectionHeader.tsx create mode 100644 src/renderer/components/team/review/FileSectionPlaceholder.tsx create mode 100644 src/renderer/components/team/review/portionCollapse.ts create mode 100644 src/renderer/hooks/useContinuousScrollNav.ts create mode 100644 src/renderer/hooks/useLazyFileContent.ts create mode 100644 src/renderer/hooks/useVisibleFileSection.ts 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/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 27ef36fb..90c81634 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -4,6 +4,8 @@ import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import * as readline from 'readline'; +import { TeamConfigReader } from './TeamConfigReader'; + import type { TaskBoundaryParser } from './TaskBoundaryParser'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { @@ -39,7 +41,8 @@ export class ChangeExtractorService { constructor( private readonly logsFinder: TeamMemberLogsFinder, - private readonly boundaryParser: TaskBoundaryParser + private readonly boundaryParser: TaskBoundaryParser, + private readonly configReader: TeamConfigReader = new TeamConfigReader() ) {} /** Получить все изменения агента */ @@ -51,6 +54,7 @@ export class ChangeExtractorService { } const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName); + const projectPath = await this.resolveProjectPath(teamName); // Собираем все snippets из всех JSONL файлов const allSnippets: SnippetDiff[] = []; @@ -70,7 +74,7 @@ export class ChangeExtractorService { allSnippets.push(...snippets); } - const files = this.aggregateByFile(allSnippets); + const files = this.aggregateByFile(allSnippets, projectPath); let totalLinesAdded = 0; let totalLinesRemoved = 0; @@ -106,6 +110,8 @@ export class ChangeExtractorService { return this.emptyTaskChangeSet(teamName, taskId); } + const projectPath = await this.resolveProjectPath(teamName); + // Парсим boundaries для каждого лог-файла и ищем scope данной задачи const allScopes: TaskChangeScope[] = []; for (const ref of logRefs) { @@ -118,12 +124,12 @@ export class ChangeExtractorService { // Если scope не найден — fallback на весь файл if (allScopes.length === 0) { - return this.fallbackSingleTaskScope(teamName, taskId, logRefs); + return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath); } // Фильтруем snippets по tool_use IDs из scope const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds)); - const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds); + const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds, projectPath); const worstTier = Math.max(...allScopes.map((s) => s.confidence.tier)); const warnings: string[] = []; @@ -157,6 +163,16 @@ export class ChangeExtractorService { // ---- Private methods ---- + /** Получить projectPath из конфига команды */ + private async resolveProjectPath(teamName: string): Promise { + try { + const config = await this.configReader.getConfig(teamName); + return config?.projectPath?.trim() || undefined; + } catch { + return undefined; + } + } + /** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */ private async parseJSONLFile(filePath: string): Promise { // Сначала считываем все записи в память для двух проходов @@ -376,11 +392,19 @@ export class ChangeExtractorService { totalAdded += added; totalRemoved += removed; } + // Normalize separators for cross-platform path stripping + const normalizedFp = fp.replace(/\\/g, '/'); + const normalizedProject = projectPath?.replace(/\\/g, '/'); + const relative = normalizedProject + ? normalizedFp.startsWith(normalizedProject + '/') + ? normalizedFp.slice(normalizedProject.length + 1) + : normalizedFp.startsWith(normalizedProject) + ? normalizedFp.slice(normalizedProject.length) + : normalizedFp.split('/').slice(-3).join('/') + : normalizedFp.split('/').slice(-3).join('/'); return { filePath: fp, - relativePath: projectPath - ? fp.replace(projectPath + '/', '') - : fp.split('/').slice(-3).join('/'), + relativePath: relative, snippets: data.snippets, linesAdded: totalAdded, linesRemoved: totalRemoved, @@ -487,7 +511,8 @@ export class ChangeExtractorService { /** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */ private async extractFilteredChanges( logRefs: LogFileRef[], - allowedToolUseIds: Set + allowedToolUseIds: Set, + projectPath?: string ): Promise { const allSnippets: SnippetDiff[] = []; for (const ref of logRefs) { @@ -503,27 +528,29 @@ export class ChangeExtractorService { allSnippets.push(...snippets); } } - return this.aggregateByFile(allSnippets); + return this.aggregateByFile(allSnippets, projectPath); } /** Извлечь все изменения из одного файла */ private async extractAllChanges( filePath: string, - _memberName: string + _memberName: string, + projectPath?: string ): Promise { const snippets = await this.parseJSONLFile(filePath); - return this.aggregateByFile(snippets); + return this.aggregateByFile(snippets, projectPath); } /** Fallback: вернуть все изменения из лог-файлов как Tier 4 */ private async fallbackSingleTaskScope( teamName: string, taskId: string, - logRefs: LogFileRef[] + logRefs: LogFileRef[], + projectPath?: string ): Promise { const allFiles: FileChangeSummary[] = []; for (const ref of logRefs) { - const files = await this.extractAllChanges(ref.filePath, ref.memberName); + const files = await this.extractAllChanges(ref.filePath, ref.memberName, projectPath); allFiles.push(...files); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9a620781..50484622 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -335,7 +335,8 @@ function buildAgentBlockUsagePolicy(): string { ${AGENT_BLOCK_OPEN} (internal instructions: commands, script usage, paths, etc.) ${AGENT_BLOCK_CLOSE} -- Put ONLY the internal instructions inside the agent-only block.`; +- Put ONLY the internal instructions inside the agent-only block. +- CRITICAL: Messages to "user" (the human) must NEVER contain agent-only blocks. Write them as plain readable text — the human sees these messages directly in the UI. Agent-only blocks are stripped before display, so a message containing ONLY an agent-only block will appear completely empty.`; } function getSystemLocale(): string { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 7df60b1f..6cdca5d9 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -495,10 +495,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele } }, [teamName, refreshTeamData]); + const selectReviewFile = useStore((s) => s.selectReviewFile); + const handleViewChanges = useCallback((taskId: string) => { setReviewDialogState({ open: true, mode: 'task', taskId }); }, []); + const handleViewChangesForFile = useCallback( + (taskId: string, filePath?: string) => { + setReviewDialogState({ open: true, mode: 'task', taskId }); + if (filePath) { + selectReviewFile(filePath); + } + }, + [selectReviewFile] + ); + const handleDeleteTeam = useCallback((): void => { setDeleteConfirmOpen(true); }, []); @@ -1093,6 +1105,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setSendDialogOpen(true); }} onMessageVisible={handleMessageVisible} + onTaskIdClick={(taskId) => { + const task = taskMap.get(taskId); + if (task) setSelectedTask(task); + }} /> @@ -1331,6 +1347,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onOwnerChange={(taskId, owner) => { void updateTaskOwner(teamName, taskId, owner); }} + onViewChanges={handleViewChangesForFile} /> void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; + /** Called when a task ID link (e.g. #10) is clicked in message text. */ + onTaskIdClick?: (taskId: string) => void; } function getStringField(obj: StructuredMessage, key: string): string | null { @@ -125,6 +127,11 @@ function getSystemMessageLabel(text: string): string | null { // Full message card — left colored border, name badge, collapsible content // --------------------------------------------------------------------------- +/** Convert `#` in plain text to markdown links with task:// protocol. */ +function linkifyTaskIdsInMarkdown(text: string): string { + return text.replace(/#(\d+)/g, '[#$1](task://$1)'); +} + export const ActivityItem = ({ message, teamName, @@ -135,6 +142,7 @@ export const ActivityItem = ({ onMemberNameClick, onCreateTask, onReply, + onTaskIdClick, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const formattedRole = formatAgentRole(memberRole); @@ -153,11 +161,13 @@ export const ActivityItem = ({ const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; const [isExpanded, setIsExpanded] = useState(!systemLabel); - // Strip agent-only blocks from displayed text - const displayText = useMemo( - () => (structured ? null : stripAgentBlocks(message.text)), - [structured, message.text] - ); + // Strip agent-only blocks from displayed text + linkify task IDs + const displayText = useMemo(() => { + if (structured) return null; + const stripped = stripAgentBlocks(message.text).trim(); + if (!stripped) return null; // All content was agent-only blocks → show summary instead + return onTaskIdClick ? linkifyTaskIdsInMarkdown(stripped) : stripped; + }, [structured, message.text, onTaskIdClick]); // Check if this is a reply message const parsedReply = useMemo( @@ -355,9 +365,31 @@ export const ActivityItem = ({
) : parsedReply ? ( - ) : ( - - )} + ) : displayText ? ( + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const taskId = link.getAttribute('href')?.replace('task://', ''); + if (taskId) onTaskIdClick(taskId); + } + } + : undefined + } + > + + + ) : summaryText ? ( +

+ {summaryText} +

+ ) : null} {message.attachments?.length && message.messageId ? ( void; /** Called when a message enters the viewport (for marking as read). */ onMessageVisible?: (message: InboxMessage) => void; + /** Called when a task ID link (e.g. #10) is clicked in message text. */ + onTaskIdClick?: (taskId: string) => void; } const VIEWPORT_THRESHOLD = 0.15; @@ -35,6 +37,7 @@ const MessageRowWithObserver = ({ onCreateTask, onReply, onVisible, + onTaskIdClick, }: { message: InboxMessage; teamName: string; @@ -46,6 +49,7 @@ const MessageRowWithObserver = ({ onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; onVisible?: (message: InboxMessage) => void; + onTaskIdClick?: (taskId: string) => void; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -89,6 +93,7 @@ const MessageRowWithObserver = ({ onMemberNameClick={onMemberNameClick} onCreateTask={onCreateTask} onReply={onReply} + onTaskIdClick={onTaskIdClick} />
); @@ -103,6 +108,7 @@ export const ActivityTimeline = ({ onReplyToMessage, onMemberClick, onMessageVisible, + onTaskIdClick, }: ActivityTimelineProps): React.JSX.Element => { const colorMap = members ? buildMemberColorMap(members) : new Map(); const memberInfo = new Map(); @@ -167,6 +173,7 @@ export const ActivityTimeline = ({ onCreateTask={onCreateTaskFromMessage} onReply={onReplyToMessage} onVisible={onMessageVisible} + onTaskIdClick={onTaskIdClick} /> ); })} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 31b1aef7..a7511691 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -23,6 +23,7 @@ import { } from '@renderer/components/ui/select'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { markAsRead } from '@renderer/services/commentReadStorage'; +import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap, @@ -31,7 +32,15 @@ import { TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; import { formatDistanceToNow } from 'date-fns'; -import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine } from 'lucide-react'; +import { + ArrowLeftFromLine, + ArrowRightFromLine, + Clock, + FileCode, + Link2, + Loader2, + PenLine, +} from 'lucide-react'; import { TaskCommentsSection } from './TaskCommentsSection'; @@ -47,6 +56,7 @@ interface TaskDetailDialogProps { onClose: () => void; onScrollToTask?: (taskId: string) => void; onOwnerChange?: (taskId: string, owner: string | null) => void; + onViewChanges?: (taskId: string, filePath?: string) => void; } export const TaskDetailDialog = ({ @@ -59,6 +69,7 @@ export const TaskDetailDialog = ({ onClose, onScrollToTask, onOwnerChange, + onViewChanges, }: TaskDetailDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const currentTask = task ? (taskMap.get(task.id) ?? task) : null; @@ -71,6 +82,35 @@ export const TaskDetailDialog = ({ if (latest > 0) markAsRead(teamName, currentTask.id, latest); }, [open, teamName, currentTask]); + // Lazy-load task changes when dialog is open and task is completed + const isTaskCompleted = currentTask?.status === 'completed'; + const activeChangeSet = useStore((s) => s.activeChangeSet); + const changeSetLoading = useStore((s) => s.changeSetLoading); + const fetchTaskChanges = useStore((s) => s.fetchTaskChanges); + + const taskChangesFiles = useMemo(() => { + if (!activeChangeSet || !currentTask) return null; + if ('taskId' in activeChangeSet && activeChangeSet.taskId === currentTask.id) { + return activeChangeSet.files; + } + return null; + }, [activeChangeSet, currentTask]); + + useEffect(() => { + if (!open || !currentTask || !isTaskCompleted || !onViewChanges) return; + // Only fetch if we don't already have data for this task + if (taskChangesFiles !== null) return; + void fetchTaskChanges(teamName, currentTask.id); + }, [ + open, + currentTask, + isTaskCompleted, + teamName, + fetchTaskChanges, + taskChangesFiles, + onViewChanges, + ]); + const handleDependencyClick = (taskId: string): void => { onClose(); onScrollToTask?.(taskId); @@ -217,6 +257,51 @@ export const TaskDetailDialog = ({ )} + {/* Changes */} + {isTaskCompleted && onViewChanges ? ( + + {changeSetLoading && !taskChangesFiles ? ( +
+ + Loading changes... +
+ ) : taskChangesFiles && taskChangesFiles.length > 0 ? ( +
+ {taskChangesFiles.map((file) => ( + + ))} +
+ ) : ( +

No file changes detected

+ )} +
+ ) : null} +
{/* Dependencies */} {blockedByIds.length > 0 ? ( diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 8941bb2d..8475bee3 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -2,26 +2,22 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { goToNextChunk, rejectChunk } from '@codemirror/merge'; import { isElectronMode } from '@renderer/api'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation'; +import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav'; +import { isLastChunkInFile, useDiffNavigation } from '@renderer/hooks/useDiffNavigation'; import { useViewedFiles } from '@renderer/hooks/useViewedFiles'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { ChevronDown, Clock, Loader2, Save, Undo2, X } from 'lucide-react'; +import { ChevronDown, Clock, X } from 'lucide-react'; import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils'; -import { CodeMirrorDiffView } from './CodeMirrorDiffView'; -import { ConfidenceBadge } from './ConfidenceBadge'; -import { DiffErrorBoundary } from './DiffErrorBoundary'; +import { ContinuousScrollView } from './ContinuousScrollView'; import { FileEditTimeline } from './FileEditTimeline'; import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp'; -import { ReviewDiffContent } from './ReviewDiffContent'; import { ReviewFileTree } from './ReviewFileTree'; import { ReviewToolbar } from './ReviewToolbar'; import { ScopeWarningBanner } from './ScopeWarningBanner'; import { ViewedProgressBar } from './ViewedProgressBar'; -import type { EditorState } from '@codemirror/state'; import type { EditorView } from '@codemirror/view'; import type { HunkDecision, TaskChangeSetV2 } from '@shared/types'; @@ -34,14 +30,6 @@ interface ChangeReviewDialogProps { taskId?: string; } -const CONTENT_SOURCE_LABELS: Record = { - 'file-history': 'File History', - 'snippet-reconstruction': 'Reconstructed', - 'disk-current': 'Current Disk', - 'git-fallback': 'Git Fallback', - unavailable: 'Unavailable', -}; - function isTaskChangeSetV2(cs: { teamName: string }): cs is TaskChangeSetV2 { return 'scope' in cs; } @@ -58,12 +46,9 @@ export const ChangeReviewDialog = ({ activeChangeSet, changeSetLoading, changeSetError, - selectedReviewFilePath, fetchAgentChanges, fetchTaskChanges, - selectReviewFile, clearChangeReview, - // Phase 2 hunkDecisions, fileDecisions, fileContents, @@ -77,22 +62,38 @@ export const ChangeReviewDialog = ({ acceptAllFile, rejectAllFile, applyReview, - // Editable diff editedContents, updateEditedContent, discardFileEdits, saveEditedFile, } = useStore(); - const editorViewRef = useRef(null); + // Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll) + const [activeFilePath, setActiveFilePath] = useState(null); const [autoViewed, setAutoViewed] = useState(true); const [timelineOpen, setTimelineOpen] = useState(false); - // Counter to force editor rebuild on discard const [discardCounter, setDiscardCounter] = useState(0); - // Cache EditorState per file to preserve undo history between file switches - const editorStateCache = useRef(new Map()); - // Current file's cached initial state (derived outside render to avoid ref access during render) - const [cachedInitialState, setCachedInitialState] = useState(undefined); + + // EditorView map for all visible file editors + const editorViewMapRef = useRef(new Map()); + const scrollContainerRef = useRef(null); + + // Proxy ref for useDiffNavigation (points to active file's editor) + const activeEditorViewRef = useRef(null); + const activeFilePathRef = useRef(null); + + // Keep refs in sync with activeFilePath + useEffect(() => { + activeFilePathRef.current = activeFilePath; + activeEditorViewRef.current = activeFilePath + ? (editorViewMapRef.current.get(activeFilePath) ?? null) + : null; + }, [activeFilePath]); + + // Continuous scroll navigation + const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({ + scrollContainerRef, + }); // Build scope key for viewed storage const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`; @@ -113,69 +114,122 @@ export const ChangeReviewDialog = ({ progress: viewedProgress, } = useViewedFiles(teamName, scopeKey, allFilePaths); - // Editable diff computed values const editedCount = Object.keys(editedContents).length; - const hasCurrentFileEdits = !!( - selectedReviewFilePath && selectedReviewFilePath in editedContents - ); - // Save current editor state to cache before switching files - const handleSelectFile = useCallback( - (filePath: string | null) => { - const view = editorViewRef.current; - if (view && selectedReviewFilePath) { - editorStateCache.current.set(selectedReviewFilePath, view.state); - } - setCachedInitialState(filePath ? editorStateCache.current.get(filePath) : undefined); - selectReviewFile(filePath); + // Scroll-spy handler + const handleVisibleFileChange = useCallback((filePath: string) => { + setActiveFilePath(filePath); + }, []); + + // Tree click → scroll to file + const handleTreeFileClick = useCallback( + (filePath: string) => { + scrollToFile(filePath); + setActiveFilePath(filePath); }, - [selectedReviewFilePath, selectReviewFile] + [scrollToFile] ); + // Accept/Reject all across all files const handleAcceptAll = useCallback(() => { - const view = editorViewRef.current; - if (view) acceptAllChunks(view); - if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath); - }, [selectedReviewFilePath, acceptAllFile]); + if (!activeChangeSet) return; + for (const file of activeChangeSet.files) { + acceptAllFile(file.filePath); + } + requestAnimationFrame(() => { + for (const view of editorViewMapRef.current.values()) { + acceptAllChunks(view); + } + }); + }, [activeChangeSet, acceptAllFile]); const handleRejectAll = useCallback(() => { - const view = editorViewRef.current; - if (view) rejectAllChunks(view); - if (selectedReviewFilePath) rejectAllFile(selectedReviewFilePath); - }, [selectedReviewFilePath, rejectAllFile]); - - const handleSaveCurrentFile = useCallback(() => { - if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath); - }, [selectedReviewFilePath, saveEditedFile]); - - const handleDiscardCurrentFile = useCallback(() => { - if (selectedReviewFilePath) { - editorStateCache.current.delete(selectedReviewFilePath); - setCachedInitialState(undefined); - discardFileEdits(selectedReviewFilePath); - setDiscardCounter((c) => c + 1); + if (!activeChangeSet) return; + for (const file of activeChangeSet.files) { + rejectAllFile(file.filePath); } - }, [selectedReviewFilePath, discardFileEdits]); + requestAnimationFrame(() => { + for (const view of editorViewMapRef.current.values()) { + rejectAllChunks(view); + } + }); + }, [activeChangeSet, rejectAllFile]); + + // Per-file callbacks for ContinuousScrollView + const handleHunkAccepted = useCallback( + (filePath: string, hunkIndex: number) => { + setHunkDecision(filePath, hunkIndex, 'accepted'); + }, + [setHunkDecision] + ); + + const handleHunkRejected = useCallback( + (filePath: string, hunkIndex: number) => { + setHunkDecision(filePath, hunkIndex, 'rejected'); + }, + [setHunkDecision] + ); + + const handleContentChanged = useCallback( + (filePath: string, content: string) => { + updateEditedContent(filePath, content); + }, + [updateEditedContent] + ); + + const handleFullyViewed = useCallback( + (filePath: string) => { + if (autoViewed && !isViewed(filePath)) { + markViewed(filePath); + } + }, + [autoViewed, isViewed, markViewed] + ); + + const handleSaveFile = useCallback( + (filePath: string) => { + void saveEditedFile(filePath); + }, + [saveEditedFile] + ); + + const handleDiscardFile = useCallback( + (filePath: string) => { + discardFileEdits(filePath); + setDiscardCounter((c) => c + 1); + }, + [discardFileEdits] + ); + + // Save active file (for Cmd+Enter keyboard shortcut) + const handleSaveActiveFile = useCallback(() => { + if (activeFilePath) void saveEditedFile(activeFilePath); + }, [activeFilePath, saveEditedFile]); + + // Continuous navigation options for cross-file hunk navigation + const continuousOptions = useMemo( + () => ({ + editorViewMapRef, + activeFilePath, + scrollToFile, + enabled: true, + }), + [activeFilePath, scrollToFile] + ); const diffNav = useDiffNavigation( activeChangeSet?.files ?? [], - selectedReviewFilePath, - handleSelectFile, - editorViewRef, + activeFilePath, + scrollToFile, + activeEditorViewRef, open, - (filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'), - (filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'), + handleHunkAccepted, + handleHunkRejected, () => onOpenChange(false), - handleSaveCurrentFile + handleSaveActiveFile, + continuousOptions ); - // Auto-viewed callback - const handleFullyViewed = useCallback(() => { - if (autoViewed && selectedReviewFilePath && !isViewed(selectedReviewFilePath)) { - markViewed(selectedReviewFilePath); - } - }, [autoViewed, selectedReviewFilePath, isViewed, markViewed]); - // Load data on open useEffect(() => { if (!open) return; @@ -210,39 +264,21 @@ export const ChangeReviewDialog = ({ useEffect(() => { if (!open) return; const cleanup = window.electronAPI?.review.onCmdN?.(() => { - const view = editorViewRef.current; + const fp = activeFilePathRef.current; + const view = fp ? editorViewMapRef.current.get(fp) : null; if (view) { rejectChunk(view); - requestAnimationFrame(() => goToNextChunk(view)); + requestAnimationFrame(() => { + if (isLastChunkInFile(view)) { + diffNav.goToNextFile(); + } else { + goToNextChunk(view); + } + }); } }); return cleanup ?? undefined; - }, [open]); - - // Lazy-load file content when file selected - useEffect(() => { - if (!open || !selectedReviewFilePath) return; - if (fileContents[selectedReviewFilePath] || fileContentsLoading[selectedReviewFilePath]) return; - void fetchFileContent(teamName, memberName, selectedReviewFilePath); - }, [ - open, - selectedReviewFilePath, - teamName, - memberName, - fileContents, - fileContentsLoading, - fetchFileContent, - ]); - - const selectedFile = useMemo(() => { - if (!activeChangeSet || !selectedReviewFilePath) return null; - return activeChangeSet.files.find((f) => f.filePath === selectedReviewFilePath) ?? null; - }, [activeChangeSet, selectedReviewFilePath]); - - const fileContent = selectedReviewFilePath ? fileContents[selectedReviewFilePath] : null; - const isFileContentLoading = selectedReviewFilePath - ? (fileContentsLoading[selectedReviewFilePath] ?? false) - : false; + }, [open, diffNav]); // Compute toolbar stats const reviewStats = useMemo(() => { @@ -278,6 +314,12 @@ export const ChangeReviewDialog = ({ void applyReview(teamName, taskId, memberName); }, [applyReview, teamName, taskId, memberName]); + // Active file for timeline (derived from scroll-spy) + const activeFile = useMemo(() => { + if (!activeChangeSet || !activeFilePath) return null; + return activeChangeSet.files.find((f) => f.filePath === activeFilePath) ?? null; + }, [activeChangeSet, activeFilePath]); + const title = mode === 'agent' ? `Changes by ${memberName ?? 'unknown'}` @@ -310,9 +352,6 @@ export const ChangeReviewDialog = ({ {activeChangeSet.totalFiles} files, +{activeChangeSet.totalLinesAdded} - {activeChangeSet.totalLinesRemoved} - {mode === 'task' && isTaskChangeSetV2(activeChangeSet) && ( - - )} )} - {/* Scope info / warnings */} - {mode === 'task' && - activeChangeSet && - isTaskChangeSetV2(activeChangeSet) && - (activeChangeSet.warnings.length > 0 || activeChangeSet.scope.confidence.tier >= 2) && ( - - )} + {/* Scope info / warnings + confidence badge */} + {mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && ( + + )} {/* Apply error */} {applyError && ( @@ -394,22 +430,23 @@ export const ChangeReviewDialog = ({
- {/* Edit Timeline */} - {selectedFile?.timeline && selectedFile.timeline.events.length > 0 && ( + {/* Edit Timeline for active file */} + {activeFile?.timeline && activeFile.timeline.events.length > 0 && (
- {/* Diff content */} -
- {selectedFile ? ( -
- {/* File header with content source badge and save/discard */} -
- - {selectedFile.relativePath} - - {selectedFile.isNewFile && ( - - NEW - - )} - {fileContent?.contentSource && ( - - {CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? - fileContent.contentSource} - - )} - {/* File-level decision indicator */} - {fileDecisions[selectedFile.filePath] && ( - - {fileDecisions[selectedFile.filePath]} - - )} - -
- {hasCurrentFileEdits && ( - <> - - - - - - Discard all edits for this file - - - - - - - - Save file to disk - - ⌘↵ - - - - - )} -
-
- - {/* Loading state */} - {isFileContentLoading && ( -
- - Loading file content... -
- )} - - {/* CodeMirror diff view when file content is available */} - {!isFileContentLoading && - fileContent && - fileContent.contentSource !== 'unavailable' && - fileContent.modifiedFullContent !== null && ( -
- - - setHunkDecision(selectedFile.filePath, idx, 'accepted') - } - onHunkRejected={(idx) => - setHunkDecision(selectedFile.filePath, idx, 'rejected') - } - onFullyViewed={handleFullyViewed} - editorViewRef={editorViewRef} - onContentChanged={(content) => { - updateEditedContent(selectedFile.filePath, content); - }} - /> - -
- )} - - {/* Fallback: Phase 1 snippet view when content unavailable */} - {!isFileContentLoading && - (!fileContent || fileContent.contentSource === 'unavailable') && ( -
- -
- )} -
- ) : ( -
- Select a file to view changes -
- )} -
+ {/* Continuous scroll diff content */} + )} diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx index cd3e328b..4993e488 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx +++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx @@ -25,6 +25,7 @@ import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; import { EditorView, keymap, lineNumbers } from '@codemirror/view'; import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils'; +import { portionCollapseExtension } from './portionCollapse'; interface CodeMirrorDiffViewProps { original: string; @@ -45,6 +46,10 @@ interface CodeMirrorDiffViewProps { onContentChanged?: (content: string) => void; /** Cached EditorState to restore (preserves undo history between file switches) */ initialState?: EditorState; + /** Use portion collapse instead of CM's collapseUnchanged (Expand N / Expand All buttons) */ + usePortionCollapse?: boolean; + /** Lines per "Expand N" click (only with usePortionCollapse). Default: 100 */ + portionSize?: number; } /** Synchronous language extension for common file types (bundled by Vite) */ @@ -275,6 +280,8 @@ export const CodeMirrorDiffView = ({ editorViewRef: externalViewRef, onContentChanged, initialState, + usePortionCollapse = false, + portionSize = 100, }: CodeMirrorDiffViewProps): React.ReactElement => { const containerRef = useRef(null); const viewRef = useRef(null); @@ -305,6 +312,8 @@ export const CodeMirrorDiffView = ({ const langCompartment = useRef(new Compartment()); // Compartment for merge view — allows dynamic collapse reconfigure without editor recreation const mergeCompartment = useRef(new Compartment()); + // Compartment for portion collapse (separate from merge to allow independent reconfigure) + const portionCompartment = useRef(new Compartment()); // Collapse as ref — used in buildExtensions (initial value) without triggering full rebuild const collapseRef = useRef({ enabled: collapseUnchangedProp, margin: collapseMargin }); @@ -322,7 +331,7 @@ export const CodeMirrorDiffView = ({ syntaxHighlightDeletions: true, }; - if (collapse) { + if (collapse && !usePortionCollapse) { mergeConfig.collapseUnchanged = { margin, minSize: 4, @@ -442,7 +451,7 @@ export const CodeMirrorDiffView = ({ return unifiedMergeView(mergeConfig); }, - [original, showMergeControls, scrollToNextChunk] + [original, showMergeControls, scrollToNextChunk, usePortionCollapse] ); const buildExtensions = useCallback(() => { @@ -660,8 +669,21 @@ export const CodeMirrorDiffView = ({ ) ); + // Portion collapse — must come AFTER merge view so ChunkField is available + extensions.push( + portionCompartment.current.of( + usePortionCollapse && collapseRef.current.enabled + ? portionCollapseExtension({ + margin: collapseRef.current.margin, + minSize: 4, + portionSize, + }) + : [] + ) + ); + return extensions; - }, [readOnly, showMergeControls, buildMergeExtension]); + }, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize]); useEffect(() => { if (!containerRef.current) return; @@ -729,16 +751,27 @@ export const CodeMirrorDiffView = ({ }; }, [fileName, buildExtensions, initialState]); - // Dynamic collapse toggle — reconfigure compartment in-place, preserving undo history + // Dynamic collapse toggle — reconfigure compartments in-place, preserving undo history useEffect(() => { const view = viewRef.current; if (!view) return; view.dispatch({ - effects: mergeCompartment.current.reconfigure( - buildMergeExtension(collapseUnchangedProp, collapseMargin) - ), + effects: [ + mergeCompartment.current.reconfigure( + buildMergeExtension(collapseUnchangedProp, collapseMargin) + ), + portionCompartment.current.reconfigure( + usePortionCollapse && collapseUnchangedProp + ? portionCollapseExtension({ + margin: collapseMargin, + minSize: 4, + portionSize, + }) + : [] + ), + ], }); - }, [collapseUnchangedProp, collapseMargin, buildMergeExtension]); + }, [collapseUnchangedProp, collapseMargin, buildMergeExtension, usePortionCollapse, portionSize]); // Auto-viewed detection via IntersectionObserver useEffect(() => { diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx new file mode 100644 index 00000000..20f0dcb3 --- /dev/null +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useMemo } from 'react'; + +import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent'; +import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection'; + +import { FileSectionDiff } from './FileSectionDiff'; +import { FileSectionHeader } from './FileSectionHeader'; +import { FileSectionPlaceholder } from './FileSectionPlaceholder'; + +import type { EditorView } from '@codemirror/view'; +import type { FileChangeWithContent, HunkDecision } from '@shared/types'; +import type { FileChangeSummary } from '@shared/types/review'; + +interface ContinuousScrollViewProps { + files: FileChangeSummary[]; + fileContents: Record; + fileContentsLoading: Record; + viewedSet: Set; + editedContents: Record; + fileDecisions: Record; + collapseUnchanged: boolean; + applying: boolean; + autoViewed: boolean; + discardCounter: number; + onHunkAccepted: (filePath: string, hunkIndex: number) => void; + onHunkRejected: (filePath: string, hunkIndex: number) => void; + onFullyViewed: (filePath: string) => void; + onContentChanged: (filePath: string, content: string) => void; + onDiscard: (filePath: string) => void; + onSave: (filePath: string) => void; + onVisibleFileChange: (filePath: string) => void; + scrollContainerRef: React.RefObject; + editorViewMapRef: React.MutableRefObject>; + isProgrammaticScroll: React.RefObject; + teamName: string; + memberName: string | undefined; + fetchFileContent: ( + teamName: string, + memberName: string | undefined, + filePath: string + ) => Promise; +} + +export const ContinuousScrollView = ({ + files, + fileContents, + fileContentsLoading, + viewedSet, + editedContents, + fileDecisions, + collapseUnchanged, + applying, + autoViewed, + discardCounter, + onHunkAccepted, + onHunkRejected, + onFullyViewed, + onContentChanged, + onDiscard, + onSave, + onVisibleFileChange, + scrollContainerRef, + editorViewMapRef, + isProgrammaticScroll, + teamName, + memberName, + fetchFileContent, +}: ContinuousScrollViewProps): React.ReactElement => { + const filePaths = useMemo(() => files.map((f) => f.filePath), [files]); + + const { registerFileSectionRef } = useVisibleFileSection({ + onVisibleFileChange, + scrollContainerRef, + isProgrammaticScroll, + }); + + const { registerLazyRef } = useLazyFileContent({ + teamName, + memberName, + filePaths, + scrollContainerRef, + fileContents, + fileContentsLoading, + fetchFileContent, + enabled: true, + }); + + // Combined ref callback: registers element in both scroll-spy and lazy-load observers + const combinedRef = useCallback( + (filePath: string) => { + const sectionRef = registerFileSectionRef(filePath); + const lazyRef = registerLazyRef(filePath); + + return (element: HTMLElement | null) => { + sectionRef(element); + lazyRef(element); + }; + }, + [registerFileSectionRef, registerLazyRef] + ); + + const handleEditorViewReady = useCallback( + (filePath: string, view: EditorView | null) => { + if (view) { + editorViewMapRef.current.set(filePath, view); + } else { + editorViewMapRef.current.delete(filePath); + } + }, + [editorViewMapRef] + ); + + if (files.length === 0) { + return ( +
+ No file changes detected +
+ ); + } + + return ( +
+ {files.map((file) => { + const filePath = file.filePath; + const content = fileContents[filePath] ?? null; + const hasContent = filePath in fileContents; + const hasEdits = filePath in editedContents; + const isViewed = viewedSet.has(filePath); + const decision = fileDecisions[filePath]; + + return ( +
+ + + {hasContent ? ( + + ) : ( + + )} +
+ ); + })} +
+ ); +}; diff --git a/src/renderer/components/team/review/FileSectionDiff.tsx b/src/renderer/components/team/review/FileSectionDiff.tsx new file mode 100644 index 00000000..3ff04bc0 --- /dev/null +++ b/src/renderer/components/team/review/FileSectionDiff.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useRef } from 'react'; + +import { CodeMirrorDiffView } from './CodeMirrorDiffView'; +import { DiffErrorBoundary } from './DiffErrorBoundary'; +import { FileSectionPlaceholder } from './FileSectionPlaceholder'; +import { ReviewDiffContent } from './ReviewDiffContent'; + +import type { EditorView } from '@codemirror/view'; +import type { FileChangeWithContent } from '@shared/types'; +import type { FileChangeSummary } from '@shared/types/review'; + +interface FileSectionDiffProps { + file: FileChangeSummary; + fileContent: FileChangeWithContent | null; + isLoading: boolean; + collapseUnchanged: boolean; + onHunkAccepted: (filePath: string, hunkIndex: number) => void; + onHunkRejected: (filePath: string, hunkIndex: number) => void; + onFullyViewed: (filePath: string) => void; + onContentChanged: (filePath: string, content: string) => void; + onEditorViewReady: (filePath: string, view: EditorView | null) => void; + discardCounter: number; + autoViewed: boolean; + isViewed: boolean; +} + +export const FileSectionDiff = ({ + file, + fileContent, + isLoading, + collapseUnchanged, + onHunkAccepted, + onHunkRejected, + onFullyViewed, + onContentChanged, + onEditorViewReady, + discardCounter, + autoViewed, + isViewed, +}: FileSectionDiffProps): React.ReactElement => { + const localEditorViewRef = useRef(null); + const sentinelRef = useRef(null); + + // Register/unregister EditorView with parent Map + useEffect(() => { + return () => { + onEditorViewReady(file.filePath, null); + }; + }, [file.filePath, onEditorViewReady]); + + // Sync EditorView ref to parent after CM creates the view + useEffect(() => { + if (localEditorViewRef.current) { + onEditorViewReady(file.filePath, localEditorViewRef.current); + } + }); + + // Auto-viewed sentinel observer + useEffect(() => { + if (!sentinelRef.current || !autoViewed || isViewed) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + onFullyViewed(file.filePath); + } + } + }, + { threshold: 0.85 } + ); + + observer.observe(sentinelRef.current); + return () => observer.disconnect(); + }, [autoViewed, isViewed, file.filePath, onFullyViewed]); + + // Loading state + if (isLoading) { + return ; + } + + // Unavailable / no content fallback + const hasCodeMirrorContent = + fileContent && + fileContent.contentSource !== 'unavailable' && + fileContent.modifiedFullContent !== null; + + if (!hasCodeMirrorContent) { + return ( +
+ +
+
+ ); + } + + return ( +
+ + onHunkAccepted(file.filePath, idx)} + onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)} + onContentChanged={(content) => onContentChanged(file.filePath, content)} + editorViewRef={localEditorViewRef} + /> + +
+
+ ); +}; diff --git a/src/renderer/components/team/review/FileSectionHeader.tsx b/src/renderer/components/team/review/FileSectionHeader.tsx new file mode 100644 index 00000000..72a7113a --- /dev/null +++ b/src/renderer/components/team/review/FileSectionHeader.tsx @@ -0,0 +1,108 @@ +import React from 'react'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { Loader2, Save, Undo2 } from 'lucide-react'; + +import type { FileChangeWithContent, HunkDecision } from '@shared/types'; +import type { FileChangeSummary } from '@shared/types/review'; + +const CONTENT_SOURCE_LABELS: Record = { + 'file-history': 'File History', + 'snippet-reconstruction': 'Reconstructed', + 'disk-current': 'Current Disk', + 'git-fallback': 'Git Fallback', + unavailable: 'Unavailable', +}; + +interface FileSectionHeaderProps { + file: FileChangeSummary; + fileContent: FileChangeWithContent | null; + fileDecision: HunkDecision | undefined; + hasEdits: boolean; + applying: boolean; + onDiscard: (filePath: string) => void; + onSave: (filePath: string) => void; +} + +export const FileSectionHeader = ({ + file, + fileContent, + fileDecision, + hasEdits, + applying, + onDiscard, + onSave, +}: FileSectionHeaderProps): React.ReactElement => { + return ( +
+ {file.relativePath} + + {file.isNewFile && ( + + NEW + + )} + + {fileContent?.contentSource && ( + + {CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource} + + )} + + {fileDecision && ( + + {fileDecision} + + )} + +
+ {hasEdits && ( + <> + + + + + Discard all edits for this file + + + + + + + Save file to disk + + ⌘↵ + + + + + )} +
+
+ ); +}; diff --git a/src/renderer/components/team/review/FileSectionPlaceholder.tsx b/src/renderer/components/team/review/FileSectionPlaceholder.tsx new file mode 100644 index 00000000..4fe5df16 --- /dev/null +++ b/src/renderer/components/team/review/FileSectionPlaceholder.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +interface FileSectionPlaceholderProps { + fileName: string; +} + +export const FileSectionPlaceholder = ({ + fileName, +}: FileSectionPlaceholderProps): React.ReactElement => ( +
+
+ {fileName} +
+
+ +
+
+
+
+
+
+
+); diff --git a/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx b/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx index 2c482846..baaaa0f6 100644 --- a/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx +++ b/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx @@ -9,11 +9,15 @@ interface KeyboardShortcutsHelpProps { const shortcuts = [ { keys: ['\u2325+J'], action: 'Next change' }, + { keys: ['\u2325+K'], action: 'Previous change' }, + { keys: ['\u2325+\u2193'], action: 'Next file' }, + { keys: ['\u2325+\u2191'], action: 'Previous file' }, { keys: ['\u2318+Y'], action: 'Accept change' }, { keys: ['\u2318+N'], action: 'Reject change' }, { keys: ['\u2318+\u21A9'], action: 'Save file' }, { keys: ['\u2318+Z'], action: 'Undo' }, { keys: ['\u2318+\u21E7+Z'], action: 'Redo' }, + { keys: ['?'], action: 'Toggle shortcuts' }, { keys: ['Esc'], action: 'Close dialog' }, ]; diff --git a/src/renderer/components/team/review/ReviewFileTree.tsx b/src/renderer/components/team/review/ReviewFileTree.tsx index 6fc311cd..60979539 100644 --- a/src/renderer/components/team/review/ReviewFileTree.tsx +++ b/src/renderer/components/team/review/ReviewFileTree.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; @@ -14,6 +14,7 @@ interface ReviewFileTreeProps { viewedSet?: Set; onMarkViewed?: (filePath: string) => void; onUnmarkViewed?: (filePath: string) => void; + activeFilePath?: string; } interface TreeNode { @@ -108,6 +109,7 @@ const FileStatusIcon = ({ status }: { status: FileStatus }) => { const TreeItem = ({ node, selectedFilePath, + activeFilePath, onSelectFile, depth, hunkDecisions, @@ -117,6 +119,7 @@ const TreeItem = ({ }: { node: TreeNode; selectedFilePath: string | null; + activeFilePath?: string; onSelectFile: (filePath: string) => void; depth: number; hunkDecisions: Record; @@ -126,15 +129,19 @@ const TreeItem = ({ }) => { if (node.isFile && node.file) { const isSelected = node.file.filePath === selectedFilePath; + const isActive = node.file.filePath === activeFilePath && !isSelected; const status = getFileStatus(node.file, hunkDecisions); return ( - Accept all changes in current file + Accept all changes across all files @@ -153,7 +170,7 @@ export const ReviewToolbar = ({ Reject All - Reject all changes in current file + Reject all changes across all files diff --git a/src/renderer/components/team/review/ScopeWarningBanner.tsx b/src/renderer/components/team/review/ScopeWarningBanner.tsx index 0c0cf948..502310eb 100644 --- a/src/renderer/components/team/review/ScopeWarningBanner.tsx +++ b/src/renderer/components/team/review/ScopeWarningBanner.tsx @@ -3,6 +3,8 @@ import { useState } from 'react'; import { cn } from '@renderer/lib/utils'; import { AlertTriangle, ChevronRight, Info, ShieldCheck, X } from 'lucide-react'; +import { ConfidenceBadge } from './ConfidenceBadge'; + import type { TaskScopeConfidence } from '@shared/types'; import type { FC } from 'react'; @@ -81,8 +83,13 @@ export const ScopeWarningBanner = ({ Read more + +
+ + + {onDismiss && ( - )} diff --git a/src/renderer/components/team/review/portionCollapse.ts b/src/renderer/components/team/review/portionCollapse.ts new file mode 100644 index 00000000..be951d9b --- /dev/null +++ b/src/renderer/components/team/review/portionCollapse.ts @@ -0,0 +1,315 @@ +import { updateOriginalDoc } from '@codemirror/merge'; +import { type Extension, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state'; +import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view'; + +import { getChunks } from './CodeMirrorDiffUtils'; + +import type { ChangeDesc, EditorState, Transaction } from '@codemirror/state'; + +// ─── Configuration ─── + +interface PortionCollapseConfig { + margin?: number; + minSize?: number; + portionSize?: number; +} + +// ─── State Effects ─── + +export const expandPortion = StateEffect.define<{ pos: number; count: number }>({ + map: (value, mapping: ChangeDesc) => ({ + pos: mapping.mapPos(value.pos), + count: value.count, + }), +}); + +export const expandAllAtPos = StateEffect.define({ + map: (pos, mapping: ChangeDesc) => mapping.mapPos(pos), +}); + +// ─── Widget ─── + +class PortionCollapseWidget extends WidgetType { + constructor( + readonly lineCount: number, + readonly portionSize: number + ) { + super(); + } + + toDOM(view: EditorView): HTMLElement { + const container = document.createElement('div'); + container.className = 'cm-portion-collapse'; + + const text = document.createElement('span'); + text.className = 'cm-portion-collapse-text'; + text.textContent = `\u00B7\u00B7\u00B7 ${this.lineCount} unchanged line${this.lineCount !== 1 ? 's' : ''} \u00B7\u00B7\u00B7`; + container.appendChild(text); + + const actions = document.createElement('div'); + actions.className = 'cm-portion-collapse-actions'; + + if (this.lineCount > this.portionSize) { + const expandBtn = document.createElement('button'); + expandBtn.className = 'cm-portion-expand-btn'; + expandBtn.textContent = `Expand ${this.portionSize}`; + expandBtn.title = `Show next ${this.portionSize} lines`; + expandBtn.onmousedown = (e) => { + e.preventDefault(); + e.stopPropagation(); + const pos = view.posAtDOM(container); + view.dispatch({ + effects: expandPortion.of({ pos, count: this.portionSize }), + }); + }; + actions.appendChild(expandBtn); + } + + const expandAllBtn = document.createElement('button'); + expandAllBtn.className = 'cm-portion-expand-all-btn'; + expandAllBtn.textContent = 'Expand All'; + expandAllBtn.title = `Show all ${this.lineCount} unchanged lines`; + expandAllBtn.onmousedown = (e) => { + e.preventDefault(); + e.stopPropagation(); + const pos = view.posAtDOM(container); + view.dispatch({ + effects: expandAllAtPos.of(pos), + }); + }; + actions.appendChild(expandAllBtn); + + container.appendChild(actions); + return container; + } + + eq(other: PortionCollapseWidget): boolean { + return this.lineCount === other.lineCount && this.portionSize === other.portionSize; + } + + // eslint-disable-next-line @typescript-eslint/class-literal-property-style -- WidgetType defines estimatedHeight as getter, cannot override with property + get estimatedHeight(): number { + return 28; + } + + ignoreEvent(event: Event): boolean { + return event instanceof MouseEvent; + } +} + +// ─── Helpers ─── + +function buildPortionRanges( + state: EditorState, + margin: number, + minSize: number, + portionSize: number +): DecorationSet { + const result = getChunks(state); + const doc = state.doc; + + if (!result) return Decoration.none; + + const chunks = result.chunks; + const builder = new RangeSetBuilder(); + + let prevLine = 1; + + for (let i = 0; ; i++) { + const chunk = i < chunks.length ? chunks[i] : null; + const collapseFrom = i ? prevLine + margin : 1; + const collapseTo = chunk ? doc.lineAt(chunk.fromB).number - 1 - margin : doc.lines; + const lines = collapseTo - collapseFrom + 1; + + if (lines >= minSize) { + const from = doc.line(collapseFrom).from; + const to = doc.line(collapseTo).to; + const widget = new PortionCollapseWidget(lines, portionSize); + + builder.add(from, to, Decoration.replace({ widget, block: true })); + } + + if (!chunk) break; + + prevLine = doc.lineAt(Math.min(doc.length, chunk.toB)).number; + } + + return builder.finish(); +} + +function handleExpandPortion( + decorations: DecorationSet, + value: { pos: number; count: number }, + state: EditorState, + minSize: number, + portionSize: number +): DecorationSet { + const { pos, count } = value; + const doc = state.doc; + + let targetFrom = -1; + let targetTo = -1; + + decorations.between(0, doc.length, (from, to) => { + if (from <= pos && pos <= to) { + targetFrom = from; + targetTo = to; + return false; + } + }); + + if (targetFrom < 0) return decorations; + + const fromLine = doc.lineAt(targetFrom).number; + const toLine = doc.lineAt(targetTo).number; + const newFromLine = fromLine + count; + const remainingLines = toLine - newFromLine + 1; + + if (remainingLines < minSize) { + return decorations.update({ + filter: (from) => from !== targetFrom, + }); + } + + const newFrom = doc.line(newFromLine).from; + const widget = new PortionCollapseWidget(remainingLines, portionSize); + + return decorations.update({ + filter: (from) => from !== targetFrom, + add: [Decoration.replace({ widget, block: true }).range(newFrom, targetTo)], + }); +} + +function handleExpandAll(decorations: DecorationSet, pos: number): DecorationSet { + return decorations.update({ + filter: (from, to) => !(from <= pos && pos <= to), + }); +} + +// ─── Theme ─── + +const portionCollapseTheme = EditorView.theme({ + '.cm-portion-collapse': { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '4px 12px', + backgroundColor: 'var(--color-surface-raised)', + borderTop: '1px solid var(--color-border)', + borderBottom: '1px solid var(--color-border)', + minHeight: '28px', + cursor: 'default', + userSelect: 'none', + }, + + '.cm-portion-collapse-text': { + fontSize: '12px', + color: 'var(--color-text-muted)', + letterSpacing: '0.5px', + }, + + '.cm-portion-collapse-actions': { + display: 'flex', + alignItems: 'center', + gap: '6px', + }, + + '.cm-portion-expand-btn': { + padding: '2px 10px', + fontSize: '11px', + fontWeight: '500', + lineHeight: '18px', + color: 'var(--color-text-secondary)', + backgroundColor: 'transparent', + border: '1px solid var(--color-border)', + borderRadius: '4px', + cursor: 'pointer', + transition: 'all 0.15s ease', + '&:hover': { + color: 'var(--color-text)', + backgroundColor: 'rgba(255, 255, 255, 0.06)', + borderColor: 'var(--color-border-emphasis)', + }, + '&:active': { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + }, + + '.cm-portion-expand-all-btn': { + padding: '2px 10px', + fontSize: '11px', + fontWeight: '500', + lineHeight: '18px', + color: 'var(--color-text-muted)', + backgroundColor: 'transparent', + border: '1px solid transparent', + borderRadius: '4px', + cursor: 'pointer', + transition: 'all 0.15s ease', + '&:hover': { + color: 'var(--color-text-secondary)', + backgroundColor: 'rgba(255, 255, 255, 0.04)', + borderColor: 'var(--color-border)', + }, + '&:active': { + backgroundColor: 'rgba(255, 255, 255, 0.08)', + }, + }, +}); + +// ─── Extension ─── + +export function portionCollapseExtension(config?: PortionCollapseConfig): Extension { + const margin = config?.margin ?? 3; + const minSize = config?.minSize ?? 4; + const portionSize = config?.portionSize ?? 100; + + const field = StateField.define({ + create(state: EditorState): DecorationSet { + return buildPortionRanges(state, margin, minSize, portionSize); + }, + + update(deco: DecorationSet, tr: Transaction): DecorationSet { + // 1. Expand effects + let result = deco; + let hasExpandEffect = false; + for (const effect of tr.effects) { + if (effect.is(expandPortion)) { + hasExpandEffect = true; + result = handleExpandPortion(result, effect.value, tr.state, minSize, portionSize); + } + if (effect.is(expandAllAtPos)) { + hasExpandEffect = true; + result = handleExpandAll(result, effect.value); + } + } + if (hasExpandEffect) return result; + + // 2. Accept chunk (updateOriginalDoc) → full rebuild + if (tr.effects.some((e) => e.is(updateOriginalDoc))) { + return buildPortionRanges(tr.state, margin, minSize, portionSize); + } + + // 3. Document changed (reject, user edit) → full rebuild + if (tr.docChanged) { + return buildPortionRanges(tr.state, margin, minSize, portionSize); + } + + // 4. Lazy init + if (deco === Decoration.none) { + const chunks = getChunks(tr.state); + if (chunks) { + return buildPortionRanges(tr.state, margin, minSize, portionSize); + } + } + + return deco; + }, + + provide(f) { + return EditorView.decorations.from(f); + }, + }); + + return [field, portionCollapseTheme]; +} diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts index e2ce3627..0773652a 100644 --- a/src/renderer/constants/teamColors.ts +++ b/src/renderer/constants/teamColors.ts @@ -23,6 +23,8 @@ const TEAMMATE_COLORS: Record = { cyan: { border: '#06b6d4', badge: 'rgba(6, 182, 212, 0.15)', text: '#22d3ee' }, orange: { border: '#f97316', badge: 'rgba(249, 115, 22, 0.15)', text: '#fb923c' }, pink: { border: '#ec4899', badge: 'rgba(236, 72, 153, 0.15)', text: '#f472b6' }, + /** Reserved for the human user — never assigned to team members. */ + user: { border: '#f5f5f4', badge: 'rgba(245, 245, 244, 0.12)', text: '#d6d3d1' }, }; const DEFAULT_COLOR: TeamColorSet = TEAMMATE_COLORS.blue; diff --git a/src/renderer/hooks/useContinuousScrollNav.ts b/src/renderer/hooks/useContinuousScrollNav.ts new file mode 100644 index 00000000..cf05f2a2 --- /dev/null +++ b/src/renderer/hooks/useContinuousScrollNav.ts @@ -0,0 +1,50 @@ +import { type RefObject, useCallback, useRef } from 'react'; + +import { waitForScrollEnd } from '@renderer/hooks/navigation/utils'; + +interface UseContinuousScrollNavOptions { + scrollContainerRef: RefObject; +} + +interface UseContinuousScrollNavReturn { + scrollToFile: (filePath: string) => void; + isProgrammaticScroll: RefObject; +} + +export function useContinuousScrollNav( + options: UseContinuousScrollNavOptions +): UseContinuousScrollNavReturn { + const { scrollContainerRef } = options; + + const isProgrammaticScroll = useRef(false); + const scrollGeneration = useRef(0); + + const scrollToFile = useCallback( + (filePath: string) => { + const container = scrollContainerRef.current; + if (!container) return; + + const section = container.querySelector( + `[data-file-path="${CSS.escape(filePath)}"]` + ); + if (!section) return; + + const gen = ++scrollGeneration.current; + isProgrammaticScroll.current = true; + + section.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + void waitForScrollEnd(container, 500).then(() => { + if (scrollGeneration.current === gen) { + isProgrammaticScroll.current = false; + } + }); + }, + [scrollContainerRef] + ); + + return { + scrollToFile, + isProgrammaticScroll, + }; +} diff --git a/src/renderer/hooks/useDiffNavigation.ts b/src/renderer/hooks/useDiffNavigation.ts index 9c804065..20082d19 100644 --- a/src/renderer/hooks/useDiffNavigation.ts +++ b/src/renderer/hooks/useDiffNavigation.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge'; +import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils'; import type { EditorView } from '@codemirror/view'; import type { FileChangeSummary } from '@shared/types/review'; @@ -19,6 +20,74 @@ interface DiffNavigationState { setShowShortcutsHelp: (show: boolean) => void; } +export interface ContinuousNavigationOptions { + editorViewMapRef: React.MutableRefObject>; + activeFilePath: string | null; + scrollToFile: (filePath: string) => void; + enabled: boolean; +} + +function getEditorViewRefs( + continuousOptions?: ContinuousNavigationOptions +): Map | null { + return continuousOptions?.enabled ? continuousOptions.editorViewMapRef.current : null; +} + +function getActiveEditorView( + editorViewRef: React.RefObject, + continuousOptions?: ContinuousNavigationOptions +): EditorView | null { + const editorViewRefs = getEditorViewRefs(continuousOptions); + if (!editorViewRefs) { + return editorViewRef.current; + } + + const { activeFilePath } = continuousOptions!; + + // 1. Focused editor + for (const [, view] of editorViewRefs) { + if (view.hasFocus) return view; + } + + // 2. activeFilePath editor + if (activeFilePath) { + const view = editorViewRefs.get(activeFilePath); + if (view) return view; + } + + // 3. Fallback: first editor + const firstEntry = editorViewRefs.values().next(); + return firstEntry.done ? null : firstEntry.value; +} + +function getActiveFilePath( + selectedFilePath: string | null, + continuousOptions?: ContinuousNavigationOptions +): string | null { + if (continuousOptions?.enabled && continuousOptions.activeFilePath) { + return continuousOptions.activeFilePath; + } + return selectedFilePath; +} + +export function isLastChunkInFile(view: EditorView): boolean { + const result = getChunks(view.state); + if (!result || result.chunks.length === 0) return true; + + const cursorPos = view.state.selection.main.head; + const lastChunk = result.chunks[result.chunks.length - 1]; + return cursorPos >= lastChunk.fromB; +} + +export function isFirstChunkInFile(view: EditorView): boolean { + const result = getChunks(view.state); + if (!result || result.chunks.length === 0) return true; + + const cursorPos = view.state.selection.main.head; + const firstChunk = result.chunks[0]; + return cursorPos <= firstChunk.toB; +} + export function useDiffNavigation( files: FileChangeSummary[], selectedFilePath: string | null, @@ -28,64 +97,135 @@ export function useDiffNavigation( onHunkAccepted?: (filePath: string, hunkIndex: number) => void, onHunkRejected?: (filePath: string, hunkIndex: number) => void, onClose?: () => void, - onSaveFile?: () => void + onSaveFile?: () => void, + continuousOptions?: ContinuousNavigationOptions ): DiffNavigationState { - // Track hunk index keyed by file path to auto-reset on file change const [hunkState, setHunkState] = useState<{ filePath: string | null; index: number }>({ filePath: selectedFilePath, index: 0, }); const [showShortcutsHelp, setShowShortcutsHelp] = useState(false); - const selectedFile = files.find((f) => f.filePath === selectedFilePath); + const activePath = getActiveFilePath(selectedFilePath, continuousOptions); + const selectedFile = files.find((f) => f.filePath === activePath); const totalHunks = selectedFile?.snippets.length ?? 0; - // Derive currentHunkIndex: reset to 0 when selectedFilePath changes - const currentHunkIndex = hunkState.filePath === selectedFilePath ? hunkState.index : 0; + const currentHunkIndex = hunkState.filePath === activePath ? hunkState.index : 0; const setCurrentHunkIndex = useCallback( (updater: number | ((prev: number) => number)) => { setHunkState((prev) => { const newIndex = typeof updater === 'function' - ? updater(prev.filePath === selectedFilePath ? prev.index : 0) + ? updater(prev.filePath === activePath ? prev.index : 0) : updater; - return { filePath: selectedFilePath, index: newIndex }; + return { filePath: activePath, index: newIndex }; }); }, - [selectedFilePath] + [activePath] ); - const goToNextHunk = useCallback(() => { - const view = editorViewRef.current; - if (view) { - goToNextChunk(view); - } - setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1)); - }, [editorViewRef, totalHunks, setCurrentHunkIndex]); - - const goToPrevHunk = useCallback(() => { - const view = editorViewRef.current; - if (view) { - goToPreviousChunk(view); - } - setCurrentHunkIndex((prev) => Math.max(prev - 1, 0)); - }, [editorViewRef, setCurrentHunkIndex]); + // Stable refs for continuousOptions to avoid stale closures + const continuousOptionsRef = useRef(continuousOptions); + useEffect(() => { + continuousOptionsRef.current = continuousOptions; + }); const goToNextFile = useCallback(() => { if (files.length === 0) return; - const currentIdx = files.findIndex((f) => f.filePath === selectedFilePath); + + const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current); + const currentIdx = files.findIndex((f) => f.filePath === currentPath); const nextIdx = currentIdx < files.length - 1 ? currentIdx + 1 : 0; - onSelectFile(files[nextIdx].filePath); + const nextFilePath = files[nextIdx].filePath; + + if (continuousOptionsRef.current?.enabled) { + continuousOptionsRef.current.scrollToFile(nextFilePath); + } else { + onSelectFile(nextFilePath); + } }, [files, selectedFilePath, onSelectFile]); const goToPrevFile = useCallback(() => { if (files.length === 0) return; - const currentIdx = files.findIndex((f) => f.filePath === selectedFilePath); + + const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current); + const currentIdx = files.findIndex((f) => f.filePath === currentPath); const prevIdx = currentIdx > 0 ? currentIdx - 1 : files.length - 1; - onSelectFile(files[prevIdx].filePath); + const prevFilePath = files[prevIdx].filePath; + + if (continuousOptionsRef.current?.enabled) { + continuousOptionsRef.current.scrollToFile(prevFilePath); + } else { + onSelectFile(prevFilePath); + } }, [files, selectedFilePath, onSelectFile]); + const goToNextHunk = useCallback(() => { + const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current); + if (!view) return; + + if (continuousOptionsRef.current?.enabled) { + if (isLastChunkInFile(view)) { + const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current); + const currentIdx = files.findIndex((f) => f.filePath === currentPath); + + if (currentIdx < files.length - 1) { + const nextFilePath = files[currentIdx + 1].filePath; + continuousOptionsRef.current.scrollToFile(nextFilePath); + + requestAnimationFrame(() => { + const opts = continuousOptionsRef.current; + const nextView = opts?.editorViewMapRef.current.get(nextFilePath); + if (nextView) { + nextView.dispatch({ selection: { anchor: 0 } }); + goToNextChunk(nextView); + } + }); + } + } else { + goToNextChunk(view); + } + } else { + goToNextChunk(view); + } + + setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1)); + }, [editorViewRef, totalHunks, setCurrentHunkIndex, files, selectedFilePath]); + + const goToPrevHunk = useCallback(() => { + const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current); + if (!view) return; + + if (continuousOptionsRef.current?.enabled) { + if (isFirstChunkInFile(view)) { + const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current); + const currentIdx = files.findIndex((f) => f.filePath === currentPath); + + if (currentIdx > 0) { + const prevFilePath = files[currentIdx - 1].filePath; + continuousOptionsRef.current.scrollToFile(prevFilePath); + + requestAnimationFrame(() => { + const opts = continuousOptionsRef.current; + const prevView = opts?.editorViewMapRef.current.get(prevFilePath); + if (prevView) { + const docLength = prevView.state.doc.length; + prevView.dispatch({ selection: { anchor: docLength } }); + goToPreviousChunk(prevView); + } + }); + } + } else { + goToPreviousChunk(view); + } + } else { + goToPreviousChunk(view); + } + + setCurrentHunkIndex((prev) => Math.max(prev - 1, 0)); + }, [editorViewRef, setCurrentHunkIndex, files, selectedFilePath]); + const goToHunk = useCallback( (index: number) => { setCurrentHunkIndex(Math.max(0, Math.min(index, totalHunks - 1))); @@ -94,14 +234,16 @@ export function useDiffNavigation( ); const acceptCurrentHunk = useCallback(() => { - if (selectedFilePath && onHunkAccepted) { - onHunkAccepted(selectedFilePath, currentHunkIndex); + const path = getActiveFilePath(selectedFilePath, continuousOptionsRef.current); + if (path && onHunkAccepted) { + onHunkAccepted(path, currentHunkIndex); } }, [selectedFilePath, currentHunkIndex, onHunkAccepted]); const rejectCurrentHunk = useCallback(() => { - if (selectedFilePath && onHunkRejected) { - onHunkRejected(selectedFilePath, currentHunkIndex); + const path = getActiveFilePath(selectedFilePath, continuousOptionsRef.current); + if (path && onHunkRejected) { + onHunkRejected(path, currentHunkIndex); } }, [selectedFilePath, currentHunkIndex, onHunkRejected]); @@ -114,25 +256,43 @@ export function useDiffNavigation( onSaveFileRef.current = onSaveFile; }, [onClose, onSaveFile]); - // Keyboard handler — new shortcuts for editable diff + // Keyboard handler useEffect(() => { if (!isDialogOpen) return; const handler = (event: KeyboardEvent) => { - // Skip if CM keymap already handled this event if (event.defaultPrevented) return; - // Skip inputs/textareas if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return; } const isMeta = event.metaKey || event.ctrlKey; - // Alt+J -> next change + // Alt+J -> next hunk (cross-file in continuous mode) if (event.altKey && event.key.toLowerCase() === 'j') { event.preventDefault(); - const view = editorViewRef.current; - if (view) goToNextChunk(view); + goToNextHunk(); + return; + } + + // Alt+K -> prev hunk (cross-file in continuous mode) + if (event.altKey && event.key.toLowerCase() === 'k') { + event.preventDefault(); + goToPrevHunk(); + return; + } + + // Alt+ArrowDown -> next file + if (event.altKey && event.key === 'ArrowDown') { + event.preventDefault(); + goToNextFile(); + return; + } + + // Alt+ArrowUp -> prev file + if (event.altKey && event.key === 'ArrowUp') { + event.preventDefault(); + goToPrevFile(); return; } @@ -143,30 +303,50 @@ export function useDiffNavigation( return; } - // Cmd+Y -> accept + scroll (fallback when editor not focused) + // Cmd+Y -> accept chunk + next (cross-file aware) if (isMeta && event.key.toLowerCase() === 'y') { event.preventDefault(); - const view = editorViewRef.current; + const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current); if (view) { acceptChunk(view); - requestAnimationFrame(() => goToNextChunk(view)); + requestAnimationFrame(() => { + if (continuousOptionsRef.current?.enabled && isLastChunkInFile(view)) { + goToNextFile(); + } else { + goToNextChunk(view); + } + }); } return; } + // ? -> toggle shortcuts help + if (event.key === '?' && !isMeta && !event.altKey) { + event.preventDefault(); + setShowShortcutsHelp((prev) => !prev); + return; + } + // Escape handling if (event.key === 'Escape') { if (showShortcutsHelp) { event.preventDefault(); setShowShortcutsHelp(false); } - // Note: main Escape handling for closing dialog is in ChangeReviewDialog itself } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); - }, [isDialogOpen, showShortcutsHelp, editorViewRef]); + }, [ + isDialogOpen, + showShortcutsHelp, + editorViewRef, + goToNextFile, + goToPrevFile, + goToNextHunk, + goToPrevHunk, + ]); return { currentHunkIndex, diff --git a/src/renderer/hooks/useLazyFileContent.ts b/src/renderer/hooks/useLazyFileContent.ts new file mode 100644 index 00000000..e6ed8680 --- /dev/null +++ b/src/renderer/hooks/useLazyFileContent.ts @@ -0,0 +1,150 @@ +import { type RefObject, useCallback, useEffect, useRef } from 'react'; + +import type { FileChangeWithContent } from '@shared/types'; + +const MAX_CONCURRENT = 3; +const PRELOAD_COUNT = 5; + +interface UseLazyFileContentOptions { + teamName: string; + memberName: string | undefined; + filePaths: string[]; + scrollContainerRef: RefObject; + fileContents: Record; + fileContentsLoading: Record; + fetchFileContent: ( + teamName: string, + memberName: string | undefined, + filePath: string + ) => Promise; + enabled: boolean; +} + +interface UseLazyFileContentReturn { + registerLazyRef: (filePath: string) => (element: HTMLElement | null) => void; +} + +export function useLazyFileContent(options: UseLazyFileContentOptions): UseLazyFileContentReturn { + const { enabled, scrollContainerRef } = options; + + const activeLoads = useRef(new Set()); + const pendingQueue = useRef([]); + const observerRef = useRef(null); + const elementRefs = useRef(new Map()); + + // Stable ref to avoid stale closures in observer/processQueue callbacks + const optionsRef = useRef(options); + useEffect(() => { + optionsRef.current = options; + }); + + const shouldLoad = useCallback((filePath: string): boolean => { + const opts = optionsRef.current; + if (opts.fileContents[filePath]) return false; + if (opts.fileContentsLoading[filePath]) return false; + if (activeLoads.current.has(filePath)) return false; + return true; + }, []); + + // Refs for loadFile/processQueue to avoid circular useCallback deps + const loadFileRef = useRef<(fp: string) => Promise>(); + const processQueueRef = useRef<() => void>(); + + loadFileRef.current = async (filePath: string) => { + if (!shouldLoad(filePath)) return; + activeLoads.current.add(filePath); + try { + const opts = optionsRef.current; + await opts.fetchFileContent(opts.teamName, opts.memberName, filePath); + } finally { + activeLoads.current.delete(filePath); + processQueueRef.current?.(); + } + }; + + processQueueRef.current = () => { + while (activeLoads.current.size < MAX_CONCURRENT && pendingQueue.current.length > 0) { + const nextPath = pendingQueue.current.shift()!; + if (shouldLoad(nextPath)) { + void loadFileRef.current?.(nextPath); + } + } + }; + + const enqueueLoad = useCallback( + (filePath: string) => { + if (!shouldLoad(filePath)) return; + + if (activeLoads.current.size < MAX_CONCURRENT) { + void loadFileRef.current?.(filePath); + } else { + if (!pendingQueue.current.includes(filePath)) { + pendingQueue.current.push(filePath); + } + } + }, + [shouldLoad] + ); + + // Preload first N files on mount + useEffect(() => { + if (!enabled) return; + const toPreload = optionsRef.current.filePaths.slice(0, PRELOAD_COUNT); + for (const fp of toPreload) { + enqueueLoad(fp); + } + }, [enabled, enqueueLoad]); + + // IntersectionObserver for lazy loading + useEffect(() => { + if (!enabled || !scrollContainerRef.current) return; + + observerRef.current = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const filePath = entry.target.getAttribute('data-lazy-file'); + if (!filePath) continue; + enqueueLoad(filePath); + } + }, + { + root: scrollContainerRef.current, + rootMargin: '200% 0px 200% 0px', + threshold: 0, + } + ); + + // Observe already mounted elements + for (const [, element] of elementRefs.current) { + observerRef.current.observe(element); + } + + return () => { + observerRef.current?.disconnect(); + observerRef.current = null; + }; + }, [enabled, scrollContainerRef, enqueueLoad]); + + const registerLazyRef = useCallback((filePath: string) => { + return (element: HTMLElement | null) => { + const observer = observerRef.current; + + const prev = elementRefs.current.get(filePath); + if (prev && observer) { + observer.unobserve(prev); + } + elementRefs.current.delete(filePath); + + if (element) { + element.setAttribute('data-lazy-file', filePath); + elementRefs.current.set(filePath, element); + if (observer) { + observer.observe(element); + } + } + }; + }, []); + + return { registerLazyRef }; +} diff --git a/src/renderer/hooks/useVisibleFileSection.ts b/src/renderer/hooks/useVisibleFileSection.ts new file mode 100644 index 00000000..3bac63e4 --- /dev/null +++ b/src/renderer/hooks/useVisibleFileSection.ts @@ -0,0 +1,114 @@ +import { type RefObject, useCallback, useEffect, useRef } from 'react'; + +interface UseVisibleFileSectionOptions { + onVisibleFileChange: (filePath: string) => void; + scrollContainerRef: RefObject; + isProgrammaticScroll: RefObject; +} + +interface UseVisibleFileSectionReturn { + registerFileSectionRef: (filePath: string) => (element: HTMLElement | null) => void; +} + +export function useVisibleFileSection( + options: UseVisibleFileSectionOptions +): UseVisibleFileSectionReturn { + const { onVisibleFileChange, scrollContainerRef, isProgrammaticScroll } = options; + + const visibleFilePaths = useRef>(new Set()); + const elementRefs = useRef>(new Map()); + const observerRef = useRef(null); + const debounceRef = useRef>(); + + const updateTopmostVisible = useCallback(() => { + if (isProgrammaticScroll.current) return; + if (visibleFilePaths.current.size === 0) return; + + let topmostPath: string | null = null; + let minTop = Infinity; + + visibleFilePaths.current.forEach((filePath) => { + const element = elementRefs.current.get(filePath); + if (element) { + const rect = element.getBoundingClientRect(); + if (rect.top < minTop) { + minTop = rect.top; + topmostPath = filePath; + } + } + }); + + if (topmostPath) { + onVisibleFileChange(topmostPath); + } + }, [onVisibleFileChange, isProgrammaticScroll]); + + const debouncedUpdate = useCallback(() => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(updateTopmostVisible, 100); + }, [updateTopmostVisible]); + + useEffect(() => { + if (!scrollContainerRef.current) return; + + observerRef.current = new IntersectionObserver( + (entries) => { + let changed = false; + + for (const entry of entries) { + const filePath = entry.target.getAttribute('data-file-path'); + if (!filePath) continue; + + if (entry.isIntersecting && entry.intersectionRatio >= 0.1) { + if (!visibleFilePaths.current.has(filePath)) { + visibleFilePaths.current.add(filePath); + changed = true; + } + } else { + if (visibleFilePaths.current.has(filePath)) { + visibleFilePaths.current.delete(filePath); + changed = true; + } + } + } + + if (changed) { + debouncedUpdate(); + } + }, + { + root: scrollContainerRef.current, + threshold: 0.1, + rootMargin: '0px', + } + ); + + return () => { + observerRef.current?.disconnect(); + observerRef.current = null; + clearTimeout(debounceRef.current); + }; + }, [scrollContainerRef, debouncedUpdate]); + + const registerFileSectionRef = useCallback((filePath: string) => { + return (element: HTMLElement | null) => { + const observer = observerRef.current; + if (!observer) return; + + const prev = elementRefs.current.get(filePath); + if (prev) { + observer.unobserve(prev); + elementRefs.current.delete(filePath); + visibleFilePaths.current.delete(filePath); + } + + if (element) { + element.setAttribute('data-file-path', filePath); + elementRefs.current.set(filePath, element); + observer.observe(element); + } + }; + }, []); + + return { registerFileSectionRef }; +} diff --git a/src/renderer/index.css b/src/renderer/index.css index 55692a24..24997fd0 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -492,6 +492,11 @@ body { -webkit-app-region: no-drag; } +/* Prevent drag region bleed-through on Windows: all fixed overlays must be no-drag */ +.fixed { + -webkit-app-region: no-drag; +} + /* Hide horizontal scrollbar in tab bar */ .scrollbar-none { scrollbar-width: none; diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 2f0579af..13898af7 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -93,13 +93,9 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead') - ); - if (lead) { - map.set('user', map.get(lead.name) ?? getMemberColor(0)); - } + // "user" = the human operator; gets a unique reserved color + // that is never assigned to any team member. + map.set('user', 'user'); return map; }