- 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.
68 KiB
Архитектурный план: 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
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)
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):
- Symlink-проверка для readDir: при рекурсивном обходе каждый entry может быть symlink. Нужно
fs.lstat()+fs.realpath()для каждого entry, проверяя что target внутри projectRoot. - Валидация имён файлов при создании: запрет NUL bytes, запрет
./..как имени, запрет/и\в имени, максимальная длина 255 символов. - TOCTOU mitigation: использовать
O_NOFOLLOWпри открытии файлов или проверять послеopen()черезfstat(), что дескриптор указывает на файл внутри projectRoot. - Запрет записи в .git/: добавить
.gitв список запрещённых для записи директорий (чтение можно разрешить для отображения, но НЕ запись).
4.3 Файловые лимиты и защита от DoS
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на открытый tabEditorViewна ВЕСЬ редактор (активный файл)- При закрытии 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(actionsloadFileTree,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):
// Общий 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, а не дублировать поля:
// 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):
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 будет реализовывать аналогичную, но с другим источником данных.
Решение (обязательное):
- Извлечь generic
buildTree<T>(items, getPath, isFile)вsrc/renderer/utils/fileTreeBuilder.ts - Сортировка (dirs first, alphabetical) тоже в
fileTreeBuilder.ts ReviewFileTree+EditorFileTreeиспользуют одну и ту же функцию
Проблема 2: Тема CodeMirror -- частичное дублирование с diffTheme.
~50% стилей diffTheme (&, .cm-gutters, .cm-scroller, .cm-content, .cm-cursor, .cm-selectionBackground) идентичны.
Решение:
// 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:
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:
- Показать
confirm()(существующийConfirmDialog): "You have unsaved changes in N files." - Три кнопки: Save All & Close, Discard & Close, Cancel
Escapeвнутри confirm = Cancel (возврат к редактору)
Добавить в editorSlice:
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+titletooltip - Табы: 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+Btoggle 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 -- добавить:
EditorStatusBar.tsx,EditorBinaryState.tsx - Секция 2.1 -- добавить
hasUnsavedChangescomputed getter - Секция 2.3 -- добавить
disambiguatedLabel?: string - Секция 7.2 -- добавить "Close overlay with unsaved changes" (три кнопки)
- Секция 9.2 -- добавить
isBinaryвReadFileResult - Секция 11 --
Cmd+[/]->Cmd+Shift+[/]; добавитьCmd+B,Cmd+G - Секция 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).
Гранулярные селекторы:
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 |