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.
This commit is contained in:
iliya 2026-02-28 12:58:20 +02:00
parent 0c2f70b2b2
commit 7192ca68fb
9 changed files with 2445 additions and 53 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
# In-App Code Editor — План реализации
## Обзор
На странице `TeamDetailView` рядом с путём проекта (`data.config.projectPath`) добавляется кнопка "Open in Editor", открывающая полноэкранный редактор кода прямо внутри приложения. Редактор позволяет просматривать файловое дерево проекта, открывать файлы во вкладках с подсветкой синтаксиса, редактировать и сохранять их, создавать/удалять файлы, искать по содержимому, и отображать git-статусы.
## Tech Stack
- **Editor engine**: CodeMirror 6 (20+ пакетов `@codemirror/*` уже в `package.json`, 16 языковых пакетов)
- **Не ProseMirror**: ProseMirror -- rich-text WYSIWYG, CodeMirror -- код-редактор. Один автор (Marijn Haverbeke), CM6 уже глубоко интегрирован
- **UI**: React 18, Tailwind CSS, lucide-react иконки, Radix UI (контекстное меню, confirm dialog)
- **State**: Zustand slice (`editorSlice.ts`)
- **Виртуализация**: `@tanstack/react-virtual` (уже в проекте)
- **Fuzzy search**: `cmdk` v1.0.4 (уже в зависимостях)
- **Новые npm-зависимости**: `@codemirror/search` (~15KB gzipped) — для встроенного Cmd+F поиска в файле. Остальное уже установлено
## Ключевые архитектурные решения
| Решение | Обоснование |
|---------|-------------|
| `ProjectFileService` (не `FileEditorService`) | Лучше отражает scope; аналог `TeamDataService` |
| Stateless сервис (без `rootPath` в конструкторе) | Каждый метод принимает `projectRoot`; не привязан к одному проекту |
| EditorState pooling (не CSS show/hide) | Один EditorView + `Map<tabId, EditorState>` в useRef; экономия RAM ~8-12x |
| `editorModifiedFiles: Set<string>` (не `Record<string, string>`) | Контент живёт только в CM6 EditorState; 0 re-render при наборе текста |
| `validateFilePath()` из `pathValidation.ts` (не свой `assertInsideRoot`) | Уже проверяет traversal, symlinks, sensitive patterns, cross-platform |
| `projectRoot` в module-level state (не от renderer) | Фиксируется при `editor:open`; IPC handlers берут из state |
| Overlay вместо Radix Dialog | Radix Dialog ограничивает фокус, конфликтует с CM6 |
## Навигация по плану
| Файл | Содержимое |
|------|------------|
| [architecture.md](architecture.md) | Архитектура, безопасность, state, IPC API, сервисы, компоненты, CM6, shortcuts, CSS |
| [iter-0-refactoring.md](iter-0-refactoring.md) | PR 0: Обязательные рефакторинги R1-R4 (отдельный PR) |
| [iter-1-walking-skeleton.md](iter-1-walking-skeleton.md) | Итерация 1: Read-only файловый браузер |
| [iter-2-editable-save.md](iter-2-editable-save.md) | Итерация 2: Editable CodeMirror + сохранение |
| [iter-3-multi-tab-crud.md](iter-3-multi-tab-crud.md) | Итерация 3: Multi-tab + создание/удаление файлов |
| [iter-4-search-shortcuts.md](iter-4-search-shortcuts.md) | Итерация 4: Горячие клавиши, поиск, UX polish |
| [iter-5-git-watching.md](iter-5-git-watching.md) | Итерация 5: Git status, file watching, conflict detection |
| [file-list.md](file-list.md) | Риски, бенчмарки, полный список файлов |
## Общая статистика
- **Новые файлы**: ~30
- **Модификации**: ~17 существующих файлов
- **Тесты**: ~15 новых тестовых файлов
- **Итерации**: 6 (PR 0 + 5 итераций)

View file

@ -0,0 +1,822 @@
# Архитектура
## Архитектурная диаграмма
```
┌─────────────────────────────────────────────┐
│ TeamDetailView.tsx │
│ [FolderOpen icon] [Edit button] ◄──────────┤ Кнопка запуска
└──────────────────┬──────────────────────────┘
│ open={true}
┌──────────────────▼──────────────────────────┐
│ ProjectEditorOverlay (fixed inset-0) │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ EditorFile- │ │ EditorTabBar │ │
│ │ Tree │ │ ┌────────────────┐ │ │
│ │ (generic │ │ │ CodeMirrorEditor│ │ │
│ │ FileTree │ │ │ (single View, │ │ │
│ │ + render- │ │ │ pooled States) │ │ │
│ │ props) │ │ └────────────────┘ │ │
│ └──────────────┘ │ EditorStatusBar │ │
│ └──────────────────────┘ │
└──────────────────┬──────────────────────────┘
│ IPC (invokeIpcWithResult)
┌──────────────────▼──────────────────────────┐
│ Preload Bridge │
│ editor: { readDir, readFile, writeFile, │
│ createFile, deleteFile, createDir, │
│ searchInFiles, gitStatus } │
└──────────────────┬──────────────────────────┘
┌──────────────────▼──────────────────────────┐
│ Main Process: editor.ts (IPC handlers) │
│ activeProjectRoot (module-level state) │
│ wrapHandler() из ipcWrapper.ts │
│ │
│ ┌────────────────────────────────────┐ │
│ │ ProjectFileService (stateless) │ │
│ │ validateFilePath() на КАЖДЫЙ вызов │ │
│ │ fs.readdir / readFile / writeFile │ │
│ │ atomic write (tmp + rename) │ │
│ └────────────────────────────────────┘ │
│ ┌────────────────────────────────────┐ │
│ │ FileSearchService (итерация 4) │ │
│ │ GitStatusService (итерация 5) │ │
│ │ EditorFileWatcher (итерация 5) │ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
## Компонентная иерархия
```
src/renderer/components/team/editor/
├── ProjectEditorOverlay.tsx # Полноэкранный overlay (max 150 LOC)
├── EditorFileTree.tsx # Обёртка над generic FileTree (max 200 LOC)
├── EditorTabBar.tsx # Панель вкладок (max 100 LOC)
├── CodeMirrorEditor.tsx # CM6 wrapper: lifecycle + EditorState pooling (max 150 LOC)
├── EditorToolbar.tsx # Save, Undo, Redo, язык (max 100 LOC)
├── EditorStatusBar.tsx # Ln:Col, язык, отступы, кодировка (max 80 LOC)
├── EditorContextMenu.tsx # Context menu для дерева файлов (итерация 3)
├── NewFileDialog.tsx # Inline-input для имени нового файла (итерация 3)
├── QuickOpenDialog.tsx # Cmd+P fuzzy search (итерация 4)
├── SearchInFilesPanel.tsx # Cmd+Shift+F результаты (итерация 4)
├── EditorBreadcrumb.tsx # Breadcrumb навигация (итерация 4)
├── EditorEmptyState.tsx # Нет открытых файлов + shortcuts шпаргалка
├── EditorBinaryState.tsx # Заглушка для бинарных файлов
├── EditorErrorState.tsx # Заглушка для ошибок чтения (EACCES, ENOENT)
├── EditorShortcutsHelp.tsx # Модальное окно shortcuts (кнопка ?)
└── GitStatusBadge.tsx # M/U/A бейджи в дереве (итерация 5)
src/renderer/components/common/
└── FileTree.tsx # Generic FileTree<T> с render-props (рефакторинг из ReviewFileTree)
```
## Слои и направление зависимостей
```
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)
```
Обратных зависимостей нет. Каждый слой зависит только от нижнего.
---
## Безопасность
Каждый IPC handler, работающий с файловой системой, ОБЯЗАН выполнять полный набор проверок. Ниже -- чеклист для каждого handler и описание конкретных уязвимостей.
### Обязательный чеклист для каждого IPC handler
```
[ ] projectRoot из module-level state, НЕ из параметров renderer (SEC-5)
[ ] validateFilePath(path, projectRoot) ДО файловой операции (SEC-1) — кроме readDir (см. ниже)
[ ] Для WRITE-операций (writeFile, createFile, createDir, deleteFile): ДОПОЛНИТЕЛЬНО проверить `isPathWithinRoot(normalizedPath, activeProjectRoot)` ПОСЛЕ `validateFilePath()`. Причина: `validateFilePath()` считает `~/.claude` разрешённой директорией (для read-use-case review.ts), но editor НЕ должен записывать за пределы проекта (SEC-14)
[ ] Для readDir: containment через `isPathWithinAllowedDirectories()`, НЕ `validateFilePath()`. Sensitive файлы помечаются `isSensitive: true`, но НЕ фильтруются. Symlinks: `realpath()` + re-check containment (SEC-2, SEC-6)
[ ] fs.lstat() + isFile()/isDirectory() перед чтением (SEC-4)
[ ] stats.size <= MAX_FILE_SIZE_FULL (2MB) для полной загрузки; <= MAX_FILE_SIZE_PREVIEW (5MB) для preview (SEC-4)
[ ] Buffer.byteLength(content) <= MAX_WRITE_SIZE (2MB) перед записью
[ ] Device paths (/dev/, /proc/, /sys/) блокируются (SEC-4)
[ ] Запись в .git/ запрещена (SEC-12)
[ ] Post-read realpath verify -- TOCTOU mitigation (SEC-3)
[ ] Atomic write через tmp + rename (SEC-9)
[ ] Для rename (если добавлен): ОБА пути валидируются (SEC-10) -- НЕ в MVP
[ ] validateFileName() при создании файлов (SEC-7)
[ ] Только literal search в searchInFiles, НЕ regex (SEC-8)
[ ] Логирование через createLogger('IPC:editor')
[ ] Обёртка в wrapHandler -> IpcResult<T>
```
### Конкретные уязвимости и их решения
| ID | Уязвимость | Критичность | Решение |
|----|-----------|-------------|---------|
| SEC-1 | Path traversal через IPC | Critical | `validateFilePath()` из `pathValidation.ts` на каждом handler. Для `rename` -- оба пути |
| SEC-2 | Symlink escape в readDir | Critical | `entry.isSymbolicLink()` -> `fs.realpath()` -> `validateFilePath()`. Молча пропускать symlinks за пределами |
| SEC-3 | TOCTOU race condition | High | Post-read: `fs.realpath()` + повторная `validateFilePath()`. Write: atomic tmp + rename |
| SEC-4 | File size / device DoS | High | `fs.lstat()` + `isFile()` до чтения. Block `/dev/`, `/proc/`, `/sys/`. Лимит 2MB |
| SEC-5 | projectRoot от renderer | High | Module-level `let activeProjectRoot` в `editor.ts`. Устанавливается через `editor:open` |
| SEC-6 | Credential leakage | Medium | `validateFilePath()` блокирует read. В дереве: иконка замка, "Sensitive file" при клике |
| SEC-7 | XSS через имена файлов | Medium | React JSX экранирует. `validateFileName()` при создании: запрет control chars, path separators, NUL, `..`, длина > 255 |
| SEC-8 | ReDoS в searchInFiles | Medium | Только literal string search. Max 1000 файлов, max 1MB на файл |
| SEC-9 | Non-atomic write | Medium | Переиспользовать `atomicWriteAsync()` из `team/atomicWrite.ts` (randomUUID, fsync, EXDEV fallback, mkdir). Перемещается в `src/main/utils/atomicWrite.ts` |
| SEC-10 | rename двойная валидация | High | Валидировать оба пути + проверить что newPath не существует. **НЕ в MVP** -- rename убран из ProjectFileService |
| SEC-12 | Запись в .git/ | Medium | Проверка `isGitInternalPath()` в writeFile/createFile/rename |
| SEC-13 | IPC rate limiting | Low | Debounce на renderer + max 100 вызовов/секунду на main. AbortController |
| SEC-14 | `validateFilePath()` allows `~/.claude` writes | High | `validateFilePath()` считает `~/.claude/**` разрешённой директорией (линия 112: `isPathWithinRoot(target, claudeDir) → true`). Для read — ОК (review.ts). Для editor write — НЕТ: без дополнительной проверки editor может перезаписать `~/.claude/settings.json`, `teams/*/config.json` и др. Решение: в КАЖДОМ write-handler ПОСЛЕ `validateFilePath()` добавить `isPathWithinRoot(validation.normalizedPath!, activeProjectRoot)`. Если false — throw |
| SEC-15 | `editor:open` projectPath validation | Medium | `editor:open` принимает `projectPath` от renderer без валидации. Злонамеренный renderer может передать `"/"`, делая все пути валидными. Решение: validate при `editor:open``path.isAbsolute()`, `fs.stat().isDirectory()`, `!== '/'`, `!isPathWithinRoot(path, claudeDir)` |
### SEC-11: ИСПРАВЛЕНО (hotfix применён)
`handleSaveEditedFile` в `src/main/ipc/review.ts` ранее принимал `filePath` от renderer без валидации. **Hotfix уже применён**: добавлен `validateFilePath(filePath, null)` с проверкой перед записью, блокировкой недопустимых путей и логированием отказов. Патч также инвалидирует кеш `FileContentResolver` после сохранения.
### Новые security-утилиты (добавить в `src/main/utils/`)
| Утилита | Файл | Назначение |
|---------|------|------------|
| `validateFileName(name)` | `pathValidation.ts` | Запрет `.`, `..`, control chars, path separators, NUL, length > 255 |
| `isDevicePath(path)` | `pathValidation.ts` | Проверка `/dev/`, `/proc/`, `/sys/`, `\\\\.\\` |
| `isGitInternalPath(path)` | `pathValidation.ts` | Проверка `.git/` в пути (запрет записи, не чтения) |
| `atomicWriteAsync(path, content)` | `atomicWrite.ts` | **Перемещение** из `src/main/services/team/atomicWrite.ts`НЕ писать заново. Уже имеет randomUUID, fsync, EXDEV fallback |
### Паттерн IPC handler (обязательный)
```typescript
// src/main/ipc/editor.ts
let activeProjectRoot: string | null = null;
async function handleEditorReadFile(
_event: IpcMainInvokeEvent,
filePath: string
): Promise<IpcResult<ReadFileResult>> {
return wrapHandler('readFile', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
// 1. Path validation (traversal, sensitive, symlink)
const validation = validateFilePath(filePath, activeProjectRoot);
if (!validation.valid) throw new Error(validation.error!);
// 1b. Project-only containment (SEC-14: block ~/.claude writes)
// ОБЯЗАТЕЛЬНО для write-handlers (writeFile, createFile, createDir, deleteFile)
// Для read-handlers (readFile, readDir) — не нужно (validateFilePath достаточно)
// if (!isPathWithinRoot(validation.normalizedPath!, activeProjectRoot)) {
// throw new Error('Path is outside project root');
// }
// 2. Device path block
if (isDevicePath(validation.normalizedPath!)) throw new Error('Device files blocked');
// 3. File type check
const stats = await fs.lstat(validation.normalizedPath!);
if (!stats.isFile()) throw new Error('Not a regular file');
// 4. Size check
if (stats.size > MAX_FILE_SIZE) throw new Error('File too large');
// 5. Binary check
const isBinary = await detectBinary(validation.normalizedPath!);
// 6. Read
const content = isBinary ? '' : await fs.readFile(validation.normalizedPath!, 'utf8');
// 7. Post-read TOCTOU verify
const realPath = await fs.realpath(validation.normalizedPath!);
const postValidation = validateFilePath(realPath, activeProjectRoot);
if (!postValidation.valid) throw new Error('Path changed during read');
return { content, size: stats.size, truncated: false, encoding: 'utf-8', isBinary };
});
}
```
---
## State Management
### Zustand slice: `editorSlice.ts`
Минимальный slice с Группой 1 создаётся на итерации 1. Группы 2-4 добавляются на итерациях 2-3.
Slice разбит на 4 логические группы:
```typescript
export interface EditorSlice {
// ═══════════════════════════════════════════════════
// Группа 1: File tree state + actions
// ═══════════════════════════════════════════════════
editorProjectPath: string | null;
editorFileTree: FileTreeEntry | null;
editorFileTreeLoading: boolean;
editorFileTreeError: string | null;
openEditor: (projectPath: string) => Promise<void>;
closeEditor: () => void;
// closeEditor() выполняет полный cleanup:
// try {
// 1. IPC editor:close → сброс activeProjectRoot + остановка watcher (best-effort)
// } catch (e) { console.error('editor:close failed', e); }
// finally {
// // ВСЕГДА выполняется, даже если IPC упал:
// 2. stateCache.current.clear() — освободить все EditorState из Map
// 3. scrollTopCache.current.clear() — освободить scroll positions
// 4. viewRef.current?.destroy() — уничтожить активный EditorView
// 5. Сброс slice state: tabs=[], tree=null, modified=Set(), loading={}, errors={}
// }
loadFileTree: (dirPath: string) => Promise<void>;
expandDirectory: (dirPath: string) => Promise<void>;
// ═══════════════════════════════════════════════════
// Группа 2: Tab management
// ═══════════════════════════════════════════════════
editorOpenTabs: EditorFileTab[];
editorActiveTabId: string | null;
openFile: (filePath: string) => Promise<void>;
closeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void;
// ═══════════════════════════════════════════════════
// Группа 3: Content + Save
// ВАЖНО: Контент НЕ хранится в store!
// Контент живёт в EditorState (Map<tabId, EditorState> в useRef).
// В store -- только dirty flags, loading и статусы сохранения.
// ═══════════════════════════════════════════════════
editorFileLoading: Record<string, boolean>; // per-file loading indicator
editorModifiedFiles: Set<string>; // dirty markers (НЕ содержимое!)
editorSaving: Record<string, boolean>;
editorSaveError: Record<string, string>;
markFileModified: (filePath: string) => void; // debounced, 300ms
markFileSaved: (filePath: string) => void;
saveFile: (filePath: string, content: string) => Promise<void>;
// Компонент CodeMirrorEditor вызывает: saveFile(filePath, viewRef.current.state.doc.toString())
// Store НЕ обращается к useRef — контент передаётся как аргумент при вызове
saveAllFiles: (getContent: (filePath: string) => string | null) => Promise<void>;
// CodeMirrorEditor передаёт callback: saveAllFiles((fp) => stateCache.current.get(fp)?.doc.toString() ?? null)
discardChanges: (filePath: string) => void;
hasUnsavedChanges: () => boolean; // derived getter
// ═══════════════════════════════════════════════════
// Группа 4: File operations (итерация 3)
// ═══════════════════════════════════════════════════
createFile: (parentDir: string, name: string) => Promise<void>;
deleteFile: (filePath: string) => Promise<void>;
createDirectory: (parentDir: string, name: string) => Promise<void>;
}
```
### EditorFileTab
```typescript
interface EditorFileTab {
id: string; // = filePath (уникальный ключ)
filePath: string; // Абсолютный путь
fileName: string; // Имя файла для отображения
disambiguatedLabel?: string; // "(main/utils)" для дублей
language: string; // Определяется по расширению
}
```
### EditorState pooling (Map в useRef)
Контент файлов живёт ТОЛЬКО в CodeMirror EditorState. Один активный EditorView на весь редактор.
```typescript
// CodeMirrorEditor.tsx
const stateCache = useRef(new Map<string, EditorState>());
const scrollTopCache = useRef(new Map<string, number>()); // scroll position per tab
const viewRef = useRef<EditorView | null>(null);
// Переключение таба:
function switchTab(oldTabId: string, newTabId: string) {
// 1. Сохранить state + scroll текущего таба
if (viewRef.current) {
stateCache.current.set(oldTabId, viewRef.current.state);
scrollTopCache.current.set(oldTabId, viewRef.current.scrollDOM.scrollTop);
viewRef.current.destroy();
}
// 2. Восстановить или создать state нового таба
const existingState = stateCache.current.get(newTabId);
viewRef.current = new EditorView({
state: existingState ?? EditorState.create({ doc: content, extensions }),
parent: containerRef.current!,
});
// 3. Восстановить scroll position (EditorState не хранит scrollTop — это свойство DOM)
const savedScrollTop = scrollTopCache.current.get(newTabId);
if (savedScrollTop !== undefined) {
requestAnimationFrame(() => {
viewRef.current?.scrollDOM.scrollTop = savedScrollTop;
});
}
}
// LRU eviction при > 30 states:
if (stateCache.current.size > 30) {
// Вытеснить oldest, сохранив { content: doc.toString(), cursorPos }
// При возврате -- восстановить через EditorState.create()
}
```
### Что в store vs что в local state
| Данные | Где хранить | Почему |
|--------|-------------|--------|
| Дерево файлов, табы, dirty flags | Zustand store | Переживает перемонтирование overlay |
| Содержимое файлов | EditorState (useRef Map) | Без re-render при наборе |
| Scroll position, resize panels | useState | Локальное UI-состояние |
| Контекстное меню state | useState | Эфемерное |
| Поисковый запрос в дереве | useState | Локальное |
| expandedDirs | Zustand store | Сохраняется при re-open |
| Sidebar width | localStorage | Persist между сессиями |
### Гранулярные Zustand-селекторы (обязательно)
```typescript
// Каждый компонент подписывается ТОЛЬКО на свои данные:
const tabList = useStore(s => s.editorOpenTabs, shallow); // TabBar
const activeId = useStore(s => s.editorActiveTabId); // CodeMirrorEditor
const treeLoading = useStore(s => s.editorFileTreeLoading); // FileTreePanel
// FileTreePanel НЕ подписывается на tabs/content
// TabBar НЕ подписывается на tree state
// CodeMirrorEditor НЕ подписывается на tree/tabs
```
---
## IPC API
### Полная таблица каналов
| Канал | Итерация | Направление | Типы запроса/ответа | Описание |
|-------|----------|-------------|---------------------|----------|
| `editor:open` | 1 | renderer -> main | `(projectPath: string)` -> `IpcResult<void>` | Инициализировать editor, установить activeProjectRoot. **Валидация projectPath (SEC-15)**: `path.isAbsolute()`, `fs.stat().isDirectory()`, `!== '/'`/`'C:\\'`, `!isPathWithinRoot(path, claudeDir)` |
| `editor:close` | 1 | renderer -> main | `()` -> `IpcResult<void>` | Cleanup: сбросить activeProjectRoot, остановить watcher (если запущен) |
| `editor:readDir` | 1 | renderer -> main | `(dirPath: string, maxEntries?: number)` -> `IpcResult<ReadDirResult>` | Чтение директории (depth=1, lazy). Default `maxEntries=500`. "Show all" вызывает с `maxEntries=10000` |
| `editor:readFile` | 1 | renderer -> main | `(filePath: string)` -> `IpcResult<ReadFileResult>` | Чтение файла с binary detection |
| `editor:writeFile` | 2 | renderer -> main | `(filePath: string, content: string)` -> `IpcResult<void>` | Atomic write (tmp + rename) |
| `editor:createFile` | 3 | renderer -> main | `(parentDir: string, name: string, content?: string)` -> `IpcResult<void>` | Создание файла с validateFileName |
| `editor:createDir` | 3 | renderer -> main | `(parentDir: string, name: string)` -> `IpcResult<void>` | Создание директории |
| `editor:deleteFile` | 3 | renderer -> main | `(filePath: string)` -> `IpcResult<void>` | Удаление через shell.trashItem() |
| `editor:searchInFiles` | 4 | renderer -> main | `(query: string, options?: { caseSensitive?: boolean })` -> `IpcResult<SearchResult[]>` | Literal search, default case-insensitive (как SessionSearcher), max 100 results. Кнопка "Aa" в UI для toggle |
| `editor:gitStatus` | 5 | renderer -> main | `()` -> `IpcResult<GitFileStatus[]>` | git status --porcelain, кеш 5 сек |
| `editor:watchDir` | 5 | renderer -> main | `()` -> `IpcResult<void>` | Запуск file watcher |
| `editor:change` | 5 | main -> renderer | event: `EditorFileChangeEvent` | Файл изменился на диске |
### Типы (src/shared/types/editor.ts)
```typescript
interface FileTreeEntry {
name: string;
path: string; // Абсолютный путь
type: 'file' | 'directory';
size?: number; // Только для файлов
isSensitive?: boolean; // true для .env, .key, credentials и т.д. — показывать с замком
children?: FileTreeEntry[];
}
interface ReadDirResult {
entries: FileTreeEntry[];
truncated: boolean; // > MAX_DIR_ENTRIES
}
interface ReadFileResult {
content: string;
size: number;
mtimeMs: number; // Unix timestamp (stats.mtimeMs) — baseline для conflict detection (итерация 5)
truncated: boolean;
encoding: string;
isBinary: boolean;
}
interface GitFileStatus {
path: string;
status: 'modified' | 'untracked' | 'staged' | 'deleted';
}
interface SearchResult {
filePath: string;
line: number;
column: number;
lineContent: string;
matchLength: number;
}
interface EditorFileChangeEvent {
type: 'change' | 'delete' | 'create';
path: string;
}
```
### API транспорт
Editor API доступен ТОЛЬКО через Electron IPC (`window.electronAPI.editor.*`). HTTP/REST endpoint НЕ требуется -- приложение не имеет standalone browser-режима. Все вызовы проходят через preload bridge (`invokeIpcWithResult`), который автоматически разворачивает `IpcResult<T>`.
### Дедупликация IPC-запросов
`Map<string, Promise<ReadFileResult>>` в renderer. Если файл уже загружается -- ждать результат, не создавать новый запрос. Invalidate при save.
---
## Main Process: ProjectFileService
Файл: `src/main/services/editor/ProjectFileService.ts`
Stateless сервис. Каждый метод принимает `projectRoot` как первый аргумент. Паттерн аналогичен `TeamDataService`.
```typescript
class ProjectFileService {
// НЕТ конструктора с rootPath
// Создаётся в module-scope editor.ts (паттерн reviewDecisionStore в review.ts)
async readDir(projectRoot: string, dirPath: string, depth?: number, maxEntries?: number): Promise<ReadDirResult>
async readFile(projectRoot: string, filePath: string): Promise<ReadFileResult>
async writeFile(projectRoot: string, filePath: string, content: string): Promise<void>
async createFile(projectRoot: string, parentDir: string, name: string, content?: string): Promise<void>
async deleteFile(projectRoot: string, filePath: string): Promise<void>
async createDir(projectRoot: string, parentDir: string, name: string): Promise<void>
async fileExists(projectRoot: string, filePath: string): Promise<boolean>
}
```
### Файловые лимиты и константы
```typescript
const MAX_FILE_SIZE_FULL = 2 * 1024 * 1024; // 2 MB -- полная загрузка в CM6
const MAX_FILE_SIZE_PREVIEW = 5 * 1024 * 1024; // 5 MB -- preview (100 строк)
const MAX_WRITE_SIZE = 2 * 1024 * 1024; // 2 MB
const MAX_DIR_ENTRIES = 500; // Per directory (не 10,000!)
const MAX_DIR_DEPTH = 15;
const MAX_FILENAME_LENGTH = 255;
const MAX_PATH_LENGTH = 4096;
const IGNORED_DIRS = ['.git', 'node_modules', '.next', 'dist', '__pycache__', '.cache', '.venv', '.tox', 'vendor'];
const IGNORED_FILES = ['.DS_Store', 'Thumbs.db'];
const BLOCKED_PATHS = ['/dev/', '/proc/', '/sys/', '\\\\.\\'];
```
### Тиерная стратегия readFile
| Размер | Поведение | Константа |
|--------|-----------|-----------|
| < 256 KB | Мгновенная загрузка, полный контент в CM6 | -- |
| 256 KB -- 2 MB | Progress indicator, полный контент в CM6 | `MAX_FILE_SIZE_FULL` |
| 2 MB -- 5 MB | Preview only (первые 100 строк) + warning banner "File too large for editing" | `MAX_FILE_SIZE_PREVIEW` |
| > 5 MB | Предложить открыть в external editor (`shell:openPath`), контент НЕ читается | -- |
Для preview-режима (2-5 MB): `readFile` возвращает `{ content: first100Lines, truncated: true, ... }`. CM6 открывается в `readOnly` режиме.
Дополнительно: детектировать минификацию (строка > 10,000 chars) -- banner "Minified" + предложение line wrapping. Binary detection: null bytes в первых 8KB или расширение (.png, .wasm, .jpg, .zip и т.д.).
### Atomic write
**Переиспользовать существующий `atomicWriteAsync()`** из `src/main/services/team/atomicWrite.ts` (НЕ писать новый). Он надёжнее:
- `randomUUID()` для tmp-имён (vs `pid.Date.now()` — менее уникально)
- `fsync()` (best-effort) для durability
- `EXDEV` fallback (cross-filesystem: `copyFile` + `unlink`)
- `mkdir({ recursive: true })` для безопасности
**Рефакторинг**: переместить `atomicWriteAsync()` из `src/main/services/team/atomicWrite.ts` в `src/main/utils/atomicWrite.ts` (shared utility). Обновить все импорты в team-сервисах (TeamTaskWriter, TeamDataService, TeamKanbanManager и др.). Или, при высоком blast radius, просто импортировать из `team/atomicWrite.ts` напрямую (допустимый cross-domain import для общей утилиты).
```typescript
// src/main/utils/atomicWrite.ts (перемещено из team/atomicWrite.ts)
// Используется в: ProjectFileService.writeFile(), TeamTaskWriter, TeamDataService, ...
import { atomicWriteAsync } from '@main/utils/atomicWrite';
```
### Регистрация в handlers.ts
`ProjectFileService` создаётся в module-scope внутри `editor.ts` (паттерн `reviewDecisionStore` в review.ts:55). НЕ передаётся через `initializeIpcHandlers()`его сигнатура уже имеет 15+ параметров.
```typescript
// src/main/ipc/editor.ts (module-level)
const projectFileService = new ProjectFileService();
// src/main/ipc/handlers.ts — добавить 3 вызова:
import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor';
// В initializeIpcHandlers():
initializeEditorHandlers(); // без аргументов — сервис в module scope editor.ts
// В registerXxx блок:
registerEditorHandlers(ipcMain);
// В removeIpcHandlers():
removeEditorHandlers(ipcMain);
```
---
## Компоненты
### ProjectEditorOverlay.tsx (max 150 LOC)
**Ответственность**: Layout shell -- `fixed inset-0 z-50`, header с кнопкой закрытия, split layout (sidebar + main).
- Паттерн: точная копия `ChangeReviewDialog.tsx` (строка 508) -- raw `<div>`, не Radix Dialog
- macOS traffic light padding: `var(--macos-traffic-light-padding-left, 72px)` в header
- `inert` атрибут на фоновый контент пока overlay открыт
- При открытии: фокус на первый файл в дереве (или CM6 если таб открыт)
- При закрытии: вернуть фокус на кнопку "Open in Editor" через `returnFocusRef`
- Escape/X с unsaved changes: ConfirmDialog с тремя кнопками -- "Save All & Close" / "Discard & Close" / "Cancel"
- Кнопка `?` в header: открывает `EditorShortcutsHelp`
### EditorFileTree.tsx (max 200 LOC)
**Ответственность**: Тонкая обёртка над generic `FileTree<FileTreeEntry>`.
- Предоставляет `renderNodeExtra` с dirty marker + file type icon
- Предоставляет `renderNodeIcon` с иконками по типу файла
- Context menu integration (делегирует `EditorContextMenu`)
- Git status badges через `renderNodeExtra` (итерация 5)
- Пустой проект: "No files found. Create a new file?"
- Sensitive файлы: иконка замка, при клике "Sensitive file, cannot open"
- Max визуальный indent: 12 уровней (`min(level, 12) * 12px`), tooltip с полным путём
- Длинные имена: `truncate` + `title` tooltip
- ARIA: `role="tree"`, `role="treeitem"`, `aria-expanded`, `role="group"`, keyboard navigation (arrow keys)
### Generic FileTree.tsx (common/, max 250 LOC)
**Ответственность**: Переиспользуемый generic tree с render-props.
```typescript
interface FileTreeProps<T extends { name: string; path: string; type: 'file' | 'directory' }> {
nodes: TreeNode<T>[];
activeNodePath: string | null;
onNodeClick: (node: TreeNode<T>) => void;
renderLeafNode?: (node: TreeNode<T>, isSelected: boolean, depth: number) => React.ReactNode;
renderFolderLabel?: (node: TreeNode<T>, isOpen: boolean, depth: number) => React.ReactNode;
renderNodeIcon?: (node: TreeNode<T>) => React.ReactNode;
collapsedFolders: Set<string>;
onToggleFolder: (fullPath: string) => void;
}
// TreeNode<T> -- generic обёртка, возвращаемая buildTree<T>():
interface TreeNode<T> {
name: string; // Имя узла (или "src/main" при collapse)
fullPath: string; // Полный путь
isFile: boolean;
data?: T; // Исходный элемент (только для leaf)
children: TreeNode<T>[];
}
```
- `ReviewFileTree`: использует `renderLeafNode` для полного рендеринга (FileStatusIcon, Eye, +/-) с кастом `node.data as FileChangeSummary`
- `EditorFileTree`: использует `renderLeafNode` для dirty marker + file type icon с кастом `node.data as FileTreeEntry`
- `renderLeafNode` заменяет весь leaf-элемент (не просто "extra"), что покрывает сложные сценарии ReviewFileTree (11 пропсов из store)
- Виртуализация через `@tanstack/react-virtual` с итерации 4: `flattenTree(tree, expandedDirs) -> FlatNode[]` + `useVirtualizer({ count, estimateSize: () => 28 })`
### EditorTabBar.tsx (max 100 LOC)
**Ответственность**: Панель вкладок с переключением, закрытием, dirty indicator.
- Modified dot ПЕРЕД текстом (не обрезается при truncate)
- Max-width ~160px на таб, `truncate`, tooltip с полным путём
- Disambiguation: два "index.ts" показывают "(main/utils)" и "(renderer/utils)" через `getDisambiguatedTabLabel()`
- Иконки файлов по типу на вкладках
- Middle-click close, X button close
- ARIA: `role="tablist"`, `role="tab"`, `aria-selected`
### CodeMirrorEditor.tsx (max 150 LOC)
**Ответственность**: CM6 lifecycle -- EditorState pooling, extensions, keybindings.
- Один EditorView на весь редактор (активный файл)
- `Map<tabId, EditorState>` в useRef
- Extensions через `buildEditorExtensions(options)` -- фабрика, компонент не знает о конкретных CM plugins
- Dirty flag через debounced `EditorView.updateListener` (300ms)
- LRU eviction при > 30 states
- Паттерн lifecycle из `MembersJsonEditor.tsx` (строки 27-73)
### EditorStatusBar.tsx (max 80 LOC)
**Ответственность**: Нижняя полоска: `[Ln 42, Col 15] | [TypeScript] | [UTF-8] | [Spaces: 2] | [LF]`
- Данные из CM6 state (cursor position, language)
- CSS: `bg-surface-sidebar border-t border-border text-text-muted text-xs h-6`
### EditorBinaryState.tsx (max 60 LOC)
**Ответственность**: Заглушка вместо CM6 для бинарных файлов.
- Иконка файла, тип, размер
- Кнопки "Open in System Viewer" (`shell:openPath`) и "Close Tab"
### EditorErrorState.tsx (max 60 LOC)
**Ответственность**: Заглушка при ошибке чтения.
- AlertTriangle + текст ошибки + [Retry] + [Close Tab]
- ENOENT: "File was deleted. Create new? / Close tab"
- EACCES: "Permission denied"
---
## File Tree
### Lazy loading
- Начальная загрузка: только root level (depth=1)
- Expand директории: IPC `editor:readDir` для конкретной папки (depth=1)
- Prefetch при hover (debounced 200ms) -- опционально
- MAX_ENTRIES_PER_DIR = 500; при превышении: "N more files..." + кнопка "Show all"
### Фильтрация и сортировка
- Скрывать на стороне main process: `.git`, `node_modules`, `.next`, `dist`, `__pycache__`, `.cache`, `.venv`, `.tox`, `vendor`, `.DS_Store`, `Thumbs.db`
- Сортировка: директории сначала, затем файлы; внутри группы -- alphabetical
- Локальный fuzzy filter по имени (без IPC)
### Виртуализация (итерация 4)
```typescript
// flattenTree преобразует иерархию в плоский массив для виртуализации
function flattenTree(tree: FileTreeEntry[], expandedDirs: Set<string>): FlatNode[] { ... }
// В компоненте:
const flatNodes = useMemo(() => flattenTree(tree, expandedDirs), [tree, expandedDirs]);
const virtualizer = useVirtualizer({
count: flatNodes.length,
estimateSize: () => 28,
getScrollElement: () => scrollRef.current,
});
```
Benchmark: 5000+ файлов, все папки раскрыты, FPS скролла >= 55fps.
### Контекстное меню (итерация 3)
- Правый клик на файл: Open, Delete, Copy Path, Reveal in Finder
- Правый клик на директорию: New File, New Directory, Delete, Copy Path, Reveal in Finder
- Правый клик на пустом: New File, New Directory
---
## CodeMirror Integration
### Extensions
Все уже установлены в проекте. Список extensions для editor (собираются в `buildEditorExtensions()`):
```typescript
interface EditorExtensionOptions {
readOnly: boolean;
fileName: string;
onContentChanged?: () => void; // debounced dirty flag
onSave?: () => void; // Cmd+S
tabSize?: number; // default 2
lineWrapping?: boolean; // toggle
}
// Compartments для динамических настроек (toggle без пересоздания EditorView)
// Паттерн из CodeMirrorDiffView.tsx (langCompartment, mergeCompartment, portionCompartment)
// ВАЖНО: Compartments хранить в useRef внутри CodeMirrorEditor, НЕ на уровне модуля:
// const readOnlyCompartment = useRef(new Compartment());
// const lineWrappingCompartment = useRef(new Compartment());
// const tabSizeCompartment = useRef(new Compartment());
// Причина: useRef гарантирует изоляцию если компонент монтируется дважды (React Strict Mode).
// Паттерн из CodeMirrorDiffView.tsx:332-336 (langCompartment/mergeCompartment/portionCompartment в useRef).
function buildEditorExtensions(options: EditorExtensionOptions): Extension[] {
return [
// Языковые
getLanguageExtension(options.fileName), // внутри тоже Compartment (из codemirrorLanguages.ts)
syntaxHighlighting(oneDarkHighlightStyle),
// UI
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
bracketMatching(),
closeBrackets(),
// История
history(),
// Поиск (CM6 built-in, @codemirror/search)
search(),
// Настройки через Compartment (переключаются через view.dispatch без потери undo)
// ВАЖНО: readOnly требует ОБА facet для корректного UX (паттерн из CodeMirrorDiffView.tsx:482-483):
// - EditorState.readOnly — блокирует мутации документа
// - EditorView.editable — убирает contenteditable + cursor (без него курсор мигает в read-only)
readOnlyCompartment.current.of(options.readOnly
? [EditorView.editable.of(false), EditorState.readOnly.of(true)]
: []),
lineWrappingCompartment.current.of(options.lineWrapping ? EditorView.lineWrapping : []),
tabSizeCompartment.current.of(indentUnit.of(' '.repeat(options.tabSize ?? 2))),
// Все keymaps ОБЯЗАТЕЛЬНО через keymap.of() — bare KeyBinding[] не является Extension!
// Паттерн из CodeMirrorDiffView.tsx:492 и MembersJsonEditor.tsx:40
keymap.of([
...defaultKeymap,
...historyKeymap,
...searchKeymap,
...closeBracketsKeymap,
indentWithTab,
{ key: 'Mod-s', run: () => { options.onSave?.(); return true; } },
]),
// onChange (debounced)
EditorView.updateListener.of(update => {
if (update.docChanged) options.onContentChanged?.();
}),
// Тема
baseEditorTheme, // из codemirrorTheme.ts
];
}
// Toggle line wrapping (итерация 5) — без потери undo/scroll:
// view.dispatch({ effects: lineWrappingCompartment.reconfigure(EditorView.lineWrapping) });
// view.dispatch({ effects: lineWrappingCompartment.reconfigure([]) });
// Refs на compartments хранить в useRef компонента CodeMirrorEditor
```
### Определение языка
Функция `getSyncLanguageExtension(fileName)` извлекается из `CodeMirrorDiffView.tsx` в `src/renderer/utils/codemirrorLanguages.ts`. 16+ языков синхронно + `@codemirror/language-data` async fallback для остальных. Используется `Compartment` для ленивой инжекции.
### Тема
Базовая тема извлекается из `diffTheme` (`CodeMirrorDiffView.tsx` строки 158-198) в `src/renderer/utils/codemirrorTheme.ts`:
```typescript
export const baseEditorTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
fontSize: '13px',
},
'.cm-gutters': {
backgroundColor: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)',
},
'.cm-cursor': { borderLeftColor: 'var(--color-text)' },
'.cm-selectionBackground': { backgroundColor: 'rgba(100, 153, 255, 0.2)' },
// ... остальные базовые стили
});
```
Diff-специфичные стили (`.cm-changedLine`, `.cm-deletedChunk`, `.cm-merge-*`, `.cm-collapsedLines`) выносятся в отдельный `const diffSpecificTheme = EditorView.theme({...})` внутри `CodeMirrorDiffView.tsx`. В `buildExtensions()` diff-view использует `[baseEditorTheme, diffSpecificTheme]`, а editor -- только `[baseEditorTheme]`. Light theme работает автоматически через CSS-переменные.
### EditorView lifecycle
Один EditorView, переключение через EditorState pooling. При tab switch ~3-5ms для 100KB файла. Undo history, cursor, selection сохраняются в EditorState.
---
## Keyboard Shortcuts
| Shortcut | Действие | Итерация |
|----------|---------|----------|
| `Cmd+S` | Сохранить активный файл | 2 |
| `Cmd+Shift+S` | Сохранить все | 2 |
| `Cmd+W` | Закрыть активный tab | 3 |
| `Cmd+P` | Quick Open (fuzzy search файлов) | 4 |
| `Cmd+F` | Поиск в файле (CM6 search) | 2 |
| `Cmd+Shift+F` | Поиск по содержимому файлов | 4 |
| `Cmd+Shift+[` / `Cmd+Shift+]` | Переключение табов влево/вправо | 4 |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Переключение табов (MRU) | 4 |
| `Cmd+B` | Toggle file tree sidebar | 4 |
| `Cmd+G` | Go to line (CM6 gotoLine) | 4 |
| `Cmd+Z` / `Cmd+Shift+Z` | Undo/Redo (CM6 native) | 2 |
| `Escape` | Закрыть overlay (с confirm при unsaved) | 1 |
Замечания:
- `Cmd+[` / `Cmd+]` НЕ используются для табов -- это indent/outdent в CM6 и VS Code
- `Cmd+S` перехватывается через CodeMirror keymap (не глобальный listener) -- нет конфликта с другими горячими клавишами
- Sidebar width persist в localStorage
---
## CSS-переменные
### Уже имеющиеся (100% достаточно для MVP)
- Surfaces: `--color-surface`, `--color-surface-raised`, `--color-surface-sidebar`
- Borders: `--color-border`, `--color-border-subtle`, `--color-border-emphasis`
- Text: `--color-text`, `--color-text-secondary`, `--color-text-muted`
- Code: `--code-bg`, `--code-border`, `--code-line-number`, `--code-filename`
- Syntax: `--syntax-string`, `--syntax-comment`, `--syntax-keyword` и все остальные
- Scrollbar: `--scrollbar-thumb`, `--scrollbar-thumb-hover`
- Cards: `--card-bg`, `--card-border`, `--card-header-bg`
### Рекомендуемые дополнения (добавить в `:root` в `index.css`)
```css
--editor-tab-active-bg: var(--color-surface);
--editor-tab-inactive-bg: var(--color-surface-sidebar);
--editor-tab-modified-dot: #f59e0b;
--editor-tab-border: var(--color-border);
--editor-statusbar-bg: var(--color-surface-sidebar);
--editor-statusbar-text: var(--color-text-muted);
--editor-sidebar-resize-handle: rgba(148, 163, 184, 0.15);
--editor-sidebar-resize-handle-hover: rgba(148, 163, 184, 0.3);
```

View file

@ -41,7 +41,7 @@
└──────────────┬──────────────────────────┘
┌──────────────▼──────────────────────────┐
│ Main Process: FileEditorService │
│ Main Process: ProjectFileService │
│ (sandboxed path validation) │
│ ┌─────────────────────────────────┐ │
│ │ fs.readdir / fs.readFile / │ │
@ -514,7 +514,7 @@ editorSaveError: Record<string, string> // per-file
2. editorSlice.openEditor(data.config.projectPath)
3. set({ editorProjectPath, editorFileTreeLoading: true })
4. IPC: editor:readDir(projectPath, depth=1)
5. Main: FileEditorService.readDir() → валидация пути → fs.readdir
5. Main: ProjectFileService.readDir() → валидация пути → fs.readdir
6. Результат: FileTreeEntry[]
7. set({ editorFileTree, editorFileTreeLoading: false })
8. CodeEditorOverlay рендерится (fixed inset-0 z-50)
@ -528,10 +528,10 @@ editorSaveError: Record<string, string> // per-file
3. Проверка: есть ли уже tab с этим filePath?
ДА → setActiveTab(tabId)
НЕТ → создать tab, IPC: editor:readFile(filePath)
4. Main: FileEditorService.readFile() → валидация → fs.readFile
4. Main: ProjectFileService.readFile() → валидация → fs.readFile
5. Результат: ReadFileResult { content, size, truncated }
6. set({ editorFileContents[filePath]: content })
7. CM EditorView создаётся с content
7. CM EditorState создаётся, единственный EditorView пересоздаётся
```
### 10.3 Сохранение файла
@ -539,11 +539,11 @@ editorSaveError: Record<string, string> // per-file
```
1. Юзер нажимает Cmd+S (или кнопку Save)
2. editorSlice.saveFile(filePath)
3. content = editorModifiedContents[filePath] ?? editorFileContents[filePath]
3. content = EditorState (из useRef Map) ?? editorFileContents[filePath]
4. set({ editorSaving[filePath]: true })
5. IPC: editor:writeFile(filePath, content)
6. Main: FileEditorService.writeFile() → валидация → fs.writeFile (atomic via tmp+rename)
7. set({ editorSaving: false, editorModifiedContents: remove filePath })
6. Main: ProjectFileService.writeFile() → валидация → fs.writeFile (atomic via tmp+rename)
7. set({ editorSaving: false, editorModifiedFiles: remove filePath })
8. Tab isModified indicator исчезает
```

View file

@ -57,7 +57,12 @@ export default defineConfig({
// CJS format so bundled deps can use __dirname/require.
// Use .cjs extension since package.json has "type": "module".
format: 'cjs',
entryFileNames: '[name].cjs'
entryFileNames: '[name].cjs',
// Set UV_THREADPOOL_SIZE before any module code runs.
// Must be in the banner because ESM→CJS hoists imports above top-level code.
// On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher;
// with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock.
banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}`
}
}
}

View file

@ -2681,6 +2681,25 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 159
},
"openrouter/anthropic/claude-opus-4.6": {
"cache_creation_input_token_cost": 0.00000625,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "openrouter",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"openrouter/anthropic/claude-sonnet-4.5": {
"input_cost_per_image": 0.0048,
"cache_creation_input_token_cost": 0.00000375,

View file

@ -435,9 +435,8 @@ function initializeServices(): void {
const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback);
const reviewApplier = new ReviewApplierService();
// Fire-and-forget: warm up CLI and install teamctl.js at startup
void teamProvisioningService.warmup();
void new TeamAgentToolsInstaller().ensureInstalled();
// warmup() and ensureInstalled() are deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup.
httpServer = new HttpServer();
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
@ -449,9 +448,8 @@ function initializeServices(): void {
};
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
// Start periodic health checks for registered CLI processes (every 2s).
// Dead processes get stoppedAt written to processes.json → FileWatcher picks it up.
teamDataService.startProcessHealthPolling();
// startProcessHealthPolling() is deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup.
// Initialize IPC handlers with registry
initializeIpcHandlers(
@ -657,6 +655,14 @@ function createWindow(): void {
}
}, 0);
setTimeout(() => updaterService.checkForUpdates(), 3000);
// Defer non-critical startup work to avoid thread pool contention.
// The window is now visible and responsive; these run in the background.
setTimeout(() => {
void teamProvisioningService.warmup();
void new TeamAgentToolsInstaller().ensureInstalled();
teamDataService.startProcessHealthPolling();
}, 5000);
}
});

View file

@ -54,6 +54,12 @@ const INSTALL_TIMEOUT_MS = 120_000;
/** Max redirects to follow when fetching from GCS */
const MAX_REDIRECTS = 5;
/** Socket timeout for HTTP requests — covers DNS + TCP + TLS + first byte (ms) */
const HTTP_CONNECT_TIMEOUT_MS = 15_000;
/** Overall timeout for getStatus() to prevent UI hanging indefinitely (ms) */
const GET_STATUS_TIMEOUT_MS = 25_000;
/** Max retries for EBUSY (antivirus scanning the new binary) */
const EBUSY_MAX_RETRIES = 3;
@ -80,40 +86,64 @@ function buildChildEnv(): NodeJS.ProcessEnv {
/**
* Follow redirects manually for https.get (Node https does NOT auto-follow).
* Includes a socket-level timeout covering DNS + TCP connect + TLS + first byte.
*/
function httpsGetFollowRedirects(
url: string,
redirectsLeft = MAX_REDIRECTS
redirectsLeft = MAX_REDIRECTS,
timeoutMs = HTTP_CONNECT_TIMEOUT_MS
): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'http:' ? http : https;
let settled = false;
transport
.get(url, (res) => {
const status = res.statusCode ?? 0;
const settleResolve = (value: IncomingMessage): void => {
if (settled) return;
settled = true;
resolve(value);
};
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.destroy();
reject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).toString();
const settleReject = (err: Error): void => {
if (settled) return;
settled = true;
reject(err);
};
const req = transport.get(url, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.destroy();
httpsGetFollowRedirects(redirectUrl, redirectsLeft - 1).then(resolve, reject);
settleReject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).toString();
res.destroy();
httpsGetFollowRedirects(redirectUrl, redirectsLeft - 1, timeoutMs).then(
settleResolve,
settleReject
);
return;
}
if (status !== 200) {
res.destroy();
reject(new Error(`HTTP ${status} fetching ${url}`));
return;
}
if (status !== 200) {
res.destroy();
settleReject(new Error(`HTTP ${status} fetching ${url}`));
return;
}
resolve(res);
})
.on('error', reject);
settleResolve(res);
});
// Socket-level timeout: fires if the socket is idle for timeoutMs at any point
// during DNS resolution, TCP connect, TLS handshake, or waiting for response headers.
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Connection timed out after ${timeoutMs}ms fetching ${url}`));
});
req.on('error', (err) => settleReject(err instanceof Error ? err : new Error(String(err))));
});
}
@ -211,19 +241,44 @@ export class CliInstallerService {
authMethod: null,
};
// Run the actual status gathering with an overall timeout.
// On timeout, return whatever partial result was collected so far.
const ref = { current: result };
await Promise.race([
this.gatherStatus(ref),
new Promise<void>((resolve) =>
setTimeout(() => {
logger.warn(
`getStatus() timed out after ${GET_STATUS_TIMEOUT_MS}ms, returning partial result`
);
resolve();
}, GET_STATUS_TIMEOUT_MS)
),
]);
return result;
}
/**
* Gathers CLI status information, mutating the provided result object.
* Split from getStatus() to enable overall timeout via Promise.race
* on timeout, getStatus() returns whatever fields were populated so far.
*/
private async gatherStatus(ref: { current: CliInstallationStatus }): Promise<void> {
const r = ref.current;
const binaryPath = await ClaudeBinaryResolver.resolve();
if (binaryPath) {
result.installed = true;
result.binaryPath = binaryPath;
r.installed = true;
r.binaryPath = binaryPath;
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
env: buildChildEnv(),
});
result.installedVersion = normalizeVersion(stdout);
r.installedVersion = normalizeVersion(stdout);
logger.info(
`Installed CLI version: "${stdout.trim()}" → normalized: "${result.installedVersion}"`
`Installed CLI version: "${stdout.trim()}" → normalized: "${r.installedVersion}"`
);
} catch (err) {
logger.warn('Failed to get CLI version:', getErrorMessage(err));
@ -236,35 +291,29 @@ export class CliInstallerService {
env: buildChildEnv(),
});
const auth = JSON.parse(authStdout.trim()) as { loggedIn?: boolean; authMethod?: string };
result.authLoggedIn = auth.loggedIn === true;
result.authMethod = auth.authMethod ?? null;
logger.info(
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}`
);
r.authLoggedIn = auth.loggedIn === true;
r.authMethod = auth.authMethod ?? null;
logger.info(`Auth status: loggedIn=${r.authLoggedIn}, method=${r.authMethod ?? 'null'}`);
} catch (err) {
logger.warn('Failed to check auth status:', getErrorMessage(err));
result.authLoggedIn = false;
r.authLoggedIn = false;
}
}
try {
const latestRaw = await fetchText(`${GCS_BASE}/latest`);
result.latestVersion = normalizeVersion(latestRaw);
logger.info(
`Latest CLI version: "${latestRaw.trim()}" → normalized: "${result.latestVersion}"`
);
r.latestVersion = normalizeVersion(latestRaw);
logger.info(`Latest CLI version: "${latestRaw.trim()}" → normalized: "${r.latestVersion}"`);
if (result.installedVersion && result.latestVersion) {
result.updateAvailable = isVersionOlder(result.installedVersion, result.latestVersion);
if (r.installedVersion && r.latestVersion) {
r.updateAvailable = isVersionOlder(r.installedVersion, r.latestVersion);
logger.info(
`Update available: ${result.updateAvailable} (${result.installedVersion}${result.latestVersion})`
`Update available: ${r.updateAvailable} (${r.installedVersion}${r.latestVersion})`
);
}
} catch (err) {
logger.warn('Failed to fetch latest CLI version:', getErrorMessage(err));
}
return result;
}
// ---------------------------------------------------------------------------

View file

@ -252,6 +252,49 @@ describe('CliInstallerService', () => {
});
});
describe('getStatus timeout', () => {
it('returns partial result when gatherStatus hangs', async () => {
allowConsoleLogs();
vi.useFakeTimers();
// ClaudeBinaryResolver.resolve() never settles — simulates thread pool exhaustion
vi.mocked(ClaudeBinaryResolver.resolve).mockReturnValue(new Promise(() => {}));
const statusPromise = service.getStatus();
// Advance past GET_STATUS_TIMEOUT_MS (25s)
await vi.advanceTimersByTimeAsync(26_000);
const status = await statusPromise;
// Should return the default (partial) result — not hang forever
expect(status.installed).toBe(false);
expect(status.installedVersion).toBeNull();
expect(status.binaryPath).toBeNull();
vi.useRealTimers();
});
it('returns full result when gatherStatus completes before timeout', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
vi.mocked(execCli)
.mockResolvedValueOnce({ stdout: '2.5.0 (Claude Code)', stderr: '' })
.mockResolvedValueOnce({
stdout: '{"loggedIn":true,"authMethod":"api_key"}',
stderr: '',
});
const status = await service.getStatus();
expect(status.installed).toBe(true);
expect(status.installedVersion).toBe('2.5.0');
expect(status.authLoggedIn).toBe(true);
expect(status.authMethod).toBe('api_key');
});
});
describe('sendProgress with destroyed window', () => {
it('does not throw when window is destroyed', async () => {
allowConsoleLogs();