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:
parent
5278214d8b
commit
0c2f70b2b2
31 changed files with 3167 additions and 102 deletions
1295
docs/iterations/edit-project/plan-architecture.md
Normal file
1295
docs/iterations/edit-project/plan-architecture.md
Normal file
File diff suppressed because it is too large
Load diff
613
docs/iterations/edit-project/plan-iterations.md
Normal file
613
docs/iterations/edit-project/plan-iterations.md
Normal 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 раз
|
||||
```
|
||||
|
||||
---
|
||||
723
docs/iterations/edit-project/plan-reuse-analysis.md
Normal file
723
docs/iterations/edit-project/plan-reuse-analysis.md
Normal 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).
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue