From 0c2f70b2b29c8c17cd542a71de1f3a6b036276a6 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 22:36:06 +0200 Subject: [PATCH] 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. --- .../edit-project/plan-architecture.md | 1295 +++++++++++++++++ .../edit-project/plan-iterations.md | 613 ++++++++ .../edit-project/plan-reuse-analysis.md | 723 +++++++++ src/main/ipc/review.ts | 10 +- src/main/ipc/teams.ts | 79 +- .../services/team/TeamAgentToolsInstaller.ts | 14 +- src/main/services/team/TeamDataService.ts | 8 + src/main/services/team/TeamTaskWriter.ts | 29 + src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 8 + src/renderer/api/httpClient.ts | 7 + .../team/dialogs/CreateTeamDialog.tsx | 6 +- .../team/dialogs/TaskDetailDialog.tsx | 199 ++- .../team/members/MemberDetailHeader.tsx | 78 +- src/renderer/constants/teamColors.ts | 2 +- src/renderer/store/slices/teamSlice.ts | 16 + src/shared/types/api.ts | 5 + test/main/ipc/teams.test.ts | 28 +- .../services/analysis/ChunkBuilder.test.ts | 4 + .../CliInstallerService.test.ts | 13 +- test/main/services/team/teamctl.test.ts | 2 +- test/main/utils/childProcess.test.ts | 24 +- test/main/utils/costCalculation.test.ts | 42 + test/renderer/store/cliInstallerSlice.test.ts | 4 + test/renderer/store/notificationSlice.test.ts | 15 +- test/renderer/store/sessionSlice.test.ts | 8 +- test/renderer/store/storeTestUtils.ts | 12 + test/renderer/store/teamSlice.test.ts | 4 +- test/renderer/utils/dateGrouping.test.ts | 14 +- test/renderer/utils/projectLookup.test.ts | 2 +- test/renderer/utils/sessionExporter.test.ts | 2 +- 31 files changed, 3167 insertions(+), 102 deletions(-) create mode 100644 docs/iterations/edit-project/plan-architecture.md create mode 100644 docs/iterations/edit-project/plan-iterations.md create mode 100644 docs/iterations/edit-project/plan-reuse-analysis.md diff --git a/docs/iterations/edit-project/plan-architecture.md b/docs/iterations/edit-project/plan-architecture.md new file mode 100644 index 00000000..d68ed496 --- /dev/null +++ b/docs/iterations/edit-project/plan-architecture.md @@ -0,0 +1,1295 @@ +# Архитектурный план: In-App Code Editor + +## Контекст + +На странице TeamDetailView рядом с путём проекта (`data.config.projectPath`, строка ~761 файла `TeamDetailView.tsx`) добавляется кнопка, открывающая полноэкранный редактор кода прямо внутри приложения. Редактор базируется на **CodeMirror 6** (уже используется в проекте -- 17 пакетов `@codemirror/*` в `package.json`), а **не** ProseMirror. Это решение основано на том, что CodeMirror -- единственный редактор кода в зависимостях проекта, с готовым набором языковых расширений и темой `oneDark`. + +## Оценки + +- **Надежность решения**: 8/10 -- CodeMirror 6 проверен в продакшене (VS Code web, Obsidian), все зависимости уже в проекте. +- **Уверенность в плане**: 8/10 -- архитектура повторяет паттерны ChangeReviewDialog (full-screen overlay + file tree + CM editor). + +--- + +## Архитектурная диаграмма (ASCII) + +``` + ┌─────────────────────────────────────────┐ + │ TeamDetailView.tsx │ + │ [FolderOpen icon] [Edit button] ◄──────┤ Кнопка запуска + └───────────────────┬─────────────────────┘ + │ open={true} + ┌───────────────────▼─────────────────────┐ + │ CodeEditorOverlay (full-screen) │ + │ ┌──────────────┐ ┌──────────────────┐ │ + │ │ FileTreePanel│ │ EditorTabsPanel │ │ + │ │ │ │ ┌────────────┐ │ │ + │ │ ProjectTree │ │ │ EditorTab │ │ │ + │ │ component │ │ │ EditorTab │ │ │ + │ │ (recursive) │ │ └────────────┘ │ │ + │ │ │ │ ┌────────────┐ │ │ + │ │ │ │ │CodeMirror │ │ │ + │ │ │ │ │EditorView │ │ │ + │ └──────────────┘ │ └────────────┘ │ │ + │ └──────────────────┘ │ + └────────────────────────────────────────-┘ + │ IPC + ┌──────────────▼──────────────────────────┐ + │ Preload Bridge │ + │ editor.readDir / readFile / writeFile │ + │ editor.createFile / deleteFile │ + └──────────────┬──────────────────────────┘ + │ + ┌──────────────▼──────────────────────────┐ + │ Main Process: FileEditorService │ + │ (sandboxed path validation) │ + │ ┌─────────────────────────────────┐ │ + │ │ fs.readdir / fs.readFile / │ │ + │ │ fs.writeFile / fs.unlink / │ │ + │ │ fs.mkdir │ │ + │ └─────────────────────────────────┘ │ + └─────────────────────────────────────────┘ +``` + +--- + +## 1. Компонентная иерархия + +### 1.1 Новые компоненты + +Размещение: `src/renderer/components/team/editor/` + +``` +editor/ +├── CodeEditorOverlay.tsx # Полноэкранный overlay (аналог ChangeReviewDialog) +├── FileTreePanel.tsx # Левая панель с деревом файлов +├── FileTreeNode.tsx # Рекурсивная нода дерева (файл / директория) +├── EditorTabsPanel.tsx # Правая панель: вкладки + CodeMirror +├── EditorTab.tsx # Одна вкладка открытого файла +├── CodeMirrorEditor.tsx # Обёртка CM6 для редактирования (не diff) +├── EditorToolbar.tsx # Панель инструментов (Save, Undo, Redo, язык) +├── EditorStatusBar.tsx # Status bar: Ln:Col, язык, отступы, кодировка (UX Review 17.1.4) +├── EditorEmptyState.tsx # Пустое состояние (нет открытых файлов + shortcuts шпаргалка) +├── EditorBinaryState.tsx # Заглушка для бинарных файлов (UX Review 17.1.6) +└── EditorErrorState.tsx # Заглушка для ошибок чтения файла (UX Review 17.2.5) +``` + +### 1.2 Принцип Single Responsibility + +| Компонент | Ответственность | +|-----------|----------------| +| `CodeEditorOverlay` | Layout: fixed inset-0, z-50, header/close, split layout | +| `FileTreePanel` | Загрузка дерева, expand/collapse, поиск, контекстное меню | +| `FileTreeNode` | Рендер одной ноды, иконка, клик, drag | +| `EditorTabsPanel` | Управление открытыми табами, переключение | +| `CodeMirrorEditor` | CM6 lifecycle: create/destroy EditorView, extensions, keybindings | +| `EditorToolbar` | Действия: Save (Cmd+S), язык, отступы, кодировка | + +### 1.3 Паттерн overlay (повтор ChangeReviewDialog) + +Вместо `` от Radix используем raw `
` -- точная копия паттерна из `ChangeReviewDialog.tsx` (строка 508). Причины: +- Radix Dialog ограничивает фокус внутри портала, что конфликтует с CM6 +- Full-screen overlay не нуждается в backdrop/animation -- просто замена контента +- macOS traffic light padding: `var(--macos-traffic-light-padding-left, 72px)` в header + +--- + +## 2. State Management + +### 2.1 Zustand slice: `editorSlice.ts` + +**Решение**: Новый slice в `src/renderer/store/slices/editorSlice.ts`. + +**Обоснование**: Состояние редактора (открытые табы, unsaved changes, active tab) должно переживать перемонтирование компонента overlay (например, если юзер случайно закрыл и открыл снова -- unsaved файлы должны быть на месте). + +``` +EditorSlice { + // --- Данные --- + editorProjectPath: string | null // Путь открытого проекта + editorFileTree: FileTreeNode | null // Корневое дерево + editorFileTreeLoading: boolean + editorFileTreeError: string | null + + editorOpenTabs: EditorFileTab[] // Открытые вкладки + editorActiveTabId: string | null // Активная вкладка + + editorFileContents: Record // filePath → content (read-only cache) + editorFileContentsLoading: Record + // ПЕРЕСМОТРЕНО (Performance Review 19.4): НЕ хранить modified content здесь! + // Контент живёт в EditorState (Map в useRef). + // Вместо Record использовать Set для dirty flags: + editorModifiedFiles: Set // filePath set — dirty markers only + + editorSaving: Record // filePath → saving in progress + editorSaveError: Record // filePath → save error + + // --- Действия --- + openEditor: (projectPath: string) => Promise + closeEditor: () => void + + loadFileTree: (dirPath: string) => Promise + expandDirectory: (dirPath: string) => Promise + + openFile: (filePath: string) => Promise + closeTab: (tabId: string) => void + setActiveTab: (tabId: string) => void + + updateContent: (filePath: string, content: string) => void + saveFile: (filePath: string) => Promise + saveAllFiles: () => Promise + discardChanges: (filePath: string) => void + + createFile: (parentDir: string, name: string) => Promise + deleteFile: (filePath: string) => Promise + createDirectory: (parentDir: string, name: string) => Promise +} +``` + +### 2.2 Локальное состояние компонентов + +Не выносить в store (а хранить в useState): +- Scroll position дерева файлов +- CM6 EditorView ref +- Размер панелей (resizable split) +- Поисковый запрос в дереве файлов +- Состояние контекстного меню + +### 2.3 Модель `EditorFileTab` + +```typescript +interface EditorFileTab { + id: string // = filePath (уникальный ключ) + filePath: string // Абсолютный путь + fileName: string // Имя файла для отображения + language: string // Определяется по расширению + isModified: boolean // Есть unsaved changes (derived) +} +``` + +### 2.4 Интеграция в AppState + +Файл `src/renderer/store/types.ts` -- добавить `EditorSlice` в union type `AppState`. + +--- + +## 3. IPC API Design + +### 3.1 Новые IPC-каналы + +Файл: `src/preload/constants/ipcChannels.ts` + +``` +// ============================================================================= +// Editor API Channels +// ============================================================================= + +EDITOR_READ_DIR = 'editor:readDir' +EDITOR_READ_FILE = 'editor:readFile' +EDITOR_WRITE_FILE = 'editor:writeFile' +EDITOR_CREATE_FILE = 'editor:createFile' +EDITOR_DELETE_FILE = 'editor:deleteFile' +EDITOR_CREATE_DIR = 'editor:createDir' +EDITOR_RENAME = 'editor:rename' +EDITOR_FILE_EXISTS = 'editor:fileExists' +``` + +### 3.2 IPC-типы + +Файл: `src/shared/types/editor.ts` (NEW) + +``` +FileTreeEntry { + name: string + path: string // Абсолютный путь + type: 'file' | 'directory' + size?: number // Только для файлов + children?: FileTreeEntry[] // Только для директорий (lazy) +} + +ReadDirResult { + entries: FileTreeEntry[] + truncated: boolean // Если > MAX_DIR_ENTRIES +} + +ReadFileResult { + content: string + size: number + truncated: boolean // Если > MAX_FILE_SIZE + encoding: string +} +``` + +### 3.3 Паттерн IPC handler + +Файл: `src/main/ipc/editor.ts` (NEW) + +Повторяет паттерн `review.ts`: +- module-level state (`let fileService: ProjectFileService | null`) +- `initializeEditorHandlers(service)` +- `registerEditorHandlers(ipcMain)` +- `removeEditorHandlers(ipcMain)` +- `wrapHandler` из `src/main/ipc/ipcWrapper.ts` (общий, НЕ копия из `review.ts`) + +### 3.4 ElectronAPI расширение + +Файл: `src/shared/types/api.ts` -- добавить `EditorAPI` interface и свойство `editor: EditorAPI` в `ElectronAPI`. + +Файл: `src/preload/index.ts` -- добавить секцию `editor: { ... }` в объект `electronAPI`, все через `invokeIpcWithResult()`. + +--- + +## 4. Main Process: ProjectFileService + +### 4.1 Сервис + +Файл: `src/main/services/editor/ProjectFileService.ts` (NEW) + +Единственная ответственность: безопасные файловые операции внутри заданного projectPath. + +> **РЕВИЗИЯ:** Сервис stateless (без `rootPath` в конструкторе). Каждый метод принимает `projectRoot` как первый аргумент. Паттерн аналогичен `TeamDataService` — не привязан к одному проекту. + +**Критическая безопасность**: Path traversal prevention через `validateFilePath()` из `pathValidation.ts`. + +``` +ProjectFileService { + // Stateless — нет конструктора с rootPath + // Все методы принимают projectRoot + проверяют через validateFilePath() + + readDir(projectRoot: string, dirPath: string, depth?: number): Promise + readFile(projectRoot: string, filePath: string): Promise + writeFile(projectRoot: string, filePath: string, content: string): Promise + createFile(projectRoot: string, parentDir: string, name: string, content?: string): Promise + deleteFile(projectRoot: string, filePath: string): Promise + createDir(projectRoot: string, parentDir: string, name: string): Promise + rename(projectRoot: string, oldPath: string, newPath: string): Promise + fileExists(projectRoot: string, filePath: string): Promise +} +``` + +### 4.2 Path Validation + +**КРИТИЧЕСКИ ВАЖНО**: Использовать `validateFilePath()` из `src/main/utils/pathValidation.ts`, а НЕ писать свой `assertInsideRoot`. Существующая функция уже обрабатывает: +- Нормализацию пути через `path.resolve()` +- Symlink resolution через `fs.realpathSync.native()` +- Проверку sensitive patterns (`.env`, `.ssh`, credentials и т.д.) +- Проверку что realpath тоже внутри allowed directories +- Cross-platform поддержку (Windows case-insensitive) + +```typescript +import { validateFilePath } from '@main/utils/pathValidation'; + +function assertInsideProject(absolutePath: string, projectRoot: string): string { + const result = validateFilePath(absolutePath, projectRoot); + if (!result.valid) { + throw new Error(`Access denied: ${result.error}`); + } + return result.normalizedPath!; +} +``` + +**Дополнительные проверки для editor (сверх validateFilePath)**: +1. **Symlink-проверка для readDir**: при рекурсивном обходе каждый entry может быть symlink. Нужно `fs.lstat()` + `fs.realpath()` для каждого entry, проверяя что target внутри projectRoot. +2. **Валидация имён файлов при создании**: запрет NUL bytes, запрет `.` / `..` как имени, запрет `/` и `\` в имени, максимальная длина 255 символов. +3. **TOCTOU mitigation**: использовать `O_NOFOLLOW` при открытии файлов или проверять после `open()` через `fstat()`, что дескриптор указывает на файл внутри projectRoot. +4. **Запрет записи в .git/**: добавить `.git` в список запрещённых для записи директорий (чтение можно разрешить для отображения, но НЕ запись). + +### 4.3 Файловые лимиты и защита от DoS + +```typescript +MAX_FILE_SIZE = 2 * 1024 * 1024 // 2 MB -- безопасный лимит для IPC + CM6 +MAX_WRITE_SIZE = 2 * 1024 * 1024 // 2 MB -- лимит на запись (защита от memory bomb) +MAX_DIR_ENTRIES = 5_000 // Защита от node_modules-подобных директорий +MAX_DIR_DEPTH = 15 // Максимальная глубина рекурсии +MAX_FILENAME_LENGTH = 255 // POSIX лимит +MAX_PATH_LENGTH = 4096 // PATH_MAX + +IGNORED_DIRS = ['.git', 'node_modules', '.next', 'dist', '__pycache__', '.cache', '.venv', '.tox', 'vendor'] +IGNORED_FILES = ['.DS_Store', 'Thumbs.db'] + +// Защита от чтения device файлов и спецфайлов +BLOCKED_PATHS = ['/dev/', '/proc/', '/sys/', '\\\\.\\'] // device files на Linux/macOS/Windows +``` + +**Важно**: Перед чтением файла **обязательно** проверить через `fs.lstat()`: +- `stats.isFile()` === true (не directory, не device, не socket, не FIFO) +- `stats.size` <= MAX_FILE_SIZE (не читать файл если stat показывает огромный размер) +- НЕ использовать `stats.isSymbolicLink()` для решения -- вместо этого `fs.realpath()` + повторная проверка containment + +**Перед записью**: проверить `Buffer.byteLength(content, 'utf8')` <= MAX_WRITE_SIZE до вызова `fs.writeFile()`. + +### 4.4 Регистрация в handlers.ts + +Файл: `src/main/ipc/handlers.ts` +- Импорт `initializeEditorHandlers`, `registerEditorHandlers`, `removeEditorHandlers` +- Создание `ProjectFileService` (stateless, без аргументов) в `initializeIpcHandlers` +- Регистрация при инициализации + +--- + +## 5. Дерево файлов + +### 5.1 Рекурсивная модель с lazy-loading + +Дерево НЕ грузится целиком. Начальная загрузка -- только root level (depth=1). При expand директории -- IPC `editor:readDir` для конкретной папки. + +``` +FileTreeNode (renderer-side) { + name: string + path: string + type: 'file' | 'directory' + size?: number + + // Для директорий: + children: FileTreeNode[] | null // null = не загружены + expanded: boolean + loading: boolean +} +``` + +### 5.2 Хранение состояния дерева + +`expandedDirs: Set` -- хранить в editorSlice. При re-open editor -- дерево подгружается заново, но expanded-состояние сохраняется. + +### 5.3 Фильтрация и сортировка + +- Скрывать: `.git`, `node_modules`, `dist`, `__pycache__` (configurable) +- Сортировка: директории сначала, затем файлы; внутри группы -- alphabetical +- Поиск: fuzzy filter по имени файла (локальный, без IPC) + +### 5.4 Контекстное меню + +Правый клик на ноде: +- Файл: Open, Delete, Rename, Copy Path +- Директория: New File, New Directory, Delete, Rename, Copy Path +- Пустое место: New File, New Directory + +--- + +## 6. CodeMirror интеграция + +### 6.1 Подход + +Компонент `CodeMirrorEditor.tsx` -- обёртка аналогичная `MembersJsonEditor.tsx` (строки 27-59) и `CodeMirrorDiffView.tsx`, но для single-file editing (не diff). + +### 6.2 Extensions (переиспользование) + +Из уже имеющихся в `CodeMirrorDiffView.tsx`: + +``` +- Language detection (файл → extension → LanguageDescription) + Все 17 языков уже подключены: JS/TS, Python, Rust, Go, Java, C++, CSS, HTML, + JSON, YAML, XML, SQL, PHP, Markdown, Less, Sass +- oneDarkHighlightStyle (уже импортируется) +- lineNumbers() +- history() + historyKeymap +- indentWithTab +- defaultKeymap +- syntaxHighlighting() +``` + +Дополнительно для editor (не diff): + +``` +- closeBrackets + closeBracketsKeymap (уже используется в MembersJsonEditor) +- bracketMatching (уже используется в MembersJsonEditor) +- EditorView.updateListener для onChange +- Cmd+S keymap для save +- search + searchKeymap (Cmd+F) +- indentUnit настройка (2/4 spaces) +- EditorView.lineWrapping (toggle) +- highlightActiveLine +- highlightActiveLineGutter +``` + +### 6.3 Определение языка по расширению + +Функция `getLanguageExtension(fileName)` -- уже реализована в `CodeMirrorDiffView.tsx` (примерно строки 1-25, маппинг extension -> language plugin). Вынести в общий util `src/renderer/utils/codemirrorLanguage.ts` для переиспользования. + +### 6.4 Тема + +Единая тема для всего приложения: `oneDark` + CSS custom properties из `index.css`. Дополнительная кастомизация через `EditorView.theme({})`: +- Фон: `var(--color-surface)` +- Шрифт: `ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace` +- font-size: 13px (чуть крупнее чем в diff view) + +### 6.5 Управление EditorView lifecycle + +> **ПЕРЕСМОТРЕНО после Performance Review (секция 19.1-19.2).** Оригинальный подход CSS show/hide заменён на EditorState pooling. + +- ~~Один `EditorView` на открытый tab~~ -> Один `EditorView` на ВЕСЬ редактор (активный файл) +- При закрытии tab: `savedStates.delete(tabId)` +- Хранить `Map` в ref (НЕ EditorView!) +- При переключении tab: `savedStates.set(oldId, view.state)` -> `view.destroy()` -> `new EditorView({ state: savedStates.get(newId), parent: container })` +- LRU eviction при >30 states: сохранить content + cursor, вытеснить undo history +- Паттерн: аналог initialState в CodeMirrorDiffView.tsx (строки 699-705) + +--- + +## 7. Tab-система для нескольких файлов + +### 7.1 Модель + +``` +openTabs: EditorFileTab[] +activeTabId: string | null +``` + +### 7.2 Поведение + +- Клик на файл в дереве: + - Если tab уже открыт -- activate + - Если нет -- создать tab, загрузить содержимое через IPC, activate +- Закрытие tab: + - Если есть unsaved changes -- confirm dialog (Save / Discard / Cancel) + - Cmd+W закрывает активный tab +- Modified indicator: точка на tab (аналог VS Code) +- Порядок табов: по порядку открытия, drag-to-reorder не нужен на первой итерации + +--- + +## 8. Error Handling Strategy + +### 8.1 Уровни ошибок + +| Уровень | Обработка | Пример | +|---------|----------|--------| +| IPC failure | Toast/banner в overlay | Сеть, main process crash | +| File read error | Inline в tab | ENOENT, EACCES, binary file | +| File write error | Inline + retry | EACCES, disk full | +| Path traversal | Reject + log | Попытка выйти за projectPath | +| File too large | Inline warning | > MAX_FILE_SIZE | + +### 8.2 Паттерн ошибок в slice + +Повторяет teamSlice: + +``` +editorFileTreeError: string | null +editorSaveError: Record // per-file +``` + +### 8.3 Main process + +`wrapEditorHandler()` -- ловит все исключения, возвращает `IpcResult`. + +### 8.4 Renderer + +`unwrapIpc('editor:readFile', ...)` -- стандартный паттерн из `unwrapIpc.ts`. + +--- + +## 9. Производительность + +### 9.1 Большие директории + +- **Lazy loading**: грузим дерево по одному уровню, expand подгружает children +- **Фильтрация**: `node_modules`, `.git` и т.д. фильтруются на стороне main process (НЕ отправляются по IPC) +- **Лимит**: MAX_DIR_ENTRIES = 10,000 entries per directory, truncation flag + +### 9.2 Большие файлы + +- **Лимит**: MAX_FILE_SIZE = 5MB. Больше -- показываем warning, предлагаем открыть в внешнем редакторе (`shell:openPath`) +- **Бинарные файлы**: Определять по magic bytes / extension. Показывать "Binary file, cannot edit" +- **CM6 производительность**: CodeMirror 6 обрабатывает файлы до 5MB без проблем (virtual rendering) + +### 9.3 Оптимизация IPC + +- **File content caching**: кэшируем `editorFileContents` в store. Invalidate при save. +- **Debounced onChange**: updateContent вызывается при каждом keystroke, но это локальная операция (set state). Фактический save только по Cmd+S. +- **Tree caching**: после загрузки дерево хранится в store. Re-fetch только при explicit refresh (F5 или кнопка refresh). + +### 9.4 Memory + +- Удалять CM EditorView при закрытии tab для освобождения памяти +- Не хранить больше 20 одновременно открытых EditorView (soft limit, предупреждение) + +--- + +## 10. Data Flow + +### 10.1 Открытие редактора + +``` +1. Юзер кликает кнопку [Code] рядом с projectPath в TeamDetailView +2. editorSlice.openEditor(data.config.projectPath) +3. set({ editorProjectPath, editorFileTreeLoading: true }) +4. IPC: editor:readDir(projectPath, depth=1) +5. Main: FileEditorService.readDir() → валидация пути → fs.readdir +6. Результат: FileTreeEntry[] +7. set({ editorFileTree, editorFileTreeLoading: false }) +8. CodeEditorOverlay рендерится (fixed inset-0 z-50) +``` + +### 10.2 Открытие файла + +``` +1. Юзер кликает на файл в FileTreePanel +2. editorSlice.openFile(filePath) +3. Проверка: есть ли уже tab с этим filePath? + ДА → setActiveTab(tabId) + НЕТ → создать tab, IPC: editor:readFile(filePath) +4. Main: FileEditorService.readFile() → валидация → fs.readFile +5. Результат: ReadFileResult { content, size, truncated } +6. set({ editorFileContents[filePath]: content }) +7. CM EditorView создаётся с content +``` + +### 10.3 Сохранение файла + +``` +1. Юзер нажимает Cmd+S (или кнопку Save) +2. editorSlice.saveFile(filePath) +3. content = editorModifiedContents[filePath] ?? editorFileContents[filePath] +4. set({ editorSaving[filePath]: true }) +5. IPC: editor:writeFile(filePath, content) +6. Main: FileEditorService.writeFile() → валидация → fs.writeFile (atomic via tmp+rename) +7. set({ editorSaving: false, editorModifiedContents: remove filePath }) +8. Tab isModified indicator исчезает +``` + +### 10.4 Создание/удаление файла + +``` +Создание: +1. Юзер через контекстное меню → "New File" +2. Inline input в дереве (имя файла) +3. IPC: editor:createFile(parentDir, name) +4. Main: fs.writeFile(path.join(parentDir, name), '') +5. Обновить дерево: expandDirectory(parentDir) +6. Автоматически открыть новый файл в tab + +Удаление: +1. Контекстное меню → "Delete" +2. Confirm dialog +3. IPC: editor:deleteFile(filePath) +4. Main: fs.unlink (файл) или fs.rm (директория, recursive) +5. Закрыть tab если был открыт +6. Обновить дерево +``` + +--- + +## 11. Keyboard Shortcuts + +| Shortcut | Действие | +|----------|---------| +| `Cmd+S` | Сохранить активный файл | +| `Cmd+Shift+S` | Сохранить все | +| `Cmd+W` | Закрыть активный tab | +| `Cmd+P` | Quick Open (поиск файла) -- Phase 2 | +| `Cmd+F` | Поиск в файле (CM6 search) | +| `Escape` | Закрыть overlay (с confirm при unsaved changes) | +| `Cmd+Shift+[` / `Cmd+Shift+]` | Переключение табов влево/вправо | +| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Переключение табов (MRU) | +| `Cmd+B` | Toggle file tree sidebar | +| `Cmd+G` | Go to line (CM6 gotoLine) | +| `Cmd+Z` / `Cmd+Shift+Z` | Undo/Redo (CM6 native) | + +--- + +## 12. Новые зависимости + +**Нет новых npm-зависимостей!** Все нужные пакеты уже в `package.json`: +- CodeMirror 6 -- 17 пакетов `@codemirror/*` +- lucide-react -- иконки (File, Folder, FolderOpen, Save, X, Plus, Trash2) +- Radix UI -- для контекстного меню (Popover) и confirm dialog (Dialog) + +--- + +## 13. План итераций реализации + +### Итерация 1: Read-Only File Browser +- `FileEditorService` с `readDir` + `readFile` (main process) +- IPC каналы `editor:readDir`, `editor:readFile` +- `editorSlice` (минимальный: tree + openFile + tabs) +- `CodeEditorOverlay` + `FileTreePanel` + `CodeMirrorEditor` (read-only) +- Кнопка в TeamDetailView + +### Итерация 2: File Editing + Save +- `writeFile` в сервисе + IPC +- Modified content tracking в store +- Cmd+S save +- Unsaved changes indicator (dot on tab) +- Close tab с confirm + +### Итерация 3: File Operations +- `createFile`, `deleteFile`, `createDir`, `rename` в сервисе +- Контекстное меню в дереве файлов +- Inline rename в дереве + +### Итерация 4: Polish +- Quick Open (Cmd+P) -- fuzzy search по файлам +- Binary file detection +- Large file warning +- File watcher integration (auto-refresh tree при внешних изменениях) +- Resizable split panels + +--- + +## 14. Список файлов для создания/модификации + +### Новые файлы (~15) + +| Файл | Описание | +|------|----------| +| `src/shared/types/editor.ts` | Типы: FileTreeEntry, ReadDirResult, ReadFileResult | +| `src/main/services/editor/ProjectFileService.ts` | Main process сервис файловых операций (stateless) | +| `src/main/ipc/editor.ts` | IPC handlers для editor | +| `src/main/ipc/ipcWrapper.ts` | Общий `createIpcWrapper()` (извлечь из review.ts) | +| `src/renderer/store/slices/editorSlice.ts` | Zustand slice (итерация 2+) | +| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Full-screen overlay | +| `src/renderer/components/team/editor/EditorFileTree.tsx` | Обёртка над generic FileTree | +| `src/renderer/components/common/FileTree.tsx` | Generic FileTree с render-props (рефакторинг из ReviewFileTree) | +| `src/renderer/components/team/editor/EditorTabsPanel.tsx` | Табы + editor | +| `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | CM6 wrapper | +| `src/renderer/components/team/editor/EditorToolbar.tsx` | Toolbar | +| `src/renderer/components/team/editor/EditorEmptyState.tsx` | Empty state | +| `src/renderer/utils/codemirrorLanguages.ts` | Языковой маппинг (извлечь из CodeMirrorDiffView) | +| `src/renderer/utils/codemirrorTheme.ts` | Базовая тема CM (извлечь из diffTheme) | +| `src/renderer/utils/fileTreeBuilder.ts` | buildTree + сортировка (извлечь из ReviewFileTree) | + +### Модификации (~10) + +| Файл | Изменение | +|------|-----------| +| `src/preload/constants/ipcChannels.ts` | +8 констант EDITOR_* | +| `src/preload/index.ts` | +секция `editor: { ... }` в electronAPI | +| `src/shared/types/api.ts` | +`EditorAPI` interface, +`editor: EditorAPI` в `ElectronAPI` | +| `src/shared/types/index.ts` | +export из editor.ts | +| `src/main/ipc/handlers.ts` | +регистрация editor handlers | +| `src/main/ipc/review.ts` | Заменить локальный `wrapReviewHandler` на import из `ipcWrapper.ts` | +| `src/renderer/store/types.ts` | +`EditorSlice` в AppState union (итерация 2) | +| `src/renderer/store/index.ts` | +`createEditorSlice` (итерация 2) | +| `src/renderer/components/team/TeamDetailView.tsx` | +кнопка Code + импорт ProjectEditorOverlay | +| `src/renderer/components/team/review/ReviewFileTree.tsx` | Рефакторинг: использовать generic FileTree + fileTreeBuilder | +| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Рефакторинг: импортировать из codemirrorLanguages.ts и codemirrorTheme.ts | + +--- + +## 15. Риски и митигации + +| Риск | Вероятность | Митигация | +|------|------------|-----------| +| Path traversal через IPC | Средняя | `validateFilePath()` из pathValidation.ts на КАЖДОМ IPC handler | +| CM6 тормозит на файлах >2MB | Низкая | Hard limit 2MB + warning + external editor fallback | +| node_modules в дереве -- OOM | Высокая | IGNORED_DIRS фильтр на main process + MAX_DIR_ENTRIES | +| Race condition при save (TOCTOU) | Высокая | Atomic write (tmp + rename) + fstat после open + saving flag | +| Unsaved data loss при crash | Средняя | Phase 2: autosave в localStorage/IndexedDB | +| Symlink escape из rootPath | Высокая | `validateFilePath()` уже делает `fs.realpathSync.native()` + re-check | +| Device file DoS (/dev/zero) | Средняя | `fs.lstat()` + `isFile()` проверка ДО чтения | +| Credential leakage (.env, .key) | Высокая | `validateFilePath()` проверяет SENSITIVE_PATTERNS | +| XSS через имена файлов | Низкая | React экранирует автоматически; НЕ использовать innerHTML | +| IPC flooding | Средняя | Debounce на renderer + AbortController | +| ReDoS в searchInFiles | Средняя | Только literal search, НЕ regex от пользователя | + +--- + +## 16. Архитектурная ревизия (SOLID / DRY / Clean Architecture) + +> Добавлено после ревизии архитектором. Все замечания основаны на анализе реального кода проекта. + +### 16.1 SOLID-анализ + +#### S -- Single Responsibility + +**Проблема 1: `FileTreePanel.tsx` несёт двойную ответственность.** +В плане FileTreePanel отвечает и за загрузку данных дерева (IPC вызовы, expand/collapse), и за рендеринг UI (поиск, контекстное меню). + +**Решение:** Разделить на два слоя: +- `FileTreePanel.tsx` -- чистый UI: рендерит дерево, принимает данные через store +- Логика загрузки и expand -- ТОЛЬКО в `editorSlice.ts` (actions `loadFileTree`, `expandDirectory`) +- Контекстное меню -- отдельный `EditorContextMenu.tsx` (уже запланирован на итерацию 3) + +**Проблема 2: `CodeMirrorEditor.tsx` смешивает CM lifecycle + keybindings + onChange.** + +**Решение:** Извлечь extensions builder в отдельный `buildEditorExtensions.ts` (аналогично `buildExtensions()` в `CodeMirrorDiffView.tsx` строки 477-688). Keybindings (Cmd+S и др.) -- часть extensions, но собираются в builder, а не в компоненте. + +#### O -- Open/Closed + +**Проблема: FileTree не расширяем через render-prop.** +`ReviewFileTree.tsx` уже содержит `TreeItem` с review-специфичным рендерингом (FileStatusIcon, +/- lines). EditorFileTree будет содержать свой рендеринг (dirty marker, file type icon). Два дерева -- два набора рендеринга без общей абстракции. + +**Решение (render-prop / compound components):** +```typescript +// Общий generic FileTree +interface FileTreeProps { + nodes: T[]; + activeNodePath: string | null; + onNodeClick: (node: T) => void; + renderNodeExtra?: (node: T) => React.ReactNode; // Правая часть (статус/кол-во строк) + renderNodeIcon?: (node: T) => React.ReactNode; // Иконка слева от имени + collapsedFolders: Set; + onToggleFolder: (fullPath: string) => void; +} +``` +`ReviewFileTree` добавляет `FileStatusIcon` + `+/-` строки через `renderNodeExtra`. +`EditorFileTree` добавляет dirty-маркер и file type icon. +Оба дерева используют один `buildTree()` и `TreeItem` рендеринг. + +#### L -- Liskov Substitution + +Наследований в плане нет (React -- composition over inheritance). Корректно. + +`FileTreeNode` должен расширять `FileTreeEntry`, а не дублировать поля: +```typescript +// shared/types/editor.ts +interface FileTreeEntry { name: string; path: string; type: 'file' | 'directory'; size?: number; } + +// renderer (local type) +interface FileTreeNode extends FileTreeEntry { + children: FileTreeNode[] | null; + expanded: boolean; + loading: boolean; +} +``` + +#### I -- Interface Segregation + +**Проблема: `editorSlice` с 15+ actions -- слишком толстый интерфейс.** +Сравнение: `changeReviewSlice` содержит ~25 actions и это одна из самых сложных фич в проекте. + +**Решение:** Логически разделить EditorSlice на 4 группы (оставить в одном файле, т.к. Zustand slices -- flat intersection, но документировать секциями): +``` +// Группа 1: File tree state + actions +editorProjectPath, editorFileTree, editorFileTreeLoading, editorFileTreeError +openEditor, closeEditor, loadFileTree, expandDirectory + +// Группа 2: Tab management +editorOpenTabs, editorActiveTabId +openFile, closeTab, setActiveTab + +// Группа 3: Content + Save +editorFileContents, editorModifiedContents, editorSaving, editorSaveError +updateContent, saveFile, saveAllFiles, discardChanges + +// Группа 4: File operations (итерация 3) +createFile, deleteFile, createDirectory +``` + +#### D -- Dependency Inversion + +**Проблема:** `CodeMirrorEditor.tsx` напрямую зависит от конкретных CM extensions. + +**Решение:** Extensions собираются в фабрике `buildEditorExtensions(options)`: +```typescript +interface EditorExtensionOptions { + readOnly: boolean; + fileName: string; + onContentChanged?: (content: string) => void; + onSave?: () => void; + tabSize?: number; + lineWrapping?: boolean; +} +``` +Компонент вызывает `buildEditorExtensions(opts)` и не знает о конкретных extensions. + +### 16.2 DRY-анализ + +**Проблема 1: Дублирование `buildTree()` + сортировки.** +`ReviewFileTree.tsx` строки 42-83 содержат `buildTree()` с collapse-логикой. `EditorFileTree` будет реализовывать аналогичную, но с другим источником данных. + +**Решение (обязательное):** +1. Извлечь generic `buildTree(items, getPath, isFile)` в `src/renderer/utils/fileTreeBuilder.ts` +2. Сортировка (dirs first, alphabetical) тоже в `fileTreeBuilder.ts` +3. `ReviewFileTree` + `EditorFileTree` используют одну и ту же функцию + +**Проблема 2: Тема CodeMirror -- частичное дублирование с `diffTheme`.** +~50% стилей `diffTheme` (`&`, `.cm-gutters`, `.cm-scroller`, `.cm-content`, `.cm-cursor`, `.cm-selectionBackground`) идентичны. + +**Решение:** +```typescript +// src/renderer/utils/codemirrorTheme.ts +export const baseEditorTheme = EditorView.theme({/* общие стили */}); + +// CodeMirrorDiffView.tsx -- импортирует baseEditorTheme + свои diff-стили +// CodeMirrorEditor.tsx -- импортирует baseEditorTheme + свои editor-стили +``` + +**Проблема 3: `wrapEditorHandler` -- копия `wrapReviewHandler`.** +В плане `wrapEditorHandler()` в `editor.ts` -- 1:1 копия из `review.ts` (строки 133-145). + +**Решение:** Извлечь общий `createIpcWrapper(logPrefix)` в `src/main/ipc/ipcWrapper.ts`: +```typescript +export function createIpcWrapper(logPrefix: string) { + const log = createLogger(logPrefix); + return async function wrap(op: string, fn: () => Promise): Promise> { + try { return { success: true, data: await fn() }; } + catch (error) { + const msg = error instanceof Error ? error.message : String(error); + log.error(`handler error [${op}]:`, msg); + return { success: false, error: msg }; + } + }; +} +``` + +### 16.3 Clean Architecture -- направление зависимостей + +Потоки зависимостей проверены -- корректны: +``` +shared/types/editor.ts (чистые типы, zero deps) + <- main/services/editor/ (зависит от fs, path, shared/types) + <- main/ipc/editor.ts (зависит от service + shared types) + <- preload/index.ts (зависит от ipcChannels) + <- renderer/store/ (зависит от api layer + shared types) + <- renderer/components/ (зависит от store + utils) +``` + +**Проблема: `FileEditorService` принимает `rootPath` в конструкторе.** +Привязывает один сервис к одному проекту. При переключении команды -- нужно пересоздавать. + +**Решение: Stateless service (рекомендуется, 9/10).** +Каждый метод принимает `projectRoot` как аргумент. Валидация -- в каждом методе. +Это паттерн `TeamDataService` (нет привязки к конкретной команде в конструкторе). +В `handlers.ts` создаётся один экземпляр `ProjectFileService()` без аргументов. + +### 16.4 Security -- переиспользование существующей валидации + +**Проблема:** План описывает свой `assertInsideRoot()`, но в проекте уже есть `validateFilePath()` в `src/main/utils/pathValidation.ts` которая: +- Проверяет абсолютность пути +- Предотвращает path traversal +- Блокирует sensitive files (.ssh, .env, .pem и т.д.) +- Проверяет symlink escapes через `fs.realpathSync` + +**Решение:** НЕ писать свой `assertInsideRoot()`. Использовать `validateFilePath(filePath, projectRoot)` из `pathValidation.ts`. Дополнительно нужна ТОЛЬКО проверка что projectRoot -- валидный абсолютный путь (однократно при `openEditor`). + +### 16.5 Именование -- приведение к единому стилю + +В plan-architecture.md сервис назван `FileEditorService`, в plan-iterations.md -- `ProjectFileService`. + +**Рекомендация:** Использовать `ProjectFileService` везде -- лучше отражает суть (файловые операции в рамках проекта), не путается с "editor" (который в renderer). + +--- + +## 17. UX Review + +> Добавлено после UX-ревью. Анализ user journeys, keyboard-first, accessibility, edge cases. + +### 17.1 Критично для MVP + +#### 17.1.1 Unsaved changes при закрытии overlay (Escape / кнопка X) + +**Проблема:** В секции 11 `Escape` закрывает overlay, но нигде не описано, что происходит с unsaved changes при закрытии ВСЕГО overlay. В секции 7.2 confirm описан только для закрытия отдельного tab, не для overlay. + +**Рекомендация:** При `Escape` или клике на `X`, если есть ЛЮБОЙ таб с `isModified: true`: +1. Показать `confirm()` (существующий `ConfirmDialog`): "You have unsaved changes in N files." +2. Три кнопки: **Save All & Close**, **Discard & Close**, **Cancel** +3. `Escape` внутри confirm = Cancel (возврат к редактору) + +Добавить в `editorSlice`: +```typescript +hasUnsavedChanges: () => boolean // derived: Object.keys(editorModifiedContents).length > 0 +``` + +#### 17.1.2 Файл удалён извне пока открыт в табе + +**Проблема:** Нигде не описано, что делать если файл, открытый в табе, удалён или переименован на диске (другим процессом, CLI-агентом). Claude Agent активно меняет файлы -- это реальный сценарий. + +**Рекомендация:** +- При попытке `saveFile` с ENOENT -- показать inline-ошибку в табе: "File was deleted. Create new? / Close tab" +- При `editor:change` (FileWatcher, итерация 5) -- если файл удалён, показать subtle banner: "File no longer exists on disk" +- Для MVP (без FileWatcher): проверять `fileExists` перед `writeFile`. Если ENOENT -- показать ошибку, не падать. + +#### 17.1.3 Два таба с одинаковым именем (разные пути) + +**Проблема:** `EditorFileTab.fileName` -- просто имя файла. Если открыть `src/main/utils/index.ts` и `src/renderer/utils/index.ts` -- оба таба покажут "index.ts". Различить невозможно. + +**Рекомендация:** VS Code решает добавлением минимального disambiguating parent: +``` +index.ts (main/utils) index.ts (renderer/utils) +``` +Утилита `getDisambiguatedTabLabel(tabs)` в `src/renderer/utils/tabLabelDisambiguation.ts`. + +#### 17.1.4 Status bar (line:col, язык, кодировка) + +**Проблема:** В плане нет status bar -- базовый элемент любого код-редактора. + +**Рекомендация:** `EditorStatusBar.tsx` -- нижняя полоска overlay: +``` +[Ln 42, Col 15] | [TypeScript] | [UTF-8] | [Spaces: 2] | [LF] +``` +CSS: `bg-surface-sidebar border-t border-border text-text-muted text-xs h-6` + +#### 17.1.5 Keyboard shortcuts -- конфликт Cmd+[/] + +**Проблема:** Секция 11: `Cmd+[` / `Cmd+]` для табов. Но в VS Code и CM6 это indent/outdent. + +**Рекомендация:** `Cmd+Shift+[` / `Cmd+Shift+]` для табов. `Ctrl+Tab`/`Ctrl+Shift+Tab` как альтернатива. + +#### 17.1.6 Binary файлы -- конкретный UI + +**Проблема:** Секция 9.2 -- только текст, нет дизайна. + +**Рекомендация:** `EditorBinaryState.tsx` вместо CM6: иконка, тип/размер, кнопки "Open in System Viewer" и "Close Tab". Добавить `isBinary: boolean` в `ReadFileResult`. + +#### 17.1.7 Accessibility: ARIA roles + +**Проблема:** `ReviewFileTree` -- только `aria-label`. Нет `role="tree"`, `role="treeitem"`, `aria-expanded`. + +**Рекомендация:** +- File tree: `role="tree"`, `role="treeitem"`, `aria-expanded`, `role="group"`, arrow keys +- Tab bar: `role="tablist"`, `role="tab"`, `role="tabpanel"`, `aria-selected` + +#### 17.1.8 Focus management + +**Проблема:** Не описано, куда идёт фокус при открытии/закрытии overlay. + +**Рекомендация:** +- Открытие: фокус на первый файл в дереве (или CM6 если таб открыт) +- Закрытие: вернуть фокус на кнопку "Open in Editor" (`returnFocusRef`) +- `inert` атрибут на фон пока overlay открыт + +### 17.2 Важно, но не блокирует MVP + +#### 17.2.1 Discoverability -- подсказки горячих клавиш + +- `EditorEmptyState` показывает шпаргалку shortcuts +- Tooltip на кнопках toolbar: "Save (Cmd+S)" +- Кнопка `?` в header -- модальное окно со всеми шорткатами + +#### 17.2.2 Пустой проект и проект с 1 файлом + +- 0 файлов: "No files found. Create a new file?" + кнопка +- 1 файл: автоматически открыть в табе +- Все скрыты: "All files are in excluded directories" + +#### 17.2.3 Глубокая вложенность (20+ уровней) + +- Max визуальный indent: 12 уровней (`min(level, 12) * 12px`) +- Tooltip на глубоких узлах с полным путём +- `buildTree` коллапс однодетных папок + +#### 17.2.4 Очень длинные имена файлов + +- File tree: `truncate` + `title` tooltip +- Табы: max-width ~160px, modified dot ПЕРЕД текстом +- Breadcrumb: средние сегменты `...` + +#### 17.2.5 Ошибка чтения файла (EACCES, ENOENT) + +Показать: иконка AlertTriangle + текст ошибки + [Retry] + [Close Tab] + +#### 17.2.6 Resizable sidebar + +- Default: 240px, min 160px, max 50% viewport +- Drag handle: `cursor-col-resize` +- Persist в localStorage +- `Cmd+B` toggle sidebar + +### 17.3 Nice to Have (после MVP) + +| Фича | Приоритет | +|------|-----------| +| Cmd+Shift+P Command Palette (`cmdk`) | P2 | +| Split View (Cmd+\) | P3 | +| Minimap | P4 | +| Drag & Drop файлов | P4 | +| Indent guides | P2 | +| Find & Replace (Cmd+H) | P2 | +| Auto-save draft | P2 | + +### 17.4 Правки к существующим секциям + +1. **Секция 1.1** -- добавить: `EditorStatusBar.tsx`, `EditorBinaryState.tsx` +2. **Секция 2.1** -- добавить `hasUnsavedChanges` computed getter +3. **Секция 2.3** -- добавить `disambiguatedLabel?: string` +4. **Секция 7.2** -- добавить "Close overlay with unsaved changes" (три кнопки) +5. **Секция 9.2** -- добавить `isBinary` в `ReadFileResult` +6. **Секция 11** -- `Cmd+[/]` -> `Cmd+Shift+[/]`; добавить `Cmd+B`, `Cmd+G` +7. **Секция 14** -- обновить: ~14 файлов вместо ~12 + +--- + +## 18. Security Review + +> Полный аудит безопасности. Проведён на основе анализа существующих паттернов проекта (`pathValidation.ts`, `validation.ts`, `review.ts`, `preload/index.ts`) и 8 планируемых IPC каналов editor. + +### SEC-1: Path Traversal -- использовать validateFilePath (Critical) + +**Уязвимость**: Каждый из 8 IPC каналов принимает путь от renderer. Скомпрометированный renderer может отправить `../../etc/passwd` или `/etc/shadow`. + +**Текущий статус**: Секция 4.2 уже исправлена -- описывает `validateFilePath()` вместо кастомного `assertInsideRoot()`. Хорошо. + +**Дополнительные требования**: +- Для `editor:rename` -- валидировать ОБА пути (oldPath и newPath) +- Для `editor:readDir` -- валидировать dirPath и КАЖДЫЙ обнаруженный entry +- Не доверять конструкции `path.join(projectRoot, relativePath)` без последующей проверки -- это не защищает от `path.join('/project', '/etc/passwd')` (абсолютный путь перезаписывает base) + +### SEC-2: Symlink Resolution при рекурсивном обходе (Critical) + +**Уязвимость**: `readDir` рекурсивно обходит директорию. Если внутри проекта symlink `./data -> /etc/`, readDir вернёт содержимое `/etc/`. + +**Решение**: В `safeReadDir()` для каждого entry проверять `entry.isSymbolicLink()`. Если да -- `fs.realpath()` + `validateFilePath()` на resolved target. Молча пропускать symlinks, ведущие за пределы projectRoot. + +### SEC-3: TOCTOU Race Condition (High) + +**Уязвимость**: Между `validateFilePath(path)` и `fs.readFile(path)` файл может быть заменён на symlink к sensitive файлу. + +**Решение**: После `fs.readFile()` повторно `fs.realpath()` + `validateFilePath()` (post-read verification). Для записи: atomic write через tmp + `rename()`. Вероятность эксплуатации в desktop-app низкая, но импакт критический. + +### SEC-4: File Size DoS / Device Files (High) + +**Уязвимость**: Чтение `/dev/zero` (бесконечный поток нулей) или огромных файлов. Device файлы показывают `size = 0` в stat. + +**Текущий статус**: Секция 4.3 исправлена -- лимит 2MB, проверка `isFile()`, блокировка `/dev/`, `/proc/`, `/sys/`. + +### SEC-5: projectRoot НЕ от renderer (High) + +**Уязвимость**: Скомпрометированный renderer отправляет `projectRoot = '/'` и обходит все проверки. + +**Решение**: При stateless-подходе (секция 16.3): projectRoot хранится в module-level `let activeProjectRoot` в `editor.ts`. Устанавливается через `editor:open(projectPath)` (с валидацией). IPC handlers берут rootPath из module-level state, НЕ принимают от renderer. + +### SEC-6: Credential Leakage через readDir (Medium) + +**Уязвимость**: `.env`, `credentials.json`, `*.key` видны в дереве. `validateFilePath()` блокирует readFile, но readDir покажет имена. + +**Решение**: Показывать в дереве с визуальной пометкой (иконка замка). При клике -- "Sensitive file, cannot open in editor". Рассмотреть расширение SENSITIVE_PATTERNS: `*.p12`, `*.pfx`, `serviceAccountKey.json`. + +### SEC-7: XSS через имена файлов (Medium) + +**Уязвимость**: Имя `.txt` безопасно в React JSX, но опасно в `document.title`, tooltip с raw HTML, или `window.open()` title. + +**Решение**: Рендерить имена только через JSX `{fileName}`. При создании: `validateFileName()` в main process -- запрет control characters (`\x00-\x1f`), path separators (`/\:`), имён `.` и `..`, длины > 255. + +### SEC-8: ReDoS в searchInFiles (Medium, итерация 4) + +**Уязвимость**: Malicious regex `(a+)+$` вызывает catastrophic backtracking в main process. + +**Решение**: Только literal string search. Если regex нужен -- `re2` engine или `worker_thread` с timeout. Лимит: max 1000 файлов, max 1MB на файл. + +### SEC-9: Atomic Write (Medium) + +**Решение**: Write в tmp файл (`${dir}/.tmp.${basename}.${pid}.${Date.now()}`) + `rename()`. Cleanup tmp при ошибке. `rename()` атомарен только на одном filesystem -- tmp в той же директории обязательно. + +### SEC-10: editor:rename -- двойная валидация (High) + +**Уязвимость**: Если валидируется только oldPath, можно переименовать файл ЗА ПРЕДЕЛЫ проекта или перезаписать чужой файл. + +**Решение**: Валидировать ОБА пути через `validateFilePath()`. Проверить что newPath не существует (не перезаписывать). Валидировать новое имя файла. + +### SEC-11: СУЩЕСТВУЮЩАЯ уязвимость в review.ts (Critical, existing!) + +**ВНИМАНИЕ**: `handleSaveEditedFile` в `src/main/ipc/review.ts` (строка 254) принимает `filePath` от renderer и передаёт в `ReviewApplierService.saveEditedFile()` (строка 320 `ReviewApplierService.ts`), который вызывает `writeFile(filePath, content, 'utf8')` **БЕЗ КАКОЙ-ЛИБО ВАЛИДАЦИИ ПУТИ**. Скомпрометированный renderer может записать произвольный файл куда угодно в файловой системе. + +**Решение**: Добавить `validateFilePath()` в `handleSaveEditedFile` ДО записи. Это нужно исправить КАК МОЖНО СКОРЕЕ, НЕЗАВИСИМО от editor-фичи, как отдельный hotfix. + +### SEC-12: Запрет записи в .git/ (Medium) + +**Уязвимость**: Модификация файлов в `.git/` (особенно `hooks/`, `config`) может привести к произвольному выполнению кода при `git commit/push/pull`. + +**Решение**: В `ProjectFileService.writeFile/createFile/rename` -- проверка что target path не внутри `.git/` директории. Чтение `.git/` -- можно разрешить (для информации), запись -- запретить. + +### SEC-13: IPC Rate Limiting (Low) + +**Уязвимость**: Скомпрометированный renderer спамит IPC вызовами, вызывая disk I/O saturation. + +**Решение**: Debounce на renderer (уже запланирован). На main process: простой counter -- max 100 вызовов/секунду. AbortController для отмены предыдущего readDir при новом запросе. + +### Сводная таблица уязвимостей + +| ID | Уязвимость | Критичность | Статус | +|----|-----------|-------------|--------| +| SEC-1 | Path traversal через IPC | Critical | Исправлено в секции 4.2 | +| SEC-2 | Symlink escape в readDir | Critical | Нужно добавить в реализацию | +| SEC-3 | TOCTOU race condition | High | Нужно добавить post-read verify | +| SEC-4 | File size / device DoS | High | Исправлено в секции 4.3 | +| SEC-5 | projectRoot от renderer | High | Нужно зафиксировать в module-level state | +| SEC-6 | Credential leakage | Medium | Частично покрыто validateFilePath | +| SEC-7 | XSS через имена файлов | Medium | React JSX покрывает, нужна validateFileName | +| SEC-8 | ReDoS в поиске | Medium | Нужно literal search, не regex | +| SEC-9 | Non-atomic write | Medium | Нужен tmp+rename | +| SEC-10 | rename двойная валидация | High | Нужно при реализации | +| SEC-11 | **review.ts без валидации** | **Critical** | **СУЩЕСТВУЮЩИЙ БАГ, нужен hotfix** | +| SEC-12 | Запись в .git/ | Medium | Нужно при реализации | +| SEC-13 | IPC rate limiting | Low | Optional | + +### Чеклист для реализации каждого IPC handler + +``` +[ ] validateFilePath(path, projectRoot) ДО файловой операции +[ ] projectRoot из module-level state, НЕ из параметров renderer +[ ] fs.lstat() + isFile()/isDirectory() перед чтением +[ ] stats.size <= MAX_FILE_SIZE (2MB) перед чтением +[ ] Buffer.byteLength(content) <= MAX_WRITE_SIZE (2MB) перед записью +[ ] Для rename: ОБА пути валидируются +[ ] Для readDir: каждый entry + symlinks проверяются +[ ] validateFileName() при создании файлов +[ ] Логирование через createLogger('IPC:editor') +[ ] Обёртка в wrapHandler -> IpcResult +[ ] Device paths (/dev/, /proc/, /sys/) блокируются +[ ] Запись в .git/ запрещена +[ ] Post-read realpath verify (TOCTOU mitigation) +``` + +--- + +## 19. Performance Review + +> Аудит производительности по 9 направлениям. Основан на анализе реального кода: CodeMirrorDiffView.tsx (EditorView lifecycle, initialState, langCompartment), MembersJsonEditor.tsx (CM6 create/destroy), FileWatcher.ts (fs.watch patterns), changeReviewSlice.ts (file content caching), virtual scrolling в DateGroupedSessions/ChatHistory/NotificationsView. + +--- + +### 19.1 Memory Leaks -- EditorView lifecycle (Impact: CRITICAL) + +**Проблема:** План (секция 6.5) предлагает `Map` + CSS show/hide (`display: none/block`). При 20+ табах это 20 живых EditorView в DOM: +- DocumentTree ~2x размер файла +- DOM MutationObserver, ResizeObserver, event listeners на каждом +- Incremental parse tree языкового парсера +- 1MB файл = ~15-25MB RAM на EditorView +- 20 табов x 500KB = ~400-500MB RAM + +**В проекте сейчас:** CodeMirrorDiffView.tsx (строки 694-717) корректно вызывает `view.destroy()` в cleanup. MembersJsonEditor.tsx (строки 68-71) аналогично. Оба пересоздают EditorView, НЕ скрывают. + +**Решение (ОБЯЗАТЕЛЬНАЯ замена):** EditorState pooling + single EditorView: +``` +1. Map в useRef (НЕ EditorView, НЕ Zustand) +2. Один активный EditorView на весь редактор +3. Переключение таба: + a. savedStates.set(oldTabId, view.state) // undo, cursor, selection + b. currentView.destroy() + c. new EditorView({ state: savedStates.get(newTabId), parent: container }) +4. Закрытие таба: savedStates.delete(tabId) +5. Паттерн initialState уже есть в CodeMirrorDiffView (строка 699-705) +``` + +Память: EditorState ~1.5x документа (JS only) vs EditorView ~10-15x (DOM). Экономия ~8-12x. + +LRU при >30 states: вытеснять oldest, сохраняя `doc.toString()` + cursor (без undo). + +**Benchmark:** 25 файлов x 200KB. `performance.memory.usedJSHeapSize`: CSS hide ~500MB vs pooling ~80-120MB. + +--- + +### 19.2 CSS show/hide vs re-mount (Impact: CRITICAL) + +**Проблема (секция 6.5):** "show/hide через CSS" -- неправильно: +- 20 EditorView = огромный DOM tree +- `display: none` НЕ отключает observers +- requestMeasure() продолжает вызываться +- При `display: block` -- пересчёт высот строк (LAG) + +Re-mount из EditorState: 100KB файл ~3-5ms, undo сохраняется, scroll восстанавливается через `EditorView.scrollIntoView(pos)`. + +**Решение:** Заменить секцию 6.5: +``` +1. Один EditorView, один DOM-контейнер, один активный файл +2. Map в useRef +3. save state -> destroy -> new view from saved state +4. Dirty flag через debounced updateListener (300ms) +5. LRU eviction при > 30 states +``` + +--- + +### 19.3 IPC Bottlenecks -- readDir/readFile (Impact: HIGH) + +**Проблема:** readDir 10,000+ файлов: JSON 500KB-2MB, main thread 50-200ms. readFile 5MB: structured clone ~30-100ms. + +**A. readDir -- усиленный lazy loading:** +- Только root level при открытии +- expand -> readDir(path, depth=1) +- MAX_ENTRIES_PER_DIR = 500 (не 10,000) +- \>500: "N more files..." + "Show all" +- Prefetch при hover (debounced 200ms) + +**B. readFile -- тиерная стратегия:** +- <256KB: мгновенно +- 256KB-2MB: progress indicator +- 2MB-5MB: preview (100 строк + warning) +- \>5MB: external editor (shell:openPath) + +**C. Main process:** AbortSignal, concurrency limit=3, дедупликация. + +**Benchmark:** 5000 файлов -> дерево < 200ms. + +--- + +### 19.4 React Re-renders -- keystroke storm (Impact: HIGH) + +**Проблема:** `editorModifiedContents: Record` -- каждый keystroke -> set() -> новый объект -> все подписчики рендерятся. + +**Решение -- НЕ хранить content в Zustand:** +``` +// Контент ТОЛЬКО в EditorState CodeMirror +// Zustand: editorModifiedFiles: Set // только dirty flags +// save: savedEditorStates.get(path)?.doc.toString() +``` + +0 keystroke re-renders. Dirty flag debounced 300ms (паттерн из CodeMirrorDiffView строки 517-527). + +Гранулярные селекторы: +```typescript +const tabList = useStore(s => s.editorOpenTabs, shallow); +const activeId = useStore(s => s.editorActiveTabId); +``` + +**Benchmark:** React DevTools Profiler. FileTreePanel/TabBar НЕ рендерятся при наборе. + +--- + +### 19.5 File Tree -- виртуализация (Impact: HIGH) + +**Проблема:** 5000+ рекурсивных FileTreeNode = 200-500ms render. + +**Фаза 1 (итерации 1-2):** Lazy loading + MAX_VISIBLE_NODES=1000 + auto-collapse. + +**Фаза 2 (итерация 4):** @tanstack/react-virtual (уже в проекте -- DateGroupedSessions, ChatHistory, NotificationsView): +``` +flattenTree(tree, expandedDirs) -> FlatNode[] +useVirtualizer({ count, estimateSize: () => 28 }) +``` + +**Benchmark:** lodash src, все папки раскрыты. FPS скролла через Chrome DevTools. + +--- + +### 19.6 Large Files -- минификация (Impact: MEDIUM) + +CM6 virtual scrolling по СТРОКАМ. Одна строка 1MB = один DOM-элемент = LAG. + +**Трёхуровневая защита:** +``` +Размер: <500KB полный | 500KB-2MB без syntax | 2MB-5MB read-only | >5MB external +Строки: >10,000 chars -> banner "Minified" + Pretty-print/lineWrapping +Binary: null bytes в первых 8KB или расширение (.png, .wasm) +``` + +--- + +### 19.7 Concurrent Operations (Impact: MEDIUM) + +10 быстрых кликов = 10 параллельных readFile. + +**Решение:** Дедупликация через `Map` + concurrency limit=3 в main process. + +--- + +### 19.8 File Watcher (Impact: MEDIUM) + +Проект использует `fs.watch({ recursive: true })`, не chokidar. Electron 40/Node 20+ OK. + +**Решение:** fs.watch + фильтр (node_modules/.git/dist) + debounce 200ms + **opt-in** (ручной F5 по умолчанию) + cleanup. + +--- + +### 19.9 Bundle Size (Impact: LOW) + +Все CM6 пакеты установлены. Нужен только `@codemirror/search` (~15KB gzipped). Незначительно. + +--- + +### Сводная таблица + +| # | Проблема | Impact | Итерация | Статус в плане | +|---|---------|--------|----------|---------------| +| 19.1 | EditorView memory 20+ табов | **CRITICAL** | 1 | НЕВЕРНО -- EditorState pooling | +| 19.2 | CSS show/hide vs re-mount | **CRITICAL** | 1 | НЕВЕРНО -- single EditorView | +| 19.3 | IPC readDir/readFile | **HIGH** | 1 | Частично -- тиеры + очередь | +| 19.4 | Zustand keystroke re-renders | **HIGH** | 2 | НЕ покрыт -- content вне store | +| 19.5 | FileTree без виртуализации | **HIGH** | 4 | НЕ покрыт -- react-virtual | +| 19.6 | Минификация/длинные строки | **MEDIUM** | 1 | Частично -- 3 уровня | +| 19.7 | Concurrent readFile | **MEDIUM** | 1 | НЕ покрыт -- дедупликация | +| 19.8 | fs.watch overhead | **MEDIUM** | 5 | OK, но opt-in | +| 19.9 | Bundle size | **LOW** | 1 | OK | + +--- diff --git a/docs/iterations/edit-project/plan-iterations.md b/docs/iterations/edit-project/plan-iterations.md new file mode 100644 index 00000000..258a99a0 --- /dev/null +++ b/docs/iterations/edit-project/plan-iterations.md @@ -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()` для IpcResult. Ближайший пример: `review.ts`. + +5. **Preload bridge**: `invokeIpcWithResult()` для 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` + CSS show/hide (как описано в plan-architecture секция 6.5). Использовать **EditorState pooling**: `Map` в 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>` для readFile. Если файл уже загружается -- ждать результат, не создавать новый запрос. + +### Итерация 2: Performance-critical + +1. **НЕ хранить modified content в Zustand (CRITICAL):** `editorModifiedContents: Record` из секции 2.1 plan-architecture -- УБРАТЬ. Контент живёт только в EditorState CodeMirror. В Zustand: `editorModifiedFiles: Set` (только 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 раз +``` + +--- diff --git a/docs/iterations/edit-project/plan-reuse-analysis.md b/docs/iterations/edit-project/plan-reuse-analysis.md new file mode 100644 index 00000000..76226588 --- /dev/null +++ b/docs/iterations/edit-project/plan-reuse-analysis.md @@ -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; + selectItem: (id: string | null) => void; +} + +export const createSomeSlice: StateCreator = (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; // Файлы с несохранёнными изменениями + editorError: string | null; + + // File content cache (path -> content) + editorFileContents: Record; + editorFileContentsLoading: Record; + + // Actions + openEditor: (projectPath: string) => Promise; + closeEditor: () => void; + loadFileTree: (dirPath: string) => Promise; + openFile: (filePath: string) => Promise; + closeFile: (filePath: string) => void; + setActiveFile: (filePath: string) => void; + updateFileContent: (filePath: string, content: string) => void; + saveFile: (filePath: string) => Promise; + saveAllDirty: () => Promise; +} +``` + +**Важно:** Следовать правилу из 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` -- кеш содержимого файлов +- `fileContentsLoading: Record` -- состояние загрузки per-file +- `editedContents: Record` -- несохранённые изменения +- `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` -- весь контент файла в памяти 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()` паттерн для 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()` | `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 = { name, fullPath, isFile, data?: T, children } + - export function buildTree(items: T[], getRelativePath: (item: T) => string): TreeNode[] + - export function sortTreeNodes(nodes: TreeNode[]): TreeNode[] + +src/renderer/components/common/FileTree.tsx: + - Generic FileTree 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 + - Provides renderNodeExtra with FileStatusIcon + +/- lines + - Reads hunkDecisions/fileDecisions from store + +src/renderer/components/team/editor/EditorFileTree.tsx: + - Thin wrapper around FileTree + - 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"` на корневой `
    ` +- `role="treeitem"` + `aria-expanded` на каждой папке +- `role="group"` на вложенных `
      ` +- `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> { + 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` для всех видимых файлов в continuous scroll view. Это допустимо для review (10-50 файлов одновременно), но **НЕДОПУСТИМО** для editor с 20+ табами. + +Для editor использовать **EditorState pooling**: +```typescript +// ПРАВИЛЬНО для editor: +const stateCache = useRef(new Map()); +const viewRef = useRef(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`** (строка 74) хранит полный текст каждого редактированного файла в Zustand. В review это терпимо (изменения применяются и сбрасываются). Для editor каждый keystroke вызывает `set()` с новым Record -- все Zustand-подписчики перерисовываются. + +Для editor **контент живёт только в EditorState**, не в Zustand. В store хранить: +```typescript +editorModifiedFiles: Set // 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). diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 20c56752..942a9e38 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -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; }); } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 1c6a45a1..c92eb224 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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> { + 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( diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 74cb5f60..ad268f4e 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -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; } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 8cea6a4c..95d39107 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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 { + await this.taskWriter.updateFields(teamName, taskId, fields); + } + async setTaskNeedsClarification( teamName: string, taskId: string, diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index 9f1eb4e8..94e9b9ba 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -190,6 +190,35 @@ export class TeamTaskWriter { }); } + async updateFields( + teamName: string, + taskId: string, + fields: { subject?: string; description?: string } + ): Promise { + 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, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 19042fdc..d44ef5e0 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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'; diff --git a/src/preload/index.ts b/src/preload/index.ts index d3a96086..0abdc76b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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(TEAM_UPDATE_TASK_OWNER, teamName, taskId, owner); }, + updateTaskFields: async ( + teamName: string, + taskId: string, + fields: { subject?: string; description?: string } + ) => { + return invokeIpcWithResult(TEAM_UPDATE_TASK_FIELDS, teamName, taskId, fields); + }, startTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult<{ notifiedOwner: boolean }>(TEAM_START_TASK, teamName, taskId); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index aaaececb..fe7a14a6 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -703,6 +703,13 @@ export class HttpAPIClient implements ElectronAPI { ): Promise => { 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 => { + 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'); }, diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 9aea9e3e..17be5289 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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: { diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 0ebf0cca..9af73cca 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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 = ({ {headerExtra ?
      {headerExtra}
      : null}
- {currentTask.subject} + {editingSubject ? ( +
+ 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 ? : null} +
+ ) : ( + + {currentTask.subject} + + + )} {currentTask.activeForm ? ( {currentTask.activeForm} ) : null} @@ -317,12 +414,106 @@ export const TaskDetailDialog = ({ {/* Description */} } defaultOpen> - {currentTask.description ? ( -
+ {editingDescription ? ( +
+
+ + +
+ {descriptionPreview ? ( +
+ {descriptionDraft.trim() ? ( + + ) : ( +

Nothing to preview

+ )} +
+ ) : ( +