feat: enhance diff view with continuous scroll and lazy loading
- Introduced a continuous scroll mode for the diff view, allowing users to review multiple files in a single scrollable container. - Added lazy loading functionality to improve performance by loading file content as it approaches the viewport. - Implemented a new portion collapse feature to allow users to expand unchanged regions incrementally, enhancing context retention during reviews. - Updated navigation to support smooth scrolling between files and improved keyboard shortcuts for file navigation. - Enhanced the review toolbar to manage actions across all files, including bulk accept/reject options. - Added new hooks and components to support the continuous scroll and lazy loading features, ensuring a seamless user experience.
This commit is contained in:
parent
e4aa544f57
commit
0df816bba6
31 changed files with 7588 additions and 342 deletions
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
255
docs/iterations/diff-view/continuous-scroll/overview.md
Normal file
255
docs/iterations/diff-view/continuous-scroll/overview.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# Continuous Scroll Diff View -- Overview
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Текущий Review Dialog показывает diff для одного файла за раз. Пользователь переключает файлы через дерево слева. Это создает трение при ревью: нужно кликать каждый файл, терять контекст между файлами, невозможно быстро пролистать все изменения.
|
||||
|
||||
**Continuous Scroll Diff View** -- это режим, в котором все файлы changeset-а отображаются в одном непрерывном скролле, аналогично GitHub PR diff view. Каждый файл начинается с заголовка (sticky header), за которым идет diff. Пользователь скроллит вниз и видит все изменения последовательно. File tree слева синхронизируется с текущей видимой позицией (scroll-spy), клик на файл в дереве плавно прокручивает к нему.
|
||||
|
||||
---
|
||||
|
||||
## 2. Целевой UX
|
||||
|
||||
### Что видит пользователь
|
||||
|
||||
1. Открывает Review Dialog с несколькими файлами
|
||||
2. Слева -- file tree (как сейчас), справа -- непрерывный скролл всех файлов
|
||||
3. Каждый файл начинается со **sticky header** (имя файла, badges, +/-) -- при скролле header "прилипает" к верху
|
||||
4. Под header -- CodeMirror diff view для этого файла
|
||||
5. Неизменённые регионы свёрнуты (portionCollapse), с возможностью развернуть порциями ("Expand 100" / "Expand All")
|
||||
6. Файлы, контент которых ещё не загружен, показывают placeholder с skeleton
|
||||
7. File tree подсвечивает текущий видимый файл (scroll-spy)
|
||||
8. Клик по файлу в дереве -> плавный скролл к этому файлу
|
||||
9. Cmd+Y/N accept/reject работают для видимого файла (или focused editor)
|
||||
10. "Accept All" / "Reject All" применяются ко ВСЕМ файлам
|
||||
11. Progress bar показывает "12 of 45 changes reviewed"
|
||||
12. Auto-viewed помечает файлы по мере скролла
|
||||
|
||||
### Когда включается continuous mode
|
||||
|
||||
- Когда файлов > 1 в changeset -- continuous mode автоматически
|
||||
- Когда файл один -- обычный single-file mode (без изменений)
|
||||
|
||||
---
|
||||
|
||||
## 3. Архитектурные решения
|
||||
|
||||
### 3.1. Почему НЕ @tanstack/react-virtual
|
||||
|
||||
Виртуализация (react-virtual, react-window и т.д.) работает по принципу: рендерить только элементы в viewport, остальные -- placeholder с фиксированной высотой.
|
||||
|
||||
**Проблема для CodeMirror:**
|
||||
- CodeMirror EditorView требует реального DOM-узла для создания editor instance
|
||||
- EditorView рассчитывает layout, позиции строк, viewport -- всё завязано на реальный DOM
|
||||
- При "виртуализации" EditorView нужно destroy/create при входе/выходе из viewport
|
||||
- destroy теряет undo history, scroll position внутри editor, cursor position
|
||||
- create -- тяжёлая операция (парсинг, syntax highlighting, merge computation)
|
||||
|
||||
**Альтернатива: lazy loading + portionCollapse:**
|
||||
- Все файлы существуют в DOM одновременно
|
||||
- Но их контент загружается lazy (Phase 2)
|
||||
- Неизменённые регионы свёрнуты через portionCollapse (Phase 4)
|
||||
- Итог: 50 файлов в DOM, но каждый занимает минимум строк (только changed lines + margin)
|
||||
|
||||
### 3.2. Почему кастомный portionCollapse
|
||||
|
||||
CodeMirror из коробки поддерживает `collapseUnchanged` в `unifiedMergeView`:
|
||||
|
||||
```typescript
|
||||
unifiedMergeView({
|
||||
collapseUnchanged: { margin: 3, minSize: 4 }
|
||||
});
|
||||
```
|
||||
|
||||
**Проблема:** встроенный collapse -- monolithic. Кнопка "expand" раскрывает ВСЮ свёрнутую область, без возможности:
|
||||
- Раскрыть порцию строк (например, 100 строк за одно нажатие)
|
||||
- Раскрыть полностью по отдельной кнопке
|
||||
- Показать контекст постепенно
|
||||
|
||||
**Решение:** кастомный `portionCollapse.ts` -- StateField + Decoration, который:
|
||||
- Управляет свёрнутыми регионами как `RangeSet<Decoration>`
|
||||
- Поддерживает partial expand (portionSize=100 строк за нажатие)
|
||||
- Полностью заменяет встроенный collapseUnchanged
|
||||
|
||||
### 3.3. Lazy loading вместо виртуализации
|
||||
|
||||
Файлы загружают контент по мере приближения к viewport:
|
||||
|
||||
- IntersectionObserver с `rootMargin: '200% 0px 200% 0px'` на placeholder каждого файла
|
||||
- Когда placeholder входит в расширенный viewport -- `fetchFileContent()` запускается
|
||||
- Пока контент грузится -- placeholder показывает skeleton
|
||||
- После загрузки -- CodeMirrorDiffView рендерится
|
||||
|
||||
Это даёт:
|
||||
- Быстрый первичный рендер (только заголовки + placeholders)
|
||||
- Предварительная загрузка за 2 viewport-высоты до видимости
|
||||
- Нет потери undo history (EditorView живёт, пока диалог открыт)
|
||||
|
||||
---
|
||||
|
||||
## 4. Карта файлов
|
||||
|
||||
### 4.1. Новые файлы (8)
|
||||
|
||||
| Файл | Путь | Фаза | Ответственность |
|
||||
|------|------|------|-----------------|
|
||||
| `FileSectionHeader.tsx` | `src/renderer/components/team/review/FileSectionHeader.tsx` | Phase 1 | Sticky header для каждого файла: имя, badges (+/-), content source, viewed checkbox, file-level decision indicator. Использует `position: sticky; top: 0; z-index: 10`. |
|
||||
| `FileSectionDiff.tsx` | `src/renderer/components/team/review/FileSectionDiff.tsx` | Phase 1 | Обёртка над CodeMirrorDiffView для одного файла в continuous scroll. Управляет lifecycle EditorView (onEditorViewReady(filePath, view \| null) единый callback), содержит sentinel для auto-viewed, передаёт все props в CodeMirrorDiffView. |
|
||||
| `FileSectionPlaceholder.tsx` | `src/renderer/components/team/review/FileSectionPlaceholder.tsx` | Phase 1 | Placeholder-скелетон для файла, пока контент не загружен. Фиксированная высота (~200px). Содержит IntersectionObserver trigger для lazy loading (Phase 2). |
|
||||
| `ContinuousScrollView.tsx` | `src/renderer/components/team/review/ContinuousScrollView.tsx` | Phase 1 | Главный контейнер: рендерит файлы последовательно (FileSectionHeader + FileSectionDiff/Placeholder). Хранит EditorView Map (Phase 5). useImperativeHandle для доступа к Map из родителя. Обрабатывает scroll events для scroll-spy. |
|
||||
| `useVisibleFileSection.ts` | `src/renderer/hooks/useVisibleFileSection.ts` | Phase 1 | Hook для scroll-spy: IntersectionObserver определяет, какой file section сейчас виден в viewport. Возвращает `activeFilePath`. Учитывает programmatic scroll (flag `isProgrammaticScroll`). |
|
||||
| `useContinuousScrollNav.ts` | `src/renderer/hooks/useContinuousScrollNav.ts` | Phase 1 | Hook для programmatic navigation: `scrollToFile(filePath)` -- плавный скролл к конкретному файлу. Использует `Element.scrollIntoView({ behavior: 'smooth' })`. Устанавливает `isProgrammaticScroll` flag для подавления scroll-spy. |
|
||||
| `useLazyFileContent.ts` | `src/renderer/hooks/useLazyFileContent.ts` | Phase 2 | Hook для lazy loading контента файлов: IntersectionObserver с rootMargin для prefetch. Вызывает `fetchFileContent()` из store. Отслеживает loaded/loading state per file. |
|
||||
| `portionCollapse.ts` | `src/renderer/components/team/review/portionCollapse.ts` | Phase 4 | CodeMirror StateField + Decoration для partial collapse неизменённых regions. Кнопки "Expand 100" (portionSize=100) и "Expand All". Rebuilds decorations после accept/reject. Включает `portionCollapseTheme` со стилями. |
|
||||
|
||||
### 4.2. Модифицируемые файлы (8)
|
||||
|
||||
| Файл | Путь | Фазы | Изменения |
|
||||
|------|------|------|-----------|
|
||||
| `ChangeReviewDialog.tsx` | `src/renderer/components/team/review/ChangeReviewDialog.tsx` | Phase 1, 3, 5 | **Phase 1:** условный рендер ContinuousScrollView vs single-file mode, убирается file header из content area. **Phase 3:** continuousOptions передаётся в useDiffNavigation (10-й параметр). **Phase 5:** handleAcceptAll/RejectAll multi-file, per-file discardCounters, continuousScrollActiveFilePath state, isContinuousMode computed, EditorView Map через ref. |
|
||||
| `ReviewFileTree.tsx` | `src/renderer/components/team/review/ReviewFileTree.tsx` | Phase 1 | Highlight active file из scroll-spy (не только selected), новый prop `activeFilePath` для visual indicator (отличается от `selectedFilePath`). В continuous mode `activeFilePath` определяется scroll-spy, `selectedFilePath` не используется. |
|
||||
| `CodeMirrorDiffView.tsx` | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Phase 4 | Замена встроенного `collapseUnchanged` на кастомный portionCollapse extension. Новый prop `usePortionCollapse` (boolean). Добавление portionCollapse StateField в buildExtensions() через отдельный Compartment. |
|
||||
| `changeReviewSlice.ts` | `src/renderer/store/slices/changeReviewSlice.ts` | Phase 2 | Новый action `prefetchFileContents(teamName, memberName, filePaths)` -- batch-загрузка контента нескольких файлов. Вызывается из useLazyFileContent при пересечении IntersectionObserver. |
|
||||
| `useDiffNavigation.ts` | `src/renderer/hooks/useDiffNavigation.ts` | Phase 3 | Новый optional param `continuousOptions?: ContinuousNavigationOptions` (10-й параметр). Внутри keyboard handler: `getActiveEditorView()` проверяет focused editor первым, затем activeFilePath, затем первый editor. Cross-file chunk navigation при достижении последнего chunk в файле. Helpers: `isLastChunkInFile()`, `isFirstChunkInFile()`. |
|
||||
| `ReviewToolbar.tsx` | `src/renderer/components/team/review/ReviewToolbar.tsx` | Phase 5 | Новые props: `isContinuousMode`, `reviewedCount`, `totalHunks`. Tooltip "Accept all changes across all files" в continuous mode. Progress bar компонент. |
|
||||
| `KeyboardShortcutsHelp.tsx` | `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` | Phase 3 | Новые shortcuts: Alt+K (prev change), Alt+ArrowDown/Up (next/prev file), ? (toggle help). |
|
||||
| `useContinuousScrollNav.ts` | `src/renderer/hooks/useContinuousScrollNav.ts` | Phase 3 | Уточнение scrollToFile: принудительный setActiveFilePath после стабилизации scroll. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Зависимости между фазами
|
||||
|
||||
```
|
||||
Phase 4 (portionCollapse) ─────────────────────────────────────┐
|
||||
(изолированный CM extension, можно параллельно с 2/3) │
|
||||
│
|
||||
Phase 1 (Continuous Scroll + Scroll-Spy) ──┬──> Phase 2 ───────┼──> Phase 5
|
||||
(базовая инфраструктура) │ (Lazy Loading) │ (Polish)
|
||||
│ │
|
||||
├──> Phase 3 ────────┘
|
||||
│ (Navigation)
|
||||
│
|
||||
└──> Phase 5
|
||||
(EditorView Map + Toolbar)
|
||||
```
|
||||
|
||||
**Детали:**
|
||||
|
||||
| Зависимость | Причина |
|
||||
|-------------|---------|
|
||||
| Phase 1 -> Phase 2 | useLazyFileContent использует IntersectionObserver на placeholder, созданном в ContinuousScrollView |
|
||||
| Phase 1 -> Phase 3 | Keyboard navigation в continuous mode требует scroll infrastructure (scrollToFile) и scroll-spy (activeFilePath) |
|
||||
| Phase 1 -> Phase 5 | EditorView Map живёт в ContinuousScrollView. Accept All/Reject All итерируют по Map. |
|
||||
| Phase 4 (параллельно) | portionCollapse.ts -- изолированный CM StateField/Extension. Не зависит от ContinuousScrollView. Может разрабатываться и тестироваться отдельно на обычном CodeMirrorDiffView. |
|
||||
| Phase 5 -> после 1-4 | Финальная полировка, интеграция всех компонентов. Требует: ContinuousScrollView (Phase 1), lazy loading (Phase 2), navigation (Phase 3), portionCollapse (Phase 4). |
|
||||
|
||||
**Рекомендованный порядок:**
|
||||
|
||||
```
|
||||
Неделя 1: Phase 1 + Phase 4 (параллельно)
|
||||
Неделя 2: Phase 2 + Phase 3 (параллельно, после Phase 1)
|
||||
Неделя 3: Phase 5 (после всех)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Критические edge-cases
|
||||
|
||||
| # | Кейс | Решение | Фаза |
|
||||
|---|------|---------|------|
|
||||
| 1 | **Scroll-spy + programmatic scroll race:** scroll-spy определяет "не тот" файл во время programmatic scroll (scrollToFile) | `isProgrammaticScroll` ref flag. scrollToFile устанавливает flag=true. Scroll-spy игнорирует IntersectionObserver events пока flag=true. `waitForScrollEnd()` (через `scrollend` event или debounced timeout 150ms) сбрасывает flag и берёт финальный видимый файл. | Phase 1 |
|
||||
| 2 | **50 EditorViews в памяти:** потенциальная проблема с памятью и производительностью при большом количестве файлов | portionCollapse минимизирует DOM-контент каждого editor (свёрнутые regions = 0 DOM-нод). Lazy loading (Phase 2) гарантирует постепенную загрузку. Если профилирование покажет проблемы -- destroy EditorViews далеко за viewport (будущая оптимизация, не в Phase 5). | Phase 5 |
|
||||
| 3 | **Keyboard Cmd+Y/N -- какой editor:** несколько EditorView на экране, нужно определить целевой | Приоритет: (1) EditorView, содержащий `document.activeElement` (user clicked into it), (2) EditorView для `activeFilePath` из scroll-spy. Реализовано в `resolveActiveEditorView()`. | Phase 5 |
|
||||
| 4 | **Cross-file hunk navigation:** goToNextChunk в последнем chunk файла -> нужно перейти к следующему файлу | goToNextChunk не выходит за пределы одного EditorView. Для cross-file: определить, что cursor на последнем chunk (`isLastChunkInFile()`), -> scrollToFile(nextFile) + goToNextChunk(nextView). Реализуется в useDiffNavigation Phase 3 рефакторинге. | Phase 3 |
|
||||
| 5 | **portionCollapse + accept/reject:** после accept chunk-а, неизменённые regions меняются | portionCollapse rebuilds decorations через `EditorView.updateListener`. При изменении doc или original (updateOriginalDoc effect) -- декорации пересчитываются. | Phase 4 |
|
||||
| 6 | **Auto-viewed threshold 0.85:** sentinel при threshold 1.0 может не срабатывать из-за collapse | Threshold 0.85 для 1px sentinel элемента. portionCollapse может значительно уменьшить высоту файла, из-за чего sentinel может быть "видим" до полного просмотра. 0.85 дает margin. Sentinel размещается ПОСЛЕ CodeMirrorDiffView. | Phase 1, 5 |
|
||||
| 7 | **Lazy loading race: файл не загружен при scrollToFile** | scrollToFile прокручивает к placeholder. useLazyFileContent автоматически запустит загрузку через IntersectionObserver. Placeholder -> skeleton -> loaded diff. Пользователь видит transition. | Phase 2 |
|
||||
| 8 | **Sticky header z-index stacking:** несколько sticky headers при быстром скролле | Каждый header имеет `z-index: 10`. Только один виден как sticky (ближайший к top). Следующий header "выталкивает" предыдущий. CSS `position: sticky; top: 0` с корректным stacking context. | Phase 1 |
|
||||
| 9 | **Discard one file в continuous mode:** пересоздание одного EditorView не должно сломать остальные | Per-file `discardCounters: Record<string, number>`. Key FileSectionDiff: `${filePath}:${discardCounters[filePath]}`. Инкремент counter только для одного файла -> React пересоздает только этот компонент. | Phase 5 |
|
||||
| 10 | **Accept All + scroll position:** Accept All меняет высоту всех editors, scroll может "прыгнуть" | Браузер корректирует scroll для элементов выше viewport автоматически. Для элементов в viewport -- пользователь видит изменения, что ожидаемо. Не корректируем scroll искусственно. | Phase 5 |
|
||||
| 11 | **File с unavailable content в continuous mode** | FileSectionDiff проверяет `contentSource`. Если `unavailable` -- рендерит fallback ReviewDiffContent вместо CodeMirrorDiffView. EditorView не создается -> не попадает в Map. Accept All/Reject All для таких файлов -- только store update. | Phase 1 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Чеклист верификации
|
||||
|
||||
Полный чеклист для тестирования после реализации всех 5 фаз.
|
||||
|
||||
### Phase 1: Continuous Scroll + Scroll-Spy
|
||||
|
||||
- [ ] ContinuousScrollView рендерит все файлы последовательно
|
||||
- [ ] Sticky headers "прилипают" при скролле и корректно сменяют друг друга
|
||||
- [ ] Scroll-spy определяет текущий видимый файл
|
||||
- [ ] ReviewFileTree подсвечивает видимый файл (не только selected)
|
||||
- [ ] Клик по файлу в tree -> плавный scroll к этому файлу
|
||||
- [ ] Programmatic scroll не вызывает "мерцание" в file tree (isProgrammaticScroll flag)
|
||||
- [ ] Single-file mode (1 файл) -- работает как раньше, без ContinuousScrollView
|
||||
- [ ] Файлы с `unavailable` content -- показывают fallback
|
||||
- [ ] Пустой changeset (0 файлов) -- сообщение "No file changes detected"
|
||||
|
||||
### Phase 2: Lazy Loading
|
||||
|
||||
- [ ] При открытии dialog загружается контент только видимых файлов (1-3 штуки)
|
||||
- [ ] При скролле вниз -- файлы загружаются за 2 viewport-высоты до видимости
|
||||
- [ ] Placeholder с skeleton виден пока контент грузится
|
||||
- [ ] После загрузки -- placeholder заменяется CodeMirrorDiffView
|
||||
- [ ] Быстрый скролл через много файлов -- не спамит запросы (MAX_CONCURRENT=3 throttle)
|
||||
- [ ] Повторное посещение файла -- контент уже в кэше (store), нет повторного запроса
|
||||
|
||||
### Phase 3: Navigation
|
||||
|
||||
- [ ] Alt+J -- переход к следующему change в текущем editor
|
||||
- [ ] Alt+K -- переход к предыдущему change
|
||||
- [ ] Alt+ArrowDown -- переход к следующему файлу (smooth scroll)
|
||||
- [ ] Alt+ArrowUp -- переход к предыдущему файлу (smooth scroll)
|
||||
- [ ] Cmd+Y -- accept chunk + next chunk
|
||||
- [ ] Cmd+N -- reject chunk + next chunk
|
||||
- [ ] Cross-file navigation: после последнего chunk в файле -> переход к первому chunk следующего файла
|
||||
- [ ] Keyboard shortcuts работают и с focused editor, и без фокуса (fallback на activeFilePath)
|
||||
- [ ] ? -- toggle shortcuts help dialog
|
||||
|
||||
### Phase 4: Portion Collapse
|
||||
|
||||
- [ ] Неизменённые regions >= 10 строк свёрнуты по умолчанию (minSize=4 + margin=3 с обеих сторон = 10 строк минимум для создания collapse)
|
||||
- [ ] Widget "N unchanged lines" виден на месте свёрнутого региона
|
||||
- [ ] Клик "Expand 100" -- раскрывает 100 строк (portionSize=100)
|
||||
- [ ] Если строк меньше portionSize -- только кнопка "Expand All" (без "Expand N")
|
||||
- [ ] Клик "Expand All" -- раскрывает свёрнутый регион полностью
|
||||
- [ ] Accept chunk -> decorations пересчитываются (новые неизменённые areas корректно collapse)
|
||||
- [ ] Reject chunk -> decorations пересчитываются
|
||||
- [ ] Работает в single-file mode (без ContinuousScrollView)
|
||||
|
||||
### Phase 5: Polish
|
||||
|
||||
- [ ] "Accept All" -> все hunks во всех файлах accepted (store + CM)
|
||||
- [ ] "Reject All" -> все hunks во всех файлах rejected (store + CM)
|
||||
- [ ] Tooltip "Accept all changes across all files" (не "in current file")
|
||||
- [ ] Progress bar "12 of 45 reviewed" обновляется при accept/reject
|
||||
- [ ] Cmd+Y с focused editor -> accept в этом editor
|
||||
- [ ] Cmd+Y без фокуса -> accept в activeFilePath editor
|
||||
- [ ] Cmd+Enter -> save только activeFilePath
|
||||
- [ ] Discard файла -> только этот EditorView пересоздается
|
||||
- [ ] Auto-viewed помечает файлы по мере скролла (multiple files per scroll)
|
||||
- [ ] Auto-viewed toggle off -> скролл не помечает файлы
|
||||
- [ ] Закрытие dialog -> viewed state сохранён (persistent localStorage)
|
||||
- [ ] 20+ файлов -- нет видимых лагов при scroll/accept all
|
||||
|
||||
### Cross-cutting
|
||||
|
||||
- [ ] Escape закрывает dialog
|
||||
- [ ] Typecheck: `pnpm typecheck` проходит без ошибок
|
||||
- [ ] Lint: `pnpm lint:fix` без warnings
|
||||
- [ ] Тесты: `pnpm test` все проходят
|
||||
- [ ] Нет регрессий в single-file mode
|
||||
- [ ] macOS: traffic light padding корректен
|
||||
- [ ] Dark/light theme: все CSS variables работают
|
||||
|
||||
---
|
||||
|
||||
## 8. Ссылки на файлы фаз
|
||||
|
||||
- [Phase 1: Continuous Scroll + Scroll-Spy](./phase-1-continuous-scroll-and-scroll-spy.md)
|
||||
- [Phase 2: Lazy Loading](./phase-2-lazy-loading.md)
|
||||
- [Phase 3: Navigation](./phase-3-navigation.md)
|
||||
- [Phase 4: Portion Collapse](./phase-4-portion-collapse.md)
|
||||
- [Phase 5: Polish + EditorView Map + Toolbar](./phase-5-polish.md)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,790 @@
|
|||
# Фаза 2: Lazy Loading контента
|
||||
|
||||
## 1. Обзор
|
||||
|
||||
**Предпосылка:** В фазе 1 continuous scroll рендерит все файлы одновременно. Но контент файлов (`FileChangeWithContent`) загружается через IPC-вызов `fetchFileContent(teamName, memberName, filePath)` — это сетевой запрос к main process, который читает файл с диска, строит diff и возвращает `originalFullContent` + `modifiedFullContent`.
|
||||
|
||||
**Проблема:** При открытии review с 30+ файлами загрузка всех сразу:
|
||||
- Блокирует main process 30 последовательными IPC-вызовами
|
||||
- UI показывает 30 skeleton placeholders одновременно
|
||||
- Пользователь видит контент только после загрузки всех файлов
|
||||
- Для больших файлов (>10K строк) задержка ощутима
|
||||
|
||||
**Решение:** Lazy loading — контент загружается по мере приближения файла к viewport:
|
||||
- Первые 5 файлов предзагружаются при mount (без ожидания scroll)
|
||||
- Остальные файлы загружаются при пересечении rootMargin "200% 0px" (2 viewport-высоты до видимости)
|
||||
- Максимум 3 параллельных загрузки (throttle) — не перегружать main process
|
||||
- Приоритет: файлы ближе к viewport загружаются раньше
|
||||
|
||||
**Результат:** Пользователь видит первые файлы через ~200ms, остальные подгружаются бесшовно при скролле.
|
||||
|
||||
---
|
||||
|
||||
## 2. Новые файлы
|
||||
|
||||
### 2.1. `useLazyFileContent.ts`
|
||||
|
||||
**Путь:** `src/renderer/hooks/useLazyFileContent.ts`
|
||||
|
||||
**Назначение:** IntersectionObserver-based lazy loading контента файлов через `fetchFileContent` из changeReviewSlice.
|
||||
|
||||
#### Interface
|
||||
|
||||
```typescript
|
||||
import type { RefObject } from 'react';
|
||||
import type { FileChangeWithContent } from '@shared/types';
|
||||
|
||||
interface UseLazyFileContentOptions {
|
||||
/** Имя команды (для fetchFileContent) */
|
||||
teamName: string;
|
||||
|
||||
/** Имя участника (для fetchFileContent) */
|
||||
memberName: string | undefined;
|
||||
|
||||
/** Список всех filePath в порядке рендеринга */
|
||||
filePaths: string[];
|
||||
|
||||
/** Scroll container ref (ContinuousScrollView outer div) */
|
||||
scrollContainerRef: RefObject<HTMLElement>;
|
||||
|
||||
/**
|
||||
* Загруженный контент из store (для проверки: уже загружен?).
|
||||
* Тип: Record<string, FileChangeWithContent> из changeReviewSlice.
|
||||
*/
|
||||
fileContents: Record<string, FileChangeWithContent>;
|
||||
|
||||
/** Флаги загрузки из store (для проверки: уже грузится?) */
|
||||
fileContentsLoading: Record<string, boolean>;
|
||||
|
||||
/**
|
||||
* Функция загрузки контента из store.
|
||||
* Сигнатура точно как в changeReviewSlice.fetchFileContent:
|
||||
* (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>
|
||||
*
|
||||
* Внутри store уже есть guard от дубликатов (строка 264):
|
||||
* if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
|
||||
* Поэтому двойной вызов безопасен.
|
||||
*/
|
||||
fetchFileContent: (
|
||||
teamName: string,
|
||||
memberName: string | undefined,
|
||||
filePath: string
|
||||
) => Promise<void>;
|
||||
|
||||
/** Lazy loading включён (false = загрузить всё сразу, для fallback) */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseLazyFileContentReturn {
|
||||
/**
|
||||
* Регистрация file section для lazy-load наблюдения.
|
||||
* Возвращает ref callback — передать в div section.
|
||||
* Пример: <div ref={registerLazyRef(file.filePath)}>
|
||||
*/
|
||||
registerLazyRef: (filePath: string) => (element: HTMLElement | null) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### Полная реализация (описание)
|
||||
|
||||
```typescript
|
||||
export function useLazyFileContent(
|
||||
options: UseLazyFileContentOptions
|
||||
): UseLazyFileContentReturn {
|
||||
const {
|
||||
teamName,
|
||||
memberName,
|
||||
filePaths,
|
||||
scrollContainerRef,
|
||||
fileContents,
|
||||
fileContentsLoading,
|
||||
fetchFileContent,
|
||||
enabled,
|
||||
} = options;
|
||||
|
||||
// === Throttle State ===
|
||||
|
||||
// Set: filePath текущих in-flight загрузок
|
||||
const activeLoads = useRef(new Set<string>());
|
||||
|
||||
// Queue: filePath ожидающих загрузки (FIFO, но с приоритетом)
|
||||
const pendingQueue = useRef<string[]>([]);
|
||||
|
||||
// Max параллельных загрузок
|
||||
const MAX_CONCURRENT = 3;
|
||||
|
||||
// Observer ref
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
// Element refs
|
||||
const elementRefs = useRef(new Map<string, HTMLElement>());
|
||||
|
||||
// Stable refs для текущих значений (избежание stale closures)
|
||||
const fileContentsRef = useRef(fileContents);
|
||||
const fileContentsLoadingRef = useRef(fileContentsLoading);
|
||||
|
||||
useEffect(() => {
|
||||
fileContentsRef.current = fileContents;
|
||||
fileContentsLoadingRef.current = fileContentsLoading;
|
||||
}, [fileContents, fileContentsLoading]);
|
||||
|
||||
// === Throttled Loader ===
|
||||
|
||||
/**
|
||||
* Проверяет, нужно ли загружать filePath:
|
||||
* - Не загружен (нет в fileContents)
|
||||
* - Не грузится (нет в fileContentsLoading или false)
|
||||
* - Не в activeLoads (не in-flight)
|
||||
*
|
||||
* ВАЖНО: проверяем fileContentsRef (ref), а не fileContents (prop) —
|
||||
* чтобы callback IntersectionObserver видел актуальное состояние.
|
||||
*/
|
||||
const shouldLoad = useCallback((filePath: string): boolean => {
|
||||
if (fileContentsRef.current[filePath]) return false;
|
||||
if (fileContentsLoadingRef.current[filePath]) return false;
|
||||
if (activeLoads.current.has(filePath)) return false;
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Запустить загрузку одного файла.
|
||||
* Возвращает Promise (для chaining).
|
||||
*/
|
||||
const loadFile = useCallback(
|
||||
async (filePath: string): Promise<void> => {
|
||||
if (!shouldLoad(filePath)) return;
|
||||
|
||||
activeLoads.current.add(filePath);
|
||||
try {
|
||||
await fetchFileContent(teamName, memberName, filePath);
|
||||
} finally {
|
||||
activeLoads.current.delete(filePath);
|
||||
// После завершения — попробовать следующий из очереди
|
||||
processQueue();
|
||||
}
|
||||
},
|
||||
[teamName, memberName, fetchFileContent, shouldLoad]
|
||||
);
|
||||
|
||||
/**
|
||||
* Обработать очередь: запустить загрузки пока slots < MAX_CONCURRENT.
|
||||
*/
|
||||
const processQueue = useCallback(() => {
|
||||
while (
|
||||
activeLoads.current.size < MAX_CONCURRENT &&
|
||||
pendingQueue.current.length > 0
|
||||
) {
|
||||
const nextPath = pendingQueue.current.shift()!;
|
||||
if (shouldLoad(nextPath)) {
|
||||
void loadFile(nextPath);
|
||||
}
|
||||
// Если nextPath уже не нужен (загружен за время ожидания) — пропускаем, берём следующий
|
||||
}
|
||||
}, [shouldLoad, loadFile]);
|
||||
|
||||
/**
|
||||
* Добавить filePath в очередь загрузки.
|
||||
* Если есть свободные слоты — загрузить сразу.
|
||||
* Если нет — добавить в pending queue.
|
||||
*/
|
||||
const enqueueLoad = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!shouldLoad(filePath)) return;
|
||||
|
||||
if (activeLoads.current.size < MAX_CONCURRENT) {
|
||||
// Есть свободный слот — загружаем сразу
|
||||
void loadFile(filePath);
|
||||
} else {
|
||||
// Очередь заполнена — добавить в pending (если ещё нет)
|
||||
if (!pendingQueue.current.includes(filePath)) {
|
||||
pendingQueue.current.push(filePath);
|
||||
}
|
||||
}
|
||||
},
|
||||
[shouldLoad, loadFile]
|
||||
);
|
||||
|
||||
// === Preload первых N файлов при mount ===
|
||||
|
||||
const PRELOAD_COUNT = 5;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Загрузить первые 5 файлов сразу
|
||||
const toPreload = filePaths.slice(0, PRELOAD_COUNT);
|
||||
for (const fp of toPreload) {
|
||||
enqueueLoad(fp);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled]); // Намеренно: только при mount (enabled = true)
|
||||
|
||||
// === IntersectionObserver ===
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
|
||||
const filePath = entry.target.getAttribute('data-lazy-file');
|
||||
if (!filePath) continue;
|
||||
|
||||
enqueueLoad(filePath);
|
||||
|
||||
// После загрузки — перестать наблюдать (загружается один раз)
|
||||
// Но мы не можем unobserve сразу (загрузка async) — unobserve когда контент загружен
|
||||
// Проще: observer продолжает наблюдать, shouldLoad() вернёт false для загруженных
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainerRef.current,
|
||||
// 200% от viewport сверху и снизу — предзагрузка за 2 экрана
|
||||
rootMargin: '200% 0px 200% 0px',
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// Зарегистрировать все уже mounted элементы
|
||||
for (const [, element] of elementRefs.current) {
|
||||
observerRef.current.observe(element);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
};
|
||||
}, [enabled, scrollContainerRef, enqueueLoad]);
|
||||
|
||||
// === Register ref callback ===
|
||||
|
||||
const registerLazyRef = useCallback((filePath: string) => {
|
||||
return (element: HTMLElement | null) => {
|
||||
const observer = observerRef.current;
|
||||
|
||||
// Cleanup previous
|
||||
const prev = elementRefs.current.get(filePath);
|
||||
if (prev && observer) {
|
||||
observer.unobserve(prev);
|
||||
}
|
||||
elementRefs.current.delete(filePath);
|
||||
|
||||
// Register new
|
||||
if (element) {
|
||||
element.setAttribute('data-lazy-file', filePath);
|
||||
elementRefs.current.set(filePath, element);
|
||||
if (observer) {
|
||||
observer.observe(element);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { registerLazyRef };
|
||||
}
|
||||
```
|
||||
|
||||
#### Ключевые аспекты
|
||||
|
||||
##### rootMargin "200% 0px 200% 0px"
|
||||
|
||||
IntersectionObserver `rootMargin` расширяет область наблюдения за пределы видимого viewport. `200%` означает 2x viewport-высоты сверху и снизу.
|
||||
|
||||
**Пример:** Viewport = 800px. rootMargin = 200% -> +1600px сверху и снизу. Файл начнёт загружаться когда его section находится в 1600px от видимой области.
|
||||
|
||||
**Почему 200%:** Smooth scroll на Chromium покрывает ~300-400px/сек. При viewport 800px пользователь доскроллит до следующей "зоны" за 2-4 секунды. Загрузка файла через IPC занимает ~50-200ms. 200% даёт достаточный запас для предзагрузки.
|
||||
|
||||
##### MAX_CONCURRENT = 3
|
||||
|
||||
**Почему 3, а не больше:**
|
||||
- Electron main process обрабатывает IPC последовательно (single thread)
|
||||
- Каждый `fetchFileContent` читает файл, парсит diff, возвращает контент
|
||||
- 3 параллельных запроса = main process занят ~100% на файловых операциях
|
||||
- Больше 3 = запросы встают в очередь IPC, но main process не ускоряется
|
||||
- Бонус: оставляет "дышать" main process для других IPC (file watcher, config)
|
||||
|
||||
##### Preload первых 5 файлов
|
||||
|
||||
При mount (открытие диалога) загружаем первые 5 файлов немедленно (без ожидания IntersectionObserver).
|
||||
|
||||
**Почему 5:**
|
||||
- Viewport обычно вмещает 2-3 file sections
|
||||
- 5 = 2-3 видимых + 2 "за кадром" для плавного scroll
|
||||
- Preload занимает ~200-500ms (3 параллельно + 2 в очереди)
|
||||
|
||||
**Timing:** Preload запускается одновременно с рендерингом DOM. К моменту первого paint IntersectionObserver ещё не успел сработать, но preload уже отправил запросы.
|
||||
|
||||
##### Приоритет в очереди
|
||||
|
||||
В текущей реализации очередь FIFO (first-in, first-out). Файлы добавляются в порядке пересечения rootMargin — ближайшие к viewport первыми.
|
||||
|
||||
**Возможное улучшение (если потребуется):** Реордеринг очереди при scroll event. Но FIFO достаточно для типичного use case (скролл сверху вниз).
|
||||
|
||||
##### Repeated observations
|
||||
|
||||
IntersectionObserver продолжает наблюдать все элементы, даже загруженные. Это ОК:
|
||||
1. Callback вызовется для уже загруженного файла
|
||||
2. `shouldLoad()` проверяет `fileContentsRef.current[filePath]` -> файл есть -> `return false`
|
||||
3. `enqueueLoad` ничего не делает
|
||||
|
||||
Альтернатива `observer.unobserve()` после загрузки добавляет сложности (нужен callback из store, race conditions). Текущий подход проще и не имеет performance penalty (observer callback -- O(1) проверка).
|
||||
|
||||
---
|
||||
|
||||
## 3. Модификации существующих файлов
|
||||
|
||||
### 3.1. `changeReviewSlice.ts` -- НЕ требует изменений
|
||||
|
||||
**Решение: `prefetchFileContents` НЕ НУЖЕН.**
|
||||
|
||||
Изначально предполагался convenience-метод `prefetchFileContents` для batch-вызова. Однако при ревью обнаружено:
|
||||
|
||||
1. `useLazyFileContent` уже реализует preload первых 5 файлов через `enqueueLoad` в useEffect при mount -- это полностью покрывает потребность в batch preload.
|
||||
2. `fetchFileContent` уже имеет внутренний guard от дубликатов (строка 262-264 в `changeReviewSlice.ts`):
|
||||
```typescript
|
||||
const state = get();
|
||||
// Skip if already loaded or loading
|
||||
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
|
||||
```
|
||||
3. `useLazyFileContent.enqueueLoad` добавляет поверх store guard ещё `activeLoads` ref-трекинг для throttle -- т.е. тройная защита от дубликатов.
|
||||
|
||||
Добавление `prefetchFileContents` в store создаст дублирование с `useLazyFileContent` preload и не даст throttle (все запросы уйдут параллельно). **Оставляем store без изменений.**
|
||||
|
||||
---
|
||||
|
||||
### 3.2. `ContinuousScrollView.tsx`
|
||||
|
||||
#### Интеграция `useLazyFileContent`
|
||||
|
||||
**Новые props (добавляются к существующим props фазы 1):**
|
||||
|
||||
```typescript
|
||||
interface ContinuousScrollViewProps {
|
||||
// ... все существующие props из фазы 1 (см. phase-1 документ) ...
|
||||
|
||||
// === НОВЫЕ для фазы 2 ===
|
||||
/** Имя команды */
|
||||
teamName: string;
|
||||
|
||||
/** Имя участника */
|
||||
memberName: string | undefined;
|
||||
|
||||
/**
|
||||
* Функция загрузки контента из store.
|
||||
* Сигнатура: (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>
|
||||
* Из changeReviewSlice.fetchFileContent
|
||||
*/
|
||||
fetchFileContent: (
|
||||
teamName: string,
|
||||
memberName: string | undefined,
|
||||
filePath: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Интеграция в компоненте:**
|
||||
|
||||
```typescript
|
||||
export const ContinuousScrollView = (props: ContinuousScrollViewProps) => {
|
||||
const {
|
||||
files,
|
||||
fileContents,
|
||||
fileContentsLoading,
|
||||
teamName,
|
||||
memberName,
|
||||
fetchFileContent,
|
||||
scrollContainerRef,
|
||||
isProgrammaticScroll,
|
||||
// ... rest из Phase 1
|
||||
} = props;
|
||||
|
||||
const filePaths = useMemo(() => files.map((f) => f.filePath), [files]);
|
||||
|
||||
// Scroll-spy (фаза 1)
|
||||
const { registerFileSectionRef } = useVisibleFileSection({
|
||||
onVisibleFileChange: props.onVisibleFileChange,
|
||||
scrollContainerRef,
|
||||
isProgrammaticScroll,
|
||||
});
|
||||
|
||||
// Lazy loading (фаза 2)
|
||||
const { registerLazyRef } = useLazyFileContent({
|
||||
teamName,
|
||||
memberName,
|
||||
filePaths,
|
||||
scrollContainerRef,
|
||||
fileContents,
|
||||
fileContentsLoading,
|
||||
fetchFileContent,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Комбинированный ref callback: регистрация в обоих observers
|
||||
const combinedRef = useCallback(
|
||||
(filePath: string) => {
|
||||
const sectionRef = registerFileSectionRef(filePath);
|
||||
const lazyRef = registerLazyRef(filePath);
|
||||
|
||||
return (element: HTMLElement | null) => {
|
||||
sectionRef(element);
|
||||
lazyRef(element);
|
||||
};
|
||||
},
|
||||
[registerFileSectionRef, registerLazyRef]
|
||||
);
|
||||
|
||||
// EditorView registration callback (Phase 1, без изменений)
|
||||
const handleEditorViewReady = useCallback(
|
||||
(filePath: string, view: EditorView | null) => {
|
||||
if (view) {
|
||||
props.editorViewMapRef.current.set(filePath, view);
|
||||
} else {
|
||||
props.editorViewMapRef.current.delete(filePath);
|
||||
}
|
||||
},
|
||||
[props.editorViewMapRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
|
||||
{files.map((file) => {
|
||||
const filePath = file.filePath;
|
||||
const content = fileContents[filePath] ?? null;
|
||||
const isLoading = fileContentsLoading[filePath] ?? false;
|
||||
const hasContent = content !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filePath}
|
||||
ref={combinedRef(filePath)} // <-- Комбинированный ref (Phase 1 scroll-spy + Phase 2 lazy)
|
||||
className="border-b border-border"
|
||||
>
|
||||
<FileSectionHeader
|
||||
file={file}
|
||||
fileContent={content}
|
||||
fileDecision={props.fileDecisions[filePath]}
|
||||
hasEdits={filePath in props.editedContents}
|
||||
applying={props.applying}
|
||||
onDiscard={props.onDiscard}
|
||||
onSave={props.onSave}
|
||||
/>
|
||||
|
||||
{/* Контент ещё не загружен — placeholder */}
|
||||
{!hasContent && isLoading && (
|
||||
<FileSectionPlaceholder fileName={file.relativePath} />
|
||||
)}
|
||||
|
||||
{/* Контент ещё не начал грузиться — тоже placeholder */}
|
||||
{!hasContent && !isLoading && (
|
||||
<FileSectionPlaceholder fileName={file.relativePath} />
|
||||
)}
|
||||
|
||||
{/* Контент загружен — diff */}
|
||||
{hasContent && (
|
||||
<FileSectionDiff
|
||||
file={file}
|
||||
fileContent={content}
|
||||
isLoading={false}
|
||||
collapseUnchanged={props.collapseUnchanged}
|
||||
onHunkAccepted={props.onHunkAccepted}
|
||||
onHunkRejected={props.onHunkRejected}
|
||||
onFullyViewed={props.onFullyViewed}
|
||||
onContentChanged={props.onContentChanged}
|
||||
onEditorViewReady={handleEditorViewReady}
|
||||
discardCounter={props.discardCounter}
|
||||
autoViewed={props.autoViewed}
|
||||
isViewed={props.viewedSet.has(filePath)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{files.length === 0 && (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
No file changes detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Отличие от Phase 1 ContinuousScrollView:** В Phase 1 `ref={registerFileSectionRef(filePath)}` использовался напрямую. В Phase 2 заменён на `ref={combinedRef(filePath)}`, который вызывает оба ref callback (scroll-spy + lazy). Phase 1 рендерил `FileSectionDiff` / `FileSectionPlaceholder` по условию `isLoading` (ternary). Phase 2 добавляет промежуточное состояние "не начал грузиться" (`!hasContent && !isLoading`).
|
||||
|
||||
#### Два placeholder состояния
|
||||
|
||||
| Состояние | `hasContent` | `isLoading` | Что показывать |
|
||||
|-----------|-------------|-------------|----------------|
|
||||
| Не начал грузиться | false | false | `FileSectionPlaceholder` |
|
||||
| Грузится | false | true | `FileSectionPlaceholder` |
|
||||
| Загружен | true | - | `FileSectionDiff` |
|
||||
| Ошибка загрузки | false | false | `FileSectionPlaceholder` (потом retry) |
|
||||
|
||||
**Замечание:** "Не начал грузиться" -- файл ещё не попал в rootMargin IntersectionObserver. Placeholder показывается, но без индикатора загрузки. Визуально идентичен "грузится" -- это ОК, пользователь не различает.
|
||||
|
||||
**Ошибка загрузки:** `fetchFileContent` в store ставит `fileContentsLoading[fp] = false` (строка 279) и НЕ записывает в `fileContents` (строка 278 -- catch блок). Результат: `hasContent = false, isLoading = false` -- снова placeholder. IntersectionObserver при следующем пересечении вызовет `enqueueLoad` -- retry произойдёт автоматически (при re-scroll).
|
||||
|
||||
**Важно:** `shouldLoad()` в `useLazyFileContent` проверяет `fileContentsRef.current[filePath]` -- после ошибки этого ключа нет, поэтому повторный вызов пройдёт. Также `fileContentsLoadingRef.current[filePath]` будет `false` (store сбросил loading). Таким образом retry корректно сработает.
|
||||
|
||||
Если нужен явный retry без scroll: добавить кнопку "Retry" в placeholder. Но для фазы 2 автоматический retry через scroll достаточен.
|
||||
|
||||
#### `combinedRef` -- объединение двух ref callbacks
|
||||
|
||||
```typescript
|
||||
const combinedRef = useCallback(
|
||||
(filePath: string) => {
|
||||
const sectionRef = registerFileSectionRef(filePath);
|
||||
const lazyRef = registerLazyRef(filePath);
|
||||
|
||||
return (element: HTMLElement | null) => {
|
||||
sectionRef(element);
|
||||
lazyRef(element);
|
||||
};
|
||||
},
|
||||
[registerFileSectionRef, registerLazyRef]
|
||||
);
|
||||
```
|
||||
|
||||
**Зачем:** Оба хука (`useVisibleFileSection`, `useLazyFileContent`) используют IntersectionObserver на одном и том же элементе (file section div). Вместо двух отдельных ref -- один объединённый.
|
||||
|
||||
**data attributes:** Каждый callback ставит свой атрибут:
|
||||
- `registerFileSectionRef` -> `data-file-path`
|
||||
- `registerLazyRef` -> `data-lazy-file`
|
||||
|
||||
Оба атрибута на одном элементе -- ОК, они используются разными observers.
|
||||
|
||||
---
|
||||
|
||||
### 3.3. `ChangeReviewDialog.tsx`
|
||||
|
||||
#### Убрать lazy-load useEffect
|
||||
|
||||
**Было** (строки 224-237 текущего файла):
|
||||
```typescript
|
||||
// Lazy-load file content when file selected
|
||||
useEffect(() => {
|
||||
if (!open || !selectedReviewFilePath) return;
|
||||
if (fileContents[selectedReviewFilePath] || fileContentsLoading[selectedReviewFilePath]) return;
|
||||
void fetchFileContent(teamName, memberName, selectedReviewFilePath);
|
||||
}, [
|
||||
open,
|
||||
selectedReviewFilePath,
|
||||
teamName,
|
||||
memberName,
|
||||
fileContents,
|
||||
fileContentsLoading,
|
||||
fetchFileContent,
|
||||
]);
|
||||
```
|
||||
|
||||
**Стало:** Удалить этот useEffect целиком. Загрузка контента теперь полностью делегирована `useLazyFileContent` внутри `ContinuousScrollView`:
|
||||
- Preload первых 5 файлов при mount
|
||||
- Остальные подгружаются по IntersectionObserver
|
||||
|
||||
#### Передать новые props в ContinuousScrollView
|
||||
|
||||
```tsx
|
||||
<ContinuousScrollView
|
||||
// ... props из фазы 1 ...
|
||||
teamName={teamName}
|
||||
memberName={memberName}
|
||||
fetchFileContent={fetchFileContent}
|
||||
/>
|
||||
```
|
||||
|
||||
**Примечание:** `teamName` берётся из props `ChangeReviewDialogProps`, `memberName` оттуда же (optional prop). `fetchFileContent` берётся из `useStore()` (строка 77 текущего файла).
|
||||
|
||||
---
|
||||
|
||||
## 4. Throttle реализация: детали
|
||||
|
||||
### Структура данных
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ activeLoads │ Set<string> -- max 3 элемента
|
||||
│ (in-flight) │
|
||||
├─────────────────┤
|
||||
│ pendingQueue │ string[] -- FIFO очередь
|
||||
│ (waiting) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Жизненный цикл загрузки
|
||||
|
||||
```
|
||||
1. IntersectionObserver fires -> enqueueLoad(filePath)
|
||||
2. shouldLoad() checks:
|
||||
- fileContentsRef.current[fp]? -> skip (already loaded)
|
||||
- fileContentsLoadingRef.current[fp]? -> skip (store knows about it)
|
||||
- activeLoads.has(fp)? -> skip (our local tracking)
|
||||
3. activeLoads.size < MAX_CONCURRENT?
|
||||
-> YES: loadFile(fp) immediately
|
||||
-> NO: pendingQueue.push(fp)
|
||||
4. loadFile(fp):
|
||||
- activeLoads.add(fp)
|
||||
- await fetchFileContent(teamName, memberName, fp)
|
||||
- activeLoads.delete(fp)
|
||||
- processQueue() <-- проверить, есть ли ожидающие
|
||||
5. processQueue():
|
||||
- while (activeLoads.size < MAX_CONCURRENT && pendingQueue.length > 0)
|
||||
- shift from queue, check shouldLoad, loadFile
|
||||
```
|
||||
|
||||
### Диаграмма состояний
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
IO trigger ──> │ enqueue │
|
||||
└────┬─────┘
|
||||
│
|
||||
┌────────v────────┐
|
||||
│ slots available? │
|
||||
└──┬─────────┬────┘
|
||||
│ YES │ NO
|
||||
┌──────v──┐ ┌──v───────┐
|
||||
│ loadFile │ │ add to │
|
||||
│ (async) │ │ pending │
|
||||
└──────┬───┘ │ queue │
|
||||
│ └──────────┘
|
||||
┌──────v───┐ ^
|
||||
│ complete │ │
|
||||
└──────┬───┘ │
|
||||
│ │
|
||||
┌──────v────────┐ │
|
||||
│ processQueue ├────┘
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### Взаимодействие throttle с store guard
|
||||
|
||||
`fetchFileContent` в store (строки 261-282) имеет собственный guard:
|
||||
```typescript
|
||||
const state = get();
|
||||
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
|
||||
```
|
||||
|
||||
`useLazyFileContent` добавляет `activeLoads` ref поверх. Зачем два уровня защиты:
|
||||
|
||||
1. **Store guard** предотвращает повторный IPC-вызов для загружаемого/загруженного файла -- но работает через `get()` (синхронный snapshot). Между двумя вызовами `fetchFileContent` в одном event loop tick `fileContentsLoading` ещё не обновлён (Zustand batch).
|
||||
2. **`activeLoads` ref** покрывает этот micro-timing gap -- `activeLoads.add(fp)` происходит синхронно ДО await, а `shouldLoad()` проверяет ref мгновенно.
|
||||
|
||||
Таким образом:
|
||||
- Store guard: macro-level (между renders)
|
||||
- activeLoads ref: micro-level (между тиками в одном frame)
|
||||
- Оба нужны для надёжности
|
||||
|
||||
### Приоритет загрузки
|
||||
|
||||
**Текущий подход:** FIFO. IntersectionObserver вызывает callbacks в порядке пересечения rootMargin. Для типичного скролла сверху вниз это означает: верхние файлы раньше нижних.
|
||||
|
||||
**Потенциальное улучшение (не для фазы 2):**
|
||||
|
||||
Если пользователь быстро скроллит вниз (skip middle files), можно реализовать priority queue:
|
||||
|
||||
```typescript
|
||||
// Вместо string[] использовать priority queue:
|
||||
interface PendingItem {
|
||||
filePath: string;
|
||||
priority: number; // расстояние от viewport center
|
||||
}
|
||||
|
||||
// При каждом scroll event -- пересчитать priority для pending items
|
||||
// Ближайшие к viewport -- выше приоритет
|
||||
```
|
||||
|
||||
Но это оверинжиниринг для фазы 2. FIFO достаточно:
|
||||
- IntersectionObserver с rootMargin 200% ловит файлы рано
|
||||
- 3 параллельных загрузки покрывают типичную скорость скролла
|
||||
- Даже при быстром скролле -- placeholder на 100-200ms, потом контент
|
||||
|
||||
### Refs для stale closure prevention
|
||||
|
||||
```typescript
|
||||
const fileContentsRef = useRef(fileContents);
|
||||
const fileContentsLoadingRef = useRef(fileContentsLoading);
|
||||
|
||||
useEffect(() => {
|
||||
fileContentsRef.current = fileContents;
|
||||
fileContentsLoadingRef.current = fileContentsLoading;
|
||||
}, [fileContents, fileContentsLoading]);
|
||||
```
|
||||
|
||||
**Зачем:** `shouldLoad()` замыкает `fileContentsRef` и `fileContentsLoadingRef`. Без ref-трюка callback IntersectionObserver "видит" stale `fileContents` из момента создания observer.
|
||||
|
||||
**Альтернатива:** Пересоздавать IntersectionObserver при каждом изменении `fileContents`. Но это = disconnect + observe all elements заново = bad performance.
|
||||
|
||||
### Edge case: диалог закрыт во время загрузки
|
||||
|
||||
`fetchFileContent` -- async. Если пользователь закроет диалог пока идёт загрузка:
|
||||
1. `ContinuousScrollView` unmounts -> `useLazyFileContent` cleanup
|
||||
2. IntersectionObserver disconnect
|
||||
3. Но `fetchFileContent` всё ещё in-flight в store
|
||||
4. Store обновит `fileContents` / `fileContentsLoading` -- ОК, store не зависит от компонента
|
||||
5. `clearChangeReview()` вызывается в useEffect cleanup `ChangeReviewDialog` (строка 189) -- сбросит all state
|
||||
|
||||
**Вывод:** Нет утечек и race conditions. Store корректно очищается.
|
||||
|
||||
### Edge case: файл уже загружен при re-open
|
||||
|
||||
При повторном открытии того же review:
|
||||
1. `clearChangeReview()` сбрасывает `fileContents = {}` (строка 160)
|
||||
2. `fetchAgentChanges()` / `fetchTaskChanges()` загружает свежий changeSet
|
||||
3. `useLazyFileContent` preload + observer начинают с нуля
|
||||
4. Все файлы загружаются заново (свежие данные)
|
||||
|
||||
### Edge case: circular dependency loadFile <-> processQueue
|
||||
|
||||
`loadFile` вызывает `processQueue` в finally. `processQueue` вызывает `loadFile`. Потенциальный бесконечный цикл?
|
||||
|
||||
Нет -- `loadFile` начинается с `if (!shouldLoad(filePath)) return;`, а `activeLoads.add(fp)` происходит синхронно. `processQueue` берёт из очереди (shift), проверяет `shouldLoad`, и вызывает `loadFile` через `void` (fire-and-forget). Каждый `loadFile` -- это новый async task, не рекурсия в call stack. Queue конечна (max = количество файлов). Цикла нет.
|
||||
|
||||
---
|
||||
|
||||
## 5. Консистентность с Phase 3
|
||||
|
||||
Phase 3 (Navigation) зависит от Phase 2 в следующих аспектах:
|
||||
|
||||
1. **EditorView Map** -- Phase 2 использует `editorViewMapRef` из Phase 1. Phase 3 использует тот же Map через `ContinuousNavigationOptions.editorViewRefs`. Важно: Phase 3 `editorViewRefs` это `Map<string, EditorView>` (value из `.current`), а Phase 2 работает с `MutableRefObject<Map>`. Нет конфликта -- Phase 3 читает из `.current` напрямую.
|
||||
|
||||
2. **Lazy loading + cross-file navigation** -- когда Phase 3 `goToNextFile()` делает `scrollToFile(nextFilePath)`, файл может быть ещё не загружен. IntersectionObserver с rootMargin 200% должен сработать до того как scroll доедет до файла. Если файл далеко -- placeholder покажется на ~100-200ms, потом контент подгрузится. Это приемлемый UX.
|
||||
|
||||
3. **activeFilePath** -- Phase 2 НЕ управляет `activeFilePath`. Scroll-spy из Phase 1 (`useVisibleFileSection`) определяет activeFilePath. Phase 2 только загружает контент. Phase 3 использует activeFilePath для определения "текущего" файла в навигации.
|
||||
|
||||
---
|
||||
|
||||
## 6. Проверка
|
||||
|
||||
### Функциональная проверка
|
||||
|
||||
- [ ] Открыть review с 10+ файлами
|
||||
- [ ] Первые 5 файлов показывают контент в первые ~500ms
|
||||
- [ ] Файлы 6-10 показывают placeholder, потом контент при подскролле
|
||||
- [ ] Scroll вниз -- файлы подгружаются бесшовно (placeholder -> diff)
|
||||
- [ ] Scroll быстро вниз -- плейсхолдеры видны на ~200ms, потом контент
|
||||
- [ ] Scroll обратно вверх -- уже загруженные файлы показывают diff мгновенно
|
||||
- [ ] Кликнуть на файл 15 в tree -> smooth scroll + контент загружается
|
||||
|
||||
### Throttle проверка
|
||||
|
||||
- [ ] Открыть DevTools Network tab (или console log)
|
||||
- [ ] Убедиться: максимум 3 одновременных IPC-вызова `getFileContent`
|
||||
- [ ] Остальные ждут в очереди и выполняются последовательно по 3
|
||||
|
||||
### Edge cases
|
||||
|
||||
- [ ] 0 файлов -- нет ошибок в console
|
||||
- [ ] 1 файл -- загружается мгновенно (preload)
|
||||
- [ ] Файл с ошибкой загрузки (main process throw) -- placeholder остаётся, scroll retry работает
|
||||
- [ ] Закрыть диалог во время загрузки -- нет ошибок, store очищен
|
||||
- [ ] Переоткрыть диалог -- все файлы загружаются заново
|
||||
|
||||
### Performance
|
||||
|
||||
- [ ] 30 файлов -- UI не зависает при открытии
|
||||
- [ ] Main process responsive (file watcher работает) во время загрузки 30 файлов
|
||||
- [ ] Memory: placeholder -> diff transition не утекает (EditorView create/destroy)
|
||||
- [ ] Scroll FPS > 30 при 20+ загруженных CodeMirror editors
|
||||
|
|
@ -0,0 +1,996 @@
|
|||
# Phase 3: Click-to-Scroll + Навигация
|
||||
|
||||
## Обзор
|
||||
|
||||
Фаза 3 адаптирует навигацию для continuous scroll mode. В текущей реализации (file-at-a-time) каждый файл показывается отдельно: `goToNextFile()` вызывает `onSelectFile()`, который уничтожает текущий EditorView и создаёт новый. В continuous mode все файлы видны одновременно в одном scroll container, поэтому навигация переключается на программный scroll.
|
||||
|
||||
**Ключевые изменения:**
|
||||
- Клик по файлу в sidebar = smooth scroll к секции файла (вместо уничтожения/создания editor)
|
||||
- Keyboard shortcuts (Alt+ArrowDown/Up) = scroll к следующему/предыдущему файлу
|
||||
- Cross-file hunk navigation: при достижении последнего hunk файла -- автоматический scroll к следующему файлу
|
||||
- `useDiffNavigation` работает с `Map<string, EditorView>` вместо одного `editorViewRef`
|
||||
- Публичный интерфейс `DiffNavigationState` НЕ меняется -- изменяется только внутренняя реализация
|
||||
|
||||
**Зависимости:** Phase 1 (ContinuousScrollView, useVisibleFileSection, useContinuousScrollNav) и Phase 2 (lazy loading, EditorView Map из Phase 1).
|
||||
|
||||
---
|
||||
|
||||
## Модификации
|
||||
|
||||
### 1. useDiffNavigation.ts -- полная переработка для continuous mode
|
||||
|
||||
**Файл:** `src/renderer/hooks/useDiffNavigation.ts`
|
||||
|
||||
#### Текущая сигнатура (без изменений)
|
||||
|
||||
```typescript
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
|
||||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
// --- Return interface НЕ МЕНЯЕТСЯ ---
|
||||
interface DiffNavigationState {
|
||||
currentHunkIndex: number;
|
||||
totalHunks: number;
|
||||
goToNextHunk: () => void;
|
||||
goToPrevHunk: () => void;
|
||||
goToNextFile: () => void;
|
||||
goToPrevFile: () => void;
|
||||
goToHunk: (index: number) => void;
|
||||
acceptCurrentHunk: () => void;
|
||||
rejectCurrentHunk: () => void;
|
||||
showShortcutsHelp: boolean;
|
||||
setShowShortcutsHelp: (show: boolean) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### Новый optional параметр continuousOptions
|
||||
|
||||
```typescript
|
||||
// --- НОВАЯ сигнатура (расширение, backward compatible) ---
|
||||
export function useDiffNavigation(
|
||||
files: FileChangeSummary[],
|
||||
selectedFilePath: string | null,
|
||||
onSelectFile: (path: string) => void,
|
||||
editorViewRef: React.RefObject<EditorView | null>,
|
||||
isDialogOpen: boolean,
|
||||
onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
|
||||
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
|
||||
onClose?: () => void,
|
||||
onSaveFile?: () => void,
|
||||
continuousOptions?: ContinuousNavigationOptions // <-- НОВЫЙ 10-й параметр
|
||||
): DiffNavigationState;
|
||||
```
|
||||
|
||||
**Важно:** НЕ используем overloads. Один вариант сигнатуры с optional 10-м параметром. Overloads здесь избыточны -- `continuousOptions` опционален, TypeScript корректно проверяет типы без overload.
|
||||
|
||||
#### Новый тип ContinuousNavigationOptions
|
||||
|
||||
```typescript
|
||||
interface ContinuousNavigationOptions {
|
||||
/**
|
||||
* Map всех EditorView по filePath. Заполняется в ContinuousScrollView.
|
||||
* Это НЕ ref -- передаётся сам Map (через .current снаружи).
|
||||
* Передаётся как value, но мутируется извне (Map reference стабильна).
|
||||
*/
|
||||
editorViewRefs: Map<string, EditorView>;
|
||||
|
||||
/**
|
||||
* Текущий видимый файл из scroll-spy (Phase 1 useVisibleFileSection).
|
||||
* НЕ selectedFilePath -- это activeFilePath.
|
||||
* Обновляется при скролле.
|
||||
*/
|
||||
activeFilePath: string | null;
|
||||
|
||||
/**
|
||||
* Программный scroll к секции файла из useContinuousScrollNav (Phase 1).
|
||||
* Вызывает scrollIntoView + подавление scroll-spy.
|
||||
*/
|
||||
scrollToFile: (filePath: string) => void;
|
||||
|
||||
/** Флаг continuous mode -- определяет какую логику использовать. */
|
||||
enabled: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Дизайн-решение:** Вместо создания отдельного хука (useContinuousDiffNavigation), расширяем существующий через optional 10-й параметр `continuousOptions`. Это позволяет:
|
||||
1. Не дублировать keyboard handler логику
|
||||
2. Постепенно мигрировать: `ChangeReviewDialog` просто передаёт `continuousOptions` когда continuous mode включён
|
||||
3. Сохранить обратную совместимость -- без `continuousOptions` хук работает как раньше
|
||||
|
||||
#### Внутренняя реализация -- helper: getActiveEditorView()
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Определяет "активный" EditorView для навигации.
|
||||
*
|
||||
* Приоритет:
|
||||
* 1. Focused editor -- если какой-то CM editor сейчас имеет фокус
|
||||
* 2. activeFilePath editor -- editor файла, определённого scroll-spy как видимый
|
||||
* 3. Fallback: первый editor в Map
|
||||
*
|
||||
* В legacy mode: просто возвращает editorViewRef.current.
|
||||
*/
|
||||
function getActiveEditorView(
|
||||
editorViewRef: React.RefObject<EditorView | null>,
|
||||
continuousOptions?: ContinuousNavigationOptions
|
||||
): EditorView | null {
|
||||
// Legacy mode
|
||||
if (!continuousOptions?.enabled) {
|
||||
return editorViewRef.current;
|
||||
}
|
||||
|
||||
const { editorViewRefs, activeFilePath } = continuousOptions;
|
||||
|
||||
// 1. Focused editor -- используем view.hasFocus (CM API)
|
||||
for (const [, view] of editorViewRefs) {
|
||||
if (view.hasFocus) return view;
|
||||
}
|
||||
|
||||
// 2. activeFilePath editor
|
||||
if (activeFilePath) {
|
||||
const view = editorViewRefs.get(activeFilePath);
|
||||
if (view) return view;
|
||||
}
|
||||
|
||||
// 3. Fallback: первый editor
|
||||
const firstEntry = editorViewRefs.values().next();
|
||||
return firstEntry.done ? null : firstEntry.value;
|
||||
}
|
||||
```
|
||||
|
||||
**ИСПРАВЛЕНИЕ:** Оригинальный вариант использовал `document.activeElement.closest('.cm-editor')` + сравнение с `view.dom`. Это ненадёжно -- CM editor может содержать nested elements, и `closest` не всегда корректно разрешает до внешнего `.cm-editor`. Используем встроенный `view.hasFocus` -- это официальный CM API для проверки фокуса.
|
||||
|
||||
#### Внутренняя реализация -- helper: getActiveFilePath()
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Определяет путь активного файла для контекста навигации.
|
||||
*
|
||||
* В continuous mode: activeFilePath из scroll-spy.
|
||||
* В legacy mode: selectedFilePath.
|
||||
*/
|
||||
function getActiveFilePath(
|
||||
selectedFilePath: string | null,
|
||||
continuousOptions?: ContinuousNavigationOptions
|
||||
): string | null {
|
||||
if (continuousOptions?.enabled && continuousOptions.activeFilePath) {
|
||||
return continuousOptions.activeFilePath;
|
||||
}
|
||||
return selectedFilePath;
|
||||
}
|
||||
```
|
||||
|
||||
#### Внутренняя реализация -- helper: getFilePathForView()
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Находит filePath для данного EditorView в Map.
|
||||
* Нужно для определения "в каком файле мы сейчас" при focused editor.
|
||||
*/
|
||||
function getFilePathForView(
|
||||
view: EditorView,
|
||||
editorViewRefs: Map<string, EditorView>
|
||||
): string | null {
|
||||
for (const [filePath, v] of editorViewRefs) {
|
||||
if (v === view) return filePath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Внутренняя реализация -- helpers: isLastChunkInFile() / isFirstChunkInFile()
|
||||
|
||||
```typescript
|
||||
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
|
||||
```
|
||||
|
||||
**ВАЖНО: API `getChunks`.**
|
||||
|
||||
`getChunks` реэкспортируется из `@codemirror/merge`. Сигнатура:
|
||||
```typescript
|
||||
function getChunks(state: EditorState): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null;
|
||||
```
|
||||
|
||||
Где `Chunk` имеет поля:
|
||||
- `fromA`, `toA` -- диапазон в original document (side A)
|
||||
- `fromB`, `toB` -- диапазон в modified document (side B)
|
||||
- `changes` -- внутренние изменения
|
||||
|
||||
В `unifiedMergeView` (которую мы используем) side всегда `"b"`. Позиции курсора соответствуют side B.
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Проверяет, находится ли курсор на последнем chunk файла.
|
||||
* Нужно для cross-file navigation: если на последнем chunk -- scroll к следующему файлу.
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Получаем chunks из CM state через getChunks()
|
||||
* 2. Определяем текущую позицию курсора (view.state.selection.main.head)
|
||||
* 3. Проверяем: курсор находится в или после последнего chunk
|
||||
*
|
||||
* ВАЖНО: goToNextChunk -- это StateCommand. Возвращает boolean:
|
||||
* - true: перешёл к следующему chunk (dispatch вызван)
|
||||
* - false: нет chunks в документе ИЛИ только один chunk и курсор уже в нём
|
||||
*
|
||||
* goToNextChunk возвращает false НЕ когда "нет больше chunks после текущего",
|
||||
* а когда chunks.length === 0 или chunks.length === 1 && cursor уже в нём.
|
||||
* При >1 chunks goToNextChunk ВСЕГДА возвращает true (циклическая навигация!).
|
||||
*
|
||||
* Поэтому мы НЕ можем полагаться на return value goToNextChunk для определения
|
||||
* "последний ли это chunk". Нужна отдельная проверка через getChunks().
|
||||
*/
|
||||
function isLastChunkInFile(view: EditorView): boolean {
|
||||
const result = getChunks(view.state);
|
||||
if (!result || result.chunks.length === 0) return true;
|
||||
|
||||
const cursorPos = view.state.selection.main.head;
|
||||
const chunks = result.chunks;
|
||||
const lastChunk = chunks[chunks.length - 1];
|
||||
|
||||
// Курсор в пределах последнего chunk или после него
|
||||
// fromB -- начало chunk в modified document
|
||||
// toB -- конец chunk (1 past end of last line)
|
||||
return cursorPos >= lastChunk.fromB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Аналогично для первого chunk.
|
||||
*/
|
||||
function isFirstChunkInFile(view: EditorView): boolean {
|
||||
const result = getChunks(view.state);
|
||||
if (!result || result.chunks.length === 0) return true;
|
||||
|
||||
const cursorPos = view.state.selection.main.head;
|
||||
const firstChunk = result.chunks[0];
|
||||
|
||||
// Курсор в пределах первого chunk или перед ним
|
||||
return cursorPos <= firstChunk.toB;
|
||||
}
|
||||
```
|
||||
|
||||
**ИСПРАВЛЕНИЕ:** Уточнено поведение `goToNextChunk` -- это **циклическая** навигация (moveByChunk берёт `chunks[(pos + offset) % chunks.length]`). При >1 chunks всегда возвращает `true`. Поэтому:
|
||||
- `const moved = goToNextChunk(view); if (!moved)` -- значит 0 или 1 chunk, а НЕ "последний chunk"
|
||||
- Для определения "последний chunk" нужен `isLastChunkInFile()`
|
||||
- В `goToNextHunk` правильная логика: **сначала** проверить `isLastChunkInFile`, **потом** решить -- переходить к следующему файлу или вызвать `goToNextChunk`
|
||||
|
||||
#### Изменения в goToNextFile()
|
||||
|
||||
```typescript
|
||||
const goToNextFile = useCallback(() => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
|
||||
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
|
||||
const nextIdx = currentIdx < files.length - 1 ? currentIdx + 1 : 0;
|
||||
const nextFilePath = files[nextIdx].filePath;
|
||||
|
||||
if (continuousOptions?.enabled) {
|
||||
// Continuous mode: smooth scroll к следующему файлу
|
||||
continuousOptions.scrollToFile(nextFilePath);
|
||||
// НЕ вызываем onSelectFile -- scroll-spy обновит activeFilePath сам
|
||||
} else {
|
||||
// Legacy mode: переключение файла
|
||||
onSelectFile(nextFilePath);
|
||||
}
|
||||
}, [files, selectedFilePath, onSelectFile, continuousOptions]);
|
||||
```
|
||||
|
||||
**Важно:** В continuous mode `goToNextFile()` НЕ вызывает `onSelectFile()`. Вместо этого:
|
||||
1. Вызывается `scrollToFile(nextFilePath)` из `useContinuousScrollNav`
|
||||
2. `scrollToFile` выполняет `element.scrollIntoView({ behavior: 'smooth' })`
|
||||
3. `isProgrammaticScroll` подавляет scroll-spy
|
||||
4. `waitForScrollEnd()` ждёт стабилизации (timeout 500ms, из `navigation/utils.ts`)
|
||||
5. `isProgrammaticScroll = false`, scroll-spy обнаруживает новый видимый файл
|
||||
6. `activeFilePath` обновляется через `onVisibleFileChange` callback
|
||||
|
||||
#### Изменения в goToPrevFile()
|
||||
|
||||
```typescript
|
||||
const goToPrevFile = useCallback(() => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
|
||||
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
|
||||
const prevIdx = currentIdx > 0 ? currentIdx - 1 : files.length - 1;
|
||||
const prevFilePath = files[prevIdx].filePath;
|
||||
|
||||
if (continuousOptions?.enabled) {
|
||||
continuousOptions.scrollToFile(prevFilePath);
|
||||
} else {
|
||||
onSelectFile(prevFilePath);
|
||||
}
|
||||
}, [files, selectedFilePath, onSelectFile, continuousOptions]);
|
||||
```
|
||||
|
||||
#### Изменения в goToNextHunk()
|
||||
|
||||
```typescript
|
||||
const goToNextHunk = useCallback(() => {
|
||||
const view = getActiveEditorView(editorViewRef, continuousOptions);
|
||||
if (!view) return;
|
||||
|
||||
if (continuousOptions?.enabled) {
|
||||
// Cross-file hunk navigation
|
||||
if (isLastChunkInFile(view)) {
|
||||
// Уже на последнем hunk файла -- переход к следующему файлу
|
||||
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
|
||||
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
|
||||
|
||||
if (currentIdx < files.length - 1) {
|
||||
const nextFilePath = files[currentIdx + 1].filePath;
|
||||
continuousOptions.scrollToFile(nextFilePath);
|
||||
|
||||
// После scroll -- перейти к первому hunk нового файла
|
||||
// Используем requestAnimationFrame чтобы дождаться scroll + render
|
||||
requestAnimationFrame(() => {
|
||||
const nextView = continuousOptions.editorViewRefs.get(nextFilePath);
|
||||
if (nextView) {
|
||||
// Перемещаем курсор в начало файла, потом goToNextChunk
|
||||
nextView.dispatch({
|
||||
selection: { anchor: 0 },
|
||||
});
|
||||
goToNextChunk(nextView);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Если это последний файл -- no-op (конец списка)
|
||||
} else {
|
||||
// Не последний chunk -- обычная навигация внутри файла
|
||||
goToNextChunk(view);
|
||||
}
|
||||
} else {
|
||||
// Legacy mode: навигация внутри текущего файла
|
||||
goToNextChunk(view);
|
||||
}
|
||||
|
||||
setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1));
|
||||
}, [editorViewRef, totalHunks, setCurrentHunkIndex, files, selectedFilePath, continuousOptions]);
|
||||
```
|
||||
|
||||
**ИСПРАВЛЕНИЕ (критическое):** Оригинальный вариант вызывал `goToNextChunk(view)` ПЕРЕД проверкой `isLastChunkInFile`. Проблема: `goToNextChunk` -- циклическая навигация. Если курсор на последнем chunk, `goToNextChunk` перейдёт к ПЕРВОМУ chunk (wrap-around), а потом `isLastChunkInFile` вернёт `false`. Результат: cross-file navigation никогда не сработает.
|
||||
|
||||
Правильная логика: **сначала** `isLastChunkInFile()`, **потом** решение -- переход к следующему файлу ИЛИ `goToNextChunk()` для навигации внутри файла.
|
||||
|
||||
#### Изменения в goToPrevHunk()
|
||||
|
||||
```typescript
|
||||
const goToPrevHunk = useCallback(() => {
|
||||
const view = getActiveEditorView(editorViewRef, continuousOptions);
|
||||
if (!view) return;
|
||||
|
||||
if (continuousOptions?.enabled) {
|
||||
if (isFirstChunkInFile(view)) {
|
||||
// Первый hunk файла -- переход к предыдущему файлу
|
||||
const currentPath = getActiveFilePath(selectedFilePath, continuousOptions);
|
||||
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
|
||||
|
||||
if (currentIdx > 0) {
|
||||
const prevFilePath = files[currentIdx - 1].filePath;
|
||||
continuousOptions.scrollToFile(prevFilePath);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const prevView = continuousOptions.editorViewRefs.get(prevFilePath);
|
||||
if (prevView) {
|
||||
// Перемещаем курсор в конец файла, потом goToPreviousChunk
|
||||
const docLength = prevView.state.doc.length;
|
||||
prevView.dispatch({
|
||||
selection: { anchor: docLength },
|
||||
});
|
||||
goToPreviousChunk(prevView);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Не первый chunk -- обычная навигация назад
|
||||
goToPreviousChunk(view);
|
||||
}
|
||||
} else {
|
||||
goToPreviousChunk(view);
|
||||
}
|
||||
|
||||
setCurrentHunkIndex((prev) => Math.max(prev - 1, 0));
|
||||
}, [editorViewRef, setCurrentHunkIndex, files, selectedFilePath, continuousOptions]);
|
||||
```
|
||||
|
||||
#### Изменения в acceptCurrentHunk()
|
||||
|
||||
```typescript
|
||||
const acceptCurrentHunk = useCallback(() => {
|
||||
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
|
||||
if (activePath && onHunkAccepted) {
|
||||
onHunkAccepted(activePath, currentHunkIndex);
|
||||
}
|
||||
}, [selectedFilePath, currentHunkIndex, onHunkAccepted, continuousOptions]);
|
||||
```
|
||||
|
||||
#### Изменения в rejectCurrentHunk()
|
||||
|
||||
```typescript
|
||||
const rejectCurrentHunk = useCallback(() => {
|
||||
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
|
||||
if (activePath && onHunkRejected) {
|
||||
onHunkRejected(activePath, currentHunkIndex);
|
||||
}
|
||||
}, [selectedFilePath, currentHunkIndex, onHunkRejected, continuousOptions]);
|
||||
```
|
||||
|
||||
#### Keyboard handler -- адаптация
|
||||
|
||||
**ВАЖНО: Конфликт с useContinuousScrollNav (Phase 1).**
|
||||
|
||||
В Phase 1 `useContinuousScrollNav` регистрирует keyboard listener для Alt+ArrowDown/Up. В Phase 3 `useDiffNavigation` тоже хочет обрабатывать эти клавиши. Два обработчика на одно событие -- конфликт.
|
||||
|
||||
**Решение:** Удалить keyboard handler для Alt+Arrow из `useContinuousScrollNav` (Phase 1). Вся keyboard обработка навигации живёт в `useDiffNavigation`. Причина: `useDiffNavigation` уже обрабатывает все shortcuts и имеет доступ к `continuousOptions.scrollToFile`. Дублирование нарушает single-responsibility.
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
// Skip if CM keymap already handled
|
||||
if (event.defaultPrevented) return;
|
||||
// Skip inputs/textareas
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMeta = event.metaKey || event.ctrlKey;
|
||||
|
||||
// Alt+J -> next change (работает в обоих режимах)
|
||||
if (event.altKey && event.key.toLowerCase() === 'j') {
|
||||
event.preventDefault();
|
||||
goToNextHunk();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+K -> prev change (НОВЫЙ shortcut)
|
||||
if (event.altKey && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
goToPrevHunk();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+ArrowDown -> next file (scroll в continuous mode, onSelectFile в legacy)
|
||||
if (event.altKey && event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
goToNextFile();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+ArrowUp -> prev file
|
||||
if (event.altKey && event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
goToPrevFile();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Enter -> save active file
|
||||
if (isMeta && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onSaveFileRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Y -> accept chunk + next (на active editor)
|
||||
if (isMeta && event.key.toLowerCase() === 'y') {
|
||||
event.preventDefault();
|
||||
const view = getActiveEditorView(editorViewRef, continuousOptions);
|
||||
if (view) {
|
||||
acceptChunk(view);
|
||||
requestAnimationFrame(() => {
|
||||
if (continuousOptions?.enabled && isLastChunkInFile(view)) {
|
||||
// Cross-file: scroll к следующему файлу после accept последнего chunk
|
||||
goToNextFile();
|
||||
} else {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ? -> toggle shortcuts help
|
||||
if (event.key === '?' && !isMeta && !event.altKey) {
|
||||
event.preventDefault();
|
||||
setShowShortcutsHelp((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape handling
|
||||
if (event.key === 'Escape') {
|
||||
if (showShortcutsHelp) {
|
||||
event.preventDefault();
|
||||
setShowShortcutsHelp(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [
|
||||
isDialogOpen,
|
||||
showShortcutsHelp,
|
||||
editorViewRef,
|
||||
continuousOptions,
|
||||
goToNextFile,
|
||||
goToPrevFile,
|
||||
goToNextHunk,
|
||||
goToPrevHunk,
|
||||
]);
|
||||
```
|
||||
|
||||
**ИСПРАВЛЕНИЕ:** Alt+J/K теперь вызывают `goToNextHunk()` / `goToPrevHunk()` (callback из хука), а не напрямую `goToNextChunk(view)`. Это обеспечивает cross-file навигацию в continuous mode. В оригинале Alt+J вызывал `goToNextChunk` напрямую -- cross-file не работал бы.
|
||||
|
||||
#### Полная таблица keyboard shortcuts
|
||||
|
||||
| Shortcut | Action | Legacy mode | Continuous mode |
|
||||
|----------|--------|:-----------:|:---------------:|
|
||||
| `Alt+J` | Next change (hunk) | goToNextHunk (внутри файла) | goToNextHunk (cross-file) |
|
||||
| `Alt+K` | Prev change (hunk) | goToPrevHunk (внутри файла) | goToPrevHunk (cross-file) |
|
||||
| `Alt+ArrowDown` | Next file | goToNextFile (onSelectFile) | goToNextFile (scrollToFile) |
|
||||
| `Alt+ArrowUp` | Prev file | goToPrevFile (onSelectFile) | goToPrevFile (scrollToFile) |
|
||||
| `Cmd+Y` | Accept change + next | acceptChunk + goToNextChunk | acceptChunk + cross-file navigation |
|
||||
| `Cmd+N` | Reject change + next | rejectChunk + goToNextChunk (IPC) | rejectChunk + cross-file navigation (IPC) |
|
||||
| `Cmd+Enter` | Save file | save selectedFilePath | save activeFilePath |
|
||||
| `?` | Toggle shortcuts help | toggle | toggle |
|
||||
| `Escape` | Close help / dialog | close help или dialog | close help или dialog |
|
||||
| `Ctrl+Alt+ArrowDown` | Next change (CM keymap) | goToNextChunk (built-in) | goToNextChunk (built-in per-editor) |
|
||||
| `Ctrl+Alt+ArrowUp` | Prev change (CM keymap) | goToPreviousChunk (built-in) | goToPreviousChunk (built-in per-editor) |
|
||||
|
||||
**Примечание:** Ctrl+Alt+Arrow -- это встроенный CM keymap, не наш. Он работает per-editor (без cross-file). Это ОК -- пользователи, привыкшие к CM keymap, получают привычное поведение внутри файла. Alt+J/K -- наш shortcut с cross-file.
|
||||
|
||||
---
|
||||
|
||||
### 2. useContinuousScrollNav.ts -- изменения для Phase 3
|
||||
|
||||
**Файл:** `src/renderer/hooks/useContinuousScrollNav.ts`
|
||||
|
||||
Phase 1 реализует:
|
||||
- `scrollToFile(filePath)` -- программный scroll к секции файла
|
||||
- `isProgrammaticScroll` ref -- подавление scroll-spy при программном scroll
|
||||
|
||||
Phase 3 изменения:
|
||||
|
||||
1. **Убрать keyboard handler (Alt+Arrow) из useContinuousScrollNav.** Keyboard навигация теперь полностью в `useDiffNavigation`. Это устраняет конфликт двойной регистрации event listener.
|
||||
|
||||
2. **Убрать `activeFilePath` и `filePaths` из options** -- они больше не нужны хуку (keyboard handler убран). Упрощённый interface:
|
||||
|
||||
```typescript
|
||||
interface UseContinuousScrollNavOptions {
|
||||
/** Ref на scroll container */
|
||||
scrollContainerRef: RefObject<HTMLElement>;
|
||||
|
||||
/** Диалог открыт (для cleanup) */
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
interface UseContinuousScrollNavReturn {
|
||||
/** Scroll к файлу по filePath (smooth) */
|
||||
scrollToFile: (filePath: string) => void;
|
||||
|
||||
/** Ref-flag: true пока идёт programmatic scroll */
|
||||
isProgrammaticScroll: RefObject<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
3. **scrollToFile -- без `setActiveFilePath`:**
|
||||
|
||||
```typescript
|
||||
const scrollToFile = useCallback(
|
||||
(filePath: string) => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const section = container.querySelector<HTMLElement>(
|
||||
`[data-file-path="${CSS.escape(filePath)}"]`
|
||||
);
|
||||
if (!section) return;
|
||||
|
||||
// Suppress scroll-spy during programmatic scroll
|
||||
isProgrammaticScroll.current = true;
|
||||
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
// Дождаться стабилизации scroll, потом разрешить scroll-spy
|
||||
void waitForScrollEnd(container, 500).then(() => {
|
||||
isProgrammaticScroll.current = false;
|
||||
// scroll-spy сам обнаружит новый видимый файл и обновит activeFilePath
|
||||
});
|
||||
},
|
||||
[scrollContainerRef]
|
||||
);
|
||||
```
|
||||
|
||||
**ИСПРАВЛЕНИЕ:** Оригинальный вариант вызывал `setActiveFilePath(filePath)` внутри `scrollToFile`. Проблема: `setActiveFilePath` не является частью hook state `useContinuousScrollNav` -- он живёт в parent (`ChangeReviewDialog` как `useState`). Передавать setter внутрь нарушает separation of concerns. Вместо этого: после `isProgrammaticScroll = false` scroll-spy (`useVisibleFileSection`) сам обнаружит видимый файл и вызовет `onVisibleFileChange`, который обновит `activeFilePath` в parent. Задержка ~100ms (debounce scroll-spy), но это ОК -- UI уже показывает правильный файл.
|
||||
|
||||
**waitForScrollEnd signature** (из `src/renderer/hooks/navigation/utils.ts`):
|
||||
```typescript
|
||||
function waitForScrollEnd(container: HTMLElement, timeoutMs?: number): Promise<void>
|
||||
```
|
||||
- `container` -- scroll container DOM element
|
||||
- `timeoutMs` -- fallback timeout (default 400ms, мы передаём 500ms для запаса smooth scroll)
|
||||
- Возвращает Promise, resolve когда scrollTop стабилизировался (3 consecutive frames без изменений)
|
||||
|
||||
---
|
||||
|
||||
### 3. ChangeReviewDialog.tsx -- интеграция
|
||||
|
||||
**Файл:** `src/renderer/components/team/review/ChangeReviewDialog.tsx`
|
||||
|
||||
#### Новый state: continuous mode toggle
|
||||
|
||||
```typescript
|
||||
// Новый state для continuous mode (Phase 3)
|
||||
const [isContinuousMode, setIsContinuousMode] = useState(false);
|
||||
```
|
||||
|
||||
#### EditorView Map для continuous mode
|
||||
|
||||
```typescript
|
||||
// Map всех EditorViews в continuous mode
|
||||
// Заполняется через callback из ContinuousScrollView (Phase 1)
|
||||
// Уже существует из Phase 1: editorViewMapRef
|
||||
const editorViewMapRef = useRef(new Map<string, EditorView>());
|
||||
```
|
||||
|
||||
#### Получение данных из useContinuousScrollNav
|
||||
|
||||
```typescript
|
||||
// useContinuousScrollNav теперь принимает options object (Phase 1 interface,
|
||||
// упрощённый в Phase 3):
|
||||
const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
|
||||
scrollContainerRef,
|
||||
isOpen: open,
|
||||
});
|
||||
```
|
||||
|
||||
#### Передача continuousOptions в useDiffNavigation
|
||||
|
||||
```typescript
|
||||
// Формируем continuousOptions только когда continuous mode включён.
|
||||
//
|
||||
// ВАЖНО: НЕ оборачивать editorViewMapRef.current в useMemo deps --
|
||||
// .current не реактивен. Map reference стабильна (useRef), мутируется извне.
|
||||
// useDiffNavigation обращается к Map.get() в момент вызова (не при создании options).
|
||||
// activeFilePath и scrollToFile -- реактивны, они меняются.
|
||||
const continuousOptions = useMemo(
|
||||
(): ContinuousNavigationOptions | undefined => {
|
||||
if (!isContinuousMode) return undefined;
|
||||
return {
|
||||
editorViewRefs: editorViewMapRef.current,
|
||||
activeFilePath: continuousScrollNav.activeFilePath,
|
||||
scrollToFile: continuousScrollNav.scrollToFile,
|
||||
enabled: true,
|
||||
};
|
||||
},
|
||||
[isContinuousMode, continuousScrollNav.activeFilePath, continuousScrollNav.scrollToFile]
|
||||
);
|
||||
|
||||
const diffNav = useDiffNavigation(
|
||||
activeChangeSet?.files ?? [],
|
||||
selectedReviewFilePath,
|
||||
handleSelectFile,
|
||||
editorViewRef, // Legacy ref (используется если continuousOptions undefined)
|
||||
open,
|
||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
|
||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
|
||||
() => onOpenChange(false),
|
||||
handleSaveCurrentFile,
|
||||
continuousOptions // <-- НОВЫЙ 10-й параметр
|
||||
);
|
||||
```
|
||||
|
||||
**Примечание:** `continuousScrollNav.activeFilePath` -- это state из `useContinuousScrollNav` или state из parent (`ChangeReviewDialog`). В Phase 1 `activeFilePath` управляется через `onVisibleFileChange` callback. Уточнение: `activeFilePath` -- это `useState` в `ChangeReviewDialog`, обновляется через `setActiveFilePath` callback, переданный в `ContinuousScrollView.onVisibleFileChange`.
|
||||
|
||||
#### handleSelectFile адаптация
|
||||
|
||||
```typescript
|
||||
const handleSelectFile = useCallback(
|
||||
(filePath: string | null) => {
|
||||
if (isContinuousMode && filePath) {
|
||||
// В continuous mode: scroll к секции вместо переключения
|
||||
scrollToFile(filePath);
|
||||
// НЕ вызываем selectReviewFile -- sidebar highlight управляется через activeFilePath
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy mode: старая логика
|
||||
const view = editorViewRef.current;
|
||||
if (view && selectedReviewFilePath) {
|
||||
editorStateCache.current.set(selectedReviewFilePath, view.state);
|
||||
}
|
||||
setCachedInitialState(filePath ? editorStateCache.current.get(filePath) : undefined);
|
||||
selectReviewFile(filePath);
|
||||
},
|
||||
[isContinuousMode, selectedReviewFilePath, selectReviewFile, scrollToFile]
|
||||
);
|
||||
```
|
||||
|
||||
#### handleSaveCurrentFile адаптация
|
||||
|
||||
```typescript
|
||||
const handleSaveCurrentFile = useCallback(() => {
|
||||
// В continuous mode сохраняем activeFilePath (видимый), не selectedReviewFilePath
|
||||
const targetFile = isContinuousMode
|
||||
? activeFilePath // из useState в ChangeReviewDialog
|
||||
: selectedReviewFilePath;
|
||||
|
||||
if (targetFile) void saveEditedFile(targetFile);
|
||||
}, [isContinuousMode, selectedReviewFilePath, activeFilePath, saveEditedFile]);
|
||||
```
|
||||
|
||||
#### handleAcceptAll / handleRejectAll адаптация
|
||||
|
||||
```typescript
|
||||
const handleAcceptAll = useCallback(() => {
|
||||
if (isContinuousMode) {
|
||||
// В continuous mode: accept all на ACTIVE file's editor
|
||||
if (activeFilePath) {
|
||||
const view = editorViewMapRef.current.get(activeFilePath);
|
||||
if (view) acceptAllChunks(view);
|
||||
acceptAllFile(activeFilePath);
|
||||
}
|
||||
} else {
|
||||
const view = editorViewRef.current;
|
||||
if (view) acceptAllChunks(view);
|
||||
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
|
||||
}
|
||||
}, [isContinuousMode, selectedReviewFilePath, activeFilePath, acceptAllFile]);
|
||||
```
|
||||
|
||||
#### Sidebar: подсветка activeFilePath в continuous mode
|
||||
|
||||
```typescript
|
||||
{/* File tree -- selectedFilePath меняется на activeFilePath в continuous mode */}
|
||||
<ReviewFileTree
|
||||
files={activeChangeSet.files}
|
||||
selectedFilePath={
|
||||
isContinuousMode
|
||||
? activeFilePath // из scroll-spy
|
||||
: selectedReviewFilePath // из store
|
||||
}
|
||||
onSelectFile={handleSelectFile}
|
||||
viewedSet={viewedSet}
|
||||
onMarkViewed={markViewed}
|
||||
onUnmarkViewed={unmarkViewed}
|
||||
/>
|
||||
```
|
||||
|
||||
**Примечание:** Phase 1 добавила `activeFilePath` prop в `ReviewFileTree` для мягкой подсветки (border-l). В continuous mode мы просто передаём `activeFilePath` как `selectedFilePath` -- полноценная подсветка (`bg-blue-500/20`). Это проще и визуально понятнее: один выделенный файл в tree.
|
||||
|
||||
#### Cmd+N IPC listener адаптация
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
|
||||
const view = isContinuousMode
|
||||
? getActiveEditorView(editorViewRef, continuousOptions)
|
||||
: editorViewRef.current;
|
||||
|
||||
if (view) {
|
||||
rejectChunk(view);
|
||||
requestAnimationFrame(() => {
|
||||
if (isContinuousMode && isLastChunkInFile(view)) {
|
||||
// Cross-file: scroll к следующему файлу
|
||||
diffNav.goToNextFile();
|
||||
} else {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return cleanup ?? undefined;
|
||||
}, [open, isContinuousMode, continuousOptions, diffNav]);
|
||||
```
|
||||
|
||||
**Примечание:** `getActiveEditorView` и `isLastChunkInFile` -- helper функции из `useDiffNavigation`. Для использования в `ChangeReviewDialog` нужно:
|
||||
- Либо экспортировать helpers из `useDiffNavigation.ts`
|
||||
- Либо дублировать логику (нежелательно)
|
||||
- Либо добавить метод в return interface: `diffNav.getActiveView()` / `diffNav.isOnLastChunk()`
|
||||
|
||||
**Рекомендация:** Экспортировать `getActiveEditorView` и `isLastChunkInFile` как named exports из `useDiffNavigation.ts`. Они чистые функции, не зависят от hook state.
|
||||
|
||||
---
|
||||
|
||||
### 4. KeyboardShortcutsHelp.tsx -- новые shortcuts
|
||||
|
||||
**Файл:** `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx`
|
||||
|
||||
Добавляются новые shortcuts. Текущий массив `shortcuts` (строки 10-18):
|
||||
|
||||
```typescript
|
||||
const shortcuts = [
|
||||
{ keys: ['\u2325+J'], action: 'Next change' },
|
||||
{ keys: ['\u2325+K'], action: 'Previous change' }, // НОВЫЙ
|
||||
{ keys: ['\u2325+\u2193'], action: 'Next file' }, // НОВЫЙ
|
||||
{ keys: ['\u2325+\u2191'], action: 'Previous file' }, // НОВЫЙ
|
||||
{ keys: ['\u2318+Y'], action: 'Accept change' },
|
||||
{ keys: ['\u2318+N'], action: 'Reject change' },
|
||||
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
|
||||
{ keys: ['\u2318+Z'], action: 'Undo' },
|
||||
{ keys: ['\u2318+\u21E7+Z'], action: 'Redo' },
|
||||
{ keys: ['?'], action: 'Toggle this help' }, // НОВЫЙ
|
||||
{ keys: ['Esc'], action: 'Close dialog' },
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Return Interface
|
||||
|
||||
```typescript
|
||||
interface DiffNavigationState {
|
||||
currentHunkIndex: number;
|
||||
totalHunks: number;
|
||||
goToNextHunk: () => void;
|
||||
goToPrevHunk: () => void;
|
||||
goToNextFile: () => void;
|
||||
goToPrevFile: () => void;
|
||||
goToHunk: (index: number) => void;
|
||||
acceptCurrentHunk: () => void;
|
||||
rejectCurrentHunk: () => void;
|
||||
showShortcutsHelp: boolean;
|
||||
setShowShortcutsHelp: (show: boolean) => void;
|
||||
}
|
||||
```
|
||||
|
||||
Интерфейс **НЕ меняется**. Все вызовы `diffNav.goToNextFile()`, `diffNav.goToNextHunk()` и т.д. в ChangeReviewDialog продолжают работать без изменений. Внутренняя реализация каждого метода проверяет `continuousOptions?.enabled` и выбирает стратегию.
|
||||
|
||||
---
|
||||
|
||||
## Edge-cases
|
||||
|
||||
### 1. scrollToFile + scroll-spy подавление
|
||||
|
||||
**Проблема:** При `scrollToFile(nextFile)` scroll-spy может обнаружить промежуточные файлы (мелькание activeFilePath).
|
||||
|
||||
**Решение:** `isProgrammaticScroll` ref в `useContinuousScrollNav`. При программном scroll:
|
||||
1. `isProgrammaticScroll.current = true` устанавливается ДО `scrollIntoView`
|
||||
2. Scroll-spy IntersectionObserver проверяет `isProgrammaticScroll.current` в `updateTopmostVisible()` и ИГНОРИРУЕТ обновления
|
||||
3. После стабилизации scroll (через `waitForScrollEnd(container, 500)`) -- сбрасывается в `false`
|
||||
4. Scroll-spy автоматически обнаруживает видимый файл на следующем intersection event
|
||||
|
||||
**Таймаут:** `waitForScrollEnd` имеет fallback timeout. Сигнатура: `waitForScrollEnd(container: HTMLElement, timeoutMs?: number): Promise<void>`. Default timeout 400ms. Мы передаём 500ms. Smooth scroll в Chromium занимает ~300-400ms. 500ms достаточно.
|
||||
|
||||
### 2. Cross-file hunk navigation: определение границы файла
|
||||
|
||||
**Проблема:** Как определить что мы на последнем/первом hunk файла?
|
||||
|
||||
**Решение:** Функции `isLastChunkInFile(view)` / `isFirstChunkInFile(view)` используют `getChunks(view.state)` для получения списка chunks, и сравнивают позицию курсора (`view.state.selection.main.head`) с позицией первого/последнего chunk.
|
||||
|
||||
**Критическая деталь `goToNextChunk`:**
|
||||
- `goToNextChunk` -- это `StateCommand` (тип: `(target: { state, dispatch }) => boolean`)
|
||||
- `EditorView` реализует этот интерфейс (имеет `.state` и `.dispatch()`)
|
||||
- `goToNextChunk` реализует **циклическую** навигацию: `chunks[(pos + offset) % chunks.length]`
|
||||
- При >1 chunks `goToNextChunk` **ВСЕГДА** возвращает `true` (перешёл к следующему chunk, даже если wrap-around к первому)
|
||||
- `false` возвращается ТОЛЬКО когда: chunks.length === 0, или chunks.length === 1 && cursor уже в этом chunk
|
||||
|
||||
Поэтому использовать `const moved = goToNextChunk(view); if (!moved)` для определения "последний chunk" -- **некорректно**. Нужна явная проверка `isLastChunkInFile()`.
|
||||
|
||||
### 3. Multiple EditorViews: какой active?
|
||||
|
||||
**Проблема:** В continuous mode 10+ EditorView одновременно. Какой считать "активным" для keyboard shortcuts?
|
||||
|
||||
**Решение:** Приоритет в `getActiveEditorView()`:
|
||||
1. **Focused editor** -- `view.hasFocus` (CM API). Пользователь кликнул в editor для редактирования.
|
||||
2. **activeFilePath editor** -- editor файла, определённого scroll-spy как видимый. Пользователь скроллит, но не кликает в editor.
|
||||
3. **Первый editor** -- fallback, если ни один не подходит.
|
||||
|
||||
**Нюанс:** Когда пользователь кликает в sidebar (ReviewFileTree), фокус уходит из CM editor. `view.hasFocus` становится `false` для всех. В этом случае activeFilePath editor используется корректно.
|
||||
|
||||
### 4. goToNextChunk на пустом файле (0 chunks)
|
||||
|
||||
**Проблема:** Файл целиком новый (`isNewFile: true`) -- весь контент является одним "inserted" chunk. Или файл без diff (identical). `goToNextChunk` возвращает `false` при 0 chunks.
|
||||
|
||||
**Решение:** `isLastChunkInFile` и `isFirstChunkInFile` возвращают `true` при 0 chunks. В `goToNextHunk` continuous mode: если `isLastChunkInFile` true и 0 chunks -- переходим к следующему файлу. Это корректно: файл без changes пропускается.
|
||||
|
||||
Для new file (1 chunk covering entire file): `isLastChunkInFile` вернёт `true` если курсор >= chunk.fromB. При первом заходе курсор в позиции 0 = chunk.fromB = 0, значит `isLastChunkInFile` true -- сразу переход к следующему файлу. Это может быть нежелательно для больших new files. **Решение:** Для файлов с 1 chunk можно добавить проверку `cursorPos >= lastChunk.toB - 1` (конец chunk, не начало). Но это edge case, оставляем для будущей итерации.
|
||||
|
||||
### 5. Cmd+Enter save: какой файл сохраняется?
|
||||
|
||||
**Проблема:** В continuous mode несколько файлов видны одновременно. `Cmd+Enter` должен сохранять конкретный файл.
|
||||
|
||||
**Решение:** Сохраняется файл из `handleSaveCurrentFile`:
|
||||
- В continuous mode: `activeFilePath` из scroll-spy
|
||||
- В legacy mode: `selectedReviewFilePath` из store
|
||||
|
||||
`onSaveFileRef.current` в keyboard handler вызывает `handleSaveCurrentFile`, который уже адаптирован.
|
||||
|
||||
### 6. Cross-file navigation + requestAnimationFrame timing
|
||||
|
||||
**Проблема:** При переходе к следующему файлу, `scrollToFile` триггерит smooth scroll. EditorView нового файла может быть не готов.
|
||||
|
||||
**Решение:**
|
||||
1. В Phase 1/2 ВСЕ EditorView создаются при mount (lazy loading загружает контент, но DOM + EditorView создаются сразу для загруженных файлов)
|
||||
2. `requestAnimationFrame` используется для задержки `goToNextChunk` после scroll
|
||||
3. Если EditorView ещё не доступен (файл ещё не загружен через lazy loading) -- `continuousOptions.editorViewRefs.get(filePath)` вернёт `undefined`, navigation no-op
|
||||
|
||||
**Потенциальная проблема:** rAF может сработать до завершения smooth scroll. Но для `goToNextChunk` / `goToPreviousChunk` это ОК -- CM сам scrollIntoView к chunk. Визуально: scroll к файлу + мгновенный jump к первому chunk.
|
||||
|
||||
### 7. Wrap-around: конец/начало списка файлов
|
||||
|
||||
**Поведение:**
|
||||
- `goToNextFile()` на последнем файле: wrap к первому файлу (index 0). Это текущее поведение legacy mode, сохраняем.
|
||||
- `goToNextHunk()` на последнем hunk последнего файла: no-op (не wrap). Это отличается от goToNextFile -- hunk navigation останавливается на границе.
|
||||
- `goToPrevHunk()` на первом hunk первого файла: no-op.
|
||||
|
||||
### 8. Editor state cache в continuous mode
|
||||
|
||||
**Проблема:** В legacy mode `editorStateCache` хранит EditorState для восстановления undo history при переключении файлов. В continuous mode все editors живут одновременно -- cache не нужен.
|
||||
|
||||
**Решение:** `editorStateCache` используется только в legacy mode (`handleSelectFile` проверяет `isContinuousMode`). В continuous mode undo history каждого EditorView сохраняется автоматически (editor не уничтожается при навигации).
|
||||
|
||||
### 9. goToNextChunk циклическая навигация vs наше поведение
|
||||
|
||||
**Ситуация:** `goToNextChunk` при >1 chunks делает wrap-around (с последнего chunk на первый). Наше cross-file поведение ожидает "стоп на последнем chunk -- перейти к следующему файлу".
|
||||
|
||||
**Решение:** Мы НЕ вызываем `goToNextChunk` когда `isLastChunkInFile` true. Поэтому wrap-around не происходит. `goToNextChunk` вызывается только когда мы знаем что есть следующий chunk в текущем файле.
|
||||
|
||||
---
|
||||
|
||||
## Проверка
|
||||
|
||||
### Unit тесты
|
||||
|
||||
```
|
||||
test/renderer/hooks/useDiffNavigation.test.ts
|
||||
```
|
||||
|
||||
**Тест-кейсы:**
|
||||
|
||||
1. **goToNextFile в continuous mode** -- вызывает scrollToFile, НЕ вызывает onSelectFile
|
||||
2. **goToNextFile в legacy mode** -- вызывает onSelectFile, НЕ вызывает scrollToFile
|
||||
3. **getActiveEditorView: focused editor приоритет** -- mock view.hasFocus
|
||||
4. **getActiveEditorView: fallback на activeFilePath** -- когда hasFocus false для всех
|
||||
5. **goToNextHunk: isLastChunkInFile true** -- вызывает scrollToFile для следующего файла, НЕ вызывает goToNextChunk
|
||||
6. **goToNextHunk: isLastChunkInFile false** -- вызывает goToNextChunk, НЕ переходит к файлу
|
||||
7. **goToPrevHunk cross-file** -- при isFirstChunkInFile=true, вызывает scrollToFile для предыдущего файла
|
||||
8. **Keyboard: Alt+ArrowDown** -- вызывает goToNextFile
|
||||
9. **Keyboard: Alt+ArrowUp** -- вызывает goToPrevFile
|
||||
10. **Keyboard: Alt+J** -- вызывает goToNextHunk (с cross-file)
|
||||
11. **Keyboard: Cmd+Y + cross-file** -- acceptChunk + goToNextFile если isLastChunkInFile
|
||||
12. **handleSaveCurrentFile в continuous mode** -- сохраняет activeFilePath
|
||||
13. **handleSelectFile в continuous mode** -- вызывает scrollToFile вместо selectReviewFile
|
||||
14. **isLastChunkInFile: 0 chunks** -- returns true
|
||||
15. **isLastChunkInFile: cursor before last chunk** -- returns false
|
||||
16. **isLastChunkInFile: cursor at last chunk.fromB** -- returns true
|
||||
|
||||
### Ручная проверка
|
||||
|
||||
1. Открыть review dialog в continuous mode с 5+ файлами
|
||||
2. Клик по файлу в sidebar -- плавный scroll к секции
|
||||
3. Alt+ArrowDown/Up -- навигация между файлами
|
||||
4. Alt+J -- переход к следующему hunk
|
||||
5. На последнем hunk файла: Alt+J -- scroll к следующему файлу, первый hunk
|
||||
6. Cmd+Y на последнем hunk -- accept + scroll к следующему файлу
|
||||
7. Cmd+Enter -- сохраняет видимый файл (не первый в списке)
|
||||
8. Переключить на legacy mode -- все shortcuts работают как раньше
|
||||
|
||||
### Интеграция с Phase 1/2
|
||||
|
||||
- scrollToFile корректно подавляет scroll-spy (isProgrammaticScroll)
|
||||
- activeFilePath обновляется после программного scroll (через scroll-spy, не принудительно)
|
||||
- EditorView Map содержит все созданные editors
|
||||
- Sidebar highlight синхронизирован с activeFilePath в continuous mode
|
||||
- Lazy loading не мешает навигации (placeholder для незагруженных файлов)
|
||||
|
||||
---
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Тип | ~LOC изменений |
|
||||
|------|-----|---:|
|
||||
| `src/renderer/hooks/useDiffNavigation.ts` | MODIFY | ~200 (helpers + goToNext/Prev переработка + keyboard) |
|
||||
| `src/renderer/hooks/useContinuousScrollNav.ts` | MODIFY | ~-30 (удаление keyboard handler, упрощение interface) |
|
||||
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | ~60 (continuousOptions, handleSelectFile, handleSave) |
|
||||
| `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` | MODIFY | ~10 (новые shortcuts) |
|
||||
| `test/renderer/hooks/useDiffNavigation.test.ts` | MODIFY | ~200 (новые тест-кейсы для continuous mode) |
|
||||
| **Итого** | 0 NEW + 5 MODIFY | ~440 |
|
||||
File diff suppressed because it is too large
Load diff
988
docs/iterations/diff-view/continuous-scroll/phase-5-polish.md
Normal file
988
docs/iterations/diff-view/continuous-scroll/phase-5-polish.md
Normal file
|
|
@ -0,0 +1,988 @@
|
|||
# Phase 5: Polish + EditorView Map + Toolbar адаптация
|
||||
|
||||
## 1. Обзор
|
||||
|
||||
Финальная фаза Continuous Scroll Diff View. Задачи:
|
||||
|
||||
- **EditorView Map** -- централизованный реестр всех EditorView экземпляров в ContinuousScrollView, необходимый для глобальных действий (Accept All, Reject All) и keyboard navigation.
|
||||
- **Keyboard shortcuts координация** -- Cmd+Y/N/Enter должны корректно определять, с каким EditorView работать, когда на экране отображаются десятки файлов одновременно.
|
||||
- **Auto-viewed для каждого файла** -- каждый FileSectionDiff отслеживает свой viewed-статус через IntersectionObserver.
|
||||
- **ReviewToolbar адаптация** -- кнопки "Accept All" и "Reject All" теперь оперируют ВСЕМИ файлами, а не текущим. Добавляется progress indicator.
|
||||
- **ChangeReviewDialog адаптация** -- handlers переключаются на multi-file режим, per-file discard counters.
|
||||
- **Cleanup и edge-cases** -- корректная очистка при unmount, batch-обновления для 50+ файлов.
|
||||
|
||||
**Предусловия:** Phase 1 (ContinuousScrollView), Phase 2 (lazy loading), Phase 3 (navigation), Phase 4 (portionCollapse) -- все завершены.
|
||||
|
||||
---
|
||||
|
||||
## 2. EditorView Map в ContinuousScrollView
|
||||
|
||||
### 2.1. Структура данных
|
||||
|
||||
```typescript
|
||||
// ContinuousScrollView.tsx (внутри компонента)
|
||||
const editorViewMapRef = useRef<Map<string, EditorView>>(new Map());
|
||||
```
|
||||
|
||||
Map хранит `filePath -> EditorView` для каждого смонтированного FileSectionDiff. Используется `useRef`, а не `useState`, потому что:
|
||||
- EditorView-инстансы не являются React-состоянием
|
||||
- Изменение Map не должно вызывать ре-рендер ContinuousScrollView
|
||||
- Доступ к Map нужен синхронно из event handlers
|
||||
|
||||
### 2.2. Callback-интерфейс FileSectionDiff
|
||||
|
||||
FileSectionDiff использует единый callback для регистрации/дерегистрации EditorView, как определено в Phase 1:
|
||||
|
||||
```typescript
|
||||
// FileSectionDiff.tsx — props interface (из Phase 1, секция 2.2)
|
||||
interface FileSectionDiffProps {
|
||||
filePath: string;
|
||||
original: string;
|
||||
modified: string;
|
||||
fileName: string;
|
||||
readOnly: boolean;
|
||||
showMergeControls: boolean;
|
||||
collapseUnchanged: boolean;
|
||||
discardCounter: number;
|
||||
// ... другие props
|
||||
|
||||
/**
|
||||
* Вызывается при создании EditorView (view !== null) и при уничтожении (view === null).
|
||||
* Единый callback по паттерну Phase 1.
|
||||
*/
|
||||
onEditorViewReady: (filePath: string, view: EditorView | null) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Важно:** Используется ОДИН callback `onEditorViewReady(filePath, view | null)`, а НЕ два отдельных (`onEditorViewReady` + `onEditorViewDestroyed`). Это соответствует дизайну Phase 1 (секция 2.2 FileSectionDiff), где `view === null` сигнализирует об уничтожении EditorView.
|
||||
|
||||
### 2.3. Реализация в FileSectionDiff
|
||||
|
||||
FileSectionDiff оборачивает CodeMirrorDiffView и управляет lifecycle:
|
||||
|
||||
```typescript
|
||||
// FileSectionDiff.tsx (из Phase 1, секция 2.2)
|
||||
const localEditorViewRef = useRef<EditorView | null>(null);
|
||||
|
||||
// Sync to parent Map при mount/unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// При unmount сообщить parent что view уничтожен
|
||||
onEditorViewReady(filePath, null);
|
||||
};
|
||||
}, [filePath, onEditorViewReady]);
|
||||
|
||||
// Нужен useEffect чтобы проверить ref после рендера CodeMirrorDiffView
|
||||
useEffect(() => {
|
||||
if (localEditorViewRef.current) {
|
||||
onEditorViewReady(filePath, localEditorViewRef.current);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Важно:** CodeMirrorDiffView устанавливает `editorViewRef.current` синхронно в своём useEffect. Наш вторичный useEffect (без deps) ловит это на следующем render cycle.
|
||||
|
||||
**Альтернативная реализация с requestAnimationFrame** (для гарантии синхронизации):
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const view = localEditorViewRef.current;
|
||||
if (view) {
|
||||
onEditorViewReady(filePath, view);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
if (localEditorViewRef.current) {
|
||||
onEditorViewReady(filePath, null);
|
||||
localEditorViewRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [filePath, discardCounter]);
|
||||
```
|
||||
|
||||
### 2.4. Регистрация в ContinuousScrollView
|
||||
|
||||
```typescript
|
||||
// ContinuousScrollView.tsx (единый handler по паттерну Phase 1)
|
||||
const handleEditorViewReady = useCallback(
|
||||
(filePath: string, view: EditorView | null) => {
|
||||
if (view) {
|
||||
editorViewMapRef.current.set(filePath, view);
|
||||
} else {
|
||||
editorViewMapRef.current.delete(filePath);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
```
|
||||
|
||||
Передаётся каждому `FileSectionDiff`:
|
||||
|
||||
```tsx
|
||||
<FileSectionDiff
|
||||
filePath={file.filePath}
|
||||
onEditorViewReady={handleEditorViewReady}
|
||||
// ... другие props
|
||||
/>
|
||||
```
|
||||
|
||||
### 2.5. Передача Map наружу
|
||||
|
||||
ContinuousScrollView передает Map наружу через `useImperativeHandle`:
|
||||
|
||||
```typescript
|
||||
// ContinuousScrollView.tsx
|
||||
export interface ContinuousScrollViewHandle {
|
||||
getEditorViewMap: () => Map<string, EditorView>;
|
||||
getActiveEditorView: () => EditorView | null;
|
||||
}
|
||||
|
||||
const ContinuousScrollView = forwardRef<ContinuousScrollViewHandle, ContinuousScrollViewProps>(
|
||||
(props, ref) => {
|
||||
const editorViewMapRef = useRef<Map<string, EditorView>>(new Map());
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getEditorViewMap: () => editorViewMapRef.current,
|
||||
getActiveEditorView: () => {
|
||||
// Логика определения активного editor (см. секцию 3)
|
||||
return resolveActiveEditorView(editorViewMapRef.current, props.activeFilePath);
|
||||
},
|
||||
}), [props.activeFilePath]);
|
||||
|
||||
// ...
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
В ChangeReviewDialog:
|
||||
|
||||
```typescript
|
||||
const continuousScrollRef = useRef<ContinuousScrollViewHandle>(null);
|
||||
|
||||
<ContinuousScrollView ref={continuousScrollRef} ... />
|
||||
|
||||
// Использование:
|
||||
const map = continuousScrollRef.current?.getEditorViewMap();
|
||||
const activeView = continuousScrollRef.current?.getActiveEditorView();
|
||||
```
|
||||
|
||||
**Решение:** используем `useImperativeHandle` -- он инкапсулирует логику определения активного editor внутри ContinuousScrollView, где есть доступ к scroll-spy данным.
|
||||
|
||||
---
|
||||
|
||||
## 3. Keyboard shortcuts координация (Cmd+Y/N)
|
||||
|
||||
### 3.1. Проблема
|
||||
|
||||
В single-file режиме `editorViewRef.current` -- всегда один EditorView. В continuous scroll -- их может быть десятки. Нужно определить, какой EditorView является "активным" для команд accept/reject.
|
||||
|
||||
### 3.2. Алгоритм resolveActiveEditorView
|
||||
|
||||
```typescript
|
||||
function resolveActiveEditorView(
|
||||
editorViewMap: Map<string, EditorView>,
|
||||
activeFilePath: string
|
||||
): EditorView | null {
|
||||
// 1. Приоритет: EditorView, который имеет фокус
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl) {
|
||||
for (const [, view] of editorViewMap) {
|
||||
if (view.dom.contains(activeEl)) {
|
||||
return view;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: EditorView для activeFilePath (из scroll-spy)
|
||||
if (activeFilePath) {
|
||||
return editorViewMap.get(activeFilePath) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
**Логика приоритетов:**
|
||||
1. Если пользователь кликнул в CodeMirror editor (ставит фокус) -- используем именно этот editor. `document.activeElement` будет внутри `.cm-content` элемента.
|
||||
2. Если фокус вне editor (например, после скролла мышью) -- используем editor для файла, определенного scroll-spy как видимый (`activeFilePath`).
|
||||
|
||||
### 3.3. Интеграция с useDiffNavigation (Phase 3)
|
||||
|
||||
Phase 3 уже определяет `continuousOptions?: ContinuousNavigationOptions` как 10-й параметр `useDiffNavigation`. Этот объект включает:
|
||||
|
||||
```typescript
|
||||
interface ContinuousNavigationOptions {
|
||||
editorViewRefs: Map<string, EditorView>;
|
||||
activeFilePath: string | null;
|
||||
scrollToFile: (filePath: string) => void;
|
||||
enabled: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Внутри `useDiffNavigation` Phase 3 реализует helper `getActiveEditorView()`, который определяет активный editor по приоритету: focused > activeFilePath > first editor.
|
||||
|
||||
**Phase 5 НЕ добавляет новых параметров в useDiffNavigation.** Вся логика определения активного editor уже заложена в Phase 3 через `continuousOptions`. Phase 5 лишь использует эту инфраструктуру:
|
||||
|
||||
```typescript
|
||||
// В ChangeReviewDialog.tsx — передача continuousOptions (определено Phase 3)
|
||||
const continuousOptions = useMemo(
|
||||
(): ContinuousNavigationOptions | undefined => {
|
||||
if (!isContinuousMode) return undefined;
|
||||
return {
|
||||
editorViewRefs: continuousScrollRef.current?.getEditorViewMap() ?? new Map(),
|
||||
activeFilePath: continuousScrollActiveFilePath,
|
||||
scrollToFile: scrollToFile,
|
||||
enabled: true,
|
||||
};
|
||||
},
|
||||
[isContinuousMode, continuousScrollActiveFilePath, scrollToFile]
|
||||
);
|
||||
|
||||
const diffNav = useDiffNavigation(
|
||||
activeChangeSet?.files ?? [],
|
||||
selectedReviewFilePath,
|
||||
handleSelectFile,
|
||||
editorViewRef,
|
||||
open,
|
||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
|
||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
|
||||
() => onOpenChange(false),
|
||||
handleSaveCurrentFile,
|
||||
continuousOptions // <-- 10-й параметр из Phase 3
|
||||
);
|
||||
```
|
||||
|
||||
### 3.4. Cmd+Y: Accept + goToNextChunk
|
||||
|
||||
Поток действий:
|
||||
1. `resolveActiveEditorView()` -> получаем EditorView
|
||||
2. `acceptChunk(view)` -- принимает текущий chunk в этом editor
|
||||
3. `requestAnimationFrame(() => goToNextChunk(view))` -- прокручивает к следующему chunk
|
||||
4. **Cross-file transition:** если это был последний chunk в файле, Phase 3 обрабатывает cross-file navigation через `isLastChunkInFile()` и `scrollToFile()`.
|
||||
|
||||
### 3.5. Cmd+N: Reject + goToNextChunk
|
||||
|
||||
Аналогично Cmd+Y, но вызывает `rejectChunk(view)`. Обработка через IPC-listener `window.electronAPI.review.onCmdN`:
|
||||
|
||||
```typescript
|
||||
// В ChangeReviewDialog.tsx — модификация IPC listener
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
|
||||
const view = isContinuousMode
|
||||
? continuousScrollRef.current?.getActiveEditorView() ?? null
|
||||
: editorViewRef.current;
|
||||
if (view) {
|
||||
rejectChunk(view);
|
||||
requestAnimationFrame(() => goToNextChunk(view));
|
||||
}
|
||||
});
|
||||
return cleanup ?? undefined;
|
||||
}, [open, isContinuousMode]);
|
||||
```
|
||||
|
||||
### 3.6. Cmd+Enter: Save file
|
||||
|
||||
Сохраняет только `activeFilePath`, не все файлы:
|
||||
|
||||
```typescript
|
||||
// Cmd+Enter handler
|
||||
if (isMeta && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (activeFilePath) {
|
||||
saveEditedFile(activeFilePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Где `activeFilePath` -- из scroll-spy (ContinuousScrollView props).
|
||||
|
||||
### 3.7. Alt+J: Next change
|
||||
|
||||
```typescript
|
||||
// Alt+J handler (реализовано в Phase 3 keyboard handler)
|
||||
if (event.altKey && event.key.toLowerCase() === 'j') {
|
||||
event.preventDefault();
|
||||
const view = getActiveEditorView(editorViewRef, continuousOptions);
|
||||
if (view) goToNextChunk(view);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Auto-viewed для каждого файла
|
||||
|
||||
### 4.1. Текущий механизм (single-file mode)
|
||||
|
||||
В CodeMirrorDiffView.tsx:
|
||||
- `endSentinelRef` -- невидимый `<div>` после editor
|
||||
- IntersectionObserver с `threshold: 1.0`
|
||||
- При пересечении вызывается `onFullyViewed()` callback
|
||||
- В ChangeReviewDialog: `handleFullyViewed` -> `markViewed(selectedReviewFilePath)`
|
||||
|
||||
### 4.2. Continuous mode: per-file sentinel
|
||||
|
||||
Каждый `FileSectionDiff` содержит свой sentinel для auto-viewed:
|
||||
|
||||
```typescript
|
||||
// FileSectionDiff.tsx
|
||||
const endSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endSentinelRef.current || !autoViewed) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
onFullyViewed(filePath);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.85 } // НЕ 1.0 — portionCollapse может компактить файл
|
||||
);
|
||||
|
||||
observer.observe(endSentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [filePath, autoViewed, onFullyViewed]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CodeMirrorDiffView ... />
|
||||
{/* Sentinel для auto-viewed detection */}
|
||||
<div ref={endSentinelRef} className="h-px shrink-0" />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### 4.3. Threshold: 0.85 вместо 1.0
|
||||
|
||||
Обоснование:
|
||||
- `threshold: 1.0` означает "100% элемента видимо". Для sentinel в 1px это работает.
|
||||
- Но в continuous mode sentinel может быть в viewport из-за подскролла следующего файла, пока текущий файл ещё не полностью просмотрен.
|
||||
- Решение: sentinel размещаем ПОСЛЕ CodeMirrorDiffView внутри FileSectionDiff. Threshold 0.85 дает некоторый margin для portionCollapse, который может сильно уменьшить высоту файла.
|
||||
- Sentinel для 1px элемента с threshold 0.85 сработает, когда sentinel "почти полностью" видим -- это надежно.
|
||||
|
||||
### 4.4. onFullyViewed callback в ContinuousScrollView
|
||||
|
||||
```typescript
|
||||
// ContinuousScrollView.tsx
|
||||
const handleFileFullyViewed = useCallback((filePath: string) => {
|
||||
if (autoViewed && !isViewed(filePath)) {
|
||||
markViewed(filePath);
|
||||
}
|
||||
}, [autoViewed, isViewed, markViewed]);
|
||||
```
|
||||
|
||||
Передается каждому FileSectionDiff:
|
||||
|
||||
```tsx
|
||||
<FileSectionDiff
|
||||
filePath={file.filePath}
|
||||
autoViewed={autoViewed}
|
||||
onFullyViewed={handleFileFullyViewed}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
### 4.5. Отличие от single-file mode
|
||||
|
||||
В single-file mode за один скролл пользователь видит один файл. В continuous mode несколько файлов могут быть "viewed" за один скролл. Это корректное поведение:
|
||||
|
||||
- Маленькие файлы (1-5 строк diff) мгновенно проскакивают viewport
|
||||
- Их sentinel пересекается с viewport -> onFullyViewed срабатывает
|
||||
- `markViewed()` идемпотентен (useViewedFiles проверяет через Set)
|
||||
|
||||
### 4.6. autoViewed toggle
|
||||
|
||||
Toggle в toolbar контролирует глобальный `autoViewed` state. Когда выключен:
|
||||
- IntersectionObserver все ещё работает, но `handleFileFullyViewed` проверяет `autoViewed` flag и делает early return
|
||||
- Альтернатива: не создавать IntersectionObserver при `autoViewed === false` (более оптимально)
|
||||
|
||||
Предпочтительная реализация (оптимизированная):
|
||||
|
||||
```typescript
|
||||
// FileSectionDiff.tsx
|
||||
useEffect(() => {
|
||||
if (!endSentinelRef.current || !autoViewed) return;
|
||||
// Observer создается только когда autoViewed=true
|
||||
// ...
|
||||
}, [filePath, autoViewed, onFullyViewed]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Модификация ReviewToolbar.tsx
|
||||
|
||||
### 5.1. Accept All / Reject All -- все файлы
|
||||
|
||||
Текущие tooltip:
|
||||
- "Accept all changes in current file"
|
||||
- "Reject all changes in current file"
|
||||
|
||||
В continuous mode:
|
||||
- "Accept all changes across all files"
|
||||
- "Reject all changes across all files"
|
||||
|
||||
**Реализация:** ReviewToolbar получает новый prop `isContinuousMode`:
|
||||
|
||||
```typescript
|
||||
interface ReviewToolbarProps {
|
||||
stats: { pending: number; accepted: number; rejected: number };
|
||||
changeStats: ChangeStats;
|
||||
collapseUnchanged: boolean;
|
||||
applying: boolean;
|
||||
autoViewed: boolean;
|
||||
onAutoViewedChange: (auto: boolean) => void;
|
||||
onAcceptAll: () => void;
|
||||
onRejectAll: () => void;
|
||||
onApply: () => void;
|
||||
onCollapseUnchangedChange: (collapse: boolean) => void;
|
||||
editedCount?: number;
|
||||
/** Phase 5: continuous scroll mode -- changes tooltip text */
|
||||
isContinuousMode?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Tooltip:
|
||||
|
||||
```tsx
|
||||
<TooltipContent side="bottom">
|
||||
{isContinuousMode
|
||||
? 'Accept all changes across all files'
|
||||
: 'Accept all changes in current file'}
|
||||
</TooltipContent>
|
||||
```
|
||||
|
||||
### 5.2. Progress indicator: "12 of 45 changes reviewed"
|
||||
|
||||
Новый UI элемент между change stats и action buttons.
|
||||
|
||||
```typescript
|
||||
// ReviewToolbar.tsx — новый prop
|
||||
interface ReviewToolbarProps {
|
||||
// ...
|
||||
/** Total hunks reviewed (accepted + rejected) */
|
||||
reviewedCount?: number;
|
||||
/** Total hunks across all files */
|
||||
totalHunks?: number;
|
||||
}
|
||||
```
|
||||
|
||||
Вычисление в ChangeReviewDialog:
|
||||
|
||||
```typescript
|
||||
const reviewProgress = useMemo(() => {
|
||||
if (!activeChangeSet) return { reviewed: 0, total: 0 };
|
||||
|
||||
let total = 0;
|
||||
let reviewed = 0;
|
||||
|
||||
for (const file of activeChangeSet.files) {
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
total++;
|
||||
const key = `${file.filePath}:${i}`;
|
||||
const decision = hunkDecisions[key];
|
||||
if (decision === 'accepted' || decision === 'rejected') {
|
||||
reviewed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { reviewed, total };
|
||||
}, [activeChangeSet, hunkDecisions]);
|
||||
```
|
||||
|
||||
Отображение в ReviewToolbar:
|
||||
|
||||
```tsx
|
||||
{/* Progress indicator */}
|
||||
{totalHunks !== undefined && totalHunks > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-zinc-700/50">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500/70 transition-all duration-300"
|
||||
style={{ width: `${totalHunks > 0 ? (reviewedCount! / totalHunks) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-muted">
|
||||
{reviewedCount} of {totalHunks} reviewed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Позиция в toolbar:** после change stats (`+N -M across K files`), перед separator (`<div className="h-4 w-px bg-border" />`).
|
||||
|
||||
### 5.3. Итоговый layout toolbar (слева направо)
|
||||
|
||||
1. Decision stats badges (pending, accepted, rejected)
|
||||
2. Change stats (+N -M across K files)
|
||||
3. Review progress bar ("12 of 45 reviewed")
|
||||
4. `flex-1` spacer
|
||||
5. Collapse toggle
|
||||
6. Auto-viewed toggle
|
||||
7. Separator
|
||||
8. Edited count badge (если есть)
|
||||
9. Separator (если есть edited)
|
||||
10. Accept All button
|
||||
11. Reject All button
|
||||
12. Apply button
|
||||
|
||||
---
|
||||
|
||||
## 6. Модификация ChangeReviewDialog.tsx
|
||||
|
||||
### 6.1. handleAcceptAll -- все файлы
|
||||
|
||||
Текущая реализация:
|
||||
|
||||
```typescript
|
||||
const handleAcceptAll = useCallback(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) acceptAllChunks(view);
|
||||
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
|
||||
}, [selectedReviewFilePath, acceptAllFile]);
|
||||
```
|
||||
|
||||
Continuous mode:
|
||||
|
||||
```typescript
|
||||
const handleAcceptAll = useCallback(() => {
|
||||
if (isContinuousMode) {
|
||||
// 1. Store: пометить все hunks во всех файлах как accepted
|
||||
acceptAll(); // store action — уже помечает ВСЕ файлы
|
||||
|
||||
// 2. CM: применить acceptAllChunks к каждому EditorView
|
||||
const map = continuousScrollRef.current?.getEditorViewMap();
|
||||
if (map) {
|
||||
const views = Array.from(map.values());
|
||||
// Batch: используем requestAnimationFrame для предотвращения layout thrashing
|
||||
requestAnimationFrame(() => {
|
||||
for (const view of views) {
|
||||
acceptAllChunks(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Single-file mode (без изменений)
|
||||
const view = editorViewRef.current;
|
||||
if (view) acceptAllChunks(view);
|
||||
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
|
||||
}
|
||||
}, [isContinuousMode, acceptAll, selectedReviewFilePath, acceptAllFile]);
|
||||
```
|
||||
|
||||
### 6.2. handleRejectAll -- все файлы
|
||||
|
||||
```typescript
|
||||
const handleRejectAll = useCallback(() => {
|
||||
if (isContinuousMode) {
|
||||
// 1. Store: пометить все hunks во всех файлах как rejected
|
||||
rejectAll(); // store action
|
||||
|
||||
// 2. CM: применить rejectAllChunks к каждому EditorView
|
||||
const map = continuousScrollRef.current?.getEditorViewMap();
|
||||
if (map) {
|
||||
const views = Array.from(map.values());
|
||||
requestAnimationFrame(() => {
|
||||
for (const view of views) {
|
||||
rejectAllChunks(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const view = editorViewRef.current;
|
||||
if (view) rejectAllChunks(view);
|
||||
if (selectedReviewFilePath) rejectAllFile(selectedReviewFilePath);
|
||||
}
|
||||
}, [isContinuousMode, rejectAll, selectedReviewFilePath, rejectAllFile]);
|
||||
```
|
||||
|
||||
### 6.3. handleSaveFile -- по activeFilePath
|
||||
|
||||
```typescript
|
||||
const handleSaveFile = useCallback((filePath: string) => {
|
||||
void saveEditedFile(filePath);
|
||||
}, [saveEditedFile]);
|
||||
|
||||
// Для toolbar/keyboard: сохраняет activeFilePath
|
||||
const handleSaveActiveFile = useCallback(() => {
|
||||
if (isContinuousMode) {
|
||||
// activeFilePath определяется scroll-spy в ContinuousScrollView
|
||||
// Передается через state или callback
|
||||
const activePath = continuousScrollActiveFilePath;
|
||||
if (activePath) handleSaveFile(activePath);
|
||||
} else {
|
||||
if (selectedReviewFilePath) handleSaveFile(selectedReviewFilePath);
|
||||
}
|
||||
}, [isContinuousMode, continuousScrollActiveFilePath, selectedReviewFilePath, handleSaveFile]);
|
||||
```
|
||||
|
||||
### 6.4. handleDiscardFile -- per-file
|
||||
|
||||
```typescript
|
||||
const handleDiscardFile = useCallback((filePath: string) => {
|
||||
// В continuous mode editorStateCache НЕ используется
|
||||
// (все editors живут одновременно — cache не нужен, см. Phase 1 секция 4.3)
|
||||
discardFileEdits(filePath);
|
||||
setDiscardCounters(prev => ({
|
||||
...prev,
|
||||
[filePath]: (prev[filePath] ?? 0) + 1
|
||||
}));
|
||||
}, [discardFileEdits]);
|
||||
|
||||
// Для keyboard/toolbar: discard activeFilePath
|
||||
const handleDiscardActiveFile = useCallback(() => {
|
||||
const activePath = isContinuousMode
|
||||
? continuousScrollActiveFilePath
|
||||
: selectedReviewFilePath;
|
||||
if (activePath) handleDiscardFile(activePath);
|
||||
}, [isContinuousMode, continuousScrollActiveFilePath, selectedReviewFilePath, handleDiscardFile]);
|
||||
```
|
||||
|
||||
**Важно:** `editorStateCache` не используется в continuous mode. Phase 1 (секция 4.3) устанавливает, что в continuous mode все editors живут одновременно и нет необходимости в кеше EditorState. Discard реализуется через `discardCounters` (пересоздание через key).
|
||||
|
||||
### 6.5. isContinuousMode state
|
||||
|
||||
```typescript
|
||||
// ChangeReviewDialog.tsx
|
||||
// Phase 5: continuous scroll mode
|
||||
// Вычисляется, не является toggle:
|
||||
const isContinuousMode = (activeChangeSet?.files.length ?? 0) > 1;
|
||||
```
|
||||
|
||||
**Решение:** `isContinuousMode` вычисляется, не является toggle. Continuous mode включается когда файлов > 1. Для одного файла -- обычный single-file mode (без ContinuousScrollView).
|
||||
|
||||
### 6.6. activeFilePath из ContinuousScrollView
|
||||
|
||||
ContinuousScrollView определяет видимый файл через scroll-spy и сообщает родителю:
|
||||
|
||||
```typescript
|
||||
// ContinuousScrollView.tsx props
|
||||
interface ContinuousScrollViewProps {
|
||||
// ...
|
||||
onActiveFileChange: (filePath: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
В ChangeReviewDialog:
|
||||
|
||||
```typescript
|
||||
const [continuousScrollActiveFilePath, setContinuousScrollActiveFilePath] = useState<string | null>(null);
|
||||
|
||||
<ContinuousScrollView
|
||||
onActiveFileChange={setContinuousScrollActiveFilePath}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Per-file discard counter
|
||||
|
||||
### 7.1. Проблема
|
||||
|
||||
Текущий `discardCounter` -- одно число для всего диалога. При discard оно инкрементируется, и CodeMirrorDiffView пересоздается через `key={filePath}:${discardCounter}`.
|
||||
|
||||
В continuous mode каждый файл имеет свой `CodeMirrorDiffView`. Инкремент общего counter пересоздаст ВСЕ EditorView -- это неэффективно и потеряет scroll position.
|
||||
|
||||
### 7.2. Решение: Record<string, number>
|
||||
|
||||
```typescript
|
||||
// ChangeReviewDialog.tsx
|
||||
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
|
||||
```
|
||||
|
||||
### 7.3. Использование в FileSectionDiff key
|
||||
|
||||
```tsx
|
||||
// ContinuousScrollView.tsx — передает counter каждому FileSectionDiff
|
||||
{files.map(file => (
|
||||
<FileSectionDiff
|
||||
key={`${file.filePath}:${discardCounters[file.filePath] ?? 0}`}
|
||||
filePath={file.filePath}
|
||||
discardCounter={discardCounters[file.filePath] ?? 0}
|
||||
// ...
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
Внутри FileSectionDiff, CodeMirrorDiffView:
|
||||
|
||||
```tsx
|
||||
<CodeMirrorDiffView
|
||||
key={`${filePath}:${discardCounter}`}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
### 7.4. Discard action
|
||||
|
||||
```typescript
|
||||
const handleDiscardFile = useCallback((filePath: string) => {
|
||||
// 1. Удаляем edited content из store
|
||||
discardFileEdits(filePath);
|
||||
|
||||
// 2. Инкрементируем counter ТОЛЬКО для этого файла
|
||||
setDiscardCounters(prev => ({
|
||||
...prev,
|
||||
[filePath]: (prev[filePath] ?? 0) + 1,
|
||||
}));
|
||||
}, [discardFileEdits]);
|
||||
```
|
||||
|
||||
Результат: пересоздается ТОЛЬКО EditorView для конкретного файла. Все остальные EditorViews сохраняют состояние.
|
||||
|
||||
### 7.5. Обратная совместимость
|
||||
|
||||
Для single-file mode (когда ContinuousScrollView не используется) сохраняется существующий `discardCounter: number` без изменений. `discardCounters: Record<string, number>` используется только в continuous mode. Оба варианта сосуществуют в ChangeReviewDialog:
|
||||
|
||||
```typescript
|
||||
// Single-file mode: существующий counter
|
||||
const [discardCounter, setDiscardCounter] = useState(0);
|
||||
|
||||
// Continuous mode: per-file counters
|
||||
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Cleanup при закрытии
|
||||
|
||||
### 8.1. EditorView Map
|
||||
|
||||
При unmount ContinuousScrollView:
|
||||
1. Каждый FileSectionDiff вызывает `onEditorViewReady(filePath, null)` (единый callback)
|
||||
2. Map автоматически очищается
|
||||
3. EditorView.destroy() вызывается внутри CodeMirrorDiffView cleanup
|
||||
|
||||
```typescript
|
||||
// ContinuousScrollView.tsx
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Safety: на случай если unmount происходит до cleanup дочерних
|
||||
editorViewMapRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 8.2. Store state
|
||||
|
||||
`clearChangeReview()` из changeReviewSlice уже сбрасывает:
|
||||
- `activeChangeSet`
|
||||
- `hunkDecisions`
|
||||
- `fileDecisions`
|
||||
- `fileContents`
|
||||
- `fileContentsLoading`
|
||||
- `editedContents`
|
||||
- `applying`
|
||||
- `applyError`
|
||||
|
||||
Дополнительных действий не требуется.
|
||||
|
||||
### 8.3. Viewed state
|
||||
|
||||
`viewedSet` persistent через `localStorage` (useViewedFiles -> diffViewedStorage). НЕ очищается при закрытии диалога -- это намеренное поведение (пользователь может закрыть и открыть диалог, и viewed файлы останутся).
|
||||
|
||||
### 8.4. discardCounters
|
||||
|
||||
React state -- автоматически GC при unmount компонента. Не persistent.
|
||||
|
||||
---
|
||||
|
||||
## 9. Edge-cases
|
||||
|
||||
### 9.1. 50 EditorViews в памяти
|
||||
|
||||
**Проблема:** каждый EditorView -- DOM-элемент с syntax highlighting, diff computations, merge extensions.
|
||||
|
||||
**Смягчение:**
|
||||
- portionCollapse (Phase 4) минимизирует видимый контент: свёрнутые regions не рендерят DOM-ноды
|
||||
- Lazy loading (Phase 2) гарантирует, что контент загружается по мере необходимости, а не все сразу
|
||||
|
||||
**Если профилирование покажет проблемы:**
|
||||
- Будущая оптимизация: destroy EditorView для файлов далеко за пределами viewport
|
||||
- `onEditorViewReady(filePath, null)` уже в интерфейсе -- переход на destroy/recreate модель не потребует изменения API
|
||||
- Placeholder вместо destroyed EditorView (высота сохраняется через cached `scrollHeight`)
|
||||
|
||||
**Реализация (не в Phase 5, на будущее):**
|
||||
|
||||
```typescript
|
||||
// Идея: IntersectionObserver с rootMargin для pre-destroy
|
||||
const DESTROY_MARGIN = '2000px'; // destroy если > 2000px от viewport
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const entry of entries) {
|
||||
const filePath = entry.target.dataset.filePath!;
|
||||
if (entry.isIntersecting) {
|
||||
// Восстановить EditorView
|
||||
} else {
|
||||
// Destroy EditorView, сохранить высоту
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: DESTROY_MARGIN }
|
||||
);
|
||||
```
|
||||
|
||||
### 9.2. Accept All + 50 файлов
|
||||
|
||||
**Проблема:** `acceptAllChunks` на 50 EditorView может вызвать layout thrashing.
|
||||
|
||||
**Решение:**
|
||||
|
||||
```typescript
|
||||
// Batch: один rAF на все view updates
|
||||
requestAnimationFrame(() => {
|
||||
const map = continuousScrollRef.current?.getEditorViewMap();
|
||||
if (!map) return;
|
||||
|
||||
for (const view of map.values()) {
|
||||
acceptAllChunks(view);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Это группирует все DOM-мутации в один frame. CodeMirror batches DOM updates внутри `dispatch()`, так что 50 dispatches в одном rAF -- приемлемо.
|
||||
|
||||
**Store:** `acceptAll()` уже batched -- одна транзакция `set()` обновляет все `hunkDecisions` и `fileDecisions`.
|
||||
|
||||
### 9.3. Cmd+Y/N без видимых chunks
|
||||
|
||||
**Сценарий:** все chunks в текущем файле уже accepted/rejected. Пользователь нажимает Cmd+Y.
|
||||
|
||||
**Поведение:** `acceptChunk(view)` от @codemirror/merge не делает ничего, если нет chunk под cursor. `goToNextChunk(view)` аналогично -- no-op.
|
||||
|
||||
**Это корректно.** Не нужен дополнительный feedback (звук, toast и т.д.).
|
||||
|
||||
### 9.4. File save в continuous mode
|
||||
|
||||
**Сценарий:** пользователь нажимает Cmd+Enter. Сохраняется только `activeFilePath`, НЕ все отредактированные файлы.
|
||||
|
||||
**Обоснование:**
|
||||
- Пользователь ожидает "save THIS file", не "save ALL files"
|
||||
- Для массового save есть Apply All Changes
|
||||
- Если добавить "Save All Edited" -- это отдельная фича (не в Phase 5)
|
||||
|
||||
### 9.5. Scroll position после Accept All / Reject All
|
||||
|
||||
**Проблема:** Accept All может значительно изменить высоту контента (deleted chunks исчезают). Scroll position может сместиться.
|
||||
|
||||
**Решение:** браузер автоматически корректирует scroll при изменении высоты элементов ВЫШЕ viewport. Для элементов В viewport -- пользователь увидит изменение, что ожидаемо.
|
||||
|
||||
Если нужно сохранить позицию:
|
||||
|
||||
```typescript
|
||||
// Перед Accept All
|
||||
const scrollTop = scrollContainerRef.current?.scrollTop ?? 0;
|
||||
// ... apply accept all ...
|
||||
requestAnimationFrame(() => {
|
||||
scrollContainerRef.current?.scrollTo({ top: scrollTop });
|
||||
});
|
||||
```
|
||||
|
||||
Но это может быть нежелательно (пользователь хочет видеть результат). **Решение: не корректировать scroll.**
|
||||
|
||||
### 9.6. Race condition: onEditorViewReady + component key change
|
||||
|
||||
**Сценарий:** discard file -> key меняется -> old FileSectionDiff unmount -> new mount.
|
||||
|
||||
**Порядок:**
|
||||
1. Old component: cleanup effect -> `onEditorViewReady(filePath, null)` -> Map.delete
|
||||
2. New component: effect -> `onEditorViewReady(filePath, newView)` -> Map.set
|
||||
|
||||
React гарантирует cleanup effects ПЕРЕД mount effects. Race condition невозможна.
|
||||
|
||||
### 9.7. EditorView для файла с unavailable content
|
||||
|
||||
Если `fileContent.contentSource === 'unavailable'`, FileSectionDiff рендерит fallback (ReviewDiffContent), не CodeMirrorDiffView. EditorView не создается -> не попадает в Map.
|
||||
|
||||
При Accept All/Reject All -- файлы без EditorView обрабатываются только через store (hunkDecisions). Это корректно.
|
||||
|
||||
---
|
||||
|
||||
## 10. Проверка
|
||||
|
||||
### 10.1. Автоматические тесты
|
||||
|
||||
**Unit tests:**
|
||||
|
||||
| Тест | Файл | Что проверяет |
|
||||
|------|------|---------------|
|
||||
| resolveActiveEditorView с focused editor | `resolveActiveEditorView.test.ts` | Возвращает focused EditorView из Map |
|
||||
| resolveActiveEditorView fallback на activeFilePath | `resolveActiveEditorView.test.ts` | Возвращает EditorView для activeFilePath |
|
||||
| resolveActiveEditorView пустая Map | `resolveActiveEditorView.test.ts` | Возвращает null |
|
||||
| discardCounters per-file increment | `ChangeReviewDialog.test.ts` | Инкремент только для одного файла |
|
||||
| reviewProgress computation | `ChangeReviewDialog.test.ts` | Корректный подсчет reviewed/total |
|
||||
| ReviewToolbar tooltip в continuous mode | `ReviewToolbar.test.ts` | "across all files" текст |
|
||||
|
||||
**Integration tests:**
|
||||
|
||||
| Тест | Что проверяет |
|
||||
|------|---------------|
|
||||
| Accept All в continuous mode | Store + все EditorViews обновлены |
|
||||
| Reject All в continuous mode | Store + все EditorViews обновлены |
|
||||
| Discard one file | Только один EditorView пересоздан |
|
||||
| Auto-viewed multiple files | Несколько файлов помечены viewed за один скролл |
|
||||
| Keyboard Cmd+Y с focused editor | Accept в focused editor, не в activeFilePath |
|
||||
|
||||
### 10.2. Ручное тестирование
|
||||
|
||||
**Чеклист:**
|
||||
|
||||
- [ ] Открыть review dialog с 5+ файлами
|
||||
- [ ] Проскроллить вниз — auto-viewed помечает файлы по мере скролла
|
||||
- [ ] Выключить auto-viewed toggle — скролл не помечает файлы
|
||||
- [ ] Cmd+Y в focused editor — принимает chunk в этом editor
|
||||
- [ ] Cmd+Y без фокуса — принимает chunk в activeFilePath editor
|
||||
- [ ] Cmd+N — отклоняет chunk + переходит к следующему
|
||||
- [ ] Cmd+Enter — сохраняет только текущий файл
|
||||
- [ ] "Accept All" кнопка — все chunks во всех файлах accepted
|
||||
- [ ] "Reject All" кнопка — все chunks во всех файлах rejected
|
||||
- [ ] Discard файла — только этот EditorView пересоздается
|
||||
- [ ] Progress bar обновляется при accept/reject
|
||||
- [ ] Закрытие и повторное открытие — viewed state сохранен
|
||||
- [ ] 20+ файлов — scroll не лагает
|
||||
- [ ] Accept All + 20 файлов — без видимого зависания
|
||||
|
||||
### 10.3. Performance профилирование
|
||||
|
||||
- [ ] Chrome DevTools Performance: rAF timing при Accept All с 20 файлов (должен быть < 100ms)
|
||||
- [ ] Memory: heap snapshot с 20 EditorViews (ожидание: ~50-80MB total)
|
||||
- [ ] Layout: no forced synchronous layouts при scroll
|
||||
|
||||
---
|
||||
|
||||
## Приложение: Полный diff изменений по файлам
|
||||
|
||||
### Новые файлы
|
||||
|
||||
Нет новых файлов в Phase 5 (все компоненты созданы в Phase 1-4).
|
||||
|
||||
### Модифицируемые файлы
|
||||
|
||||
| Файл | Изменения |
|
||||
|------|-----------|
|
||||
| `ContinuousScrollView.tsx` | EditorView Map, useImperativeHandle, onActiveFileChange callback |
|
||||
| `FileSectionDiff.tsx` | onEditorViewReady(filePath, view \| null) единый callback, per-file sentinel, autoViewed |
|
||||
| `ChangeReviewDialog.tsx` | isContinuousMode, handleAcceptAll/RejectAll multi-file, discardCounters, continuousScrollActiveFilePath state, EditorView Map через ref |
|
||||
| `ReviewToolbar.tsx` | isContinuousMode tooltip, progress indicator, reviewedCount/totalHunks props |
|
||||
| `useDiffNavigation.ts` | Без дополнительных изменений Phase 5 — вся continuous mode логика уже реализована в Phase 3 (continuousOptions, getActiveEditorView, cross-file navigation) |
|
||||
|
||||
### Неизменяемые файлы
|
||||
|
||||
| Файл | Причина |
|
||||
|------|---------|
|
||||
| `CodeMirrorDiffView.tsx` | Без изменений — все обертывается через FileSectionDiff |
|
||||
| `CodeMirrorDiffUtils.ts` | acceptAllChunks/rejectAllChunks уже поддерживают per-view вызов |
|
||||
| `changeReviewSlice.ts` | acceptAll()/rejectAll() уже работают со всеми файлами |
|
||||
| `useViewedFiles.ts` | markViewed() уже поддерживает per-file вызовы |
|
||||
| `ReviewFileTree.tsx` | Без изменений в Phase 5 (модифицирован в Phase 1) |
|
||||
| `KeyboardShortcutsHelp.tsx` | Без изменений в Phase 5 (модифицирован в Phase 3) |
|
||||
|
|
@ -4,6 +4,8 @@ import { createReadStream } from 'fs';
|
|||
import { stat } from 'fs/promises';
|
||||
import * as readline from 'readline';
|
||||
|
||||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
|
||||
import type { TaskBoundaryParser } from './TaskBoundaryParser';
|
||||
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import type {
|
||||
|
|
@ -39,7 +41,8 @@ export class ChangeExtractorService {
|
|||
|
||||
constructor(
|
||||
private readonly logsFinder: TeamMemberLogsFinder,
|
||||
private readonly boundaryParser: TaskBoundaryParser
|
||||
private readonly boundaryParser: TaskBoundaryParser,
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader()
|
||||
) {}
|
||||
|
||||
/** Получить все изменения агента */
|
||||
|
|
@ -51,6 +54,7 @@ export class ChangeExtractorService {
|
|||
}
|
||||
|
||||
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
|
||||
const projectPath = await this.resolveProjectPath(teamName);
|
||||
|
||||
// Собираем все snippets из всех JSONL файлов
|
||||
const allSnippets: SnippetDiff[] = [];
|
||||
|
|
@ -70,7 +74,7 @@ export class ChangeExtractorService {
|
|||
allSnippets.push(...snippets);
|
||||
}
|
||||
|
||||
const files = this.aggregateByFile(allSnippets);
|
||||
const files = this.aggregateByFile(allSnippets, projectPath);
|
||||
|
||||
let totalLinesAdded = 0;
|
||||
let totalLinesRemoved = 0;
|
||||
|
|
@ -106,6 +110,8 @@ export class ChangeExtractorService {
|
|||
return this.emptyTaskChangeSet(teamName, taskId);
|
||||
}
|
||||
|
||||
const projectPath = await this.resolveProjectPath(teamName);
|
||||
|
||||
// Парсим boundaries для каждого лог-файла и ищем scope данной задачи
|
||||
const allScopes: TaskChangeScope[] = [];
|
||||
for (const ref of logRefs) {
|
||||
|
|
@ -118,12 +124,12 @@ export class ChangeExtractorService {
|
|||
|
||||
// Если scope не найден — fallback на весь файл
|
||||
if (allScopes.length === 0) {
|
||||
return this.fallbackSingleTaskScope(teamName, taskId, logRefs);
|
||||
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath);
|
||||
}
|
||||
|
||||
// Фильтруем snippets по tool_use IDs из scope
|
||||
const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds));
|
||||
const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds);
|
||||
const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds, projectPath);
|
||||
|
||||
const worstTier = Math.max(...allScopes.map((s) => s.confidence.tier));
|
||||
const warnings: string[] = [];
|
||||
|
|
@ -157,6 +163,16 @@ export class ChangeExtractorService {
|
|||
|
||||
// ---- Private methods ----
|
||||
|
||||
/** Получить projectPath из конфига команды */
|
||||
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
return config?.projectPath?.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */
|
||||
private async parseJSONLFile(filePath: string): Promise<SnippetDiff[]> {
|
||||
// Сначала считываем все записи в память для двух проходов
|
||||
|
|
@ -376,11 +392,19 @@ export class ChangeExtractorService {
|
|||
totalAdded += added;
|
||||
totalRemoved += removed;
|
||||
}
|
||||
// Normalize separators for cross-platform path stripping
|
||||
const normalizedFp = fp.replace(/\\/g, '/');
|
||||
const normalizedProject = projectPath?.replace(/\\/g, '/');
|
||||
const relative = normalizedProject
|
||||
? normalizedFp.startsWith(normalizedProject + '/')
|
||||
? normalizedFp.slice(normalizedProject.length + 1)
|
||||
: normalizedFp.startsWith(normalizedProject)
|
||||
? normalizedFp.slice(normalizedProject.length)
|
||||
: normalizedFp.split('/').slice(-3).join('/')
|
||||
: normalizedFp.split('/').slice(-3).join('/');
|
||||
return {
|
||||
filePath: fp,
|
||||
relativePath: projectPath
|
||||
? fp.replace(projectPath + '/', '')
|
||||
: fp.split('/').slice(-3).join('/'),
|
||||
relativePath: relative,
|
||||
snippets: data.snippets,
|
||||
linesAdded: totalAdded,
|
||||
linesRemoved: totalRemoved,
|
||||
|
|
@ -487,7 +511,8 @@ export class ChangeExtractorService {
|
|||
/** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */
|
||||
private async extractFilteredChanges(
|
||||
logRefs: LogFileRef[],
|
||||
allowedToolUseIds: Set<string>
|
||||
allowedToolUseIds: Set<string>,
|
||||
projectPath?: string
|
||||
): Promise<FileChangeSummary[]> {
|
||||
const allSnippets: SnippetDiff[] = [];
|
||||
for (const ref of logRefs) {
|
||||
|
|
@ -503,27 +528,29 @@ export class ChangeExtractorService {
|
|||
allSnippets.push(...snippets);
|
||||
}
|
||||
}
|
||||
return this.aggregateByFile(allSnippets);
|
||||
return this.aggregateByFile(allSnippets, projectPath);
|
||||
}
|
||||
|
||||
/** Извлечь все изменения из одного файла */
|
||||
private async extractAllChanges(
|
||||
filePath: string,
|
||||
_memberName: string
|
||||
_memberName: string,
|
||||
projectPath?: string
|
||||
): Promise<FileChangeSummary[]> {
|
||||
const snippets = await this.parseJSONLFile(filePath);
|
||||
return this.aggregateByFile(snippets);
|
||||
return this.aggregateByFile(snippets, projectPath);
|
||||
}
|
||||
|
||||
/** Fallback: вернуть все изменения из лог-файлов как Tier 4 */
|
||||
private async fallbackSingleTaskScope(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
logRefs: LogFileRef[]
|
||||
logRefs: LogFileRef[],
|
||||
projectPath?: string
|
||||
): Promise<TaskChangeSetV2> {
|
||||
const allFiles: FileChangeSummary[] = [];
|
||||
for (const ref of logRefs) {
|
||||
const files = await this.extractAllChanges(ref.filePath, ref.memberName);
|
||||
const files = await this.extractAllChanges(ref.filePath, ref.memberName, projectPath);
|
||||
allFiles.push(...files);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -495,10 +495,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
}
|
||||
}, [teamName, refreshTeamData]);
|
||||
|
||||
const selectReviewFile = useStore((s) => s.selectReviewFile);
|
||||
|
||||
const handleViewChanges = useCallback((taskId: string) => {
|
||||
setReviewDialogState({ open: true, mode: 'task', taskId });
|
||||
}, []);
|
||||
|
||||
const handleViewChangesForFile = useCallback(
|
||||
(taskId: string, filePath?: string) => {
|
||||
setReviewDialogState({ open: true, mode: 'task', taskId });
|
||||
if (filePath) {
|
||||
selectReviewFile(filePath);
|
||||
}
|
||||
},
|
||||
[selectReviewFile]
|
||||
);
|
||||
|
||||
const handleDeleteTeam = useCallback((): void => {
|
||||
setDeleteConfirmOpen(true);
|
||||
}, []);
|
||||
|
|
@ -1093,6 +1105,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
setSendDialogOpen(true);
|
||||
}}
|
||||
onMessageVisible={handleMessageVisible}
|
||||
onTaskIdClick={(taskId) => {
|
||||
const task = taskMap.get(taskId);
|
||||
if (task) setSelectedTask(task);
|
||||
}}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
|
|
@ -1331,6 +1347,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
onOwnerChange={(taskId, owner) => {
|
||||
void updateTaskOwner(teamName, taskId, owner);
|
||||
}}
|
||||
onViewChanges={handleViewChangesForFile}
|
||||
/>
|
||||
|
||||
<ChangeReviewDialog
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ interface ActivityItemProps {
|
|||
onMemberNameClick?: (memberName: string) => void;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
/** Called when a task ID link (e.g. #10) is clicked in message text. */
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function getStringField(obj: StructuredMessage, key: string): string | null {
|
||||
|
|
@ -125,6 +127,11 @@ function getSystemMessageLabel(text: string): string | null {
|
|||
// Full message card — left colored border, name badge, collapsible content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
||||
function linkifyTaskIdsInMarkdown(text: string): string {
|
||||
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
||||
}
|
||||
|
||||
export const ActivityItem = ({
|
||||
message,
|
||||
teamName,
|
||||
|
|
@ -135,6 +142,7 @@ export const ActivityItem = ({
|
|||
onMemberNameClick,
|
||||
onCreateTask,
|
||||
onReply,
|
||||
onTaskIdClick,
|
||||
}: ActivityItemProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||
const formattedRole = formatAgentRole(memberRole);
|
||||
|
|
@ -153,11 +161,13 @@ export const ActivityItem = ({
|
|||
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
||||
const [isExpanded, setIsExpanded] = useState(!systemLabel);
|
||||
|
||||
// Strip agent-only blocks from displayed text
|
||||
const displayText = useMemo(
|
||||
() => (structured ? null : stripAgentBlocks(message.text)),
|
||||
[structured, message.text]
|
||||
);
|
||||
// Strip agent-only blocks from displayed text + linkify task IDs
|
||||
const displayText = useMemo(() => {
|
||||
if (structured) return null;
|
||||
const stripped = stripAgentBlocks(message.text).trim();
|
||||
if (!stripped) return null; // All content was agent-only blocks → show summary instead
|
||||
return onTaskIdClick ? linkifyTaskIdsInMarkdown(stripped) : stripped;
|
||||
}, [structured, message.text, onTaskIdClick]);
|
||||
|
||||
// Check if this is a reply message
|
||||
const parsedReply = useMemo(
|
||||
|
|
@ -355,9 +365,31 @@ export const ActivityItem = ({
|
|||
</div>
|
||||
) : parsedReply ? (
|
||||
<ReplyQuoteBlock reply={parsedReply} />
|
||||
) : (
|
||||
<MarkdownViewer content={displayText ?? ''} maxHeight="max-h-56" copyable bare />
|
||||
)}
|
||||
) : displayText ? (
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (e) => {
|
||||
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||
'a[href^="task://"]'
|
||||
);
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const taskId = link.getAttribute('href')?.replace('task://', '');
|
||||
if (taskId) onTaskIdClick(taskId);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MarkdownViewer content={displayText} maxHeight="max-h-56" copyable bare />
|
||||
</span>
|
||||
) : summaryText ? (
|
||||
<p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
{summaryText}
|
||||
</p>
|
||||
) : null}
|
||||
{message.attachments?.length && message.messageId ? (
|
||||
<AttachmentDisplay
|
||||
teamName={teamName}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ interface ActivityTimelineProps {
|
|||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||
/** Called when a message enters the viewport (for marking as read). */
|
||||
onMessageVisible?: (message: InboxMessage) => void;
|
||||
/** Called when a task ID link (e.g. #10) is clicked in message text. */
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const VIEWPORT_THRESHOLD = 0.15;
|
||||
|
|
@ -35,6 +37,7 @@ const MessageRowWithObserver = ({
|
|||
onCreateTask,
|
||||
onReply,
|
||||
onVisible,
|
||||
onTaskIdClick,
|
||||
}: {
|
||||
message: InboxMessage;
|
||||
teamName: string;
|
||||
|
|
@ -46,6 +49,7 @@ const MessageRowWithObserver = ({
|
|||
onCreateTask?: (subject: string, description: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
onVisible?: (message: InboxMessage) => void;
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
}): React.JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reportedRef = useRef(false);
|
||||
|
|
@ -89,6 +93,7 @@ const MessageRowWithObserver = ({
|
|||
onMemberNameClick={onMemberNameClick}
|
||||
onCreateTask={onCreateTask}
|
||||
onReply={onReply}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -103,6 +108,7 @@ export const ActivityTimeline = ({
|
|||
onReplyToMessage,
|
||||
onMemberClick,
|
||||
onMessageVisible,
|
||||
onTaskIdClick,
|
||||
}: ActivityTimelineProps): React.JSX.Element => {
|
||||
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
|
||||
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
||||
|
|
@ -167,6 +173,7 @@ export const ActivityTimeline = ({
|
|||
onCreateTask={onCreateTaskFromMessage}
|
||||
onReply={onReplyToMessage}
|
||||
onVisible={onMessageVisible}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from '@renderer/components/ui/select';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
buildMemberColorMap,
|
||||
|
|
@ -31,7 +32,15 @@ import {
|
|||
TASK_STATUS_STYLES,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeftFromLine,
|
||||
ArrowRightFromLine,
|
||||
Clock,
|
||||
FileCode,
|
||||
Link2,
|
||||
Loader2,
|
||||
PenLine,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||
|
||||
|
|
@ -47,6 +56,7 @@ interface TaskDetailDialogProps {
|
|||
onClose: () => void;
|
||||
onScrollToTask?: (taskId: string) => void;
|
||||
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
||||
onViewChanges?: (taskId: string, filePath?: string) => void;
|
||||
}
|
||||
|
||||
export const TaskDetailDialog = ({
|
||||
|
|
@ -59,6 +69,7 @@ export const TaskDetailDialog = ({
|
|||
onClose,
|
||||
onScrollToTask,
|
||||
onOwnerChange,
|
||||
onViewChanges,
|
||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||
|
|
@ -71,6 +82,35 @@ export const TaskDetailDialog = ({
|
|||
if (latest > 0) markAsRead(teamName, currentTask.id, latest);
|
||||
}, [open, teamName, currentTask]);
|
||||
|
||||
// Lazy-load task changes when dialog is open and task is completed
|
||||
const isTaskCompleted = currentTask?.status === 'completed';
|
||||
const activeChangeSet = useStore((s) => s.activeChangeSet);
|
||||
const changeSetLoading = useStore((s) => s.changeSetLoading);
|
||||
const fetchTaskChanges = useStore((s) => s.fetchTaskChanges);
|
||||
|
||||
const taskChangesFiles = useMemo(() => {
|
||||
if (!activeChangeSet || !currentTask) return null;
|
||||
if ('taskId' in activeChangeSet && activeChangeSet.taskId === currentTask.id) {
|
||||
return activeChangeSet.files;
|
||||
}
|
||||
return null;
|
||||
}, [activeChangeSet, currentTask]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !currentTask || !isTaskCompleted || !onViewChanges) return;
|
||||
// Only fetch if we don't already have data for this task
|
||||
if (taskChangesFiles !== null) return;
|
||||
void fetchTaskChanges(teamName, currentTask.id);
|
||||
}, [
|
||||
open,
|
||||
currentTask,
|
||||
isTaskCompleted,
|
||||
teamName,
|
||||
fetchTaskChanges,
|
||||
taskChangesFiles,
|
||||
onViewChanges,
|
||||
]);
|
||||
|
||||
const handleDependencyClick = (taskId: string): void => {
|
||||
onClose();
|
||||
onScrollToTask?.(taskId);
|
||||
|
|
@ -217,6 +257,51 @@ export const TaskDetailDialog = ({
|
|||
)}
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
{/* Changes */}
|
||||
{isTaskCompleted && onViewChanges ? (
|
||||
<CollapsibleTeamSection
|
||||
title="Changes"
|
||||
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
||||
defaultOpen
|
||||
>
|
||||
{changeSetLoading && !taskChangesFiles ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading changes...
|
||||
</div>
|
||||
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
|
||||
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
|
||||
{taskChangesFiles.map((file) => (
|
||||
<button
|
||||
key={file.filePath}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onViewChanges(currentTask.id, file.filePath);
|
||||
}}
|
||||
>
|
||||
<FileCode size={14} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[var(--color-text-secondary)]">
|
||||
{file.relativePath}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{file.linesAdded > 0 ? (
|
||||
<span className="text-emerald-400">+{file.linesAdded}</span>
|
||||
) : null}
|
||||
{file.linesRemoved > 0 ? (
|
||||
<span className="text-red-400">-{file.linesRemoved}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">No file changes detected</p>
|
||||
)}
|
||||
</CollapsibleTeamSection>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3 space-y-2">
|
||||
{/* Dependencies */}
|
||||
{blockedByIds.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -2,26 +2,22 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
||||
import { goToNextChunk, rejectChunk } from '@codemirror/merge';
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
||||
import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav';
|
||||
import { isLastChunkInFile, useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
||||
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { ChevronDown, Clock, Loader2, Save, Undo2, X } from 'lucide-react';
|
||||
import { ChevronDown, Clock, X } from 'lucide-react';
|
||||
|
||||
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
|
||||
import { ConfidenceBadge } from './ConfidenceBadge';
|
||||
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
||||
import { ContinuousScrollView } from './ContinuousScrollView';
|
||||
import { FileEditTimeline } from './FileEditTimeline';
|
||||
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
|
||||
import { ReviewDiffContent } from './ReviewDiffContent';
|
||||
import { ReviewFileTree } from './ReviewFileTree';
|
||||
import { ReviewToolbar } from './ReviewToolbar';
|
||||
import { ScopeWarningBanner } from './ScopeWarningBanner';
|
||||
import { ViewedProgressBar } from './ViewedProgressBar';
|
||||
|
||||
import type { EditorState } from '@codemirror/state';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { HunkDecision, TaskChangeSetV2 } from '@shared/types';
|
||||
|
||||
|
|
@ -34,14 +30,6 @@ interface ChangeReviewDialogProps {
|
|||
taskId?: string;
|
||||
}
|
||||
|
||||
const CONTENT_SOURCE_LABELS: Record<string, string> = {
|
||||
'file-history': 'File History',
|
||||
'snippet-reconstruction': 'Reconstructed',
|
||||
'disk-current': 'Current Disk',
|
||||
'git-fallback': 'Git Fallback',
|
||||
unavailable: 'Unavailable',
|
||||
};
|
||||
|
||||
function isTaskChangeSetV2(cs: { teamName: string }): cs is TaskChangeSetV2 {
|
||||
return 'scope' in cs;
|
||||
}
|
||||
|
|
@ -58,12 +46,9 @@ export const ChangeReviewDialog = ({
|
|||
activeChangeSet,
|
||||
changeSetLoading,
|
||||
changeSetError,
|
||||
selectedReviewFilePath,
|
||||
fetchAgentChanges,
|
||||
fetchTaskChanges,
|
||||
selectReviewFile,
|
||||
clearChangeReview,
|
||||
// Phase 2
|
||||
hunkDecisions,
|
||||
fileDecisions,
|
||||
fileContents,
|
||||
|
|
@ -77,22 +62,38 @@ export const ChangeReviewDialog = ({
|
|||
acceptAllFile,
|
||||
rejectAllFile,
|
||||
applyReview,
|
||||
// Editable diff
|
||||
editedContents,
|
||||
updateEditedContent,
|
||||
discardFileEdits,
|
||||
saveEditedFile,
|
||||
} = useStore();
|
||||
|
||||
const editorViewRef = useRef<EditorView | null>(null);
|
||||
// Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll)
|
||||
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
|
||||
const [autoViewed, setAutoViewed] = useState(true);
|
||||
const [timelineOpen, setTimelineOpen] = useState(false);
|
||||
// Counter to force editor rebuild on discard
|
||||
const [discardCounter, setDiscardCounter] = useState(0);
|
||||
// Cache EditorState per file to preserve undo history between file switches
|
||||
const editorStateCache = useRef(new Map<string, EditorState>());
|
||||
// Current file's cached initial state (derived outside render to avoid ref access during render)
|
||||
const [cachedInitialState, setCachedInitialState] = useState<EditorState | undefined>(undefined);
|
||||
|
||||
// EditorView map for all visible file editors
|
||||
const editorViewMapRef = useRef(new Map<string, EditorView>());
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Proxy ref for useDiffNavigation (points to active file's editor)
|
||||
const activeEditorViewRef = useRef<EditorView | null>(null);
|
||||
const activeFilePathRef = useRef<string | null>(null);
|
||||
|
||||
// Keep refs in sync with activeFilePath
|
||||
useEffect(() => {
|
||||
activeFilePathRef.current = activeFilePath;
|
||||
activeEditorViewRef.current = activeFilePath
|
||||
? (editorViewMapRef.current.get(activeFilePath) ?? null)
|
||||
: null;
|
||||
}, [activeFilePath]);
|
||||
|
||||
// Continuous scroll navigation
|
||||
const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({
|
||||
scrollContainerRef,
|
||||
});
|
||||
|
||||
// Build scope key for viewed storage
|
||||
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
|
||||
|
|
@ -113,69 +114,122 @@ export const ChangeReviewDialog = ({
|
|||
progress: viewedProgress,
|
||||
} = useViewedFiles(teamName, scopeKey, allFilePaths);
|
||||
|
||||
// Editable diff computed values
|
||||
const editedCount = Object.keys(editedContents).length;
|
||||
const hasCurrentFileEdits = !!(
|
||||
selectedReviewFilePath && selectedReviewFilePath in editedContents
|
||||
);
|
||||
|
||||
// Save current editor state to cache before switching files
|
||||
const handleSelectFile = useCallback(
|
||||
(filePath: string | null) => {
|
||||
const view = editorViewRef.current;
|
||||
if (view && selectedReviewFilePath) {
|
||||
editorStateCache.current.set(selectedReviewFilePath, view.state);
|
||||
}
|
||||
setCachedInitialState(filePath ? editorStateCache.current.get(filePath) : undefined);
|
||||
selectReviewFile(filePath);
|
||||
// Scroll-spy handler
|
||||
const handleVisibleFileChange = useCallback((filePath: string) => {
|
||||
setActiveFilePath(filePath);
|
||||
}, []);
|
||||
|
||||
// Tree click → scroll to file
|
||||
const handleTreeFileClick = useCallback(
|
||||
(filePath: string) => {
|
||||
scrollToFile(filePath);
|
||||
setActiveFilePath(filePath);
|
||||
},
|
||||
[selectedReviewFilePath, selectReviewFile]
|
||||
[scrollToFile]
|
||||
);
|
||||
|
||||
// Accept/Reject all across all files
|
||||
const handleAcceptAll = useCallback(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) acceptAllChunks(view);
|
||||
if (selectedReviewFilePath) acceptAllFile(selectedReviewFilePath);
|
||||
}, [selectedReviewFilePath, acceptAllFile]);
|
||||
if (!activeChangeSet) return;
|
||||
for (const file of activeChangeSet.files) {
|
||||
acceptAllFile(file.filePath);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
for (const view of editorViewMapRef.current.values()) {
|
||||
acceptAllChunks(view);
|
||||
}
|
||||
});
|
||||
}, [activeChangeSet, acceptAllFile]);
|
||||
|
||||
const handleRejectAll = useCallback(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) rejectAllChunks(view);
|
||||
if (selectedReviewFilePath) rejectAllFile(selectedReviewFilePath);
|
||||
}, [selectedReviewFilePath, rejectAllFile]);
|
||||
|
||||
const handleSaveCurrentFile = useCallback(() => {
|
||||
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
|
||||
}, [selectedReviewFilePath, saveEditedFile]);
|
||||
|
||||
const handleDiscardCurrentFile = useCallback(() => {
|
||||
if (selectedReviewFilePath) {
|
||||
editorStateCache.current.delete(selectedReviewFilePath);
|
||||
setCachedInitialState(undefined);
|
||||
discardFileEdits(selectedReviewFilePath);
|
||||
setDiscardCounter((c) => c + 1);
|
||||
if (!activeChangeSet) return;
|
||||
for (const file of activeChangeSet.files) {
|
||||
rejectAllFile(file.filePath);
|
||||
}
|
||||
}, [selectedReviewFilePath, discardFileEdits]);
|
||||
requestAnimationFrame(() => {
|
||||
for (const view of editorViewMapRef.current.values()) {
|
||||
rejectAllChunks(view);
|
||||
}
|
||||
});
|
||||
}, [activeChangeSet, rejectAllFile]);
|
||||
|
||||
// Per-file callbacks for ContinuousScrollView
|
||||
const handleHunkAccepted = useCallback(
|
||||
(filePath: string, hunkIndex: number) => {
|
||||
setHunkDecision(filePath, hunkIndex, 'accepted');
|
||||
},
|
||||
[setHunkDecision]
|
||||
);
|
||||
|
||||
const handleHunkRejected = useCallback(
|
||||
(filePath: string, hunkIndex: number) => {
|
||||
setHunkDecision(filePath, hunkIndex, 'rejected');
|
||||
},
|
||||
[setHunkDecision]
|
||||
);
|
||||
|
||||
const handleContentChanged = useCallback(
|
||||
(filePath: string, content: string) => {
|
||||
updateEditedContent(filePath, content);
|
||||
},
|
||||
[updateEditedContent]
|
||||
);
|
||||
|
||||
const handleFullyViewed = useCallback(
|
||||
(filePath: string) => {
|
||||
if (autoViewed && !isViewed(filePath)) {
|
||||
markViewed(filePath);
|
||||
}
|
||||
},
|
||||
[autoViewed, isViewed, markViewed]
|
||||
);
|
||||
|
||||
const handleSaveFile = useCallback(
|
||||
(filePath: string) => {
|
||||
void saveEditedFile(filePath);
|
||||
},
|
||||
[saveEditedFile]
|
||||
);
|
||||
|
||||
const handleDiscardFile = useCallback(
|
||||
(filePath: string) => {
|
||||
discardFileEdits(filePath);
|
||||
setDiscardCounter((c) => c + 1);
|
||||
},
|
||||
[discardFileEdits]
|
||||
);
|
||||
|
||||
// Save active file (for Cmd+Enter keyboard shortcut)
|
||||
const handleSaveActiveFile = useCallback(() => {
|
||||
if (activeFilePath) void saveEditedFile(activeFilePath);
|
||||
}, [activeFilePath, saveEditedFile]);
|
||||
|
||||
// Continuous navigation options for cross-file hunk navigation
|
||||
const continuousOptions = useMemo(
|
||||
() => ({
|
||||
editorViewMapRef,
|
||||
activeFilePath,
|
||||
scrollToFile,
|
||||
enabled: true,
|
||||
}),
|
||||
[activeFilePath, scrollToFile]
|
||||
);
|
||||
|
||||
const diffNav = useDiffNavigation(
|
||||
activeChangeSet?.files ?? [],
|
||||
selectedReviewFilePath,
|
||||
handleSelectFile,
|
||||
editorViewRef,
|
||||
activeFilePath,
|
||||
scrollToFile,
|
||||
activeEditorViewRef,
|
||||
open,
|
||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
|
||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
|
||||
handleHunkAccepted,
|
||||
handleHunkRejected,
|
||||
() => onOpenChange(false),
|
||||
handleSaveCurrentFile
|
||||
handleSaveActiveFile,
|
||||
continuousOptions
|
||||
);
|
||||
|
||||
// Auto-viewed callback
|
||||
const handleFullyViewed = useCallback(() => {
|
||||
if (autoViewed && selectedReviewFilePath && !isViewed(selectedReviewFilePath)) {
|
||||
markViewed(selectedReviewFilePath);
|
||||
}
|
||||
}, [autoViewed, selectedReviewFilePath, isViewed, markViewed]);
|
||||
|
||||
// Load data on open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -210,39 +264,21 @@ export const ChangeReviewDialog = ({
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
|
||||
const view = editorViewRef.current;
|
||||
const fp = activeFilePathRef.current;
|
||||
const view = fp ? editorViewMapRef.current.get(fp) : null;
|
||||
if (view) {
|
||||
rejectChunk(view);
|
||||
requestAnimationFrame(() => goToNextChunk(view));
|
||||
requestAnimationFrame(() => {
|
||||
if (isLastChunkInFile(view)) {
|
||||
diffNav.goToNextFile();
|
||||
} else {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return cleanup ?? undefined;
|
||||
}, [open]);
|
||||
|
||||
// Lazy-load file content when file selected
|
||||
useEffect(() => {
|
||||
if (!open || !selectedReviewFilePath) return;
|
||||
if (fileContents[selectedReviewFilePath] || fileContentsLoading[selectedReviewFilePath]) return;
|
||||
void fetchFileContent(teamName, memberName, selectedReviewFilePath);
|
||||
}, [
|
||||
open,
|
||||
selectedReviewFilePath,
|
||||
teamName,
|
||||
memberName,
|
||||
fileContents,
|
||||
fileContentsLoading,
|
||||
fetchFileContent,
|
||||
]);
|
||||
|
||||
const selectedFile = useMemo(() => {
|
||||
if (!activeChangeSet || !selectedReviewFilePath) return null;
|
||||
return activeChangeSet.files.find((f) => f.filePath === selectedReviewFilePath) ?? null;
|
||||
}, [activeChangeSet, selectedReviewFilePath]);
|
||||
|
||||
const fileContent = selectedReviewFilePath ? fileContents[selectedReviewFilePath] : null;
|
||||
const isFileContentLoading = selectedReviewFilePath
|
||||
? (fileContentsLoading[selectedReviewFilePath] ?? false)
|
||||
: false;
|
||||
}, [open, diffNav]);
|
||||
|
||||
// Compute toolbar stats
|
||||
const reviewStats = useMemo(() => {
|
||||
|
|
@ -278,6 +314,12 @@ export const ChangeReviewDialog = ({
|
|||
void applyReview(teamName, taskId, memberName);
|
||||
}, [applyReview, teamName, taskId, memberName]);
|
||||
|
||||
// Active file for timeline (derived from scroll-spy)
|
||||
const activeFile = useMemo(() => {
|
||||
if (!activeChangeSet || !activeFilePath) return null;
|
||||
return activeChangeSet.files.find((f) => f.filePath === activeFilePath) ?? null;
|
||||
}, [activeChangeSet, activeFilePath]);
|
||||
|
||||
const title =
|
||||
mode === 'agent'
|
||||
? `Changes by ${memberName ?? 'unknown'}`
|
||||
|
|
@ -310,9 +352,6 @@ export const ChangeReviewDialog = ({
|
|||
{activeChangeSet.totalFiles} files, +{activeChangeSet.totalLinesAdded} -
|
||||
{activeChangeSet.totalLinesRemoved}
|
||||
</span>
|
||||
{mode === 'task' && isTaskChangeSetV2(activeChangeSet) && (
|
||||
<ConfidenceBadge confidence={activeChangeSet.scope.confidence} />
|
||||
)}
|
||||
<ViewedProgressBar
|
||||
viewed={viewedCount}
|
||||
total={viewedTotalCount}
|
||||
|
|
@ -356,16 +395,13 @@ export const ChangeReviewDialog = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Scope info / warnings */}
|
||||
{mode === 'task' &&
|
||||
activeChangeSet &&
|
||||
isTaskChangeSetV2(activeChangeSet) &&
|
||||
(activeChangeSet.warnings.length > 0 || activeChangeSet.scope.confidence.tier >= 2) && (
|
||||
<ScopeWarningBanner
|
||||
warnings={activeChangeSet.warnings}
|
||||
confidence={activeChangeSet.scope.confidence}
|
||||
/>
|
||||
)}
|
||||
{/* Scope info / warnings + confidence badge */}
|
||||
{mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && (
|
||||
<ScopeWarningBanner
|
||||
warnings={activeChangeSet.warnings}
|
||||
confidence={activeChangeSet.scope.confidence}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Apply error */}
|
||||
{applyError && (
|
||||
|
|
@ -394,22 +430,23 @@ export const ChangeReviewDialog = ({
|
|||
<div className="w-64 shrink-0 overflow-y-auto border-r border-border bg-surface-sidebar">
|
||||
<ReviewFileTree
|
||||
files={activeChangeSet.files}
|
||||
selectedFilePath={selectedReviewFilePath}
|
||||
onSelectFile={handleSelectFile}
|
||||
selectedFilePath={null}
|
||||
onSelectFile={handleTreeFileClick}
|
||||
viewedSet={viewedSet}
|
||||
onMarkViewed={markViewed}
|
||||
onUnmarkViewed={unmarkViewed}
|
||||
activeFilePath={activeFilePath ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Edit Timeline */}
|
||||
{selectedFile?.timeline && selectedFile.timeline.events.length > 0 && (
|
||||
{/* Edit Timeline for active file */}
|
||||
{activeFile?.timeline && activeFile.timeline.events.length > 0 && (
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={() => setTimelineOpen(!timelineOpen)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs text-text-secondary hover:text-text"
|
||||
>
|
||||
<Clock className="size-3.5" />
|
||||
<span>Edit Timeline ({selectedFile.timeline.events.length})</span>
|
||||
<span>Edit Timeline ({activeFile.timeline.events.length})</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ml-auto size-3 transition-transform',
|
||||
|
|
@ -419,7 +456,7 @@ export const ChangeReviewDialog = ({
|
|||
</button>
|
||||
{timelineOpen && (
|
||||
<FileEditTimeline
|
||||
timeline={selectedFile.timeline}
|
||||
timeline={activeFile.timeline}
|
||||
onEventClick={(idx) => diffNav.goToHunk(idx)}
|
||||
activeSnippetIndex={diffNav.currentHunkIndex}
|
||||
/>
|
||||
|
|
@ -428,143 +465,32 @@ export const ChangeReviewDialog = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedFile ? (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* File header with content source badge and save/discard */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
|
||||
<span className="text-xs font-medium text-text">
|
||||
{selectedFile.relativePath}
|
||||
</span>
|
||||
{selectedFile.isNewFile && (
|
||||
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
{fileContent?.contentSource && (
|
||||
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-text-muted">
|
||||
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ??
|
||||
fileContent.contentSource}
|
||||
</span>
|
||||
)}
|
||||
{/* File-level decision indicator */}
|
||||
{fileDecisions[selectedFile.filePath] && (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] ${
|
||||
fileDecisions[selectedFile.filePath] === 'accepted'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: fileDecisions[selectedFile.filePath] === 'rejected'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: 'bg-zinc-500/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{fileDecisions[selectedFile.filePath]}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{hasCurrentFileEdits && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleDiscardCurrentFile}
|
||||
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
|
||||
>
|
||||
<Undo2 className="size-3" />
|
||||
Discard
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Discard all edits for this file
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleSaveCurrentFile}
|
||||
disabled={applying}
|
||||
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
|
||||
>
|
||||
{applying ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-3" />
|
||||
)}
|
||||
Save File
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>Save file to disk</span>
|
||||
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
|
||||
⌘↵
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isFileContentLoading && (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-text-muted">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading file content...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CodeMirror diff view when file content is available */}
|
||||
{!isFileContentLoading &&
|
||||
fileContent &&
|
||||
fileContent.contentSource !== 'unavailable' &&
|
||||
fileContent.modifiedFullContent !== null && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<DiffErrorBoundary
|
||||
filePath={selectedFile.filePath}
|
||||
oldString={fileContent.originalFullContent ?? ''}
|
||||
newString={fileContent.modifiedFullContent}
|
||||
>
|
||||
<CodeMirrorDiffView
|
||||
key={`${selectedFile.filePath}:${discardCounter}`}
|
||||
original={fileContent.originalFullContent ?? ''}
|
||||
modified={fileContent.modifiedFullContent}
|
||||
fileName={selectedFile.relativePath}
|
||||
readOnly={false}
|
||||
showMergeControls={true}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
initialState={cachedInitialState}
|
||||
onHunkAccepted={(idx) =>
|
||||
setHunkDecision(selectedFile.filePath, idx, 'accepted')
|
||||
}
|
||||
onHunkRejected={(idx) =>
|
||||
setHunkDecision(selectedFile.filePath, idx, 'rejected')
|
||||
}
|
||||
onFullyViewed={handleFullyViewed}
|
||||
editorViewRef={editorViewRef}
|
||||
onContentChanged={(content) => {
|
||||
updateEditedContent(selectedFile.filePath, content);
|
||||
}}
|
||||
/>
|
||||
</DiffErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: Phase 1 snippet view when content unavailable */}
|
||||
{!isFileContentLoading &&
|
||||
(!fileContent || fileContent.contentSource === 'unavailable') && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ReviewDiffContent file={selectedFile} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Select a file to view changes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Continuous scroll diff content */}
|
||||
<ContinuousScrollView
|
||||
files={activeChangeSet.files}
|
||||
fileContents={fileContents}
|
||||
fileContentsLoading={fileContentsLoading}
|
||||
viewedSet={viewedSet}
|
||||
editedContents={editedContents}
|
||||
fileDecisions={fileDecisions}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
applying={applying}
|
||||
autoViewed={autoViewed}
|
||||
discardCounter={discardCounter}
|
||||
onHunkAccepted={handleHunkAccepted}
|
||||
onHunkRejected={handleHunkRejected}
|
||||
onFullyViewed={handleFullyViewed}
|
||||
onContentChanged={handleContentChanged}
|
||||
onDiscard={handleDiscardFile}
|
||||
onSave={handleSaveFile}
|
||||
onVisibleFileChange={handleVisibleFileChange}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
editorViewMapRef={editorViewMapRef}
|
||||
isProgrammaticScroll={isProgrammaticScroll}
|
||||
teamName={teamName}
|
||||
memberName={memberName}
|
||||
fetchFileContent={fetchFileContent}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
|||
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
|
||||
|
||||
import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils';
|
||||
import { portionCollapseExtension } from './portionCollapse';
|
||||
|
||||
interface CodeMirrorDiffViewProps {
|
||||
original: string;
|
||||
|
|
@ -45,6 +46,10 @@ interface CodeMirrorDiffViewProps {
|
|||
onContentChanged?: (content: string) => void;
|
||||
/** Cached EditorState to restore (preserves undo history between file switches) */
|
||||
initialState?: EditorState;
|
||||
/** Use portion collapse instead of CM's collapseUnchanged (Expand N / Expand All buttons) */
|
||||
usePortionCollapse?: boolean;
|
||||
/** Lines per "Expand N" click (only with usePortionCollapse). Default: 100 */
|
||||
portionSize?: number;
|
||||
}
|
||||
|
||||
/** Synchronous language extension for common file types (bundled by Vite) */
|
||||
|
|
@ -275,6 +280,8 @@ export const CodeMirrorDiffView = ({
|
|||
editorViewRef: externalViewRef,
|
||||
onContentChanged,
|
||||
initialState,
|
||||
usePortionCollapse = false,
|
||||
portionSize = 100,
|
||||
}: CodeMirrorDiffViewProps): React.ReactElement => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
|
@ -305,6 +312,8 @@ export const CodeMirrorDiffView = ({
|
|||
const langCompartment = useRef(new Compartment());
|
||||
// Compartment for merge view — allows dynamic collapse reconfigure without editor recreation
|
||||
const mergeCompartment = useRef(new Compartment());
|
||||
// Compartment for portion collapse (separate from merge to allow independent reconfigure)
|
||||
const portionCompartment = useRef(new Compartment());
|
||||
|
||||
// Collapse as ref — used in buildExtensions (initial value) without triggering full rebuild
|
||||
const collapseRef = useRef({ enabled: collapseUnchangedProp, margin: collapseMargin });
|
||||
|
|
@ -322,7 +331,7 @@ export const CodeMirrorDiffView = ({
|
|||
syntaxHighlightDeletions: true,
|
||||
};
|
||||
|
||||
if (collapse) {
|
||||
if (collapse && !usePortionCollapse) {
|
||||
mergeConfig.collapseUnchanged = {
|
||||
margin,
|
||||
minSize: 4,
|
||||
|
|
@ -442,7 +451,7 @@ export const CodeMirrorDiffView = ({
|
|||
|
||||
return unifiedMergeView(mergeConfig);
|
||||
},
|
||||
[original, showMergeControls, scrollToNextChunk]
|
||||
[original, showMergeControls, scrollToNextChunk, usePortionCollapse]
|
||||
);
|
||||
|
||||
const buildExtensions = useCallback(() => {
|
||||
|
|
@ -660,8 +669,21 @@ export const CodeMirrorDiffView = ({
|
|||
)
|
||||
);
|
||||
|
||||
// Portion collapse — must come AFTER merge view so ChunkField is available
|
||||
extensions.push(
|
||||
portionCompartment.current.of(
|
||||
usePortionCollapse && collapseRef.current.enabled
|
||||
? portionCollapseExtension({
|
||||
margin: collapseRef.current.margin,
|
||||
minSize: 4,
|
||||
portionSize,
|
||||
})
|
||||
: []
|
||||
)
|
||||
);
|
||||
|
||||
return extensions;
|
||||
}, [readOnly, showMergeControls, buildMergeExtension]);
|
||||
}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
|
@ -729,16 +751,27 @@ export const CodeMirrorDiffView = ({
|
|||
};
|
||||
}, [fileName, buildExtensions, initialState]);
|
||||
|
||||
// Dynamic collapse toggle — reconfigure compartment in-place, preserving undo history
|
||||
// Dynamic collapse toggle — reconfigure compartments in-place, preserving undo history
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: mergeCompartment.current.reconfigure(
|
||||
buildMergeExtension(collapseUnchangedProp, collapseMargin)
|
||||
),
|
||||
effects: [
|
||||
mergeCompartment.current.reconfigure(
|
||||
buildMergeExtension(collapseUnchangedProp, collapseMargin)
|
||||
),
|
||||
portionCompartment.current.reconfigure(
|
||||
usePortionCollapse && collapseUnchangedProp
|
||||
? portionCollapseExtension({
|
||||
margin: collapseMargin,
|
||||
minSize: 4,
|
||||
portionSize,
|
||||
})
|
||||
: []
|
||||
),
|
||||
],
|
||||
});
|
||||
}, [collapseUnchangedProp, collapseMargin, buildMergeExtension]);
|
||||
}, [collapseUnchangedProp, collapseMargin, buildMergeExtension, usePortionCollapse, portionSize]);
|
||||
|
||||
// Auto-viewed detection via IntersectionObserver
|
||||
useEffect(() => {
|
||||
|
|
|
|||
166
src/renderer/components/team/review/ContinuousScrollView.tsx
Normal file
166
src/renderer/components/team/review/ContinuousScrollView.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent';
|
||||
import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
|
||||
|
||||
import { FileSectionDiff } from './FileSectionDiff';
|
||||
import { FileSectionHeader } from './FileSectionHeader';
|
||||
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
||||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
interface ContinuousScrollViewProps {
|
||||
files: FileChangeSummary[];
|
||||
fileContents: Record<string, FileChangeWithContent>;
|
||||
fileContentsLoading: Record<string, boolean>;
|
||||
viewedSet: Set<string>;
|
||||
editedContents: Record<string, string>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
collapseUnchanged: boolean;
|
||||
applying: boolean;
|
||||
autoViewed: boolean;
|
||||
discardCounter: number;
|
||||
onHunkAccepted: (filePath: string, hunkIndex: number) => void;
|
||||
onHunkRejected: (filePath: string, hunkIndex: number) => void;
|
||||
onFullyViewed: (filePath: string) => void;
|
||||
onContentChanged: (filePath: string, content: string) => void;
|
||||
onDiscard: (filePath: string) => void;
|
||||
onSave: (filePath: string) => void;
|
||||
onVisibleFileChange: (filePath: string) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement>;
|
||||
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
|
||||
isProgrammaticScroll: React.RefObject<boolean>;
|
||||
teamName: string;
|
||||
memberName: string | undefined;
|
||||
fetchFileContent: (
|
||||
teamName: string,
|
||||
memberName: string | undefined,
|
||||
filePath: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ContinuousScrollView = ({
|
||||
files,
|
||||
fileContents,
|
||||
fileContentsLoading,
|
||||
viewedSet,
|
||||
editedContents,
|
||||
fileDecisions,
|
||||
collapseUnchanged,
|
||||
applying,
|
||||
autoViewed,
|
||||
discardCounter,
|
||||
onHunkAccepted,
|
||||
onHunkRejected,
|
||||
onFullyViewed,
|
||||
onContentChanged,
|
||||
onDiscard,
|
||||
onSave,
|
||||
onVisibleFileChange,
|
||||
scrollContainerRef,
|
||||
editorViewMapRef,
|
||||
isProgrammaticScroll,
|
||||
teamName,
|
||||
memberName,
|
||||
fetchFileContent,
|
||||
}: ContinuousScrollViewProps): React.ReactElement => {
|
||||
const filePaths = useMemo(() => files.map((f) => f.filePath), [files]);
|
||||
|
||||
const { registerFileSectionRef } = useVisibleFileSection({
|
||||
onVisibleFileChange,
|
||||
scrollContainerRef,
|
||||
isProgrammaticScroll,
|
||||
});
|
||||
|
||||
const { registerLazyRef } = useLazyFileContent({
|
||||
teamName,
|
||||
memberName,
|
||||
filePaths,
|
||||
scrollContainerRef,
|
||||
fileContents,
|
||||
fileContentsLoading,
|
||||
fetchFileContent,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Combined ref callback: registers element in both scroll-spy and lazy-load observers
|
||||
const combinedRef = useCallback(
|
||||
(filePath: string) => {
|
||||
const sectionRef = registerFileSectionRef(filePath);
|
||||
const lazyRef = registerLazyRef(filePath);
|
||||
|
||||
return (element: HTMLElement | null) => {
|
||||
sectionRef(element);
|
||||
lazyRef(element);
|
||||
};
|
||||
},
|
||||
[registerFileSectionRef, registerLazyRef]
|
||||
);
|
||||
|
||||
const handleEditorViewReady = useCallback(
|
||||
(filePath: string, view: EditorView | null) => {
|
||||
if (view) {
|
||||
editorViewMapRef.current.set(filePath, view);
|
||||
} else {
|
||||
editorViewMapRef.current.delete(filePath);
|
||||
}
|
||||
},
|
||||
[editorViewMapRef]
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
No file changes detected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
|
||||
{files.map((file) => {
|
||||
const filePath = file.filePath;
|
||||
const content = fileContents[filePath] ?? null;
|
||||
const hasContent = filePath in fileContents;
|
||||
const hasEdits = filePath in editedContents;
|
||||
const isViewed = viewedSet.has(filePath);
|
||||
const decision = fileDecisions[filePath];
|
||||
|
||||
return (
|
||||
<div key={filePath} ref={combinedRef(filePath)} className="border-b border-border">
|
||||
<FileSectionHeader
|
||||
file={file}
|
||||
fileContent={content}
|
||||
fileDecision={decision}
|
||||
hasEdits={hasEdits}
|
||||
applying={applying}
|
||||
onDiscard={onDiscard}
|
||||
onSave={onSave}
|
||||
/>
|
||||
|
||||
{hasContent ? (
|
||||
<FileSectionDiff
|
||||
file={file}
|
||||
fileContent={content}
|
||||
isLoading={false}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
onHunkAccepted={onHunkAccepted}
|
||||
onHunkRejected={onHunkRejected}
|
||||
onFullyViewed={onFullyViewed}
|
||||
onContentChanged={onContentChanged}
|
||||
onEditorViewReady={handleEditorViewReady}
|
||||
discardCounter={discardCounter}
|
||||
autoViewed={autoViewed}
|
||||
isViewed={isViewed}
|
||||
/>
|
||||
) : (
|
||||
<FileSectionPlaceholder fileName={file.relativePath} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
122
src/renderer/components/team/review/FileSectionDiff.tsx
Normal file
122
src/renderer/components/team/review/FileSectionDiff.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
|
||||
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
||||
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
||||
import { ReviewDiffContent } from './ReviewDiffContent';
|
||||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { FileChangeWithContent } from '@shared/types';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
interface FileSectionDiffProps {
|
||||
file: FileChangeSummary;
|
||||
fileContent: FileChangeWithContent | null;
|
||||
isLoading: boolean;
|
||||
collapseUnchanged: boolean;
|
||||
onHunkAccepted: (filePath: string, hunkIndex: number) => void;
|
||||
onHunkRejected: (filePath: string, hunkIndex: number) => void;
|
||||
onFullyViewed: (filePath: string) => void;
|
||||
onContentChanged: (filePath: string, content: string) => void;
|
||||
onEditorViewReady: (filePath: string, view: EditorView | null) => void;
|
||||
discardCounter: number;
|
||||
autoViewed: boolean;
|
||||
isViewed: boolean;
|
||||
}
|
||||
|
||||
export const FileSectionDiff = ({
|
||||
file,
|
||||
fileContent,
|
||||
isLoading,
|
||||
collapseUnchanged,
|
||||
onHunkAccepted,
|
||||
onHunkRejected,
|
||||
onFullyViewed,
|
||||
onContentChanged,
|
||||
onEditorViewReady,
|
||||
discardCounter,
|
||||
autoViewed,
|
||||
isViewed,
|
||||
}: FileSectionDiffProps): React.ReactElement => {
|
||||
const localEditorViewRef = useRef<EditorView | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Register/unregister EditorView with parent Map
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onEditorViewReady(file.filePath, null);
|
||||
};
|
||||
}, [file.filePath, onEditorViewReady]);
|
||||
|
||||
// Sync EditorView ref to parent after CM creates the view
|
||||
useEffect(() => {
|
||||
if (localEditorViewRef.current) {
|
||||
onEditorViewReady(file.filePath, localEditorViewRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-viewed sentinel observer
|
||||
useEffect(() => {
|
||||
if (!sentinelRef.current || !autoViewed || isViewed) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
onFullyViewed(file.filePath);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.85 }
|
||||
);
|
||||
|
||||
observer.observe(sentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [autoViewed, isViewed, file.filePath, onFullyViewed]);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return <FileSectionPlaceholder fileName={file.relativePath} />;
|
||||
}
|
||||
|
||||
// Unavailable / no content fallback
|
||||
const hasCodeMirrorContent =
|
||||
fileContent &&
|
||||
fileContent.contentSource !== 'unavailable' &&
|
||||
fileContent.modifiedFullContent !== null;
|
||||
|
||||
if (!hasCodeMirrorContent) {
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<ReviewDiffContent file={file} />
|
||||
<div ref={sentinelRef} className="h-1 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<DiffErrorBoundary
|
||||
filePath={file.filePath}
|
||||
oldString={fileContent.originalFullContent ?? ''}
|
||||
newString={fileContent.modifiedFullContent!}
|
||||
>
|
||||
<CodeMirrorDiffView
|
||||
key={`${file.filePath}:${discardCounter}`}
|
||||
original={fileContent.originalFullContent ?? ''}
|
||||
modified={fileContent.modifiedFullContent!}
|
||||
fileName={file.relativePath}
|
||||
readOnly={false}
|
||||
showMergeControls={true}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
usePortionCollapse={true}
|
||||
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
|
||||
onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)}
|
||||
onContentChanged={(content) => onContentChanged(file.filePath, content)}
|
||||
editorViewRef={localEditorViewRef}
|
||||
/>
|
||||
</DiffErrorBoundary>
|
||||
<div ref={sentinelRef} className="h-1 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
src/renderer/components/team/review/FileSectionHeader.tsx
Normal file
108
src/renderer/components/team/review/FileSectionHeader.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { Loader2, Save, Undo2 } from 'lucide-react';
|
||||
|
||||
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
const CONTENT_SOURCE_LABELS: Record<string, string> = {
|
||||
'file-history': 'File History',
|
||||
'snippet-reconstruction': 'Reconstructed',
|
||||
'disk-current': 'Current Disk',
|
||||
'git-fallback': 'Git Fallback',
|
||||
unavailable: 'Unavailable',
|
||||
};
|
||||
|
||||
interface FileSectionHeaderProps {
|
||||
file: FileChangeSummary;
|
||||
fileContent: FileChangeWithContent | null;
|
||||
fileDecision: HunkDecision | undefined;
|
||||
hasEdits: boolean;
|
||||
applying: boolean;
|
||||
onDiscard: (filePath: string) => void;
|
||||
onSave: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export const FileSectionHeader = ({
|
||||
file,
|
||||
fileContent,
|
||||
fileDecision,
|
||||
hasEdits,
|
||||
applying,
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: FileSectionHeaderProps): React.ReactElement => {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2">
|
||||
<span className="text-xs font-medium text-text">{file.relativePath}</span>
|
||||
|
||||
{file.isNewFile && (
|
||||
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
|
||||
{fileContent?.contentSource && (
|
||||
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-text-muted">
|
||||
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{fileDecision && (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] ${
|
||||
fileDecision === 'accepted'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: fileDecision === 'rejected'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: 'bg-zinc-500/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{fileDecision}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{hasEdits && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onDiscard(file.filePath)}
|
||||
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
|
||||
>
|
||||
<Undo2 className="size-3" />
|
||||
Discard
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Discard all edits for this file</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onSave(file.filePath)}
|
||||
disabled={applying}
|
||||
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
|
||||
>
|
||||
{applying ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-3" />
|
||||
)}
|
||||
Save File
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>Save file to disk</span>
|
||||
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
|
||||
⌘↵
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
|
||||
interface FileSectionPlaceholderProps {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export const FileSectionPlaceholder = ({
|
||||
fileName,
|
||||
}: FileSectionPlaceholderProps): React.ReactElement => (
|
||||
<div className="animate-pulse">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2">
|
||||
<span className="text-xs font-medium text-text-muted">{fileName}</span>
|
||||
<div className="h-4 w-16 rounded bg-surface-raised" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 p-4">
|
||||
<div className="h-4 w-3/4 rounded bg-surface-raised" />
|
||||
<div className="h-4 w-1/2 rounded bg-surface-raised" />
|
||||
<div className="h-4 w-5/6 rounded bg-surface-raised" />
|
||||
<div className="h-4 w-2/3 rounded bg-surface-raised" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
|
@ -14,6 +14,7 @@ interface ReviewFileTreeProps {
|
|||
viewedSet?: Set<string>;
|
||||
onMarkViewed?: (filePath: string) => void;
|
||||
onUnmarkViewed?: (filePath: string) => void;
|
||||
activeFilePath?: string;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
|
|
@ -108,6 +109,7 @@ const FileStatusIcon = ({ status }: { status: FileStatus }) => {
|
|||
const TreeItem = ({
|
||||
node,
|
||||
selectedFilePath,
|
||||
activeFilePath,
|
||||
onSelectFile,
|
||||
depth,
|
||||
hunkDecisions,
|
||||
|
|
@ -117,6 +119,7 @@ const TreeItem = ({
|
|||
}: {
|
||||
node: TreeNode;
|
||||
selectedFilePath: string | null;
|
||||
activeFilePath?: string;
|
||||
onSelectFile: (filePath: string) => void;
|
||||
depth: number;
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
|
|
@ -126,15 +129,19 @@ const TreeItem = ({
|
|||
}) => {
|
||||
if (node.isFile && node.file) {
|
||||
const isSelected = node.file.filePath === selectedFilePath;
|
||||
const isActive = node.file.filePath === activeFilePath && !isSelected;
|
||||
const status = getFileStatus(node.file, hunkDecisions);
|
||||
return (
|
||||
<button
|
||||
data-tree-file={node.file.filePath}
|
||||
onClick={() => onSelectFile(node.file!.filePath)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
: 'text-text-secondary hover:bg-surface-raised hover:text-text'
|
||||
: isActive
|
||||
? 'border-l-2 border-blue-400 text-text'
|
||||
: 'text-text-secondary hover:bg-surface-raised hover:text-text'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
|
|
@ -196,6 +203,7 @@ const TreeItem = ({
|
|||
key={child.fullPath}
|
||||
node={child}
|
||||
selectedFilePath={selectedFilePath}
|
||||
activeFilePath={activeFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
depth={depth + 1}
|
||||
hunkDecisions={hunkDecisions}
|
||||
|
|
@ -215,10 +223,23 @@ export const ReviewFileTree = ({
|
|||
viewedSet,
|
||||
onMarkViewed,
|
||||
onUnmarkViewed,
|
||||
activeFilePath,
|
||||
}: ReviewFileTreeProps) => {
|
||||
const hunkDecisions = useStore((state) => state.hunkDecisions);
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
|
||||
// Auto-scroll tree to active file when scroll-spy updates
|
||||
useEffect(() => {
|
||||
if (!activeFilePath) return;
|
||||
|
||||
const btn = document.querySelector<HTMLElement>(
|
||||
`[data-tree-file="${CSS.escape(activeFilePath)}"]`
|
||||
);
|
||||
if (btn) {
|
||||
btn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}, [activeFilePath]);
|
||||
|
||||
if (files.length === 0) {
|
||||
return <div className="p-4 text-center text-xs text-text-muted">No changed files</div>;
|
||||
}
|
||||
|
|
@ -235,6 +256,7 @@ export const ReviewFileTree = ({
|
|||
key={node.fullPath}
|
||||
node={node}
|
||||
selectedFilePath={selectedFilePath}
|
||||
activeFilePath={activeFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
depth={0}
|
||||
hunkDecisions={hunkDecisions}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export const ReviewToolbar = ({
|
|||
}: ReviewToolbarProps): React.ReactElement => {
|
||||
const hasRejected = stats.rejected > 0;
|
||||
const canApply = hasRejected && !applying;
|
||||
const totalChanges = stats.pending + stats.accepted + stats.rejected;
|
||||
const reviewedCount = stats.accepted + stats.rejected;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border bg-surface-sidebar px-4 py-2">
|
||||
|
|
@ -76,6 +78,21 @@ export const ReviewToolbar = ({
|
|||
<span className="ml-1">across {changeStats.filesChanged} files</span>
|
||||
</div>
|
||||
|
||||
{/* Review progress */}
|
||||
{totalChanges > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-zinc-700/50">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500/70 transition-all duration-300"
|
||||
style={{ width: `${(reviewedCount / totalChanges) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-muted">
|
||||
{reviewedCount}/{totalChanges}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Tooltip>
|
||||
|
|
@ -140,7 +157,7 @@ export const ReviewToolbar = ({
|
|||
Accept All
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Accept all changes in current file</TooltipContent>
|
||||
<TooltipContent side="bottom">Accept all changes across all files</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
|
@ -153,7 +170,7 @@ export const ReviewToolbar = ({
|
|||
Reject All
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Reject all changes in current file</TooltipContent>
|
||||
<TooltipContent side="bottom">Reject all changes across all files</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useState } from 'react';
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { AlertTriangle, ChevronRight, Info, ShieldCheck, X } from 'lucide-react';
|
||||
|
||||
import { ConfidenceBadge } from './ConfidenceBadge';
|
||||
|
||||
import type { TaskScopeConfidence } from '@shared/types';
|
||||
import type { FC } from 'react';
|
||||
|
||||
|
|
@ -81,8 +83,13 @@ export const ScopeWarningBanner = ({
|
|||
Read more
|
||||
<ChevronRight className={cn('size-3 transition-transform', expanded && 'rotate-90')} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<ConfidenceBadge confidence={confidence} />
|
||||
|
||||
{onDismiss && (
|
||||
<button onClick={onDismiss} className="ml-auto text-text-muted hover:text-text">
|
||||
<button onClick={onDismiss} className="text-text-muted hover:text-text">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
315
src/renderer/components/team/review/portionCollapse.ts
Normal file
315
src/renderer/components/team/review/portionCollapse.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { updateOriginalDoc } from '@codemirror/merge';
|
||||
import { type Extension, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
||||
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
|
||||
|
||||
import { getChunks } from './CodeMirrorDiffUtils';
|
||||
|
||||
import type { ChangeDesc, EditorState, Transaction } from '@codemirror/state';
|
||||
|
||||
// ─── Configuration ───
|
||||
|
||||
interface PortionCollapseConfig {
|
||||
margin?: number;
|
||||
minSize?: number;
|
||||
portionSize?: number;
|
||||
}
|
||||
|
||||
// ─── State Effects ───
|
||||
|
||||
export const expandPortion = StateEffect.define<{ pos: number; count: number }>({
|
||||
map: (value, mapping: ChangeDesc) => ({
|
||||
pos: mapping.mapPos(value.pos),
|
||||
count: value.count,
|
||||
}),
|
||||
});
|
||||
|
||||
export const expandAllAtPos = StateEffect.define<number>({
|
||||
map: (pos, mapping: ChangeDesc) => mapping.mapPos(pos),
|
||||
});
|
||||
|
||||
// ─── Widget ───
|
||||
|
||||
class PortionCollapseWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly lineCount: number,
|
||||
readonly portionSize: number
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'cm-portion-collapse';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'cm-portion-collapse-text';
|
||||
text.textContent = `\u00B7\u00B7\u00B7 ${this.lineCount} unchanged line${this.lineCount !== 1 ? 's' : ''} \u00B7\u00B7\u00B7`;
|
||||
container.appendChild(text);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'cm-portion-collapse-actions';
|
||||
|
||||
if (this.lineCount > this.portionSize) {
|
||||
const expandBtn = document.createElement('button');
|
||||
expandBtn.className = 'cm-portion-expand-btn';
|
||||
expandBtn.textContent = `Expand ${this.portionSize}`;
|
||||
expandBtn.title = `Show next ${this.portionSize} lines`;
|
||||
expandBtn.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const pos = view.posAtDOM(container);
|
||||
view.dispatch({
|
||||
effects: expandPortion.of({ pos, count: this.portionSize }),
|
||||
});
|
||||
};
|
||||
actions.appendChild(expandBtn);
|
||||
}
|
||||
|
||||
const expandAllBtn = document.createElement('button');
|
||||
expandAllBtn.className = 'cm-portion-expand-all-btn';
|
||||
expandAllBtn.textContent = 'Expand All';
|
||||
expandAllBtn.title = `Show all ${this.lineCount} unchanged lines`;
|
||||
expandAllBtn.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const pos = view.posAtDOM(container);
|
||||
view.dispatch({
|
||||
effects: expandAllAtPos.of(pos),
|
||||
});
|
||||
};
|
||||
actions.appendChild(expandAllBtn);
|
||||
|
||||
container.appendChild(actions);
|
||||
return container;
|
||||
}
|
||||
|
||||
eq(other: PortionCollapseWidget): boolean {
|
||||
return this.lineCount === other.lineCount && this.portionSize === other.portionSize;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/class-literal-property-style -- WidgetType defines estimatedHeight as getter, cannot override with property
|
||||
get estimatedHeight(): number {
|
||||
return 28;
|
||||
}
|
||||
|
||||
ignoreEvent(event: Event): boolean {
|
||||
return event instanceof MouseEvent;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function buildPortionRanges(
|
||||
state: EditorState,
|
||||
margin: number,
|
||||
minSize: number,
|
||||
portionSize: number
|
||||
): DecorationSet {
|
||||
const result = getChunks(state);
|
||||
const doc = state.doc;
|
||||
|
||||
if (!result) return Decoration.none;
|
||||
|
||||
const chunks = result.chunks;
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
|
||||
let prevLine = 1;
|
||||
|
||||
for (let i = 0; ; i++) {
|
||||
const chunk = i < chunks.length ? chunks[i] : null;
|
||||
const collapseFrom = i ? prevLine + margin : 1;
|
||||
const collapseTo = chunk ? doc.lineAt(chunk.fromB).number - 1 - margin : doc.lines;
|
||||
const lines = collapseTo - collapseFrom + 1;
|
||||
|
||||
if (lines >= minSize) {
|
||||
const from = doc.line(collapseFrom).from;
|
||||
const to = doc.line(collapseTo).to;
|
||||
const widget = new PortionCollapseWidget(lines, portionSize);
|
||||
|
||||
builder.add(from, to, Decoration.replace({ widget, block: true }));
|
||||
}
|
||||
|
||||
if (!chunk) break;
|
||||
|
||||
prevLine = doc.lineAt(Math.min(doc.length, chunk.toB)).number;
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
function handleExpandPortion(
|
||||
decorations: DecorationSet,
|
||||
value: { pos: number; count: number },
|
||||
state: EditorState,
|
||||
minSize: number,
|
||||
portionSize: number
|
||||
): DecorationSet {
|
||||
const { pos, count } = value;
|
||||
const doc = state.doc;
|
||||
|
||||
let targetFrom = -1;
|
||||
let targetTo = -1;
|
||||
|
||||
decorations.between(0, doc.length, (from, to) => {
|
||||
if (from <= pos && pos <= to) {
|
||||
targetFrom = from;
|
||||
targetTo = to;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (targetFrom < 0) return decorations;
|
||||
|
||||
const fromLine = doc.lineAt(targetFrom).number;
|
||||
const toLine = doc.lineAt(targetTo).number;
|
||||
const newFromLine = fromLine + count;
|
||||
const remainingLines = toLine - newFromLine + 1;
|
||||
|
||||
if (remainingLines < minSize) {
|
||||
return decorations.update({
|
||||
filter: (from) => from !== targetFrom,
|
||||
});
|
||||
}
|
||||
|
||||
const newFrom = doc.line(newFromLine).from;
|
||||
const widget = new PortionCollapseWidget(remainingLines, portionSize);
|
||||
|
||||
return decorations.update({
|
||||
filter: (from) => from !== targetFrom,
|
||||
add: [Decoration.replace({ widget, block: true }).range(newFrom, targetTo)],
|
||||
});
|
||||
}
|
||||
|
||||
function handleExpandAll(decorations: DecorationSet, pos: number): DecorationSet {
|
||||
return decorations.update({
|
||||
filter: (from, to) => !(from <= pos && pos <= to),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Theme ───
|
||||
|
||||
const portionCollapseTheme = EditorView.theme({
|
||||
'.cm-portion-collapse': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 12px',
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
minHeight: '28px',
|
||||
cursor: 'default',
|
||||
userSelect: 'none',
|
||||
},
|
||||
|
||||
'.cm-portion-collapse-text': {
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-muted)',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
|
||||
'.cm-portion-collapse-actions': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
},
|
||||
|
||||
'.cm-portion-expand-btn': {
|
||||
padding: '2px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
lineHeight: '18px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': {
|
||||
color: 'var(--color-text)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.06)',
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
|
||||
'.cm-portion-expand-all-btn': {
|
||||
padding: '2px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
lineHeight: '18px',
|
||||
color: 'var(--color-text-muted)',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': {
|
||||
color: 'var(--color-text-secondary)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
||||
borderColor: 'var(--color-border)',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Extension ───
|
||||
|
||||
export function portionCollapseExtension(config?: PortionCollapseConfig): Extension {
|
||||
const margin = config?.margin ?? 3;
|
||||
const minSize = config?.minSize ?? 4;
|
||||
const portionSize = config?.portionSize ?? 100;
|
||||
|
||||
const field = StateField.define<DecorationSet>({
|
||||
create(state: EditorState): DecorationSet {
|
||||
return buildPortionRanges(state, margin, minSize, portionSize);
|
||||
},
|
||||
|
||||
update(deco: DecorationSet, tr: Transaction): DecorationSet {
|
||||
// 1. Expand effects
|
||||
let result = deco;
|
||||
let hasExpandEffect = false;
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(expandPortion)) {
|
||||
hasExpandEffect = true;
|
||||
result = handleExpandPortion(result, effect.value, tr.state, minSize, portionSize);
|
||||
}
|
||||
if (effect.is(expandAllAtPos)) {
|
||||
hasExpandEffect = true;
|
||||
result = handleExpandAll(result, effect.value);
|
||||
}
|
||||
}
|
||||
if (hasExpandEffect) return result;
|
||||
|
||||
// 2. Accept chunk (updateOriginalDoc) → full rebuild
|
||||
if (tr.effects.some((e) => e.is(updateOriginalDoc))) {
|
||||
return buildPortionRanges(tr.state, margin, minSize, portionSize);
|
||||
}
|
||||
|
||||
// 3. Document changed (reject, user edit) → full rebuild
|
||||
if (tr.docChanged) {
|
||||
return buildPortionRanges(tr.state, margin, minSize, portionSize);
|
||||
}
|
||||
|
||||
// 4. Lazy init
|
||||
if (deco === Decoration.none) {
|
||||
const chunks = getChunks(tr.state);
|
||||
if (chunks) {
|
||||
return buildPortionRanges(tr.state, margin, minSize, portionSize);
|
||||
}
|
||||
}
|
||||
|
||||
return deco;
|
||||
},
|
||||
|
||||
provide(f) {
|
||||
return EditorView.decorations.from(f);
|
||||
},
|
||||
});
|
||||
|
||||
return [field, portionCollapseTheme];
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ const TEAMMATE_COLORS: Record<string, TeamColorSet> = {
|
|||
cyan: { border: '#06b6d4', badge: 'rgba(6, 182, 212, 0.15)', text: '#22d3ee' },
|
||||
orange: { border: '#f97316', badge: 'rgba(249, 115, 22, 0.15)', text: '#fb923c' },
|
||||
pink: { border: '#ec4899', badge: 'rgba(236, 72, 153, 0.15)', text: '#f472b6' },
|
||||
/** Reserved for the human user — never assigned to team members. */
|
||||
user: { border: '#f5f5f4', badge: 'rgba(245, 245, 244, 0.12)', text: '#d6d3d1' },
|
||||
};
|
||||
|
||||
const DEFAULT_COLOR: TeamColorSet = TEAMMATE_COLORS.blue;
|
||||
|
|
|
|||
50
src/renderer/hooks/useContinuousScrollNav.ts
Normal file
50
src/renderer/hooks/useContinuousScrollNav.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { type RefObject, useCallback, useRef } from 'react';
|
||||
|
||||
import { waitForScrollEnd } from '@renderer/hooks/navigation/utils';
|
||||
|
||||
interface UseContinuousScrollNavOptions {
|
||||
scrollContainerRef: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
interface UseContinuousScrollNavReturn {
|
||||
scrollToFile: (filePath: string) => void;
|
||||
isProgrammaticScroll: RefObject<boolean>;
|
||||
}
|
||||
|
||||
export function useContinuousScrollNav(
|
||||
options: UseContinuousScrollNavOptions
|
||||
): UseContinuousScrollNavReturn {
|
||||
const { scrollContainerRef } = options;
|
||||
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
const scrollGeneration = useRef(0);
|
||||
|
||||
const scrollToFile = useCallback(
|
||||
(filePath: string) => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const section = container.querySelector<HTMLElement>(
|
||||
`[data-file-path="${CSS.escape(filePath)}"]`
|
||||
);
|
||||
if (!section) return;
|
||||
|
||||
const gen = ++scrollGeneration.current;
|
||||
isProgrammaticScroll.current = true;
|
||||
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
void waitForScrollEnd(container, 500).then(() => {
|
||||
if (scrollGeneration.current === gen) {
|
||||
isProgrammaticScroll.current = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
[scrollContainerRef]
|
||||
);
|
||||
|
||||
return {
|
||||
scrollToFile,
|
||||
isProgrammaticScroll,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
|
||||
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
|
||||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
|
@ -19,6 +20,74 @@ interface DiffNavigationState {
|
|||
setShowShortcutsHelp: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ContinuousNavigationOptions {
|
||||
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
|
||||
activeFilePath: string | null;
|
||||
scrollToFile: (filePath: string) => void;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function getEditorViewRefs(
|
||||
continuousOptions?: ContinuousNavigationOptions
|
||||
): Map<string, EditorView> | null {
|
||||
return continuousOptions?.enabled ? continuousOptions.editorViewMapRef.current : null;
|
||||
}
|
||||
|
||||
function getActiveEditorView(
|
||||
editorViewRef: React.RefObject<EditorView | null>,
|
||||
continuousOptions?: ContinuousNavigationOptions
|
||||
): EditorView | null {
|
||||
const editorViewRefs = getEditorViewRefs(continuousOptions);
|
||||
if (!editorViewRefs) {
|
||||
return editorViewRef.current;
|
||||
}
|
||||
|
||||
const { activeFilePath } = continuousOptions!;
|
||||
|
||||
// 1. Focused editor
|
||||
for (const [, view] of editorViewRefs) {
|
||||
if (view.hasFocus) return view;
|
||||
}
|
||||
|
||||
// 2. activeFilePath editor
|
||||
if (activeFilePath) {
|
||||
const view = editorViewRefs.get(activeFilePath);
|
||||
if (view) return view;
|
||||
}
|
||||
|
||||
// 3. Fallback: first editor
|
||||
const firstEntry = editorViewRefs.values().next();
|
||||
return firstEntry.done ? null : firstEntry.value;
|
||||
}
|
||||
|
||||
function getActiveFilePath(
|
||||
selectedFilePath: string | null,
|
||||
continuousOptions?: ContinuousNavigationOptions
|
||||
): string | null {
|
||||
if (continuousOptions?.enabled && continuousOptions.activeFilePath) {
|
||||
return continuousOptions.activeFilePath;
|
||||
}
|
||||
return selectedFilePath;
|
||||
}
|
||||
|
||||
export function isLastChunkInFile(view: EditorView): boolean {
|
||||
const result = getChunks(view.state);
|
||||
if (!result || result.chunks.length === 0) return true;
|
||||
|
||||
const cursorPos = view.state.selection.main.head;
|
||||
const lastChunk = result.chunks[result.chunks.length - 1];
|
||||
return cursorPos >= lastChunk.fromB;
|
||||
}
|
||||
|
||||
export function isFirstChunkInFile(view: EditorView): boolean {
|
||||
const result = getChunks(view.state);
|
||||
if (!result || result.chunks.length === 0) return true;
|
||||
|
||||
const cursorPos = view.state.selection.main.head;
|
||||
const firstChunk = result.chunks[0];
|
||||
return cursorPos <= firstChunk.toB;
|
||||
}
|
||||
|
||||
export function useDiffNavigation(
|
||||
files: FileChangeSummary[],
|
||||
selectedFilePath: string | null,
|
||||
|
|
@ -28,64 +97,135 @@ export function useDiffNavigation(
|
|||
onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
|
||||
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
|
||||
onClose?: () => void,
|
||||
onSaveFile?: () => void
|
||||
onSaveFile?: () => void,
|
||||
continuousOptions?: ContinuousNavigationOptions
|
||||
): DiffNavigationState {
|
||||
// Track hunk index keyed by file path to auto-reset on file change
|
||||
const [hunkState, setHunkState] = useState<{ filePath: string | null; index: number }>({
|
||||
filePath: selectedFilePath,
|
||||
index: 0,
|
||||
});
|
||||
const [showShortcutsHelp, setShowShortcutsHelp] = useState(false);
|
||||
|
||||
const selectedFile = files.find((f) => f.filePath === selectedFilePath);
|
||||
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
|
||||
const selectedFile = files.find((f) => f.filePath === activePath);
|
||||
const totalHunks = selectedFile?.snippets.length ?? 0;
|
||||
|
||||
// Derive currentHunkIndex: reset to 0 when selectedFilePath changes
|
||||
const currentHunkIndex = hunkState.filePath === selectedFilePath ? hunkState.index : 0;
|
||||
const currentHunkIndex = hunkState.filePath === activePath ? hunkState.index : 0;
|
||||
|
||||
const setCurrentHunkIndex = useCallback(
|
||||
(updater: number | ((prev: number) => number)) => {
|
||||
setHunkState((prev) => {
|
||||
const newIndex =
|
||||
typeof updater === 'function'
|
||||
? updater(prev.filePath === selectedFilePath ? prev.index : 0)
|
||||
? updater(prev.filePath === activePath ? prev.index : 0)
|
||||
: updater;
|
||||
return { filePath: selectedFilePath, index: newIndex };
|
||||
return { filePath: activePath, index: newIndex };
|
||||
});
|
||||
},
|
||||
[selectedFilePath]
|
||||
[activePath]
|
||||
);
|
||||
|
||||
const goToNextHunk = useCallback(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1));
|
||||
}, [editorViewRef, totalHunks, setCurrentHunkIndex]);
|
||||
|
||||
const goToPrevHunk = useCallback(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) {
|
||||
goToPreviousChunk(view);
|
||||
}
|
||||
setCurrentHunkIndex((prev) => Math.max(prev - 1, 0));
|
||||
}, [editorViewRef, setCurrentHunkIndex]);
|
||||
// Stable refs for continuousOptions to avoid stale closures
|
||||
const continuousOptionsRef = useRef(continuousOptions);
|
||||
useEffect(() => {
|
||||
continuousOptionsRef.current = continuousOptions;
|
||||
});
|
||||
|
||||
const goToNextFile = useCallback(() => {
|
||||
if (files.length === 0) return;
|
||||
const currentIdx = files.findIndex((f) => f.filePath === selectedFilePath);
|
||||
|
||||
const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
|
||||
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
|
||||
const nextIdx = currentIdx < files.length - 1 ? currentIdx + 1 : 0;
|
||||
onSelectFile(files[nextIdx].filePath);
|
||||
const nextFilePath = files[nextIdx].filePath;
|
||||
|
||||
if (continuousOptionsRef.current?.enabled) {
|
||||
continuousOptionsRef.current.scrollToFile(nextFilePath);
|
||||
} else {
|
||||
onSelectFile(nextFilePath);
|
||||
}
|
||||
}, [files, selectedFilePath, onSelectFile]);
|
||||
|
||||
const goToPrevFile = useCallback(() => {
|
||||
if (files.length === 0) return;
|
||||
const currentIdx = files.findIndex((f) => f.filePath === selectedFilePath);
|
||||
|
||||
const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
|
||||
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
|
||||
const prevIdx = currentIdx > 0 ? currentIdx - 1 : files.length - 1;
|
||||
onSelectFile(files[prevIdx].filePath);
|
||||
const prevFilePath = files[prevIdx].filePath;
|
||||
|
||||
if (continuousOptionsRef.current?.enabled) {
|
||||
continuousOptionsRef.current.scrollToFile(prevFilePath);
|
||||
} else {
|
||||
onSelectFile(prevFilePath);
|
||||
}
|
||||
}, [files, selectedFilePath, onSelectFile]);
|
||||
|
||||
const goToNextHunk = useCallback(() => {
|
||||
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
|
||||
if (!view) return;
|
||||
|
||||
if (continuousOptionsRef.current?.enabled) {
|
||||
if (isLastChunkInFile(view)) {
|
||||
const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
|
||||
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
|
||||
|
||||
if (currentIdx < files.length - 1) {
|
||||
const nextFilePath = files[currentIdx + 1].filePath;
|
||||
continuousOptionsRef.current.scrollToFile(nextFilePath);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const opts = continuousOptionsRef.current;
|
||||
const nextView = opts?.editorViewMapRef.current.get(nextFilePath);
|
||||
if (nextView) {
|
||||
nextView.dispatch({ selection: { anchor: 0 } });
|
||||
goToNextChunk(nextView);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
} else {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
|
||||
setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1));
|
||||
}, [editorViewRef, totalHunks, setCurrentHunkIndex, files, selectedFilePath]);
|
||||
|
||||
const goToPrevHunk = useCallback(() => {
|
||||
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
|
||||
if (!view) return;
|
||||
|
||||
if (continuousOptionsRef.current?.enabled) {
|
||||
if (isFirstChunkInFile(view)) {
|
||||
const currentPath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
|
||||
const currentIdx = files.findIndex((f) => f.filePath === currentPath);
|
||||
|
||||
if (currentIdx > 0) {
|
||||
const prevFilePath = files[currentIdx - 1].filePath;
|
||||
continuousOptionsRef.current.scrollToFile(prevFilePath);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const opts = continuousOptionsRef.current;
|
||||
const prevView = opts?.editorViewMapRef.current.get(prevFilePath);
|
||||
if (prevView) {
|
||||
const docLength = prevView.state.doc.length;
|
||||
prevView.dispatch({ selection: { anchor: docLength } });
|
||||
goToPreviousChunk(prevView);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
goToPreviousChunk(view);
|
||||
}
|
||||
} else {
|
||||
goToPreviousChunk(view);
|
||||
}
|
||||
|
||||
setCurrentHunkIndex((prev) => Math.max(prev - 1, 0));
|
||||
}, [editorViewRef, setCurrentHunkIndex, files, selectedFilePath]);
|
||||
|
||||
const goToHunk = useCallback(
|
||||
(index: number) => {
|
||||
setCurrentHunkIndex(Math.max(0, Math.min(index, totalHunks - 1)));
|
||||
|
|
@ -94,14 +234,16 @@ export function useDiffNavigation(
|
|||
);
|
||||
|
||||
const acceptCurrentHunk = useCallback(() => {
|
||||
if (selectedFilePath && onHunkAccepted) {
|
||||
onHunkAccepted(selectedFilePath, currentHunkIndex);
|
||||
const path = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
|
||||
if (path && onHunkAccepted) {
|
||||
onHunkAccepted(path, currentHunkIndex);
|
||||
}
|
||||
}, [selectedFilePath, currentHunkIndex, onHunkAccepted]);
|
||||
|
||||
const rejectCurrentHunk = useCallback(() => {
|
||||
if (selectedFilePath && onHunkRejected) {
|
||||
onHunkRejected(selectedFilePath, currentHunkIndex);
|
||||
const path = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
|
||||
if (path && onHunkRejected) {
|
||||
onHunkRejected(path, currentHunkIndex);
|
||||
}
|
||||
}, [selectedFilePath, currentHunkIndex, onHunkRejected]);
|
||||
|
||||
|
|
@ -114,25 +256,43 @@ export function useDiffNavigation(
|
|||
onSaveFileRef.current = onSaveFile;
|
||||
}, [onClose, onSaveFile]);
|
||||
|
||||
// Keyboard handler — new shortcuts for editable diff
|
||||
// Keyboard handler
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
// Skip if CM keymap already handled this event
|
||||
if (event.defaultPrevented) return;
|
||||
// Skip inputs/textareas
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMeta = event.metaKey || event.ctrlKey;
|
||||
|
||||
// Alt+J -> next change
|
||||
// Alt+J -> next hunk (cross-file in continuous mode)
|
||||
if (event.altKey && event.key.toLowerCase() === 'j') {
|
||||
event.preventDefault();
|
||||
const view = editorViewRef.current;
|
||||
if (view) goToNextChunk(view);
|
||||
goToNextHunk();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+K -> prev hunk (cross-file in continuous mode)
|
||||
if (event.altKey && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
goToPrevHunk();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+ArrowDown -> next file
|
||||
if (event.altKey && event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
goToNextFile();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+ArrowUp -> prev file
|
||||
if (event.altKey && event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
goToPrevFile();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -143,30 +303,50 @@ export function useDiffNavigation(
|
|||
return;
|
||||
}
|
||||
|
||||
// Cmd+Y -> accept + scroll (fallback when editor not focused)
|
||||
// Cmd+Y -> accept chunk + next (cross-file aware)
|
||||
if (isMeta && event.key.toLowerCase() === 'y') {
|
||||
event.preventDefault();
|
||||
const view = editorViewRef.current;
|
||||
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
|
||||
if (view) {
|
||||
acceptChunk(view);
|
||||
requestAnimationFrame(() => goToNextChunk(view));
|
||||
requestAnimationFrame(() => {
|
||||
if (continuousOptionsRef.current?.enabled && isLastChunkInFile(view)) {
|
||||
goToNextFile();
|
||||
} else {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ? -> toggle shortcuts help
|
||||
if (event.key === '?' && !isMeta && !event.altKey) {
|
||||
event.preventDefault();
|
||||
setShowShortcutsHelp((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape handling
|
||||
if (event.key === 'Escape') {
|
||||
if (showShortcutsHelp) {
|
||||
event.preventDefault();
|
||||
setShowShortcutsHelp(false);
|
||||
}
|
||||
// Note: main Escape handling for closing dialog is in ChangeReviewDialog itself
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [isDialogOpen, showShortcutsHelp, editorViewRef]);
|
||||
}, [
|
||||
isDialogOpen,
|
||||
showShortcutsHelp,
|
||||
editorViewRef,
|
||||
goToNextFile,
|
||||
goToPrevFile,
|
||||
goToNextHunk,
|
||||
goToPrevHunk,
|
||||
]);
|
||||
|
||||
return {
|
||||
currentHunkIndex,
|
||||
|
|
|
|||
150
src/renderer/hooks/useLazyFileContent.ts
Normal file
150
src/renderer/hooks/useLazyFileContent.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { type RefObject, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import type { FileChangeWithContent } from '@shared/types';
|
||||
|
||||
const MAX_CONCURRENT = 3;
|
||||
const PRELOAD_COUNT = 5;
|
||||
|
||||
interface UseLazyFileContentOptions {
|
||||
teamName: string;
|
||||
memberName: string | undefined;
|
||||
filePaths: string[];
|
||||
scrollContainerRef: RefObject<HTMLElement | null>;
|
||||
fileContents: Record<string, FileChangeWithContent>;
|
||||
fileContentsLoading: Record<string, boolean>;
|
||||
fetchFileContent: (
|
||||
teamName: string,
|
||||
memberName: string | undefined,
|
||||
filePath: string
|
||||
) => Promise<void>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseLazyFileContentReturn {
|
||||
registerLazyRef: (filePath: string) => (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
export function useLazyFileContent(options: UseLazyFileContentOptions): UseLazyFileContentReturn {
|
||||
const { enabled, scrollContainerRef } = options;
|
||||
|
||||
const activeLoads = useRef(new Set<string>());
|
||||
const pendingQueue = useRef<string[]>([]);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const elementRefs = useRef(new Map<string, HTMLElement>());
|
||||
|
||||
// Stable ref to avoid stale closures in observer/processQueue callbacks
|
||||
const optionsRef = useRef(options);
|
||||
useEffect(() => {
|
||||
optionsRef.current = options;
|
||||
});
|
||||
|
||||
const shouldLoad = useCallback((filePath: string): boolean => {
|
||||
const opts = optionsRef.current;
|
||||
if (opts.fileContents[filePath]) return false;
|
||||
if (opts.fileContentsLoading[filePath]) return false;
|
||||
if (activeLoads.current.has(filePath)) return false;
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Refs for loadFile/processQueue to avoid circular useCallback deps
|
||||
const loadFileRef = useRef<(fp: string) => Promise<void>>();
|
||||
const processQueueRef = useRef<() => void>();
|
||||
|
||||
loadFileRef.current = async (filePath: string) => {
|
||||
if (!shouldLoad(filePath)) return;
|
||||
activeLoads.current.add(filePath);
|
||||
try {
|
||||
const opts = optionsRef.current;
|
||||
await opts.fetchFileContent(opts.teamName, opts.memberName, filePath);
|
||||
} finally {
|
||||
activeLoads.current.delete(filePath);
|
||||
processQueueRef.current?.();
|
||||
}
|
||||
};
|
||||
|
||||
processQueueRef.current = () => {
|
||||
while (activeLoads.current.size < MAX_CONCURRENT && pendingQueue.current.length > 0) {
|
||||
const nextPath = pendingQueue.current.shift()!;
|
||||
if (shouldLoad(nextPath)) {
|
||||
void loadFileRef.current?.(nextPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const enqueueLoad = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!shouldLoad(filePath)) return;
|
||||
|
||||
if (activeLoads.current.size < MAX_CONCURRENT) {
|
||||
void loadFileRef.current?.(filePath);
|
||||
} else {
|
||||
if (!pendingQueue.current.includes(filePath)) {
|
||||
pendingQueue.current.push(filePath);
|
||||
}
|
||||
}
|
||||
},
|
||||
[shouldLoad]
|
||||
);
|
||||
|
||||
// Preload first N files on mount
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const toPreload = optionsRef.current.filePaths.slice(0, PRELOAD_COUNT);
|
||||
for (const fp of toPreload) {
|
||||
enqueueLoad(fp);
|
||||
}
|
||||
}, [enabled, enqueueLoad]);
|
||||
|
||||
// IntersectionObserver for lazy loading
|
||||
useEffect(() => {
|
||||
if (!enabled || !scrollContainerRef.current) return;
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const filePath = entry.target.getAttribute('data-lazy-file');
|
||||
if (!filePath) continue;
|
||||
enqueueLoad(filePath);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainerRef.current,
|
||||
rootMargin: '200% 0px 200% 0px',
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// Observe already mounted elements
|
||||
for (const [, element] of elementRefs.current) {
|
||||
observerRef.current.observe(element);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
};
|
||||
}, [enabled, scrollContainerRef, enqueueLoad]);
|
||||
|
||||
const registerLazyRef = useCallback((filePath: string) => {
|
||||
return (element: HTMLElement | null) => {
|
||||
const observer = observerRef.current;
|
||||
|
||||
const prev = elementRefs.current.get(filePath);
|
||||
if (prev && observer) {
|
||||
observer.unobserve(prev);
|
||||
}
|
||||
elementRefs.current.delete(filePath);
|
||||
|
||||
if (element) {
|
||||
element.setAttribute('data-lazy-file', filePath);
|
||||
elementRefs.current.set(filePath, element);
|
||||
if (observer) {
|
||||
observer.observe(element);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { registerLazyRef };
|
||||
}
|
||||
114
src/renderer/hooks/useVisibleFileSection.ts
Normal file
114
src/renderer/hooks/useVisibleFileSection.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { type RefObject, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseVisibleFileSectionOptions {
|
||||
onVisibleFileChange: (filePath: string) => void;
|
||||
scrollContainerRef: RefObject<HTMLElement | null>;
|
||||
isProgrammaticScroll: RefObject<boolean>;
|
||||
}
|
||||
|
||||
interface UseVisibleFileSectionReturn {
|
||||
registerFileSectionRef: (filePath: string) => (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
export function useVisibleFileSection(
|
||||
options: UseVisibleFileSectionOptions
|
||||
): UseVisibleFileSectionReturn {
|
||||
const { onVisibleFileChange, scrollContainerRef, isProgrammaticScroll } = options;
|
||||
|
||||
const visibleFilePaths = useRef<Set<string>>(new Set());
|
||||
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const updateTopmostVisible = useCallback(() => {
|
||||
if (isProgrammaticScroll.current) return;
|
||||
if (visibleFilePaths.current.size === 0) return;
|
||||
|
||||
let topmostPath: string | null = null;
|
||||
let minTop = Infinity;
|
||||
|
||||
visibleFilePaths.current.forEach((filePath) => {
|
||||
const element = elementRefs.current.get(filePath);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.top < minTop) {
|
||||
minTop = rect.top;
|
||||
topmostPath = filePath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (topmostPath) {
|
||||
onVisibleFileChange(topmostPath);
|
||||
}
|
||||
}, [onVisibleFileChange, isProgrammaticScroll]);
|
||||
|
||||
const debouncedUpdate = useCallback(() => {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(updateTopmostVisible, 100);
|
||||
}, [updateTopmostVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let changed = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
const filePath = entry.target.getAttribute('data-file-path');
|
||||
if (!filePath) continue;
|
||||
|
||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.1) {
|
||||
if (!visibleFilePaths.current.has(filePath)) {
|
||||
visibleFilePaths.current.add(filePath);
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
if (visibleFilePaths.current.has(filePath)) {
|
||||
visibleFilePaths.current.delete(filePath);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
debouncedUpdate();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainerRef.current,
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px',
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [scrollContainerRef, debouncedUpdate]);
|
||||
|
||||
const registerFileSectionRef = useCallback((filePath: string) => {
|
||||
return (element: HTMLElement | null) => {
|
||||
const observer = observerRef.current;
|
||||
if (!observer) return;
|
||||
|
||||
const prev = elementRefs.current.get(filePath);
|
||||
if (prev) {
|
||||
observer.unobserve(prev);
|
||||
elementRefs.current.delete(filePath);
|
||||
visibleFilePaths.current.delete(filePath);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.setAttribute('data-file-path', filePath);
|
||||
elementRefs.current.set(filePath, element);
|
||||
observer.observe(element);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { registerFileSectionRef };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -93,13 +93,9 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
|
|||
map.set(removed[i].name, removed[i].color ?? getMemberColor(active.length + i));
|
||||
}
|
||||
|
||||
// Map "user" to team-lead's resolved color
|
||||
const lead = members.find(
|
||||
(m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead')
|
||||
);
|
||||
if (lead) {
|
||||
map.set('user', map.get(lead.name) ?? getMemberColor(0));
|
||||
}
|
||||
// "user" = the human operator; gets a unique reserved color
|
||||
// that is never assigned to any team member.
|
||||
map.set('user', 'user');
|
||||
|
||||
return map;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue