agent-ecosystem/docs/iterations/edit-project/plan-architecture.md
iliya 7192ca68fb feat: enhance in-app project editor with architecture documentation and service updates
- Added detailed architecture and component hierarchy documentation for the in-app project editor.
- Introduced `ProjectFileService` to manage file operations with improved path validation.
- Updated `electron.vite.config.ts` to set `UV_THREADPOOL_SIZE` for better performance on Windows.
- Deferred non-critical startup tasks in `index.ts` to avoid thread pool contention.
- Enhanced `CliInstallerService` with timeout handling for status gathering to prevent UI hangs.
- Added tests for `CliInstallerService` to ensure proper timeout behavior.
2026-02-28 12:58:20 +02:00

1295 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Архитектурный план: 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: ProjectFileService │
│ (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)
Вместо `<Dialog>` от Radix используем raw `<div className="fixed inset-0 z-50">` -- точная копия паттерна из `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<string, string> // filePath → content (read-only cache)
editorFileContentsLoading: Record<string, boolean>
// ПЕРЕСМОТРЕНО (Performance Review 19.4): НЕ хранить modified content здесь!
// Контент живёт в EditorState (Map<tabId, EditorState> в useRef).
// Вместо Record<string, string> использовать Set<string> для dirty flags:
editorModifiedFiles: Set<string> // filePath set — dirty markers only
editorSaving: Record<string, boolean> // filePath → saving in progress
editorSaveError: Record<string, string> // filePath → save error
// --- Действия ---
openEditor: (projectPath: string) => Promise<void>
closeEditor: () => void
loadFileTree: (dirPath: string) => Promise<void>
expandDirectory: (dirPath: string) => Promise<void>
openFile: (filePath: string) => Promise<void>
closeTab: (tabId: string) => void
setActiveTab: (tabId: string) => void
updateContent: (filePath: string, content: string) => void
saveFile: (filePath: string) => Promise<void>
saveAllFiles: () => Promise<void>
discardChanges: (filePath: string) => void
createFile: (parentDir: string, name: string) => Promise<void>
deleteFile: (filePath: string) => Promise<void>
createDirectory: (parentDir: string, name: string) => Promise<void>
}
```
### 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<T>()`.
---
## 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<ReadDirResult>
readFile(projectRoot: string, filePath: string): Promise<ReadFileResult>
writeFile(projectRoot: string, filePath: string, content: string): Promise<void>
createFile(projectRoot: string, parentDir: string, name: string, content?: string): Promise<void>
deleteFile(projectRoot: string, filePath: string): Promise<void>
createDir(projectRoot: string, parentDir: string, name: string): Promise<void>
rename(projectRoot: string, oldPath: string, newPath: string): Promise<void>
fileExists(projectRoot: string, filePath: string): Promise<boolean>
}
```
### 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<string>` -- хранить в 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<tabId, EditorState>` в 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<string, string> // per-file
```
### 8.3 Main process
`wrapEditorHandler<T>()` -- ловит все исключения, возвращает `IpcResult<T>`.
### 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: ProjectFileService.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: ProjectFileService.readFile() → валидация → fs.readFile
5. Результат: ReadFileResult { content, size, truncated }
6. set({ editorFileContents[filePath]: content })
7. CM EditorState создаётся, единственный EditorView пересоздаётся
```
### 10.3 Сохранение файла
```
1. Юзер нажимает Cmd+S (или кнопку Save)
2. editorSlice.saveFile(filePath)
3. content = EditorState (из useRef Map) ?? editorFileContents[filePath]
4. set({ editorSaving[filePath]: true })
5. IPC: editor:writeFile(filePath, content)
6. Main: ProjectFileService.writeFile() → валидация → fs.writeFile (atomic via tmp+rename)
7. set({ editorSaving: false, editorModifiedFiles: 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<T extends { name: string; fullPath: string; isFile: boolean }> {
nodes: T[];
activeNodePath: string | null;
onNodeClick: (node: T) => void;
renderNodeExtra?: (node: T) => React.ReactNode; // Правая часть (статус/кол-во строк)
renderNodeIcon?: (node: T) => React.ReactNode; // Иконка слева от имени
collapsedFolders: Set<string>;
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<T>(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<T>()` в `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<T>(op: string, fn: () => Promise<T>): Promise<IpcResult<T>> {
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)
**Уязвимость**: Имя `<script>alert(1)</script>.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<T>
[ ] 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<tabId, EditorView>` + 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<tabId, EditorState> в 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<tabId, EditorState> в 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<string, string>` -- каждый keystroke -> set() -> новый объект -> все подписчики рендерятся.
**Решение -- НЕ хранить content в Zustand:**
```
// Контент ТОЛЬКО в EditorState CodeMirror
// Zustand: editorModifiedFiles: Set<string> // только 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<string, Promise>` + 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 |
---