feat: implement in-app project editor with CodeMirror integration

- Added architectural plan and iteration plan for the in-app project editor.
- Introduced new components for the editor, including CodeEditorOverlay, FileTreePanel, and EditorTabsPanel.
- Established state management using Zustand for editor state persistence.
- Implemented IPC channels for file operations and editor functionality.
- Enhanced TeamDetailView with a button to open the editor overlay.
- Conducted reuse analysis for existing components to optimize codebase integration.
This commit is contained in:
iliya 2026-02-27 22:36:06 +02:00
parent 5278214d8b
commit 0c2f70b2b2
31 changed files with 3167 additions and 102 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,613 @@
# Plan: In-App Project Editor -- Iteration Plan
## Контекст и предпосылки
На странице деталей команды (`TeamDetailView.tsx`) рядом с путём проекта (строки 761-769 в `TeamDetailView.tsx`, используется `FolderOpen` иконка и `formatProjectPath()`) добавляется кнопка "Open in Editor", которая открывает полноэкранный оверлей с файловым деревом, CodeMirror-редактором, вкладками и файловыми операциями.
### Существующие паттерны, на которые опираемся
1. **Fullscreen overlay**: `ChangeReviewDialog.tsx` -- полноэкранный `fixed inset-0 z-50` компонент с хедером, левой панелью (ReviewFileTree) и правой панелью (ContinuousScrollView). Это точный архитектурный прототип.
2. **File tree**: `ReviewFileTree.tsx` -- дерево файлов с `buildTree()`, collapse/expand, активный элемент. Будет адаптирован для файлового браузера (не review).
3. **CodeMirror**: уже установлен в проекте (`@codemirror/*` ~20 пакетов), используется в `CodeMirrorDiffView.tsx`. Функция `getSyncLanguageExtension()` уже мапит расширения на языковые пакеты. Тема `diffTheme` использует CSS-переменные проекта.
4. **IPC-паттерн**: module-level state + `initialize/register/remove` тройка + `wrapHandler<T>()` для IpcResult. Ближайший пример: `review.ts`.
5. **Preload bridge**: `invokeIpcWithResult<T>()` для IpcResult, прямой `ipcRenderer.invoke()` для остальных. Группировка методов через sub-объект (как `review: ReviewAPI`).
6. **Path security**: `validateFilePath()` из `pathValidation.ts` -- проверяет путь на sensitive patterns и sandbox.
7. **Store**: Zustand slices с паттерном `data/selectedId/loading/error`.
---
## Итерация 1: Walking Skeleton (файловое дерево + read-only просмотр)
### Цель
Минимальный end-to-end вертикальный срез: кнопка "Open in Editor" на TeamDetailView открывает полноэкранный оверлей, где слева -- дерево файлов проекта, справа -- содержимое выбранного файла (read-only, с подсветкой синтаксиса через CodeMirror).
### Зависимости (npm)
Никаких новых -- все CodeMirror-пакеты и lucide-react иконки уже установлены.
### IPC каналы (новые)
| Канал | Направление | Описание |
|-------|-------------|----------|
| `editor:readDir` | renderer -> main | Рекурсивное чтение директории (возвращает дерево) |
| `editor:readFile` | renderer -> main | Чтение содержимого файла по абсолютному пути |
### Новые файлы
| Файл | Описание |
|------|----------|
| `src/shared/types/editor.ts` | Типы: `EditorTreeNode`, `EditorFileContent`, запросы/ответы |
| `src/main/services/editor/ProjectFileService.ts` | Сервис: чтение директорий (рекурсивно с лимитами) и файлов. Использует `validateFilePath()` для security |
| `src/main/ipc/editor.ts` | IPC handlers: `editor:readDir`, `editor:readFile`. Паттерн: module-level state + `wrapEditorHandler()` |
| `src/preload/constants/ipcChannels.ts` | Добавить `EDITOR_READ_DIR`, `EDITOR_READ_FILE` |
| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Главный fullscreen overlay (по образцу `ChangeReviewDialog.tsx`) |
| `src/renderer/components/team/editor/EditorFileTree.tsx` | Компонент дерева файлов (адаптация `ReviewFileTree.tsx` для filesystem -- без review-статусов) |
| `src/renderer/components/team/editor/EditorCodeView.tsx` | Read-only CodeMirror view (адаптация `CodeMirrorDiffView.tsx` без merge mode) |
### Изменения в существующих файлах
| Файл | Изменение |
|------|-----------|
| `src/shared/types/api.ts` | Добавить `EditorAPI` интерфейс + `editor: EditorAPI` в `ElectronAPI` |
| `src/preload/index.ts` | Добавить `editor:` группу в `electronAPI` объект |
| `src/main/ipc/handlers.ts` | Добавить `initialize/register/removeEditorHandlers` |
| `src/renderer/components/team/TeamDetailView.tsx` | Кнопка "Open in Editor" рядом с projectPath (строка ~770), state для open/close оверлея |
### Важные решения
- **Security**: `ProjectFileService` ОБЯЗАН использовать `validateFilePath(filePath, projectRoot)` для каждого запроса. Путь должен быть внутри projectRoot (sandbox). Нельзя читать файлы вне проекта.
- **Лимиты**: readDir рекурсия ограничена глубиной (max 10 уровней) и количеством файлов (max 5000 nodes). Исключаются `node_modules`, `.git`, `dist`, `build`, `__pycache__`, `.next`.
- **Read-only**: на этой итерации CodeMirror создаётся с `EditorState.readOnly.of(true)`.
- **Lazy loading дерева**: первый вызов readDir возвращает только верхний уровень. При раскрытии папки -- повторный вызов для поддиректории (ленивая загрузка). Или: полное дерево сразу, но с лимитом глубины и ignored patterns.
### Тестирование
- **Unit**: `ProjectFileService` -- чтение директории с mock fs, проверка security (reject paths outside projectRoot), проверка исключения node_modules.
- **Unit**: `EditorFileTree` -- snapshot тесты рендеринга дерева.
- **Manual**: открыть TeamDetailView, нажать "Open in Editor", убедиться что дерево загружается, клик по файлу показывает содержимое с подсветкой.
### Критерии готовности
- Кнопка видна на TeamDetailView рядом с путём проекта
- Оверлей открывается по клику, закрывается по Escape или X
- Дерево файлов загружается для projectPath команды
- Клик по файлу показывает содержимое с синтаксической подсветкой
- Попытка прочитать файл за пределами проекта -- отказ
- `pnpm typecheck` проходит
### Надёжность решения: 8/10
### Уверенность: 9/10
---
## Итерация 2: Editable CodeMirror + сохранение файлов
### Цель
Переключить CodeMirror из read-only в редактируемый режим. Добавить Cmd+S для сохранения. Показывать индикатор unsaved changes.
### IPC каналы (новые)
| Канал | Направление | Описание |
|-------|-------------|----------|
| `editor:writeFile` | renderer -> main | Запись содержимого файла на диск |
### Новые файлы
| Файл | Описание |
|------|----------|
| `src/renderer/components/team/editor/EditorTabBar.tsx` | Панель вкладок (один файл пока, но подготовка к multi-tab) |
| `src/renderer/components/team/editor/useEditorState.ts` | Хук для управления состоянием открытых файлов, dirty flags, save |
| `src/renderer/store/slices/editorSlice.ts` | Zustand slice: openFiles, activeFilePath, dirtyFiles, loading/error |
### Изменения в существующих файлах
| Файл | Изменение |
|------|-----------|
| `src/shared/types/editor.ts` | Добавить типы для write request/response |
| `src/shared/types/api.ts` | Добавить `writeFile` в `EditorAPI` |
| `src/main/services/editor/ProjectFileService.ts` | Метод `writeFile(projectRoot, filePath, content)` с validation |
| `src/main/ipc/editor.ts` | Handler `editor:writeFile` |
| `src/preload/index.ts` | Добавить `editor.writeFile` |
| `src/preload/constants/ipcChannels.ts` | `EDITOR_WRITE_FILE` |
| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Интеграция EditorTabBar, переключение read-only -> editable |
| `src/renderer/components/team/editor/EditorCodeView.tsx` | Убрать readOnly, добавить onChange callback, Cmd+S keymap |
| `src/renderer/store/index.ts` | Подключить editorSlice |
| `src/renderer/store/types.ts` | Расширить AppState типом editorSlice |
### Важные решения
- **Cmd+S**: перехватывается через CodeMirror keymap extension (не глобальный listener), чтобы не конфликтовать с другими горячими клавишами.
- **Dirty flag**: отслеживается через сравнение текущего содержимого с оригинальным (при загрузке). Точка в названии вкладки для dirty файлов.
- **Confirm on close**: если есть unsaved changes -- `confirm()` через существующий `ConfirmDialog`.
- **Backup**: перед записью -- никакого backup на этой итерации (файл просто перезаписывается). В будущем можно добавить.
- **Concurrency**: если файл изменился на диске пока был открыт -- пока не обрабатываем (это итерация 4-5).
### Тестирование
- **Unit**: `ProjectFileService.writeFile` -- запись с mock fs, reject для файлов вне проекта.
- **Unit**: `editorSlice` -- открытие/закрытие файлов, dirty state, сохранение.
- **Unit**: `useEditorState` -- хук тестирование с Zustand store.
- **Manual**: открыть файл, отредактировать, Cmd+S, убедиться что файл записался, dirty индикатор сбрасывается.
### Критерии готовности
- Файл редактируется в CodeMirror (не read-only)
- Cmd+S сохраняет файл
- Dirty indicator (точка) на вкладке
- При закрытии с unsaved changes -- confirmation dialog
- Сохранение отказывает для файлов вне projectRoot
### Надёжность решения: 7/10
### Уверенность: 8/10
---
## Итерация 3: Multi-tab + создание/удаление файлов
### Цель
Поддержка нескольких открытых файлов во вкладках. Контекстное меню на файловом дереве: создать файл, создать папку, удалить файл. Переименование -- вне scope.
### IPC каналы (новые)
| Канал | Направление | Описание |
|-------|-------------|----------|
| `editor:createFile` | renderer -> main | Создать файл (с опциональным начальным содержимым) |
| `editor:createDir` | renderer -> main | Создать директорию |
| `editor:deleteFile` | renderer -> main | Удалить файл (в Trash через Electron shell.trashItem) |
### Новые файлы
| Файл | Описание |
|------|----------|
| `src/renderer/components/team/editor/EditorContextMenu.tsx` | Context menu для дерева файлов (New File, New Folder, Delete, Reveal in Finder) |
| `src/renderer/components/team/editor/NewFileDialog.tsx` | Маленький inline-input для ввода имени нового файла/папки |
### Изменения в существующих файлах
| Файл | Изменение |
|------|-----------|
| `src/shared/types/editor.ts` | Типы для create/delete запросов |
| `src/shared/types/api.ts` | Расширить `EditorAPI` методами `createFile`, `createDir`, `deleteFile` |
| `src/main/services/editor/ProjectFileService.ts` | Методы `createFile`, `createDir`, `deleteFile`. deleteFile использует `shell.trashItem()` (безопасное удаление) |
| `src/main/ipc/editor.ts` | 3 новых handler |
| `src/preload/index.ts` | 3 новых метода в editor |
| `src/preload/constants/ipcChannels.ts` | `EDITOR_CREATE_FILE`, `EDITOR_CREATE_DIR`, `EDITOR_DELETE_FILE` |
| `src/renderer/components/team/editor/EditorTabBar.tsx` | Multi-tab: массив вкладок, переключение, close (X), close other tabs, middle-click close |
| `src/renderer/components/team/editor/EditorFileTree.tsx` | Right-click context menu, refresh после create/delete |
| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Управление массивом открытых файлов, переключение активной вкладки |
| `src/renderer/store/slices/editorSlice.ts` | Массив openTabs, activeTabId, actions: openFile, closeFile, switchTab, reorderTabs |
### Важные решения
- **Удаление через Trash**: используем `shell.trashItem()` (Electron API) вместо `fs.unlink()`. Это безопасно -- пользователь может восстановить файл из корзины.
- **Confirm on delete**: обязательный ConfirmDialog перед удалением.
- **Tab ordering**: drag-and-drop для вкладок через `@dnd-kit` (уже установлен в проекте).
- **Имя нового файла**: валидация -- запрет на `.`, `..`, `/` в начале, запрет на спецсимволы.
- **Refresh дерева**: после create/delete автоматически перечитываем поддерево. Не нужен FileWatcher -- явный refresh.
### Тестирование
- **Unit**: `ProjectFileService.createFile/deleteFile` с mock fs.
- **Unit**: `editorSlice` -- multi-tab actions (open, close, reorder).
- **Unit**: `EditorContextMenu` -- рендеринг, клики.
- **Manual**: открыть несколько файлов, переключаться между вкладками, создать файл, удалить файл.
### Критерии готовности
- Можно открыть несколько файлов одновременно
- Вкладки переключаются, закрываются
- Правый клик по дереву -- New File, New Folder, Delete
- Создание файла добавляет его в дерево
- Удаление -- через Trash с confirmation
### Надёжность решения: 7/10
### Уверенность: 8/10
---
## Итерация 4: Горячие клавиши, поиск, UX polish
### Цель
Клавиатурная навигация (Cmd+P quick open, Cmd+W close tab, Cmd+Shift+[ / ] switch tabs). Поиск по содержимому файлов через Cmd+Shift+F. Breadcrumb навигация. Иконки файлов по типу.
### IPC каналы (новые)
| Канал | Направление | Описание |
|-------|-------------|----------|
| `editor:searchInFiles` | renderer -> main | Поиск по содержимому файлов (grep-like) |
### Новые файлы
| Файл | Описание |
|------|----------|
| `src/renderer/components/team/editor/QuickOpenDialog.tsx` | Cmd+P dialog: fuzzy search по именам файлов (по образцу `cmdk` -- уже установлен) |
| `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | Панель результатов поиска (заменяет или дополняет file tree) |
| `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | Breadcrumb навигация по пути текущего файла |
| `src/renderer/components/team/editor/fileIcons.ts` | Маппинг расширений на lucide-react иконки и цвета |
| `src/renderer/hooks/useEditorKeyboardShortcuts.ts` | Хук для всех горячих клавиш редактора |
| `src/main/services/editor/FileSearchService.ts` | Сервис: search in files с лимитами (grep-like, max 100 results) |
### Изменения в существующих файлах
| Файл | Изменение |
|------|-----------|
| `src/shared/types/editor.ts` | Типы для search request/response |
| `src/shared/types/api.ts` | `searchInFiles` в EditorAPI |
| `src/main/ipc/editor.ts` | Handler `editor:searchInFiles` |
| `src/preload/index.ts` | `editor.searchInFiles` |
| `src/preload/constants/ipcChannels.ts` | `EDITOR_SEARCH_IN_FILES` |
| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Интеграция QuickOpen, SearchInFiles, Breadcrumb, keyboard shortcuts |
| `src/renderer/components/team/editor/EditorFileTree.tsx` | Иконки файлов по типу |
| `src/renderer/components/team/editor/EditorTabBar.tsx` | Иконки файлов на вкладках |
### Важные решения
- **Quick Open**: использовать `cmdk` (уже в зависимостях, v1.0.4) для fuzzy search по именам файлов. Список файлов загружается при открытии оверлея.
- **Search in Files**: серверная сторона делает простой grep по файлам с Node.js (readline + regex). Не используем external tools типа ripgrep -- держим zero-dependency. Лимит: 100 результатов, max 10MB на файл.
- **Горячие клавиши**: Cmd+P (quick open), Cmd+W (close tab), Cmd+S (save), Cmd+Shift+F (search), Cmd+Shift+[ / ] (switch tabs), Cmd+\ (toggle file tree).
- **Breadcrumb**: кликабельный -- каждый сегмент пути открывает эту папку в дереве.
### Тестирование
- **Unit**: `FileSearchService` -- поиск по mock файлам, лимиты.
- **Unit**: `useEditorKeyboardShortcuts` -- обработка горячих клавиш.
- **Unit**: `fileIcons.ts` -- маппинг расширений.
- **Manual**: Cmd+P, Cmd+Shift+F, навигация клавиатурой.
### Критерии готовности
- Cmd+P открывает quick open с fuzzy search
- Cmd+Shift+F показывает результаты поиска по содержимому
- Все основные горячие клавиши работают
- Breadcrumb-навигация для текущего файла
- Иконки файлов по типу в дереве и вкладках
### Надёжность решения: 7/10
### Уверенность: 7/10
---
## Итерация 5: Git status, file watching, расширенные возможности
### Цель
Показывать git status (modified/untracked/staged) в дереве файлов. Live refresh при изменениях на диске. Conflict detection при сохранении. Minimap. Line numbers toggle.
### IPC каналы (новые)
| Канал | Направление | Описание |
|-------|-------------|----------|
| `editor:gitStatus` | renderer -> main | Получить git status для директории (modified, staged, untracked) |
| `editor:watchDir` | renderer -> main | Запустить file watcher для проекта (возвращает cleanup) |
| `editor:change` | main -> renderer | Event: файл изменился на диске |
### Новые файлы
| Файл | Описание |
|------|----------|
| `src/main/services/editor/EditorFileWatcher.ts` | FileWatcher адаптация (~60 LOC) для отслеживания изменений в projectRoot |
| `src/main/services/editor/GitStatusService.ts` | Сервис: вызывает git status --porcelain и парсит вывод |
| `src/renderer/components/team/editor/GitStatusBadge.tsx` | Бейджи M/U/A рядом с файлами в дереве |
### Изменения в существующих файлах
| Файл | Изменение |
|------|-----------|
| `src/shared/types/editor.ts` | `GitFileStatus`, `EditorFileChangeEvent` |
| `src/shared/types/api.ts` | `gitStatus`, `onEditorFileChange` в EditorAPI |
| `src/main/ipc/editor.ts` | Handlers для git status и file watcher events |
| `src/preload/index.ts` | `editor.gitStatus`, `editor.onFileChange` |
| `src/preload/constants/ipcChannels.ts` | `EDITOR_GIT_STATUS`, `EDITOR_WATCH_DIR`, `EDITOR_CHANGE` |
| `src/renderer/components/team/editor/EditorFileTree.tsx` | Git status badges (M = modified, U = untracked, A = staged) |
| `src/renderer/components/team/editor/EditorCodeView.tsx` | Line wrapping toggle, conflict detection при сохранении |
| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | File watcher подписка, auto-refresh дерева, conflict modal при concurrent edit |
| `src/renderer/store/slices/editorSlice.ts` | Git status data, file watcher state |
### Важные решения
- **Git status**: вызываем child_process с git status --porcelain -u в projectRoot. Парсим вывод. Кешируем на 5 секунд. Не используем libgit2 -- слишком тяжёлый.
- **File watcher**: используем существующий chokidar-подобный паттерн (как `FileWatcher` в проекте). Debounce 200ms. При получении события -- refresh дерева и уведомление если открытый файл изменился.
- **Conflict detection**: при сохранении -- проверить mtime файла. Если изменился после последнего чтения -- показать conflict dialog (перезаписать / отменить / diff).
- **Minimap**: CodeMirror не имеет встроенного minimap. Можно использовать @replit/codemirror-minimap или пропустить. Решение: пропустить minimap (слишком специфичная dependency), вместо этого добавить line wrap toggle и go-to-line (Cmd+G).
### Тестирование
- **Unit**: `GitStatusService` -- парсинг git status --porcelain вывода.
- **Unit**: `EditorFileWatcher` -- debounce, event types.
- **Unit**: conflict detection логика.
- **Manual**: изменить файл в внешнем редакторе, убедиться что отображается conflict.
### Критерии готовности
- Git status бейджи в файловом дереве
- Auto-refresh при изменениях на диске
- Conflict detection при сохранении файла, изменённого извне
- Go-to-line (Cmd+G)
- Line wrap toggle
### Надёжность решения: 6/10
### Уверенность: 7/10
---
## Сводная таблица файлов по итерациям
### Итерация 1 (7 новых, 4 изменения)
**Новые:** `shared/types/editor.ts`, `main/services/editor/ProjectFileService.ts`, `main/ipc/editor.ts`, `renderer/components/team/editor/ProjectEditorOverlay.tsx`, `renderer/components/team/editor/EditorFileTree.tsx`, `renderer/components/team/editor/EditorCodeView.tsx`
**Изменения:** `shared/types/api.ts`, `preload/index.ts`, `preload/constants/ipcChannels.ts`, `main/ipc/handlers.ts`, `renderer/components/team/TeamDetailView.tsx`
### Итерация 2 (3 новых, ~8 изменений)
**Новые:** `renderer/components/team/editor/EditorTabBar.tsx`, `renderer/hooks/useEditorState.ts`, `renderer/store/slices/editorSlice.ts`
**Изменения:** `shared/types/editor.ts`, `shared/types/api.ts`, `main/services/editor/ProjectFileService.ts`, `main/ipc/editor.ts`, `preload/index.ts`, `preload/constants/ipcChannels.ts`, `renderer/components/team/editor/*`, `renderer/store/index.ts`, `renderer/store/types.ts`
### Итерация 3 (2 новых, ~8 изменений)
**Новые:** `renderer/components/team/editor/EditorContextMenu.tsx`, `renderer/components/team/editor/NewFileDialog.tsx`
**Изменения:** многие файлы из итерации 2
### Итерация 4 (6 новых, ~8 изменений)
**Новые:** `QuickOpenDialog.tsx`, `SearchInFilesPanel.tsx`, `EditorBreadcrumb.tsx`, `fileIcons.ts`, `useEditorKeyboardShortcuts.ts`, `main/services/editor/FileSearchService.ts`
### Итерация 5 (3 новых, ~7 изменений)
**Новые:** `EditorFileWatcher.ts`, `GitStatusService.ts`, `GitStatusBadge.tsx`
---
## Риски и предупреждения
1. **Безопасность (критичный риск)**: каждый файловый IPC handler ОБЯЗАН валидировать что запрашиваемый путь находится внутри `projectRoot`. Path traversal (`../../etc/passwd`) -- главный вектор атаки. Используем существующий `validateFilePath()` из `src/main/utils/pathValidation.ts` (НЕ писать свой).
2. **Большие проекты**: дерево файлов может содержать тысячи файлов. Обязательны excluded patterns (`node_modules`, `.git`) и лимиты. Для поиска по файлам -- лимит на размер файла.
3. **Race conditions при сохранении**: если агент Claude параллельно редактирует тот же файл -- потеря данных. Итерация 5 добавляет mtime-проверку, но полноценный lock отсутствует.
4. **Memory**: CodeMirror для очень больших файлов (10MB+) может потреблять много памяти. Лимит на размер читаемого файла: **2MB** (не 5MB -- снижено после security review; IPC сериализация удваивает потребление памяти).
5. **ProseMirror vs CodeMirror**: в requirements указан ProseMirror, но в проекте уже глубоко интегрирован CodeMirror (20+ пакетов, diff view, языковые пакеты). Рекомендация: использовать CodeMirror (не ProseMirror). ProseMirror ориентирован на rich-text, а CodeMirror -- на код. CodeMirror 6 = тот же автор (Marijn Haverbeke), уже в проекте, zero additional dependencies.
---
## Архитектурные решения после ревизии
> Добавлено после ревизии. Влияет на каждую итерацию.
### Обязательные рефакторинги ДО или ВО ВРЕМЯ итерации 1
1. **Извлечь `buildTree()` в `src/renderer/utils/fileTreeBuilder.ts`** (из `ReviewFileTree.tsx`).
Иначе будет дублирование при создании `EditorFileTree`. Рефакторинг не ломает Review -- это extract-and-import.
2. **Извлечь `getSyncLanguageExtension()` + `getAsyncLanguageDesc()` в `src/renderer/utils/codemirrorLanguages.ts`** (из `CodeMirrorDiffView.tsx`).
Аналогично -- extract-and-import, `CodeMirrorDiffView` начинает импортировать из утилиты.
3. **Извлечь базовую тему CM в `src/renderer/utils/codemirrorTheme.ts`** (из `diffTheme` в `CodeMirrorDiffView.tsx`).
Общие стили (`&`, `.cm-gutters`, `.cm-scroller`, `.cm-content`, `.cm-cursor`, `.cm-selectionBackground`) -- в общую тему.
Diff-специфичные (`.cm-changedLine`, `.cm-deletedChunk` и т.д.) -- остаются в `CodeMirrorDiffView.tsx`.
4. **Извлечь `wrapHandler` в `src/main/ipc/ipcWrapper.ts`** (из `review.ts`).
`createIpcWrapper('IPC:editor')` вместо копирования `wrapReviewHandler`.
5. **Имя сервиса: `ProjectFileService`** (не `FileEditorService`). Stateless, без `rootPath` в конструкторе.
Каждый метод принимает `projectRoot` как первый аргумент. Паттерн: `TeamDataService`.
### Изменения в итерациях по результатам ревизии
**Итерация 1:**
- `EditorFileTree.tsx` использует generic `FileTree` из `fileTreeBuilder.ts` + render-prop для иконок
- `EditorCodeView.tsx` использует extracted `codemirrorLanguages.ts` и `codemirrorTheme.ts`
- `ProjectFileService` -- stateless, `readDir(projectRoot, dirPath)`, `readFile(projectRoot, filePath)`
- Security: `validateFilePath()` из `pathValidation.ts`, НЕ свой `assertInsideRoot()`
- НЕ создавать editorSlice на итерации 1 -- state для read-only просмотра можно держать в useState
**Итерация 2:**
- `editorSlice.ts` создаётся с чёткими секциями-группами (tree / tabs / content-save)
- `buildEditorExtensions(options)` -- фабрика extensions, компонент не знает о конкретных CM plugins
- `useEditorState.ts` -> убрать. Логика целиком в slice. Хук `useEditorState` дублирует slice.
**Итерация 3:**
- Tab management actions (`openFile`, `closeTab`, `setActiveTab`) уже в slice с итерации 2
- `EditorContextMenu.tsx` -- ОК, отдельный компонент
- `NewFileDialog.tsx` -- ОК, inline input
**Итерация 4:**
- `FileSearchService.ts` -- отдельный сервис в main, ОК (SRP)
- `useEditorKeyboardShortcuts.ts` -- ОК, отдельный хук
- `fileIcons.ts` -- ОК, чистая утилита
**Итерация 5:**
- `GitStatusService.ts` -- отдельный сервис, ОК
- `EditorFileWatcher.ts` -- повторяет паттерн FileWatcher (~60 LOC), ОК
- mtime conflict detection -- необходима проверка и в `saveFile` (slice), и в `writeFile` (service)
---
## UX Review
> Добавлено после UX-ревью. Дополнения и исправления по итерациям.
### Дополнения к итерации 1 (Walking Skeleton)
1. **Focus management:** При открытии overlay -- фокус на первый файл в дереве. При закрытии -- вернуть фокус на кнопку "Open in Editor" (паттерн `returnFocusRef`). Добавить `inert` на фоновый контент.
2. **ARIA:** File tree сразу с `role="tree"`, `role="treeitem"`, `aria-expanded`, `role="group"`. Не откладывать accessibility на потом.
3. **Пустой проект:** Если `readDir` возвращает 0 видимых файлов -- показать "No files found" + "Create a new file?" (кнопка неактивна до итерации 3).
4. **Binary файлы:** Уже на итерации 1 (read-only) нужна проверка бинарности. Добавить `isBinary` в `ReadFileResult` и `EditorBinaryState.tsx` -- "This file is binary. Open in system viewer?"
5. **Глубокая вложенность:** Max визуальный indent = 12 уровней. Tooltip с полным путём на глубоких узлах.
### Дополнения к итерации 2 (Editing + Save)
1. **Status bar:** Добавить `EditorStatusBar.tsx` -- `[Ln 42, Col 15] | [TypeScript] | [Spaces: 2]`. Данные из CM6 state. CSS: `bg-surface-sidebar border-t border-border text-text-muted text-xs h-6`.
2. **Unsaved changes при закрытии overlay:** Не только при закрытии tab, но и при Escape/X для overlay. Три кнопки: "Save All & Close" / "Discard & Close" / "Cancel". Добавить `hasUnsavedChanges()` в slice.
3. **Файл удалён извне:** При `saveFile` с ENOENT -- inline-ошибка "File was deleted. Create new? / Close tab". Не падать.
### Дополнения к итерации 3 (Multi-tab + file ops)
1. **Disambiguation tab labels:** Два таба "index.ts" -- нужно показать "(main/utils)" и "(renderer/utils)". Утилита `getDisambiguatedTabLabel()` в `src/renderer/utils/tabLabelDisambiguation.ts`.
2. **Длинные имена файлов:** Табы с max-width ~160px, `truncate`, tooltip. Modified dot ПЕРЕД текстом (не обрезается при truncate).
3. **ARIA для tab bar:** `role="tablist"`, `role="tab"`, `aria-selected`, `role="tabpanel"`.
### Исправления к итерации 4 (Hotkeys, search, UX polish)
1. **Keyboard shortcuts -- конфликт:** `Cmd+[` / `Cmd+]` это indent/outdent в CM6 и VS Code! Переключение табов: `Cmd+Shift+[` / `Cmd+Shift+]` (VS Code convention). Добавить `Ctrl+Tab` / `Ctrl+Shift+Tab`.
2. **Cmd+B toggle sidebar:** Добавить в список горячих клавиш. Sidebar width persist в localStorage.
3. **Cmd+G go to line:** Добавить. CM6 уже поддерживает через `gotoLine` command.
4. **Discoverability:** Кнопка `?` в header overlay (как в ChangeReviewDialog). EmptyState показывает шпаргалку shortcuts.
### Дополнения к итерации 5 (Git, file watching)
1. **File changed on disk while open in tab:** При обнаружении изменения -- banner в табе: "File changed on disk. [Reload] [Keep mine] [Show diff]". Не перезаписывать молча.
2. **File deleted on disk while open in tab:** Banner: "File no longer exists on disk. [Close tab]". Не показывать ошибку при попытке сохранить -- показать предупреждение.
---
## Security Review -- дополнения по итерациям
> Полный аудит безопасности описан в `plan-architecture.md` секция 18. Здесь -- конкретные требования для каждой итерации.
### Итерация 1: Security-critical
1. **`ProjectFileService.readDir()`**: Валидировать КАЖДЫЙ entry через `validateFilePath()`. Для symlinks -- `fs.realpath()` + повторная проверка containment. Молча пропускать symlinks, ведущие за пределы projectRoot (см. SEC-2 в plan-architecture.md).
2. **`ProjectFileService.readFile()`**: Проверить `fs.lstat()` -> `isFile()` ДО чтения. Проверить `stats.size <= 2MB`. Блокировать пути `/dev/`, `/proc/`, `/sys/`. После чтения -- post-read verify через `fs.realpath()` (TOCTOU mitigation).
3. **projectRoot**: Хранить в module-level state `editor.ts`, НЕ принимать от renderer при каждом IPC вызове. Устанавливать через `editor:open(projectPath)` с валидацией.
4. **Sensitive файлы**: `validateFilePath()` уже блокирует `.env`, `.ssh`, `credentials.json` и т.д. В readDir: показывать с пометкой "locked", при клике -- "Sensitive file, cannot open".
### Итерация 2: Security-critical
1. **`ProjectFileService.writeFile()`**:
- `validateFilePath()` ДО записи
- `Buffer.byteLength(content, 'utf8') <= 2MB` ДО записи
- Atomic write: tmp файл в той же директории + `rename()`
- Запрет записи в `.git/` директорию
- Post-write verify не нужна (atomic rename -- одна операция)
2. **`editor:writeFile` IPC handler**: Параметр `filePath` от renderer валидируется через `validateFilePath(filePath, activeProjectRoot)`. `activeProjectRoot` из module-level state.
### Итерация 3: Security-critical
1. **`editor:createFile`**: Валидация имени файла через `validateFileName()`:
- Запрет `.` и `..` как имени
- Запрет control characters (`\x00-\x1f`)
- Запрет path separators (`/`, `\`, `:`)
- Запрет NUL bytes
- Max длина 255 символов
- Запрет sensitive паттернов (`.env`, `*.key`) при СОЗДАНИИ (опционально -- можно разрешить)
2. **`editor:deleteFile`**: Использовать `shell.trashItem()`, НЕ `fs.unlink()`. Валидация пути через `validateFilePath()`.
3. **Валидация parentDir**: При `createFile(parentDir, name)` -- валидировать и `parentDir`, и `path.join(parentDir, name)`.
### Итерация 4: Security-critical
1. **`editor:searchInFiles`**:
- ТОЛЬКО literal string search, НЕ regex от пользователя
- Max 1000 файлов для поиска, max 1MB на файл
- Запустить в `worker_thread` или с AbortController timeout
- Каждый файл для поиска валидировать через `validateFilePath()`
### Итерация 5: Medium security
1. **`editor:gitStatus`**: Выполняет `child_process.exec('git status')` -- убедиться что `cwd` установлен в projectRoot и что projectRoot валиден.
2. **`editor:watchDir`**: FileWatcher на projectRoot -- ОК, но при получении событий не передавать полные пути файлов в renderer без валидации.
3. **`editor:change` events (main->renderer)**: Пути файлов в events -- потенциальная утечка информации если watcher случайно поймает файл за пределами проекта (через symlink).
### ВНИМАНИЕ: Существующая уязвимость (не связана с editor)
**`review:saveEditedFile`** в `src/main/ipc/review.ts` записывает файл без валидации пути. См. SEC-11 в plan-architecture.md. Необходим отдельный hotfix НЕЗАВИСИМО от editor-фичи.
---
## Performance Review -- дополнения по итерациям
> Полный аудит в `plan-architecture.md` секция 19. Здесь -- конкретные performance-требования для каждой итерации.
### Итерация 1: Performance-critical
1. **EditorView lifecycle (CRITICAL):** НЕ использовать `Map<tabId, EditorView>` + CSS show/hide (как описано в plan-architecture секция 6.5). Использовать **EditorState pooling**: `Map<tabId, EditorState>` в useRef + один активный EditorView. При переключении таба: `savedStates.set(oldId, view.state)` -> `view.destroy()` -> `new EditorView({ state: savedStates.get(newId) })`. Паттерн initialState уже используется в CodeMirrorDiffView.tsx (строки 699-705).
2. **readDir лимиты:** MAX_ENTRIES_PER_DIR = 500 (не 10,000). При превышении -- "N more files..." + кнопка "Show all". Только root level при открытии, expand = depth=1 для конкретной папки.
3. **readFile тиерная стратегия:** <256KB мгновенно | 256KB-2MB с progress | 2MB-5MB preview only (100 строк) | >5MB external editor. Детектировать минификацию (строка >10,000 chars) и binary (null bytes в первых 8KB).
4. **Дедупликация IPC:** `Map<string, Promise<ReadFileResult>>` для readFile. Если файл уже загружается -- ждать результат, не создавать новый запрос.
### Итерация 2: Performance-critical
1. **НЕ хранить modified content в Zustand (CRITICAL):** `editorModifiedContents: Record<string, string>` из секции 2.1 plan-architecture -- УБРАТЬ. Контент живёт только в EditorState CodeMirror. В Zustand: `editorModifiedFiles: Set<string>` (только dirty flags). Dirty flag обновляется debounced (300ms) через EditorView.updateListener (паттерн из CodeMirrorDiffView строки 517-527).
2. **Гранулярные Zustand селекторы (обязательно):**
```typescript
const tabList = useStore(s => s.editorOpenTabs, shallow);
const activeId = useStore(s => s.editorActiveTabId);
// FileTreePanel НЕ подписывается ни на content, ни на tabs
// TabBar НЕ подписывается на tree state
```
3. **LRU eviction EditorState:** При >30 states в кеше -- вытеснять oldest, сохраняя `{ content: string, cursorPos: number }` (без undo). При возврате к вытесненному табу -- восстановить через `EditorState.create()`.
### Итерация 3: Performance-medium
1. **Tab closing -- memory cleanup:** При closeTab: `stateCache.delete(tabId)`. При closeAllTabs: `stateCache.clear()`. Явно вызывать -- не полагаться на GC.
2. **Concurrent file operations:** При createFile/deleteFile -- дебаунсить обновление дерева (500ms), не перечитывать после каждой операции.
### Итерация 4: Performance-critical
1. **File tree виртуализация (HIGH):** Перейти на `@tanstack/react-virtual` (уже в проекте -- DateGroupedSessions.tsx, ChatHistory.tsx, NotificationsView.tsx). `flattenTree(tree, expandedDirs) -> FlatNode[]` + `useVirtualizer({ count, estimateSize: () => 28 })`. Рендерить только видимые ноды.
2. **Search in files -- main process:** Запускать в worker_thread или с AbortController (timeout 5s). Limit: 100 результатов, max 1MB на файл. НЕ читать binary файлы для поиска.
3. **Quick Open (Cmd+P):** Кешировать flat file list при открытии editor. НЕ перечитывать на каждое открытие Cmd+P. Invalidate по F5 или file watcher event.
### Итерация 5: Performance-medium
1. **File watcher -- opt-in:** НЕ включать по умолчанию. Toggle "Watch for external changes". По умолчанию -- ручной refresh (F5). При включении: `fs.watch({ recursive: true })` с фильтрацией (node_modules/.git/dist) и debounce 200ms.
2. **Git status -- кеширование:** Результат `git status --porcelain` кешировать на 5 секунд (как в плане). При file watcher event -- invalidate и перечитать.
### Benchmarks для CI/Manual
```
Benchmark 1: EditorView memory
Открыть 25 файлов x 200KB
Измерить: performance.memory.usedJSHeapSize
Порог: < 150MB
Benchmark 2: Tab switch latency
Переключить таб (500KB файл с syntax highlighting)
Измерить: time from click to contentful paint
Порог: < 50ms
Benchmark 3: File tree render
5000+ файлов, все папки раскрыты (с виртуализацией)
Измерить: FPS при скролле
Порог: >= 55fps
Benchmark 4: readDir latency
Директория с 5000 файлами
Измерить: time from click to tree displayed
Порог: < 200ms
Benchmark 5: Keystroke re-renders
React DevTools Profiler при наборе текста
Порог: FileTreePanel и TabBar рендерятся 0 раз
```
---

View file

@ -0,0 +1,723 @@
# Анализ переиспользования кодовой базы для In-App Project Editor
## 1. Переиспользуемые компоненты
### 1.1 ReviewFileTree -- высокий потенциал извлечения
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/ReviewFileTree.tsx`
Это самый важный компонент для переиспользования. Внутри него:
- **`buildTree(files)` функция (строки 42-83)** -- построение дерева из плоского списка путей. Алгоритм: разбивает пути по `/`, строит иерархию `TreeNode`, коллапсирует одноуровневые папки (как в VS Code). Это **универсальный** алгоритм, не привязанный к review.
- **`TreeItem` компонент (строки 147-264)** -- рекурсивный рендеринг узла дерева с иконками, отступами, коллапсом папок.
- **`ReviewFileTree` (строки 297-376)** -- корневой компонент с auto-expand и auto-scroll.
**Проблема:** Сейчас `ReviewFileTree` жестко привязан к review-контексту:
- `TreeItem` принимает `hunkDecisions`, `fileDecisions`, `fileChunkCounts`, `viewedSet` -- всё review-специфичное
- `FileStatusIcon` рендерит статусы review (accepted/rejected/mixed/pending)
- Строки +/- в каждом файле (`linesAdded`, `linesRemoved`)
**Рекомендация:** Извлечь **generic FileTree** из `ReviewFileTree`. Структура:
1. Выделить `buildTree()` и `TreeNode` в утилиту `src/renderer/utils/fileTreeBuilder.ts`
2. Создать generic `FileTree` компонент с `renderItem` callback (render-prop для кастомизации правой части каждого файлового элемента)
3. `ReviewFileTree` становится тонкой обёрткой вокруг `FileTree` с review-специфичным `renderItem`
4. `EditorFileTree` -- вторая обёртка для редактора (показывает иконки по типу файла, dirty-маркер)
**Оценка надёжности: 8/10** -- buildTree проверен в продакшене, алгоритм коллапса протестирован.
**Оценка уверенности: 9/10** -- это чистый extract-and-wrap рефакторинг.
### 1.2 CodeMirrorDiffView -- частичное переиспользование
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/CodeMirrorDiffView.tsx`
Этот компонент содержит ценную инфраструктуру:
- **`getSyncLanguageExtension(fileName)` (строки 64-123)** -- маппинг расширений файлов на CodeMirror language extensions. 16+ языков. **Должен быть извлечён в общую утилиту.**
- **`getAsyncLanguageDesc(fileName)` (строки 126-128)** -- async fallback через `@codemirror/language-data`.
- **`diffTheme` (строки 158-283)** -- тема CodeMirror на CSS-переменных. Частично переиспользуема для обычного редактора (базовые стили `.cm-gutters`, `.cm-content`, `.cm-scroller`).
- **`langCompartment` паттерн** -- Compartment для ленивой инжекции языка. Полностью переиспользуем.
- **`buildExtensions()` (строки 477-688)** -- настройка расширений. Для редактора нужна упрощённая версия (без merge/diff, без hunk navigation).
**Что НЕ переиспользуется:** Вся diff/merge логика (`unifiedMergeView`, `mergeCompartment`, chunk navigation, merge toolbar) -- это 60%+ кода компонента.
**Рекомендация:** Создать `CodeMirrorEditor` компонент (без diff) рядом или вместо fork'а `CodeMirrorDiffView`:
1. Извлечь `getLanguageExtension()` в `src/renderer/utils/codemirrorLanguages.ts`
2. Извлечь базовую тему в `src/renderer/utils/codemirrorTheme.ts`
3. Новый `CodeMirrorEditor` использует эти утилиты + `@codemirror/autocomplete` (уже в `package.json`!)
**Оценка надёжности: 7/10** -- ядро проверено, но отделение от diff-логики требует внимания.
**Оценка уверенности: 8/10** -- чётко понятно что извлекать.
### 1.3 ChangeReviewDialog -- паттерн layout
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/ChangeReviewDialog.tsx`
Это **полноэкранный overlay** (не Radix Dialog!). Паттерн (строки 507-676):
```
fixed inset-0 z-50 flex flex-col bg-surface
├── Header (border-b, bg-surface-sidebar, macOS traffic-light padding)
├── Toolbar (border-b)
└── Content (flex flex-1 overflow-hidden)
├── Sidebar (w-64, overflow-y-auto, border-r, bg-surface-sidebar)
└── Main content area (flex-1)
```
**Что переиспользуется:**
- Layout паттерн: header + sidebar + content
- macOS traffic-light padding (`--macos-traffic-light-padding-left`, `WebkitAppRegion: 'drag'`)
- Escape-to-close (строки 346-353)
- Loading/Error/Empty states (строки 586-673)
**Рекомендация:** Создать `FullScreenPanel` layout-компонент, который предоставляет:
- Header slot с macOS-safe padding
- Optional sidebar slot
- Content slot
- Escape-to-close behaviour
- Loading/Error/Empty state handling
Или проще -- просто скопировать layout-паттерн в `ProjectEditor`, а рефакторить в общий компонент потом.
**Оценка надёжности: 7/10**
**Оценка уверенности: 7/10** -- зависит от того, насколько сильно отличается layout редактора.
### 1.4 DiffErrorBoundary -- прямое переиспользование
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/DiffErrorBoundary.tsx`
Специализированный error boundary для diff-view. Нужен **аналогичный** для CodeMirror editor. Можно обобщить:
- Переименовать в `EditorErrorBoundary`
- Убрать diff-специфичные пропы (`oldString`, `newString`)
- Добавить generic error info display
**Оценка надёжности: 9/10**
**Оценка уверенности: 9/10**
### 1.5 UI примитивы
Прямое переиспользование без изменений:
| Компонент | Путь | Применение |
|-----------|------|-----------|
| `ErrorBoundary` | `src/renderer/components/common/ErrorBoundary.tsx` | Обёртка всего редактора |
| `CopyablePath` | `src/renderer/components/common/CopyablePath.tsx` | Путь к файлу в header |
| `CopyButton` | `src/renderer/components/common/CopyButton.tsx` | Копирование содержимого |
| `ConfirmDialog` | `src/renderer/components/common/ConfirmDialog.tsx` | "Save before close?" |
| `Tooltip` | `src/renderer/components/ui/tooltip.tsx` | Тултипы на кнопках toolbar |
| `Button` | `src/renderer/components/ui/button.tsx` | Кнопки toolbar |
| `Dialog` | `src/renderer/components/ui/dialog.tsx` | Мелкие модалки (settings) |
| `Tabs` | `src/renderer/components/ui/tabs.tsx` | Табы открытых файлов |
### 1.6 Компоненты review, которые НЕ стоит переиспользовать
- `ReviewToolbar` -- слишком review-специфичен (accept/reject/apply counters)
- `ContinuousScrollView` -- scroll-spy для diff-review, не подходит для редактора
- `FileSectionDiff` / `FileSectionHeader` -- привязаны к diff workflow
- `ViewedProgressBar` -- review-only
- `ConflictDialog` -- review-only
---
## 2. Существующие IPC каналы
### 2.1 Уже есть -- файловые операции
| Канал | Файл | Что делает | Применимость |
|-------|------|-----------|-------------|
| `review:saveEditedFile` | `src/main/ipc/review.ts` | Сохраняет файл на диск (`filePath`, `content`) | **УЯЗВИМОСТЬ: нет валидации пути!** НЕ переиспользовать без исправления (см. SEC-11). Для editor -- отдельный канал с валидацией |
| `review:getFileContent` | `src/main/ipc/review.ts` | Читает файл + original + modified | Частично -- нужна упрощённая версия |
| `read-mentioned-file` | `src/main/ipc/utility.ts` | Читает файл по абсолютному пути с валидацией | Можно использовать, но ограничен `maxTokens` |
| `shell:openPath` | `src/main/ipc/utility.ts` | Открывает файл в системном приложении | "Open in external editor" |
| `shell:showInFolder` | `src/main/ipc/utility.ts` | Показывает файл в Finder | "Reveal in Finder" |
### 2.2 Чего НЕТ -- нужно создать
Для полноценного редактора проекта нужны **новые IPC каналы**:
1. **`editor:listDirectory(dirPath)`** -- рекурсивный listing файлов (с ignore-паттернами: `.git`, `node_modules`, etc.)
2. **`editor:readFile(filePath)`** -- чтение файла без ограничений `maxTokens` (в отличие от `read-mentioned-file`)
3. **`editor:saveFile(filePath, content)`** -- можно переиспользовать `review:saveEditedFile`, но лучше отдельный канал с более широкой валидацией
4. **`editor:createFile(filePath, content?)`** -- создание нового файла
5. **`editor:deleteFile(filePath)`** -- удаление файла (с `confirm` на renderer стороне)
6. **`editor:renameFile(oldPath, newPath)`** -- переименование
7. **`editor:watchDirectory(dirPath)`** -- подписка на изменения в директории (для обновления file tree)
**Паттерн регистрации** (из `src/main/ipc/review.ts`):
```typescript
// Module-level state + guard
let service: EditorService | null = null;
function getService(): EditorService { ... }
// Forward-compatible config object
export interface EditorHandlerDeps { ... }
export function initializeEditorHandlers(deps: EditorHandlerDeps): void { ... }
export function registerEditorHandlers(ipcMain: IpcMain): void { ... }
export function removeEditorHandlers(ipcMain: IpcMain): void { ... }
```
**Каналы в `ipcChannels.ts`** -- плоские `export const`, НЕ объект (подтверждено в MEMORY.md).
**Оценка надёжности: 8/10** -- паттерн отработан на 20+ модулях.
**Оценка уверенности: 9/10**
---
## 3. Zustand-паттерн для Editor Slice
### 3.1 Существующий паттерн slice'ов
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/store/types.ts`
18 slice'ов, объединённых через intersection type. Каждый slice:
```typescript
export interface SomeSlice {
// Data
someData: T[];
selectedId: string | null;
loading: boolean;
error: string | null;
// Actions
fetchData: () => Promise<void>;
selectItem: (id: string | null) => void;
}
export const createSomeSlice: StateCreator<AppState, [], [], SomeSlice> = (set, get) => (
// initial state + actions
});
```
### 3.2 Рекомендуемая структура EditorSlice
```typescript
export interface EditorSlice {
// State
editorProjectPath: string | null; // Текущий проект
editorFileTree: FileTreeNode[]; // Дерево файлов
editorFileTreeLoading: boolean;
editorOpenFiles: OpenFile[]; // Открытые файлы (табы)
editorActiveFilePath: string | null; // Активный файл
editorDirtyFiles: Set<string>; // Файлы с несохранёнными изменениями
editorError: string | null;
// File content cache (path -> content)
editorFileContents: Record<string, string>;
editorFileContentsLoading: Record<string, boolean>;
// Actions
openEditor: (projectPath: string) => Promise<void>;
closeEditor: () => void;
loadFileTree: (dirPath: string) => Promise<void>;
openFile: (filePath: string) => Promise<void>;
closeFile: (filePath: string) => void;
setActiveFile: (filePath: string) => void;
updateFileContent: (filePath: string, content: string) => void;
saveFile: (filePath: string) => Promise<void>;
saveAllDirty: () => Promise<void>;
}
```
**Важно:** Следовать правилу из CLAUDE.md -- "Store over Props": дочерние компоненты читают из store напрямую через `useStore()`.
**Куда добавить:**
1. `src/renderer/store/slices/editorSlice.ts` -- новый slice
2. Добавить `EditorSlice` в `AppState` type в `types.ts`
3. Добавить `...createEditorSlice(...args)` в `store/index.ts`
**Оценка надёжности: 9/10**
**Оценка уверенности: 9/10**
### 3.3 Ближайший аналог -- `changeReviewSlice`
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/store/slices/changeReviewSlice.ts`
Этот slice ближе всего к будущему `editorSlice`:
- `fileContents: Record<string, FileChangeWithContent>` -- кеш содержимого файлов
- `fileContentsLoading: Record<string, boolean>` -- состояние загрузки per-file
- `editedContents: Record<string, string>` -- несохранённые изменения
- `saveEditedFile(filePath)` -- сохранение на диск
- `discardFileEdits(filePath)` -- отмена изменений
- Debounced persistence
---
## 4. CSS/Theme -- переиспользование
### 4.1 Существующие CSS-переменные
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/index.css`
Полностью подходят для редактора:
| Категория | Переменные | Применение в редакторе |
|-----------|-----------|----------------------|
| Surfaces | `--color-surface`, `--color-surface-raised`, `--color-surface-sidebar` | Фон редактора, sidebar, header |
| Borders | `--color-border`, `--color-border-subtle`, `--color-border-emphasis` | Разделители панелей |
| Text | `--color-text`, `--color-text-secondary`, `--color-text-muted` | Текст в file tree, status bar |
| Code | `--code-bg`, `--code-border`, `--code-line-number`, `--code-filename` | Фон редактора, номера строк |
| Syntax | `--syntax-string`, `--syntax-comment`, `--syntax-keyword` и т.д. | Подсветка синтаксиса |
| Inline code | `--inline-code-bg`, `--inline-code-text` | Инлайн код в markdown |
| Scrollbar | `--scrollbar-thumb`, `--scrollbar-thumb-hover` | Скроллбар в file tree |
| Card | `--card-bg`, `--card-border`, `--card-header-bg` | Панели, headers |
| Skeleton | `--skeleton-base`, `--skeleton-base-light` | Loading state |
### 4.2 Тема CodeMirror
`diffTheme` в `CodeMirrorDiffView.tsx` (строки 158-283) уже использует CSS-переменные:
```typescript
'&': {
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)',
fontFamily: 'ui-monospace, SFMono-Regular, ...',
fontSize: '13px',
},
'.cm-gutters': {
backgroundColor: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)',
...
}
```
Нужно извлечь **базовую тему** (без diff-стилей `.cm-changedLine`, `.cm-deletedChunk` и т.д.) -- примерно 40% от текущей темы.
### 4.3 Light theme
Поддержка есть через `:root.light` override'ы в `index.css`. Если `diffTheme` использует CSS-переменные (а он использует), то light theme заработает автоматически.
---
## 5. CodeMirror vs ProseMirror
### 5.1 CodeMirror 6 -- уже в проекте
**Из `package.json`:**
```json
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-cpp", "@codemirror/lang-css", "@codemirror/lang-go",
"@codemirror/lang-html", "@codemirror/lang-java", "@codemirror/lang-javascript",
"@codemirror/lang-json", "@codemirror/lang-less", "@codemirror/lang-markdown",
"@codemirror/lang-php", "@codemirror/lang-python", "@codemirror/lang-rust",
"@codemirror/lang-sass", "@codemirror/lang-sql", "@codemirror/lang-xml",
"@codemirror/lang-yaml",
"@codemirror/language", "@codemirror/language-data",
"@codemirror/merge", "@codemirror/state",
"@codemirror/theme-one-dark", "@codemirror/view"
```
Это **16 языковых пакетов** + `@codemirror/language-data` (ещё ~30 языков async). Плюс `@codemirror/autocomplete` уже установлен.
### 5.2 Рекомендация: ТОЛЬКО CodeMirror 6
**Однозначно CodeMirror 6, НЕ ProseMirror.** Причины:
1. **Уже 20+ пакетов CodeMirror в зависимостях** -- нулевой overhead по bundle size
2. **Работающая инфраструктура**: `getSyncLanguageExtension()`, `getAsyncLanguageDesc()`, тема, Compartment-паттерн -- всё протестировано в production
3. **`@codemirror/autocomplete`** уже установлен -- автодополнение из коробки
4. **CodeMirror = код-редактор**, ProseMirror = rich text / WYSIWYG. Для проектного редактора нужен именно код-редактор
5. **ProseMirror добавил бы ~150-200KB** в bundle + совершенно новая экосистема плагинов
**Не нужно добавлять НИКАКИХ новых зависимостей** для базового редактора. Всё есть.
**Оценка надёжности: 10/10** -- CodeMirror 6 зрелый, используется в VSCode, Chrome DevTools
**Оценка уверенности: 10/10** -- ProseMirror для code editing = антипаттерн
---
## 6. Anti-patterns и риски
### 6.1 Размер компонентов
**Проблема:** `ChangeReviewDialog.tsx` -- **677 строк**. `CodeMirrorDiffView.tsx` -- **809 строк**. Оба на грани допустимого.
**Рекомендация для Editor:**
- `ProjectEditor.tsx` -- max 150 строк (layout shell, делегирует всё дочерним)
- `EditorFileTree.tsx` -- max 200 строк
- `EditorTabBar.tsx` -- max 100 строк
- `EditorCodePane.tsx` -- max 150 строк (обёртка вокруг CodeMirror)
- `EditorToolbar.tsx` -- max 100 строк
- Хуки (`useEditorKeyboard`, `useEditorFileOps`) -- по 50-100 строк
### 6.2 Performance с большими файлами
**Проблема:** CodeMirror 6 virtual scrolling работает, но:
- Файлы >5MB могут замедлить парсинг языка
- `readFile` через IPC сериализует содержимое как JSON string -- большие файлы замедляют IPC
**Рекомендация:**
- Лимит чтения: ~2MB (показывать "File too large, open externally")
- `EditorView.scrollPastEnd` -- чтобы пользователь мог скроллить ниже конца файла
- Lazy language loading через Compartment (уже реализовано в `CodeMirrorDiffView`)
### 6.3 Dirty state и unsaved changes
**Проблема:** `changeReviewSlice` хранит `editedContents` как `Record<string, string>` -- весь контент файла в памяти per-dirty-file. При 10+ грязных файлах это может быть гигабайт RAM.
**Рекомендация:**
- Хранить ТОЛЬКО для активного файла + 2-3 соседних табов (LRU cache)
- Для остальных -- хранить `EditorState` объект CodeMirror (он уже в памяти CM)
- При переключении табов -- сохранять `EditorState` (включая undo history), не строку
### 6.4 File watching race conditions
**Проблема:** Если пользователь редактирует файл в нашем редакторе, а CLI-агент одновременно меняет его через `review:saveEditedFile` -- конфликт.
**Рекомендация:**
- `mtime` check перед записью (как `review:checkConflict`)
- Уведомление "File changed on disk" с выбором (reload / keep mine / show diff)
### 6.5 Missing error boundaries
**Проблема:** `ErrorBoundary` в `common/` -- один на всё приложение. `DiffErrorBoundary` -- только для diff. Если CodeMirror крашится в editor mode, нужен отдельный boundary.
**Рекомендация:** Обернуть `CodeMirrorEditor` в специализированный `EditorErrorBoundary` (можно обобщить `DiffErrorBoundary`).
### 6.6 IPC parameter validation
**Проблема (CRITICAL):** В `review.ts` IPC handler `handleSaveEditedFile` **НЕ валидирует путь** -- прямой `writeFile()` без `validateFilePath()`. Это существующая уязвимость (см. секцию 10.3).
**Рекомендация:**
- **ВСЕ** IPC handlers, работающие с файлами, ОБЯЗАНЫ вызывать `validateFilePath()` из `src/main/utils/pathValidation.ts`
- Для editor: выделенный module-level `activeProjectRoot`, не принимаемый от renderer при каждом вызове
- Дополнительно: `validateFileName()` для создания файлов, `isDevicePath()` для блокировки device files, запрет записи в `.git/`
- Подробный чеклист -- в `plan-architecture.md` секция 18
---
## 7. Итоговая архитектурная рекомендация
### Что ИЗВЛЕЧЬ из существующего кода (рефакторинг):
1. `buildTree()` + `TreeNode` --> `src/renderer/utils/fileTreeBuilder.ts`
2. `getSyncLanguageExtension()` + `getAsyncLanguageDesc()` --> `src/renderer/utils/codemirrorLanguages.ts`
3. Базовая CM тема (без diff) --> `src/renderer/utils/codemirrorTheme.ts`
4. `ReviewFileTree` --> generic `FileTree` + `ReviewFileTree` wrapper
### Что СОЗДАТЬ с нуля:
1. `src/renderer/store/slices/editorSlice.ts`
2. `src/main/ipc/editor.ts` + handler'ы
3. `src/preload/constants/ipcChannels.ts` -- добавить `EDITOR_*` каналы
4. `src/preload/index.ts` -- добавить `editor` API
5. `src/renderer/components/editor/` -- компоненты редактора
6. `src/main/services/editor/EditorService.ts` -- сервис файловых операций
### Что ПЕРЕИСПОЛЬЗОВАТЬ напрямую:
- Все UI примитивы из `components/ui/`
- `ErrorBoundary`, `ConfirmDialog`, `CopyablePath`, `CopyButton`
- CSS-переменные (100% готовы)
- CodeMirror 6 пакеты (все 20+ уже в зависимостях)
- `wrapHandler<T>()` паттерн для IPC
- Zustand slice pattern
---
## 8. Архитектурная ревизия: дополнения к reuse-анализу
> Добавлено после ревизии. Конкретизирует что именно извлекать и как.
### 8.1 Обязательные рефакторинги перед реализацией
Эти рефакторинги -- не optional. Без них будет дублирование кода, нарушающее DRY:
| Что извлечь | Откуда | Куда | Строки |
|-------------|--------|------|--------|
| `buildTree()` + `collapse()` + сортировка | `ReviewFileTree.tsx:42-83` | `src/renderer/utils/fileTreeBuilder.ts` | ~50 LOC |
| `getSyncLanguageExtension()` + `getAsyncLanguageDesc()` | `CodeMirrorDiffView.tsx:64-128` | `src/renderer/utils/codemirrorLanguages.ts` | ~70 LOC |
| Базовая тема CM (без diff-стилей) | `CodeMirrorDiffView.tsx:158-198` | `src/renderer/utils/codemirrorTheme.ts` | ~40 LOC |
| `wrapReviewHandler<T>()` | `review.ts:133-145` | `src/main/ipc/ipcWrapper.ts` | ~15 LOC |
**Порядок:** Рефакторинги 1-4 выполняются ПЕРЕД написанием нового кода итерации 1.
`ReviewFileTree.tsx` и `CodeMirrorDiffView.tsx` начинают импортировать из новых утилит.
Тесты этих компонентов должны продолжать проходить (zero behavior change).
### 8.2 Расхождения между файлами планов (исправлены)
| Расхождение | plan-architecture.md | plan-iterations.md | Решение |
|-------------|---------------------|-------------------|---------|
| Имя сервиса | `FileEditorService` | `ProjectFileService` | `ProjectFileService` |
| Stateful/Stateless | `constructor(rootPath)` | Не указано | Stateless, `projectRoot` как аргумент |
| Security | Свой `assertInsideRoot()` | `validateFilePath()` | `validateFilePath()` из `pathValidation.ts` |
| editorSlice в итерации 1 | Да | Нет (хук `useEditorState`) | Нет slice в итерации 1, useState достаточно |
| `useEditorState.ts` хук | Не упомянут | Создаётся в итерации 2 | Убран, вся логика в slice |
| Overlay name | `CodeEditorOverlay` | `ProjectEditorOverlay` | `ProjectEditorOverlay` (лучше отражает scope) |
### 8.3 Review FileTree: конкретный план generic extraction
Текущий `ReviewFileTree.tsx` (~377 строк) содержит:
- `TreeNode` тип -- generic (name, fullPath, isFile, children, file?)
- `buildTree()` -- generic (принимает `files` с `.relativePath`)
- `collapse()` -- generic (одноуровневый collapse)
- `TreeItem` -- review-specific (FileStatusIcon, +/- lines, viewedSet, hunkDecisions)
- `getFileStatus()` -- review-specific
- `ReviewFileTree` -- review-specific (reads from store: hunkDecisions, fileDecisions)
**Plan для generic `FileTree`:**
```
src/renderer/utils/fileTreeBuilder.ts:
- export type TreeNode<T = unknown> = { name, fullPath, isFile, data?: T, children }
- export function buildTree<T>(items: T[], getRelativePath: (item: T) => string): TreeNode<T>[]
- export function sortTreeNodes<T>(nodes: TreeNode<T>[]): TreeNode<T>[]
src/renderer/components/common/FileTree.tsx:
- Generic FileTree<T> component
- Props: nodes, activeNodePath, onNodeClick, renderNodeExtra?, renderNodeIcon?
- Internal: TreeItem (renders folder/file, delegation через render-props)
- Handles: collapsedFolders, toggleFolder, auto-expand ancestors, auto-scroll
src/renderer/components/team/review/ReviewFileTree.tsx:
- Thin wrapper around FileTree<FileChangeSummary>
- Provides renderNodeExtra with FileStatusIcon + +/- lines
- Reads hunkDecisions/fileDecisions from store
src/renderer/components/team/editor/EditorFileTree.tsx:
- Thin wrapper around FileTree<FileTreeEntry>
- Provides renderNodeExtra with dirty marker
- Provides renderNodeIcon with file type icons
- Context menu integration
```
### 8.4 SOLID compliance checklist
- [x] SRP: FileTreePanel -- UI only, data loading in slice
- [x] SRP: CodeMirrorEditor -- lifecycle only, extensions in builder
- [x] OCP: FileTree -- generic with render-props
- [x] LSP: FileTreeNode extends FileTreeEntry (no field duplication)
- [x] ISP: EditorSlice split into 4 documented groups
- [x] DIP: Extensions via factory, not hardcoded in component
- [x] DRY: buildTree, language detection, theme, wrapHandler -- all extracted
- [x] Clean Architecture: dependency flow verified, no backward deps
---
---
## 9. UX Review: дополнения к reuse-анализу
> Добавлено после UX-ревью. Что ещё нужно переиспользовать/создать для качественного UX.
### 9.1 Дополнительные компоненты для переиспользования
| Компонент | Путь | Применение в редакторе |
|-----------|------|----------------------|
| `KeyboardShortcutsHelp` | `review/KeyboardShortcutsHelp.tsx` | Модальное окно со списком shortcuts (кнопка `?` в header) |
| `confirm()` imperative API | `common/ConfirmDialog.tsx` | "Save before close?" при Escape с unsaved changes |
### 9.2 Новые утилиты, вызванные UX-требованиями
| Утилита | Путь | Зачем |
|---------|------|-------|
| `tabLabelDisambiguation.ts` | `src/renderer/utils/` | Показ "(main/utils)" для дублей `index.ts` в табах |
| `binaryDetector.ts` | `src/main/utils/` | Определение бинарных файлов (null bytes в первых 8KB) |
### 9.3 Новые компоненты, вызванные UX-требованиями
| Компонент | Описание |
|-----------|----------|
| `EditorStatusBar.tsx` | Нижняя полоска: Ln:Col, язык, отступы, кодировка |
| `EditorBinaryState.tsx` | Заглушка для бинарных файлов вместо CM6 |
| `EditorErrorState.tsx` | Заглушка для файлов с ошибкой чтения (EACCES, ENOENT) |
| `EditorShortcutsHelp.tsx` | Модальное окно shortcuts (или переиспользовать `KeyboardShortcutsHelp`) |
### 9.4 CSS-переменные -- что уже есть, чего не хватает
**Уже есть (полностью достаточно):**
- `--color-surface`, `--color-surface-sidebar`, `--color-surface-raised` -- для background
- `--color-border`, `--color-border-subtle`, `--color-border-emphasis` -- для разделителей
- `--color-text`, `--color-text-secondary`, `--color-text-muted` -- для текста
- `--code-*`, `--syntax-*` -- для CodeMirror
- `--scrollbar-*` -- для скроллбара
- `--card-*` -- для панелей
**Не хватает (рекомендация: добавить в `:root` в `index.css`):**
```css
/* Editor-specific */
--editor-tab-active-bg: var(--color-surface);
--editor-tab-inactive-bg: var(--color-surface-sidebar);
--editor-tab-modified-dot: #f59e0b; /* amber для modified indicator */
--editor-tab-border: var(--color-border);
--editor-statusbar-bg: var(--color-surface-sidebar);
--editor-statusbar-text: var(--color-text-muted);
--editor-sidebar-resize-handle: rgba(148, 163, 184, 0.15);
--editor-sidebar-resize-handle-hover: rgba(148, 163, 184, 0.3);
```
Это обеспечит консистентность с остальными CSS-переменными проекта и лёгкую кастомизацию.
### 9.5 Accessibility -- что переиспользовать из существующего
`ReviewFileTree.tsx` (строка 232) имеет `aria-label` на expand/collapse. Это МИНИМУМ. При извлечении generic `FileTree` нужно сразу добавить:
- `role="tree"` на корневой `<ul>`
- `role="treeitem"` + `aria-expanded` на каждой папке
- `role="group"` на вложенных `<ul>`
- `role="treeitem"` + `aria-selected` на файлах
- Keyboard navigation (arrow keys) -- в `FileTree`, не в обёртках
Это не "nice to have" -- это требование WCAG 2.1 Level A для tree view.
---
---
## 10. Security Review: дополнения к reuse-анализу
> Полный аудит безопасности описан в `plan-architecture.md` секция 18. Здесь -- что из существующего кода переиспользовать для безопасности, и обнаруженные проблемы в текущем коде.
### 10.1 Переиспользуемые security-утилиты
| Утилита | Путь | Что делает | Как использовать |
|---------|------|-----------|-----------------|
| `validateFilePath()` | `src/main/utils/pathValidation.ts` | Path traversal, symlink escape, sensitive patterns | КАЖДЫЙ IPC handler ОБЯЗАН вызывать |
| `SENSITIVE_PATTERNS` | `src/main/utils/pathValidation.ts` | Regex-массив: `.env`, `.ssh`, `*.key`, `*.pem` и т.д. | Автоматически через `validateFilePath()` |
| `resolveRealPathIfExists()` | `src/main/utils/pathValidation.ts` | `fs.realpathSync.native()` с обработкой ENOENT | Автоматически через `validateFilePath()` |
| `isPathWithinAllowedDirectories()` | `src/main/utils/pathValidation.ts` | Containment check с cross-platform support | Автоматически через `validateFilePath()` |
| `isPathContained()` | `src/main/ipc/validation.ts` | Простая containment check (normalize + startsWith) | НЕ использовать отдельно -- `validateFilePath` полнее |
### 10.2 Чего НЕ хватает в существующих утилитах (нужно создать для editor)
| Утилита | Описание | Зачем |
|---------|----------|-------|
| `validateFileName(name)` | Валидация имени файла при создании | Запрет `.`, `..`, control chars, path separators, NUL, length > 255 |
| `isDevicePath(path)` | Проверка на `/dev/`, `/proc/`, `/sys/` | Блокировка device files до `fs.readFile()` |
| `isGitInternalPath(path)` | Проверка на `.git/` в пути | Запрет записи в `.git/` (чтение -- ОК) |
| `atomicWriteFile(path, content)` | Atomic write через tmp + rename | Защита от corrupt при crash/disk full |
Рекомендация: добавить в `src/main/utils/pathValidation.ts` (validateFileName, isDevicePath, isGitInternalPath) и `src/main/utils/atomicWrite.ts` (atomicWriteFile).
### 10.3 Обнаруженная уязвимость в review.ts (Critical, existing!)
**При анализе `review.ts` (секция 2.1 reuse-анализа) обнаружена уязвимость:**
`handleSaveEditedFile` (строка 254 `review.ts`) принимает `filePath` от renderer и передаёт в `ReviewApplierService.saveEditedFile()` (строка 320 `ReviewApplierService.ts`), который вызывает `writeFile(filePath, content, 'utf8')` **БЕЗ КАКОЙ-ЛИБО ВАЛИДАЦИИ ПУТИ**.
Текущий код:
```typescript
// review.ts:254
async function handleSaveEditedFile(_event, filePath, content) {
if (!filePath || typeof content !== 'string') {
return { success: false, error: 'Invalid parameters' };
}
// УЯЗВИМОСТЬ: filePath НЕ проверяется через validateFilePath()
return wrapReviewHandler('saveEditedFile', async () => {
const result = await getApplier().saveEditedFile(filePath, content);
// ...
});
}
// ReviewApplierService.ts:320
async saveEditedFile(filePath: string, content: string) {
// УЯЗВИМОСТЬ: прямая запись без валидации
await writeFile(filePath, content, 'utf8');
return { success: true };
}
```
**Импакт**: Скомпрометированный renderer может записать произвольный файл куда угодно в ФС.
**Решение**: Добавить `validateFilePath(filePath, projectRoot)` в `handleSaveEditedFile`. Нужен hotfix НЕЗАВИСИМО от editor-фичи.
### 10.4 Security-паттерн для editor IPC (обязательный)
```typescript
// src/main/ipc/editor.ts -- каждый handler ОБЯЗАН следовать этому паттерну:
let activeProjectRoot: string | null = null; // module-level, set by editor:open
async function handleEditorReadFile(
_event: IpcMainInvokeEvent,
filePath: string // от renderer
): Promise<IpcResult<ReadFileResult>> {
return wrapHandler('readFile', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
// 1. Path validation (traversal, sensitive, symlink)
const validation = validateFilePath(filePath, activeProjectRoot);
if (!validation.valid) throw new Error(validation.error!);
// 2. Device path block
if (isDevicePath(validation.normalizedPath!)) throw new Error('Device files blocked');
// 3. File type check
const stats = await fs.lstat(validation.normalizedPath!);
if (!stats.isFile()) throw new Error('Not a regular file');
// 4. Size check
if (stats.size > MAX_FILE_SIZE) throw new Error('File too large');
// 5. Read
const content = await fs.readFile(validation.normalizedPath!, 'utf8');
// 6. Post-read TOCTOU verify
const realPath = await fs.realpath(validation.normalizedPath!);
const postValidation = validateFilePath(realPath, activeProjectRoot);
if (!postValidation.valid) throw new Error('Path changed during read');
return { content, size: stats.size, truncated: false, encoding: 'utf-8' };
});
}
```
---
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan:
- `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/ReviewFileTree.tsx` - FileTree logic to extract (buildTree algorithm, TreeNode type, collapse/expand)
- `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/CodeMirrorDiffView.tsx` - CodeMirror infrastructure to extract (language detection, theme, Compartment pattern)
- `/Users/belief/dev/projects/claude/claude_team/src/renderer/store/slices/changeReviewSlice.ts` - Pattern to follow for editorSlice (fileContents cache, editedContents, saveEditedFile)
- `/Users/belief/dev/projects/claude/claude_team/src/main/ipc/review.ts` - IPC handler pattern to follow (wrapHandler, module-level state, deps injection) + EXISTING VULNERABILITY in saveEditedFile
- `/Users/belief/dev/projects/claude/claude_team/src/main/utils/pathValidation.ts` - Security validation to REUSE (not rewrite) -- validateFilePath, SENSITIVE_PATTERNS, symlink resolution
---
## Performance-Critical Reuse Notes
> Дополнение после Performance Review (plan-architecture.md секция 19). Конкретные performance-аспекты при переиспользовании кода.
### CodeMirrorDiffView -- что НЕ копировать
**`editorViewMapRef` из ChangeReviewDialog (строка 91)** хранит `Map<string, EditorView>` для всех видимых файлов в continuous scroll view. Это допустимо для review (10-50 файлов одновременно), но **НЕДОПУСТИМО** для editor с 20+ табами.
Для editor использовать **EditorState pooling**:
```typescript
// ПРАВИЛЬНО для editor:
const stateCache = useRef(new Map<string, EditorState>());
const viewRef = useRef<EditorView | null>(null);
// При переключении таба:
stateCache.current.set(oldTabId, viewRef.current!.state);
viewRef.current!.destroy();
viewRef.current = new EditorView({
state: stateCache.current.get(newTabId)!,
parent: containerRef.current!,
});
```
Паттерн `initialState` из CodeMirrorDiffView (строка 56, 699-705) -- это именно то, что нужно.
### changeReviewSlice -- что НЕ копировать
**`editedContents: Record<string, string>`** (строка 74) хранит полный текст каждого редактированного файла в Zustand. В review это терпимо (изменения применяются и сбрасываются). Для editor каждый keystroke вызывает `set()` с новым Record -- все Zustand-подписчики перерисовываются.
Для editor **контент живёт только в EditorState**, не в Zustand. В store хранить:
```typescript
editorModifiedFiles: Set<string> // dirty flags, не содержимое
```
### @tanstack/react-virtual -- использовать для FileTree
Уже в проекте. Примеры:
- `DateGroupedSessions.tsx` -- виртуализация списка сессий
- `ChatHistory.tsx` -- виртуализация чата
- `NotificationsView.tsx` -- виртуализация уведомлений
Для FileTree (итерация 4): `flattenTree() -> FlatNode[]` + `useVirtualizer()`.
### MembersJsonEditor -- правильный lifecycle паттерн
`MembersJsonEditor.tsx` (строки 27-73) -- **образцовый** паттерн для editor:
1. `EditorState.create()` с extensions
2. `new EditorView({ state, parent })` -- один раз при mount
3. `view.destroy()` -- в cleanup useEffect
4. Обновление doc через `view.dispatch({ changes: ... })` -- при prop change
5. `onChangeRef.current = onChange` -- для callback без re-create view
Этот паттерн масштабировать до EditorState pooling (Map вместо одного state).

View file

@ -5,6 +5,7 @@
*/
import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore';
import { validateFilePath } from '@main/utils/pathValidation';
import {
REVIEW_APPLY_DECISIONS,
REVIEW_CHECK_CONFLICT,
@ -259,10 +260,15 @@ async function handleSaveEditedFile(
if (!filePath || typeof content !== 'string') {
return { success: false, error: 'Invalid parameters' };
}
const pathCheck = validateFilePath(filePath, null);
if (!pathCheck.valid) {
logger.error(`saveEditedFile blocked: ${String(pathCheck.error)} (path: ${String(filePath)})`);
return { success: false, error: `Path validation failed: ${String(pathCheck.error)}` };
}
return wrapReviewHandler('saveEditedFile', async () => {
const result = await getApplier().saveEditedFile(filePath, content);
const result = await getApplier().saveEditedFile(pathCheck.normalizedPath!, content);
// Invalidate cached content so next fetch reads the saved version from disk
getContentResolver().invalidateFile(filePath);
getContentResolver().invalidateFile(pathCheck.normalizedPath!);
return result;
});
}

View file

@ -41,6 +41,7 @@ import {
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_MEMBER_ROLE,
TEAM_UPDATE_TASK_FIELDS,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
@ -186,6 +187,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_UPDATE_KANBAN_COLUMN_ORDER, handleUpdateKanbanColumnOrder);
ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus);
ipcMain.handle(TEAM_UPDATE_TASK_OWNER, handleUpdateTaskOwner);
ipcMain.handle(TEAM_UPDATE_TASK_FIELDS, handleUpdateTaskFields);
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
ipcMain.handle(TEAM_RESTORE, handleRestoreTeam);
ipcMain.handle(TEAM_PERMANENTLY_DELETE, handlePermanentlyDeleteTeam);
@ -231,6 +233,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_UPDATE_KANBAN_COLUMN_ORDER);
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER);
ipcMain.removeHandler(TEAM_UPDATE_TASK_FIELDS);
ipcMain.removeHandler(TEAM_DELETE_TEAM);
ipcMain.removeHandler(TEAM_RESTORE);
ipcMain.removeHandler(TEAM_PERMANENTLY_DELETE);
@ -1509,9 +1512,79 @@ async function handleRemoveMember(
const vMember = validateMemberName(memberName);
if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' };
return wrapTeamHandler('removeMember', () =>
getTeamDataService().removeMember(vTeam.value!, vMember.value!)
);
return wrapTeamHandler('removeMember', async () => {
const tn = vTeam.value!;
const name = vMember.value!;
await getTeamDataService().removeMember(tn, name);
// Notify the lead about removed member
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const message =
`Teammate "${name}" has been removed from the team. ` +
`They will no longer participate in team activities. Please reassign their tasks if needed.`;
try {
await provisioning.sendMessageToTeam(tn, message);
} catch {
logger.warn(`Failed to notify lead about removal of "${name}" in ${tn}`);
}
}
});
}
async function handleUpdateTaskFields(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
fields: unknown
): Promise<IpcResult<void>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
if (typeof taskId !== 'string' || !taskId.trim()) {
return { success: false, error: 'taskId must be a non-empty string' };
}
if (!fields || typeof fields !== 'object') {
return { success: false, error: 'fields must be an object' };
}
const { subject, description } = fields as { subject?: unknown; description?: unknown };
if (subject !== undefined) {
if (typeof subject !== 'string') return { success: false, error: 'subject must be a string' };
if (subject.trim().length === 0) return { success: false, error: 'subject cannot be empty' };
if (subject.length > 500)
return { success: false, error: 'subject must be 500 characters or less' };
}
if (description !== undefined && typeof description !== 'string') {
return { success: false, error: 'description must be a string' };
}
const validFields: { subject?: string; description?: string } = {};
if (typeof subject === 'string') validFields.subject = subject;
if (typeof description === 'string') validFields.description = description;
if (Object.keys(validFields).length === 0) {
return { success: false, error: 'At least one field must be provided' };
}
return wrapTeamHandler('updateTaskFields', async () => {
const tn = vTeam.value!;
await getTeamDataService().updateTaskFields(tn, taskId, validFields);
// Notify the lead about updated task fields
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const changedParts: string[] = [];
if (validFields.subject) changedParts.push('title');
if (validFields.description !== undefined) changedParts.push('description');
const message =
`Task #${taskId} has been updated by the user (changed: ${changedParts.join(', ')}). ` +
`New title: "${validFields.subject ?? '(unchanged)'}".`;
try {
await provisioning.sendMessageToTeam(tn, message);
} catch {
logger.warn(`Failed to notify lead about task fields update for #${taskId} in ${tn}`);
}
}
});
}
async function handleUpdateMemberRole(

View file

@ -3,22 +3,24 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBloc
import * as fs from 'fs';
import * as path from 'path';
// eslint-disable-next-line no-restricted-imports -- package.json is at project root, no alias available
import { version as APP_VERSION } from '../../../../package.json';
import { atomicWriteAsync } from './atomicWrite';
const TOOL_FILE_NAME = 'teamctl.js';
const TOOL_VERSION = 11;
function buildTeamCtlScript(): string {
function buildTeamCtlScript(version: string): string {
const script = String.raw`#!/usr/bin/env node
'use strict';
// Team tools (v${TOOL_VERSION})
// Team tools (v${version})
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const TOOL_VERSION = ${TOOL_VERSION};
const TOOL_VERSION = '${version}';
function nowIso() {
return new Date().toISOString();
@ -979,7 +981,7 @@ export class TeamAgentToolsInstaller {
const toolPath = path.join(toolsDir, TOOL_FILE_NAME);
await fs.promises.mkdir(toolsDir, { recursive: true });
const desired = buildTeamCtlScript();
const desired = buildTeamCtlScript(APP_VERSION);
let current: string | null = null;
try {
current = await fs.promises.readFile(toolPath, 'utf8');
@ -989,7 +991,7 @@ export class TeamAgentToolsInstaller {
}
}
if (current?.includes(`TOOL_VERSION = ${TOOL_VERSION}`)) {
if (current?.includes(`TOOL_VERSION = '${APP_VERSION}'`)) {
return toolPath;
}

View file

@ -707,6 +707,14 @@ export class TeamDataService {
await this.taskWriter.updateOwner(teamName, taskId, owner);
}
async updateTaskFields(
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
): Promise<void> {
await this.taskWriter.updateFields(teamName, taskId, fields);
}
async setTaskNeedsClarification(
teamName: string,
taskId: string,

View file

@ -190,6 +190,35 @@ export class TeamTaskWriter {
});
}
async updateFields(
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
if (fields.subject !== undefined) {
task.subject = fields.subject;
}
if (fields.description !== undefined) {
task.description = fields.description;
}
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}
async setNeedsClarification(
teamName: string,
taskId: string,

View file

@ -239,6 +239,9 @@ export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus';
/** Update task owner (reassign) */
export const TEAM_UPDATE_TASK_OWNER = 'team:updateTaskOwner';
/** Update task fields (subject, description) */
export const TEAM_UPDATE_TASK_FIELDS = 'team:updateTaskFields';
/** Soft-delete a team (sets deletedAt in config) */
export const TEAM_DELETE_TEAM = 'team:deleteTeam';

View file

@ -77,6 +77,7 @@ import {
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_MEMBER_ROLE,
TEAM_UPDATE_TASK_FIELDS,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
TERMINAL_DATA,
@ -636,6 +637,13 @@ const electronAPI: ElectronAPI = {
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_OWNER, teamName, taskId, owner);
},
updateTaskFields: async (
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_FIELDS, teamName, taskId, fields);
},
startTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<{ notifiedOwner: boolean }>(TEAM_START_TASK, teamName, taskId);
},

View file

@ -703,6 +703,13 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<void> => {
throw new Error('Team task owner update is not available in browser mode');
},
updateTaskFields: async (
_teamName: string,
_taskId: string,
_fields: { subject?: string; description?: string }
): Promise<void> => {
throw new Error('Team task fields update is not available in browser mode');
},
startTask: async (_teamName: string, _taskId: string): Promise<{ notifiedOwner: boolean }> => {
throw new Error('Team start task is not available in browser mode');
},

View file

@ -169,8 +169,7 @@ function validateRequest(
options?: { requireCwd?: boolean }
): ValidationResult {
const requireCwd = options?.requireCwd ?? true;
// eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) {
if (!TEAM_NAME_RE.test(request.teamName) || request.teamName.length > 64) {
return {
valid: false,
errors: {
@ -202,8 +201,7 @@ function validateRequest(
},
};
}
const memberNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
if (request.members.some((member) => !memberNamePattern.test(member.name.trim()))) {
if (request.members.some((member) => !MEMBER_NAME_RE.test(member.name.trim()))) {
return {
valid: false,
errors: {

View file

@ -14,6 +14,7 @@ import {
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import {
Select,
SelectContent,
@ -21,6 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { markAsRead } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
@ -36,16 +38,20 @@ import {
AlignLeft,
ArrowLeftFromLine,
ArrowRightFromLine,
Check,
Clock,
Eye,
FileCode,
FileDiff,
HelpCircle,
Link2,
Loader2,
MessageSquare,
Pencil,
PenLine,
ScrollText,
Trash2,
X,
} from 'lucide-react';
import { TaskCommentInput } from './TaskCommentInput';
@ -85,6 +91,70 @@ export const TaskDetailDialog = ({
}: TaskDetailDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
const updateTaskFields = useStore((s) => s.updateTaskFields);
// Inline editing: subject
const [editingSubject, setEditingSubject] = useState(false);
const [subjectDraft, setSubjectDraft] = useState('');
const [savingSubject, setSavingSubject] = useState(false);
// Inline editing: description
const [editingDescription, setEditingDescription] = useState(false);
const [descriptionDraft, setDescriptionDraft] = useState('');
const [descriptionPreview, setDescriptionPreview] = useState(false);
const [savingDescription, setSavingDescription] = useState(false);
const startEditSubject = useCallback(() => {
if (!currentTask) return;
setSubjectDraft(currentTask.subject);
setEditingSubject(true);
}, [currentTask]);
const saveSubject = useCallback(async () => {
if (!currentTask || savingSubject) return;
const trimmed = subjectDraft.trim();
if (!trimmed || trimmed === currentTask.subject) {
setEditingSubject(false);
return;
}
setSavingSubject(true);
try {
await updateTaskFields(teamName, currentTask.id, { subject: trimmed });
setEditingSubject(false);
} finally {
setSavingSubject(false);
}
}, [currentTask, subjectDraft, savingSubject, teamName, updateTaskFields]);
const startEditDescription = useCallback(() => {
if (!currentTask) return;
setDescriptionDraft(currentTask.description ?? '');
setDescriptionPreview(false);
setEditingDescription(true);
}, [currentTask]);
const saveDescription = useCallback(async () => {
if (!currentTask || savingDescription) return;
const newDesc = descriptionDraft.trim();
if (newDesc === (currentTask.description ?? '')) {
setEditingDescription(false);
return;
}
setSavingDescription(true);
try {
await updateTaskFields(teamName, currentTask.id, { description: newDesc });
setEditingDescription(false);
} finally {
setSavingDescription(false);
}
}, [currentTask, descriptionDraft, savingDescription, teamName, updateTaskFields]);
// Reset editing state on dialog close or task change
useEffect(() => {
setEditingSubject(false);
setEditingDescription(false);
}, [open, currentTask?.id]);
const [replyTo, setReplyTo] = useState<{
taskId: string;
author: string;
@ -209,7 +279,34 @@ export const TaskDetailDialog = ({
</span>
{headerExtra ? <div className="ml-auto mr-4">{headerExtra}</div> : null}
</div>
<DialogTitle className="text-base">{currentTask.subject}</DialogTitle>
{editingSubject ? (
<div className="flex items-center gap-2">
<Input
autoFocus
value={subjectDraft}
onChange={(e) => setSubjectDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void saveSubject();
if (e.key === 'Escape') setEditingSubject(false);
}}
onBlur={() => void saveSubject()}
disabled={savingSubject}
className="h-8 text-base"
/>
{savingSubject ? <Loader2 size={14} className="animate-spin" /> : null}
</div>
) : (
<DialogTitle
className="group flex cursor-pointer items-center gap-1.5 text-base hover:text-[var(--color-text)]"
onClick={startEditSubject}
>
{currentTask.subject}
<Pencil
size={12}
className="shrink-0 text-[var(--color-text-muted)] opacity-0 transition-opacity group-hover:opacity-100"
/>
</DialogTitle>
)}
{currentTask.activeForm ? (
<DialogDescription>{currentTask.activeForm}</DialogDescription>
) : null}
@ -317,12 +414,106 @@ export const TaskDetailDialog = ({
{/* Description */}
<CollapsibleTeamSection title="Description" icon={<AlignLeft size={14} />} defaultOpen>
{currentTask.description ? (
<div className="max-h-[200px] overflow-y-auto">
{editingDescription ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
type="button"
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
!descriptionPreview
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => setDescriptionPreview(false)}
>
<Pencil size={12} />
Edit
</button>
<button
type="button"
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
descriptionPreview
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => setDescriptionPreview(true)}
>
<Eye size={12} />
Preview
</button>
</div>
{descriptionPreview ? (
<div className="max-h-[200px] overflow-y-auto rounded border border-[var(--color-border)] p-2">
{descriptionDraft.trim() ? (
<MarkdownViewer content={descriptionDraft} maxHeight="max-h-[180px]" />
) : (
<p className="text-xs text-[var(--color-text-muted)]">Nothing to preview</p>
)}
</div>
) : (
<Textarea
autoFocus
value={descriptionDraft}
onChange={(e) => setDescriptionDraft(e.target.value)}
disabled={savingDescription}
rows={6}
className="text-xs"
placeholder="Task description (supports markdown)"
/>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
className="h-7 text-xs"
disabled={savingDescription}
onClick={() => void saveDescription()}
>
{savingDescription ? (
<Loader2 size={12} className="mr-1 animate-spin" />
) : (
<Check size={12} className="mr-1" />
)}
Save
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={savingDescription}
onClick={() => setEditingDescription(false)}
>
<X size={12} className="mr-1" />
Cancel
</Button>
</div>
</div>
) : currentTask.description ? (
<div
role="button"
tabIndex={0}
className="group max-h-[200px] cursor-pointer overflow-y-auto"
onClick={startEditDescription}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
startEditDescription();
}
}}
>
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" />
<Pencil
size={12}
className="mt-1 text-[var(--color-text-muted)] opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
) : (
<p className="text-xs text-[var(--color-text-muted)]">No description</p>
<button
type="button"
className="text-xs text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={startEditDescription}
>
Click to add description...
</button>
)}
</CollapsibleTeamSection>

View file

@ -52,44 +52,46 @@ export const MemberDetailHeader = ({
</div>
<div className="min-w-0 flex-1">
<DialogTitle className="truncate">{member.name}</DialogTitle>
<DialogDescription className="mt-1 flex items-center gap-2">
{editing ? (
<MemberRoleEditor
currentRole={member.role}
saving={updatingRole}
onSave={async (newRole) => {
try {
await onUpdateRole?.(newRole);
setEditing(false);
} catch {
// stay in editing mode so user can retry
}
}}
onCancel={() => setEditing(false)}
/>
) : (
<>
<span>{role || 'No role'}</span>
{canEditRole && (
<button
type="button"
className="inline-flex items-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setEditing(true)}
aria-label="Edit role"
>
<Pencil size={12} />
</button>
)}
</>
)}
{!editing && (
<Badge
variant="secondary"
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
>
{presenceLabel}
</Badge>
)}
<DialogDescription asChild className="mt-1 flex items-center gap-2">
<div>
{editing ? (
<MemberRoleEditor
currentRole={member.role}
saving={updatingRole}
onSave={async (newRole) => {
try {
await onUpdateRole?.(newRole);
setEditing(false);
} catch {
// stay in editing mode so user can retry
}
}}
onCancel={() => setEditing(false)}
/>
) : (
<>
<span>{role || 'No role'}</span>
{canEditRole && (
<button
type="button"
className="inline-flex items-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setEditing(true)}
aria-label="Edit role"
>
<Pencil size={12} />
</button>
)}
</>
)}
{!editing && (
<Badge
variant="secondary"
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
>
{presenceLabel}
</Badge>
)}
</div>
</DialogDescription>
</div>
</div>

View file

@ -46,7 +46,7 @@ function hashString(str: string): number {
export function getSubagentTypeColorSet(
subagentType: string,
agentConfigs?: Record<string, { color?: string }>
agentConfigs?: Record<string, { name?: string; color?: string }>
): TeamColorSet {
// Use color from agent config if available
const configColor = agentConfigs?.[subagentType]?.color;

View file

@ -136,6 +136,11 @@ export interface TeamSlice {
startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
updateTaskFields: (
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
) => Promise<void>;
addingComment: boolean;
addCommentError: string | null;
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
@ -554,6 +559,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
await get().refreshTeamData(teamName);
},
updateTaskFields: async (
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
) => {
await unwrapIpc('team:updateTaskFields', () =>
api.teams.updateTaskFields(teamName, taskId, fields)
);
await get().refreshTeamData(teamName);
},
setTaskNeedsClarification: async (teamName, taskId, value) => {
await unwrapIpc('team:setTaskClarification', () =>
api.teams.setTaskClarification(teamName, taskId, value)

View file

@ -406,6 +406,11 @@ export interface TeamsAPI {
) => Promise<void>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
updateTaskFields: (
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
) => Promise<void>;
startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>;
processSend: (teamName: string, message: string) => Promise<void>;
processAlive: (teamName: string) => Promise<boolean>;

View file

@ -1,5 +1,6 @@
import * as os from 'os';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { InboxMessage, TeamCreateRequest, TeamProvisioningProgress } from '@shared/types/team';
vi.mock('electron', () => ({
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
@ -24,6 +25,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_UPDATE_KANBAN: 'team:updateKanban',
TEAM_UPDATE_KANBAN_COLUMN_ORDER: 'team:updateKanbanColumnOrder',
TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus',
TEAM_UPDATE_TASK_FIELDS: 'team:updateTaskFields',
TEAM_UPDATE_TASK_OWNER: 'team:updateTaskOwner',
TEAM_PROCESS_SEND: 'team:processSend',
TEAM_PROCESS_ALIVE: 'team:processAlive',
@ -111,7 +113,15 @@ describe('ipc teams handlers', () => {
const service = {
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
getTeamData: vi.fn(async () => ({ teamName: 'my-team' })),
getTeamData: vi.fn(async () => ({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [] as InboxMessage[],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
})),
deleteTeam: vi.fn(async () => undefined),
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
@ -138,7 +148,11 @@ describe('ipc teams handlers', () => {
ready: true,
message: 'CLI прогрет и готов к запуску',
})),
createTeam: vi.fn(async () => ({ runId: 'run-1' })),
createTeam: vi.fn(
async (_req: TeamCreateRequest, _onProgress: (p: TeamProvisioningProgress) => void) => ({
runId: 'run-1',
})
),
getProvisioningStatus: vi.fn(async () => ({
runId: 'run-1',
teamName: 'my-team',
@ -152,7 +166,7 @@ describe('ipc teams handlers', () => {
sendMessageToTeam: vi.fn(async () => undefined),
isTeamAlive: vi.fn(() => true),
relayLeadInboxMessages: vi.fn(async () => 0),
getLiveLeadProcessMessages: vi.fn(() => []),
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
getAliveTeams: vi.fn(() => ['my-team']),
getLeadActivityState: vi.fn(() => 'idle'),
stopTeam: vi.fn(() => undefined),
@ -267,6 +281,8 @@ describe('ipc teams handlers', () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [
{
from: 'team-lead',
@ -274,9 +290,11 @@ describe('ipc teams handlers', () => {
text: 'Hello there',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
source: 'lead_session',
source: 'lead_session' as const,
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
@ -285,7 +303,7 @@ describe('ipc teams handlers', () => {
text: 'Hello there',
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_process',
source: 'lead_process' as const,
messageId: 'live-1',
},
]);

View file

@ -49,6 +49,7 @@ function createSubagent(overrides: Partial<Process>): Process {
endTime: new Date(),
durationMs: 1000,
isOngoing: false,
isParallel: false,
messages: [],
metrics: {
inputTokens: 100,
@ -406,6 +407,9 @@ describe('ChunkBuilder', () => {
timestamp: new Date(),
lastModified: new Date(),
isOngoing: false,
hasSubagents: false,
messageCount: 0,
createdAt: Date.now(),
};
const messages = [

View file

@ -149,10 +149,10 @@ describe('CliInstallerService', () => {
// Second call should send "already in progress" error
const progressCalls = mockWindow.webContents.send.mock.calls;
const errorCalls = progressCalls.filter(
([channel, data]: [string, { type: string; error?: string }]) =>
channel === 'cliInstaller:progress' &&
data.type === 'error' &&
data.error?.includes('already in progress')
(call: unknown[]) =>
(call[0] as string) === 'cliInstaller:progress' &&
(call[1] as { type: string; error?: string }).type === 'error' &&
(call[1] as { type: string; error?: string }).error?.includes('already in progress')
);
expect(errorCalls.length).toBeGreaterThanOrEqual(1);
});
@ -174,8 +174,9 @@ describe('CliInstallerService', () => {
await service.install();
const checkingCalls = mockWindow.webContents.send.mock.calls.filter(
([channel, data]: [string, { type: string }]) =>
channel === 'cliInstaller:progress' && data.type === 'checking'
(call: unknown[]) =>
(call[0] as string) === 'cliInstaller:progress' &&
(call[1] as { type: string }).type === 'checking'
);
expect(checkingCalls.length).toBeGreaterThanOrEqual(1);
});

View file

@ -1819,7 +1819,7 @@ describe('teamctl.js', () => {
const { exitCode } = run(claudeDir, ['task', 'comment', '1', '--from', '--text', 'Hello']);
// from=true → not a string → defaults to 'agent'
expect(exitCode).toBe(0);
const comments = readTask(claudeDir, '1').comments as { author: string }[];
const comments = readTask(claudeDir, '1').comments as { author: string; text: string }[];
expect(comments[0].author).toBe('agent'); // not "--text"
expect(comments[0].text).toBe('Hello');
});

View file

@ -1,5 +1,5 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
// Mock the entire child_process module so that we can inspect how our helpers
// invoke spawn/exec without hitting the real filesystem or spawning anything.
@ -41,7 +41,7 @@ describe('cli child process helpers', () => {
describe('spawnCli', () => {
it('calls spawn directly when path is ascii on windows', () => {
setPlatform('win32');
(child.spawn as unknown as vi.Mock).mockReturnValue({} as any);
(child.spawn as unknown as Mock).mockReturnValue({} as any);
const result = spawnCli('C:\\bin\\claude.exe', ['--version'], { cwd: 'x' });
expect(child.spawn).toHaveBeenCalledWith('C:\\bin\\claude.exe', ['--version'], { cwd: 'x' });
@ -53,7 +53,7 @@ describe('cli child process helpers', () => {
const error: any = new Error('spawn EINVAL');
error.code = 'EINVAL';
const fake = {} as any;
const spawnMock = child.spawn as unknown as vi.Mock;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockImplementationOnce(() => {
throw error;
});
@ -73,7 +73,7 @@ describe('cli child process helpers', () => {
it('uses shell directly when path contains non-ASCII on windows', () => {
setPlatform('win32');
const fake = {} as any;
const spawnMock = child.spawn as unknown as vi.Mock;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue(fake);
const result = spawnCli('C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd', ['a', 'b'], {
@ -89,7 +89,7 @@ describe('cli child process helpers', () => {
it('does not use shell when not on windows', () => {
setPlatform('linux');
(child.spawn as unknown as vi.Mock).mockReturnValue({} as any);
(child.spawn as unknown as Mock).mockReturnValue({} as any);
const result = spawnCli('/usr/bin/claude', ['--help']);
expect(child.spawn).toHaveBeenCalledWith('/usr/bin/claude', ['--help'], {});
expect(result).toEqual({} as any);
@ -99,7 +99,7 @@ describe('cli child process helpers', () => {
describe('execCli', () => {
it('invokes execFile when path is ASCII on windows', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as vi.Mock;
const execFileMock = child.execFile as unknown as Mock;
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: Function) => {
cb(null, 'ok', '');
@ -118,8 +118,8 @@ describe('cli child process helpers', () => {
it('skips straight to shell when path contains non-ASCII on windows', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as vi.Mock;
const execMock = child.exec as unknown as vi.Mock;
const execFileMock = child.execFile as unknown as Mock;
const execMock = child.exec as unknown as Mock;
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
cb(null, '1.2.3', '');
return {} as any;
@ -136,7 +136,7 @@ describe('cli child process helpers', () => {
it('escapes percent signs and quotes for cmd.exe in shell fallback', async () => {
setPlatform('win32');
const execMock = child.exec as unknown as vi.Mock;
const execMock = child.exec as unknown as Mock;
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
cb(null, 'ok', '');
return {} as any;
@ -154,7 +154,7 @@ describe('cli child process helpers', () => {
it('shell: true cannot be overridden by caller options', () => {
setPlatform('win32');
const spawnMock = child.spawn as unknown as vi.Mock;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue({} as any);
spawnCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--version'], { shell: false } as any);
@ -164,7 +164,7 @@ describe('cli child process helpers', () => {
it('falls back to shell when execFile throws EINVAL on windows', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as vi.Mock;
const execFileMock = child.execFile as unknown as Mock;
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: Function) => {
const err: any = new Error('spawn EINVAL');
@ -173,7 +173,7 @@ describe('cli child process helpers', () => {
return {} as any;
}
);
const execMock = child.exec as unknown as vi.Mock;
const execMock = child.exec as unknown as Mock;
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
cb(null, '2.3.4', '');
return {} as any;

View file

@ -13,6 +13,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -37,6 +39,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -67,6 +71,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
usage: {
@ -89,6 +95,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'unknown-model',
@ -114,6 +122,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -140,6 +150,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -167,6 +179,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -194,6 +208,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -225,6 +241,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-opus-20240229',
@ -252,6 +270,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-4-sonnet-20250514',
@ -282,6 +302,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -296,6 +318,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-2',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -322,6 +346,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -336,6 +362,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-2',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-opus-20240229', // Different model
@ -365,6 +393,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -387,6 +417,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -413,6 +445,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -435,6 +469,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'CLAUDE-3-5-SONNET-20241022',
@ -465,6 +501,8 @@ describe('Cost Calculation', () => {
messages.push({
type: 'assistant',
uuid: `msg-${i}`,
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -498,6 +536,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
@ -527,6 +567,8 @@ describe('Cost Calculation', () => {
{
type: 'assistant',
uuid: 'msg-1',
parentUuid: null,
isMeta: false,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',

View file

@ -88,6 +88,8 @@ describe('cliInstallerSlice', () => {
binaryPath: '/usr/local/bin/claude',
latestVersion: '2.1.59',
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
};
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
@ -112,6 +114,8 @@ describe('cliInstallerSlice', () => {
binaryPath: '/usr/local/bin/claude',
latestVersion: '2.1.59',
updateAvailable: true,
authLoggedIn: true,
authMethod: 'oauth_token',
};
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);

View file

@ -79,12 +79,15 @@ describe('notificationSlice', () => {
id,
sessionId: 's1',
projectId: 'p1',
filePath: '/path/to/session.jsonl',
source: 'tool',
lineNumber: 1,
timestamp: Date.now(),
createdAt: Date.now(),
triggerName,
severity: 'error',
message: `msg-${id}`,
isRead,
context: { projectName: 'test-project' },
});
it('marks only matching trigger notifications as read', async () => {
@ -180,12 +183,15 @@ describe('notificationSlice', () => {
id,
sessionId: 's1',
projectId: 'p1',
filePath: '/path/to/session.jsonl',
source: 'tool',
lineNumber: 1,
timestamp: Date.now(),
createdAt: Date.now(),
triggerName,
severity: 'error',
message: `msg-${id}`,
isRead,
context: { projectName: 'test-project' },
});
it('deletes only matching trigger notifications', async () => {
@ -290,13 +296,16 @@ describe('notificationSlice', () => {
id: 'error-1',
sessionId: 'session-target',
projectId: 'project-1',
filePath: '/path/to/session.jsonl',
source: 'tool',
lineNumber: 42,
timestamp: Date.now(),
createdAt: Date.now(),
toolUseId: 'tool-1',
triggerName: 'test-trigger',
severity: 'error',
message: 'Test error message',
isRead: false,
context: { projectName: 'test-project' },
...overrides,
});

View file

@ -241,13 +241,13 @@ describe('sessionSlice', () => {
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
resolveFirst = resolve as (value: unknown) => void;
})
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve;
resolveSecond = resolve as (value: unknown) => void;
})
);
@ -285,13 +285,13 @@ describe('sessionSlice', () => {
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
resolveFirst = resolve as (value: unknown) => void;
})
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve;
resolveSecond = resolve as (value: unknown) => void;
})
);

View file

@ -4,7 +4,11 @@
import { create } from 'zustand';
import { createChangeReviewSlice } from '../../../src/renderer/store/slices/changeReviewSlice';
import { createCliInstallerSlice } from '../../../src/renderer/store/slices/cliInstallerSlice';
import { createConfigSlice } from '../../../src/renderer/store/slices/configSlice';
import { createConnectionSlice } from '../../../src/renderer/store/slices/connectionSlice';
import { createContextSlice } from '../../../src/renderer/store/slices/contextSlice';
import { createConversationSlice } from '../../../src/renderer/store/slices/conversationSlice';
import { createNotificationSlice } from '../../../src/renderer/store/slices/notificationSlice';
import { createPaneSlice } from '../../../src/renderer/store/slices/paneSlice';
@ -15,7 +19,9 @@ import { createSessionSlice } from '../../../src/renderer/store/slices/sessionSl
import { createSubagentSlice } from '../../../src/renderer/store/slices/subagentSlice';
import { createTabSlice } from '../../../src/renderer/store/slices/tabSlice';
import { createTabUISlice } from '../../../src/renderer/store/slices/tabUISlice';
import { createTeamSlice } from '../../../src/renderer/store/slices/teamSlice';
import { createUISlice } from '../../../src/renderer/store/slices/uiSlice';
import { createUpdateSlice } from '../../../src/renderer/store/slices/updateSlice';
import type { AppState } from '../../../src/renderer/store/types';
@ -30,6 +36,7 @@ export function createTestStore() {
...createSessionSlice(...args),
...createSessionDetailSlice(...args),
...createSubagentSlice(...args),
...createTeamSlice(...args),
...createConversationSlice(...args),
...createTabSlice(...args),
...createTabUISlice(...args),
@ -37,6 +44,11 @@ export function createTestStore() {
...createUISlice(...args),
...createNotificationSlice(...args),
...createConfigSlice(...args),
...createConnectionSlice(...args),
...createContextSlice(...args),
...createUpdateSlice(...args),
...createChangeReviewSlice(...args),
...createCliInstallerSlice(...args),
}));
}

View file

@ -47,8 +47,8 @@ vi.mock('../../../src/renderer/utils/unwrapIpc', async (importOriginal) => {
});
function createSliceStore() {
return create<any>()((set, get) => ({
...createTeamSlice(set as never, get as never),
return create<any>()((set, get, store) => ({
...createTeamSlice(set as never, get as never, store as never),
paneLayout: {
focusedPaneId: 'pane-default',
panes: [

View file

@ -10,15 +10,11 @@ import type { Session } from '../../../src/renderer/types/data';
function createSession(id: string, createdAt: Date): Session {
return {
id,
createdAt: createdAt.toISOString(),
updatedAt: createdAt.toISOString(),
displayName: `Session ${id}`,
triggerCount: 1,
ongoing: false,
lastTriggerPreview: 'Test',
cwd: '/test',
todos: [],
totalTokens: 0,
projectId: 'test-project',
projectPath: '/test',
createdAt: createdAt.getTime(),
hasSubagents: false,
messageCount: 1,
};
}

View file

@ -28,7 +28,7 @@ function makeRepoGroup(worktrees: { id: string; path: string }[]): RepoGroupLike
name: w.id,
gitBranch: 'main',
isMainWorktree: true,
source: 'standalone' as const,
source: 'unknown' as const,
sessions: [],
createdAt: 0,
})),

View file

@ -573,7 +573,7 @@ describe('exportAsJson', () => {
// =============================================================================
describe('triggerDownload', () => {
let createElementSpy: ReturnType<typeof vi.spyOn>;
let createElementSpy: any;
let mockAnchor: { href: string; download: string; click: ReturnType<typeof vi.fn> };
beforeEach(() => {