feat: enhance project editor with new error boundary and file handling improvements

- Introduced `EditorErrorBoundary` component to catch runtime errors in CodeMirror, providing a fallback UI to prevent crashes.
- Updated file handling logic to utilize `isbinaryfile` for more reliable binary detection, replacing manual null-byte scans.
- Enhanced `openFile` method to prevent duplicate tabs for already opened files.
- Improved documentation for new components and updated file lists to reflect recent changes.
- Optimized file watcher and path validation processes for better performance and reliability.
This commit is contained in:
iliya 2026-02-28 17:55:21 +02:00
parent 736ec470d9
commit 0cb85d463c
25 changed files with 313 additions and 178 deletions

View file

@ -12,7 +12,7 @@
- **State**: Zustand slice (`editorSlice.ts`)
- **Виртуализация**: `@tanstack/react-virtual` (уже в проекте)
- **Fuzzy search**: `cmdk` v1.0.4 (уже в зависимостях)
- **Новые npm-зависимости**: `@codemirror/search` (~15KB gzipped), `@radix-ui/react-context-menu` (итерация 3, контекстное меню), `simple-git` v3.32+ (итерация 5, git status). Остальное уже установлено
- **Новые npm-зависимости**: `@codemirror/search` (итерация 1), `isbinaryfile` v5 (итерация 1, binary detection), `@radix-ui/react-context-menu` (итерация 3), `simple-git` v3.32+ (итерация 5, git status), `chokidar` v4 (итерация 5, file watcher). Остальное уже установлено
## Ключевые архитектурные решения
@ -43,7 +43,7 @@
## Общая статистика
- **Новые файлы**: ~35
- **Новые файлы**: ~36
- **Модификации**: ~18 существующих файлов
- **Тесты**: ~15 новых тестовых файлов
- **Итерации**: 6 (PR 0 + 5 итераций)

View file

@ -65,6 +65,7 @@ src/renderer/components/team/editor/
├── EditorEmptyState.tsx # Нет открытых файлов + shortcuts шпаргалка
├── EditorBinaryState.tsx # Заглушка для бинарных файлов
├── EditorErrorState.tsx # Заглушка для ошибок чтения (EACCES, ENOENT)
├── EditorErrorBoundary.tsx # React ErrorBoundary для CM6 crashes (аналог DiffErrorBoundary)
├── EditorShortcutsHelp.tsx # Модальное окно shortcuts (кнопка ?)
└── GitStatusBadge.tsx # M/U/A бейджи в дереве (итерация 5)
@ -181,8 +182,8 @@ async function handleEditorReadFile(
// 4. Size check
if (stats.size > MAX_FILE_SIZE) throw new Error('File too large');
// 5. Binary check
const isBinary = await detectBinary(validation.normalizedPath!);
// 5. Binary check (isbinaryfile v5 — UTF-16, BOM, encoding hints)
const isBinary = await isBinaryFile(validation.normalizedPath!);
// 6. Read
const content = isBinary ? '' : await fs.readFile(validation.normalizedPath!, 'utf8');
@ -241,7 +242,7 @@ export interface EditorSlice {
editorOpenTabs: EditorFileTab[];
editorActiveTabId: string | null;
openFile: (filePath: string) => Promise<void>;
openFile: (filePath: string) => Promise<void>; // Dedup: если filePath уже в editorOpenTabs → setActiveTab(existing), не создавать дубликат
closeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void;
@ -530,6 +531,7 @@ const MAX_DIR_DEPTH = 15;
const MAX_FILENAME_LENGTH = 255;
const MAX_PATH_LENGTH = 4096;
// Единый набор — используется и в readDir, и в chokidar watcher (iter-5)
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/', '\\\\.\\'];
@ -546,7 +548,7 @@ const BLOCKED_PATHS = ['/dev/', '/proc/', '/sys/', '\\\\.\\'];
Для 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 и т.д.).
Дополнительно: детектировать минификацию (строка > 10,000 chars) -- banner "Minified" + предложение line wrapping. Binary detection: `isBinaryFile()` из `isbinaryfile` v5.0.7 (UTF-16 без BOM, encoding hints, надёжнее ручного null-byte scan).
### Atomic write
@ -692,6 +694,15 @@ interface TreeNode<T> {
- ENOENT: "File was deleted. Create new? / Close tab"
- EACCES: "Permission denied"
### EditorErrorBoundary.tsx (~40-50 LOC)
**Ответственность**: React ErrorBoundary, оборачивающий `CodeMirrorEditor`. Ловит runtime-ошибки CM6 (OOM, bad extension, corrupted EditorState) и показывает fallback UI вместо краша всего overlay.
- Паттерн: аналог `DiffErrorBoundary.tsx` (уже в проекте)
- Props: `filePath`, `onRetry` (сбросить EditorState и повторить)
- Fallback UI: AlertTriangle + текст ошибки + [Retry] + [Close Tab]
- `componentDidCatch`: логировать `filePath` + error для дебага
---
## File Tree
@ -713,7 +724,7 @@ interface TreeNode<T> {
```typescript
// flattenTree преобразует иерархию в плоский массив для виртуализации
function flattenTree(tree: FileTreeEntry[], expandedDirs: Set<string>): FlatNode[] { ... }
function flattenTree(tree: FileTreeEntry[], expandedDirs: Record<string, boolean>): FlatNode[] { ... }
// В компоненте:
const flatNodes = useMemo(() => flattenTree(tree, expandedDirs), [tree, expandedDirs]);

View file

@ -92,7 +92,7 @@ Benchmark 5: Keystroke re-renders
## Полный список файлов
### Новые файлы (~30)
### Новые файлы (~36)
| # | Файл | Итерация | Описание |
|---|------|----------|----------|
@ -101,7 +101,7 @@ Benchmark 5: Keystroke re-renders
| 3 | `src/main/services/editor/index.ts` | 1 | Barrel export: `{ ProjectFileService }` (расширяется в итерациях 4-5) |
| 4 | `src/main/services/editor/FileSearchService.ts` | 4 | Search in files |
| 5 | `src/main/services/editor/GitStatusService.ts` | 5 | git status через simple-git (~80-100 LOC) |
| 6 | `src/main/services/editor/EditorFileWatcher.ts` | 5 | FileWatcher (~250-300 LOC, burst coalescing + ENOSPC fallback) |
| 6 | `src/main/services/editor/EditorFileWatcher.ts` | 5 | FileWatcher через chokidar v4 (~50-70 LOC) |
| 7 | `src/main/services/editor/conflictDetection.ts` | 5 | Утилита mtime check: сравнение mtime до/после save, conflict resolution (~40 LOC) |
| 8 | `src/main/ipc/editor.ts` | 1 | IPC handlers |
| 9 | `src/main/ipc/ipcWrapper.ts` | 1 | Общий `createIpcWrapper()` |
@ -122,17 +122,18 @@ Benchmark 5: Keystroke re-renders
| 24 | `src/renderer/components/team/editor/EditorEmptyState.tsx` | 1 | Empty state |
| 25 | `src/renderer/components/team/editor/EditorBinaryState.tsx` | 1 | Binary файлы |
| 26 | `src/renderer/components/team/editor/EditorErrorState.tsx` | 1 | Ошибки чтения |
| 27 | `src/renderer/components/team/editor/EditorContextMenu.tsx` | 3 | Context menu |
| 28 | `src/renderer/components/team/editor/NewFileDialog.tsx` | 3 | Inline-input |
| 29 | `src/renderer/components/team/editor/QuickOpenDialog.tsx` | 4 | Cmd+P dialog |
| 30 | `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | 4 | Cmd+Shift+F |
| 31 | `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | 4 | Breadcrumb |
| 32 | `src/renderer/components/team/editor/EditorShortcutsHelp.tsx` | 4 | Shortcuts modal |
| 33 | `src/renderer/components/team/editor/fileIcons.ts` | 4 | Иконки файлов |
| 34 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | 5 | M/U/A/C(conflict) бейджи |
| 35 | `src/renderer/utils/editorBridge.ts` | 2 | Module-level singleton: Store ↔ CM6 refs bridge (R3) |
| 27 | `src/renderer/components/team/editor/EditorErrorBoundary.tsx` | 1 | React ErrorBoundary для CM6 (аналог DiffErrorBoundary) |
| 29 | `src/renderer/components/team/editor/EditorContextMenu.tsx` | 3 | Context menu |
| 30 | `src/renderer/components/team/editor/NewFileDialog.tsx` | 3 | Inline-input |
| 31 | `src/renderer/components/team/editor/QuickOpenDialog.tsx` | 4 | Cmd+P dialog |
| 32 | `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | 4 | Cmd+Shift+F |
| 33 | `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | 4 | Breadcrumb |
| 34 | `src/renderer/components/team/editor/EditorShortcutsHelp.tsx` | 4 | Shortcuts modal |
| 35 | `src/renderer/components/team/editor/fileIcons.ts` | 4 | Иконки файлов |
| 36 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | 5 | M/U/A/C(conflict) бейджи |
| 37 | `src/renderer/utils/editorBridge.ts` | 2 | Module-level singleton: Store ↔ CM6 refs bridge (R3) |
### Модификации существующих файлов (~17)
### Модификации существующих файлов (~18)
| # | Файл | Итерация | Изменение |
|---|------|----------|-----------|
@ -150,7 +151,7 @@ Benchmark 5: Keystroke re-renders
| 12 | `src/renderer/api/httpClient.ts` | 1 | Stub для editor: EditorAPI (throw "not available in browser mode") |
| 13 | `src/main/ipc/teams.ts` | follow-up | Миграция wrapTeamHandler → createIpcWrapper (40+ замен, отдельный PR) |
| 14 | `src/shared/types/index.ts` | 1 | +`export type * from './editor'` (barrel re-export, паттерн как team/review/terminal) |
| 15 | `src/main/index.ts` | 5 | `mainWindow.on('closed')``cleanupEditorState()`. `shutdownServices()``cleanupEditorState()` |
| 15 | `src/main/index.ts` | 1 (расш. 5) | `mainWindow.on('closed')``cleanupEditorState()` (базовый reset в iter-1, watcher cleanup в iter-5) |
| 16 | `src/renderer/index.css` | 2 | +editor CSS-переменные |
| 17 | `src/renderer/hooks/useKeyboardShortcuts.ts` | 4 | Guard `editorOpen` для 6 конфликтующих shortcuts (R1) |

View file

@ -8,7 +8,8 @@
## Новые npm-зависимости
`@codemirror/search` (`pnpm add @codemirror/search`)
- `@codemirror/search` (`pnpm add @codemirror/search`) — встроенный Cmd+F поиск в файле
- `isbinaryfile` v5.0.7 (`pnpm add isbinaryfile`) — binary detection (33M downloads/нед, zero deps, умнее null-byte scan: UTF-16, BOM, encoding hints)
## IPC каналы
@ -39,6 +40,7 @@
| 14 | `src/renderer/components/team/editor/EditorEmptyState.tsx` | Нет открытых файлов |
| 15 | `src/renderer/components/team/editor/EditorBinaryState.tsx` | Заглушка для бинарных файлов |
| 16 | `src/renderer/components/team/editor/EditorErrorState.tsx` | Заглушка для ошибок чтения |
| 17 | `src/renderer/components/team/editor/EditorErrorBoundary.tsx` | React ErrorBoundary для CM6 (аналог DiffErrorBoundary) |
## Изменения в существующих файлах
@ -53,7 +55,7 @@
| 7 | `src/renderer/components/team/TeamDetailView.tsx` | Кнопка "Open in Editor" + state для overlay |
| 8 | `src/renderer/components/team/review/ReviewFileTree.tsx` | Рефакторинг: использовать generic FileTree + fileTreeBuilder |
| 9 | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Рефакторинг: импорт из codemirrorLanguages/Theme |
| 10 | `src/main/utils/pathValidation.ts` | Добавить `validateFileName()`, `isDevicePath()`, `isGitInternalPath()`. Экспортировать `matchesSensitivePattern()` (приватная) для `isSensitive` в readDir. **B1**: Экспортировать `isPathWithinRoot()` (приватная, строка ~30) — нужна для SEC-14 write-handler guard в iter-2 |
| 10 | `src/main/utils/pathValidation.ts` | Добавить `validateFileName()`, `isDevicePath()`, `isGitInternalPath()`. Экспортировать `matchesSensitivePattern()` (приватная) для `isSensitive` в readDir. Экспортировать `isPathWithinRoot()` (приватная, строка ~30) — нужна для SEC-15 в `editor:open` handler уже в iter-1, а также для SEC-14 write-handler guard в iter-2 |
| 11 | `src/main/index.ts` | Добавить базовый cleanup в `mainWindow.on('closed')`: вызвать `cleanupEditorState()` (экспорт из editor.ts, сбрасывает `activeProjectRoot = null`). Без этого при Cmd+Q на macOS state "утечёт" и `editor:open` откажет при следующем открытии окна. Полный watcher cleanup — итерация 5, но базовый reset нужен с итерации 1 |
| 12 | `src/renderer/api/httpClient.ts` | Stub для `editor: EditorAPI` — throw "Editor is not available in browser mode" (паттерн как `review`, `terminal`, `teams`) |
| 13 | `src/renderer/store/types.ts` | `EditorSlice` в AppState |
@ -71,7 +73,7 @@
- MAX_ENTRIES_PER_DIR = 500; при превышении -- "N more files..."
- readFile тиерная стратегия: <256KB мгновенно, 256KB-2MB progress, 2MB-5MB preview, >5MB external
- Binary detection: null bytes в первых 8KB
- Binary detection: `isbinaryfile` (v5.0.7) — `isBinaryFile(filePath)` вместо ручного null-byte scan
- Дедупликация IPC: `Map<string, Promise<ReadFileResult>>` для readFile
## UX-требования

View file

@ -45,6 +45,7 @@
1. `createFile`: `validateFileName()` -- запрет `.`, `..`, control chars, path separators, NUL, length > 255. Валидировать и `parentDir`, и `path.join(parentDir, name)` (SEC-7)
2. `deleteFile`: `shell.trashItem()`, НЕ `fs.unlink()`. `validateFilePath()` обязательна
3. Confirmation dialog перед удалением
4. `createFile`, `createDir`, `deleteFile`: `isGitInternalPath()` блокирует операции внутри `.git/` (SEC-12, аналог writeFile из iter-2)
## Performance-требования

View file

@ -8,7 +8,8 @@ Git status в дереве файлов. Live refresh при изменения
## Новые npm-зависимости
`simple-git` v3.32+ (`pnpm add simple-git`) — обёртка над git CLI с TypeScript типами, parsed StatusResult, встроенным timeout/abort. 7.9M downloads/нед, dual ESM/CJS, не native module.
- `simple-git` v3.32+ (`pnpm add simple-git`) — обёртка над git CLI с TypeScript типами, parsed StatusResult, встроенным timeout/abort. 7.9M downloads/нед, dual ESM/CJS, не native module
- `chokidar` v4.0.3 (`pnpm add chokidar@4`) — file watcher (117M downloads/нед). Решает все проблемы raw `fs.watch`: нормализация событий, recursive на Linux, ENOSPC handling, symlinks. Dual CJS/ESM, Node 14+
## IPC каналы
@ -22,7 +23,7 @@ Git status в дереве файлов. Live refresh при изменения
| # | Файл | Описание |
|---|------|----------|
| 1 | `src/main/services/editor/EditorFileWatcher.ts` | FileWatcher (~250-300 LOC, не ~60!). fs.watch + burst coalescing (200ms debounce + batch) + ENOSPC fallback to polling (Linux). Фильтрация node_modules/.git/dist |
| 1 | `src/main/services/editor/EditorFileWatcher.ts` | FileWatcher через `chokidar` v4 (~50-70 LOC). Burst coalescing, ENOSPC, recursive Linux — всё встроено. Фильтрация node_modules/.git/dist через `ignored` option |
| 2 | `src/main/services/editor/GitStatusService.ts` | Git status через `simple-git` с `StatusResult` маппингом, кеш 5 сек. Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` (~80-100 LOC) |
| 3 | `src/main/services/editor/conflictDetection.ts` | Утилита mtime check: сравнение mtime до/после save, conflict resolution (~40 LOC) |
| 4 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | M/U/A бейджи в дереве |
@ -57,9 +58,36 @@ Git status в дереве файлов. Live refresh при изменения
## Performance-требования (R4/R5)
- File watcher opt-in: по умолчанию ВЫКЛЮЧЕН. Toggle "Watch for external changes". По умолчанию ручной refresh (F5)
- `fs.watch({ recursive: true })` + фильтрация (node_modules/.git/dist) + burst coalescing 200ms
- **macOS**: FSEvents reliable (надёжность 9/10)
- **Linux**: inotify (надёжность 6/10). При `ENOSPC` → fallback на polling (5-10 сек). НЕ падать, деградировать
### chokidar конфигурация
```typescript
// src/main/services/editor/EditorFileWatcher.ts
import { watch, type FSWatcher } from 'chokidar';
let watcher: FSWatcher | null = null;
function startWatching(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void) {
watcher = watch(projectRoot, {
ignored: /(node_modules|\.git|dist|__pycache__|\.cache|\.next|\.venv|\.tox|vendor)/,
ignoreInitial: true,
followSymlinks: false,
depth: 20,
});
watcher.on('change', path => onChange({ type: 'change', path }));
watcher.on('add', path => onChange({ type: 'create', path }));
watcher.on('unlink', path => onChange({ type: 'delete', path }));
}
function stopWatching() {
watcher?.close();
watcher = null;
}
```
- **chokidar v4** решает все проблемы raw `fs.watch`: нормализация событий macOS, recursive на Linux, ENOSPC handling, debounce
- **macOS**: FSEvents через chokidar (надёжность 9/10)
- **Linux**: inotify через chokidar с автоматическим fallback (надёжность 8/10, было 6/10 с raw fs.watch)
- Git status кешировать на 5 секунд. Invalidate по file watcher event
### simple-git конфигурация
@ -109,7 +137,7 @@ function mapStatus(result: StatusResult): GitFileStatus[] {
| # | Что тестировать | Файл |
|---|----------------|------|
| 1 | `GitStatusService` -- маппинг `simple-git` StatusResult → GitFileStatus[], кеш, graceful degradation | `test/main/services/editor/GitStatusService.test.ts` |
| 2 | `EditorFileWatcher` -- debounce, event types | `test/main/services/editor/EditorFileWatcher.test.ts` |
| 2 | `EditorFileWatcher` -- chokidar watcher lifecycle (start/stop), event mapping, cleanup | `test/main/services/editor/EditorFileWatcher.test.ts` |
| 3 | `conflictDetection` -- mtime check логика | `test/main/services/editor/conflictDetection.test.ts` |
| 4 | Manual: изменить файл в внешнем редакторе -> conflict banner | — |
@ -122,5 +150,5 @@ function mapStatus(result: StatusResult): GitFileStatus[] {
## Оценка
- **Надёжность решения: 8/10** (было 7/10) -- `simple-git` убирает ~120 LOC ручного парсинга, conflict/renamed detection из коробки. ENOSPC fallback и burst coalescing проработаны.
- **Уверенность: 9/10** (было 8/10) -- simple-git 7.9M downloads/нед, TypeScript типы, встроенный timeout/abort. Риск минимален.
- **Надёжность решения: 9/10** (было 8/10) -- `simple-git` + `chokidar` убирают ~300 LOC ручного кода: парсинг porcelain, fs.watch нормализация, ENOSPC fallback. Всё покрыто проверенными пакетами.
- **Уверенность: 9/10** -- simple-git 7.9M + chokidar 117M downloads/нед. Оба с TypeScript, проверены в production.

View file

@ -11,7 +11,7 @@
*/
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import {
@ -27,14 +27,19 @@ import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:utility');
/** Cached app version — read once from package.json, not every request. */
let cachedVersion: string | null = null;
export function registerUtilityRoutes(app: FastifyInstance): void {
// App version
// App version (cached — no file I/O after first call)
app.get('/api/version', async () => {
if (cachedVersion) return cachedVersion;
try {
// Read version from package.json (works in both Electron and Node)
const pkgPath = path.resolve(__dirname, '../../../package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string };
return pkg.version;
const content = await fsp.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(content) as { version: string };
cachedVersion = pkg.version;
return cachedVersion;
} catch {
return '0.0.0';
}
@ -73,7 +78,7 @@ export function registerUtilityRoutes(app: FastifyInstance): void {
}
});
// Read mentioned file
// Read mentioned file — async I/O, no TOCTOU
app.post<{ Body: { absolutePath: string; projectRoot: string; maxTokens?: number } }>(
'/api/read-mentioned-file',
async (request) => {
@ -87,16 +92,12 @@ export function registerUtilityRoutes(app: FastifyInstance): void {
const safePath = validation.normalizedPath!;
if (!fs.existsSync(safePath)) {
return null;
}
const stats = fs.statSync(safePath);
const stats = await fsp.stat(safePath);
if (!stats.isFile()) {
return null;
}
const content = fs.readFileSync(safePath, 'utf8');
const content = await fsp.readFile(safePath, 'utf8');
const estimatedTokens = countTokens(content);
if (estimatedTokens > maxTokens) {

View file

@ -8,7 +8,7 @@
*/
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import type { FastifyInstance } from 'fastify';
@ -39,11 +39,8 @@ export function registerValidationRoutes(app: FastifyInstance): void {
return { exists: false };
}
if (!fs.existsSync(fullPath)) {
return { exists: false };
}
const stats = fs.statSync(fullPath);
// Single async stat — no TOCTOU, doesn't block the main thread
const stats = await fsp.stat(fullPath);
return { exists: true, isDirectory: stats.isDirectory() };
} catch {
return { exists: false };
@ -56,18 +53,24 @@ export function registerValidationRoutes(app: FastifyInstance): void {
'/api/validate/mentions',
async (request) => {
const { mentions, projectPath } = request.body;
const results = new Map<string, boolean>();
for (const mention of mentions) {
const fullPath = path.join(projectPath, mention.value);
if (!isPathContained(fullPath, projectPath)) {
results.set(`@${mention.value}`, false);
continue;
}
results.set(`@${mention.value}`, fs.existsSync(fullPath));
}
// Validate all mentions in parallel with async I/O
const entries = await Promise.all(
mentions.map(async (mention) => {
const fullPath = path.join(projectPath, mention.value);
if (!isPathContained(fullPath, projectPath)) {
return [`@${mention.value}`, false] as const;
}
try {
await fsp.access(fullPath);
return [`@${mention.value}`, true] as const;
} catch {
return [`@${mention.value}`, false] as const;
}
})
);
return Object.fromEntries(results);
return Object.fromEntries(entries);
}
);

View file

@ -9,6 +9,13 @@
* - Manage application lifecycle
*/
// Increase UV thread pool size BEFORE any async I/O.
// Default is 4 threads which is far too few for startup:
// binary resolution stat() calls, CLI subprocess spawning, fs.watch(),
// and readFile/readdir from IPC handlers all compete for the pool.
// On Windows this saturates all threads, blocking the event loop.
process.env.UV_THREADPOOL_SIZE ??= '16';
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import { FileContentResolver } from '@main/services/team/FileContentResolver';
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';

View file

@ -10,7 +10,7 @@
import { createLogger } from '@shared/utils/logger';
import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import {
type ClaudeMdFileInfo,
@ -77,9 +77,16 @@ function handleGetAppVersion(): string {
* Handler for 'shell:showInFolder' IPC call.
* Reveals a file in the system file manager (Finder/Explorer).
*/
function handleShellShowInFolder(_event: IpcMainInvokeEvent, filePath: string): void {
if (typeof filePath === 'string' && filePath.length > 0 && fs.existsSync(filePath)) {
async function handleShellShowInFolder(
_event: IpcMainInvokeEvent,
filePath: string
): Promise<void> {
if (typeof filePath !== 'string' || filePath.length === 0) return;
try {
await fsp.access(filePath);
shell.showItemInFolder(filePath);
} catch {
// File doesn't exist — silently ignore
}
}
@ -137,8 +144,10 @@ async function handleShellOpenPath(
const safePath = validation.normalizedPath!;
// Check if path exists
if (!fs.existsSync(safePath)) {
// Check if path exists (async to avoid blocking main thread)
try {
await fsp.access(safePath);
} catch {
logger.error(`shell:openPath - path does not exist: ${safePath}`);
return { success: false, error: 'Path does not exist' };
}
@ -224,19 +233,13 @@ async function handleReadMentionedFile(
const safePath = validation.normalizedPath!;
// Check if file exists
if (!fs.existsSync(safePath)) {
return null;
}
// Check if it's a file (not directory)
const stats = fs.statSync(safePath);
// Single async stat + read — no TOCTOU, doesn't block main thread
const stats = await fsp.stat(safePath);
if (!stats.isFile()) {
return null;
}
// Read file content
const content = fs.readFileSync(safePath, 'utf8');
const content = await fsp.readFile(safePath, 'utf8');
// Calculate tokens
const estimatedTokens = countTokens(content);

View file

@ -9,7 +9,7 @@
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
const logger = createLogger('IPC:validation');
@ -75,11 +75,8 @@ async function handleValidatePath(
return { exists: false };
}
if (!fs.existsSync(fullPath)) {
return { exists: false };
}
const stats = fs.statSync(fullPath);
// Single async stat — no TOCTOU, doesn't block the main thread
const stats = await fsp.stat(fullPath);
return {
exists: true,
isDirectory: stats.isDirectory(),
@ -99,21 +96,27 @@ async function handleValidateMentions(
mentions: { type: 'path'; value: string }[],
projectPath: string
): Promise<Record<string, boolean>> {
const results = new Map<string, boolean>();
// Validate all mentions in parallel with async I/O
// (was sequential sync existsSync — blocked main thread per mention)
const entries = await Promise.all(
mentions.map(async (mention) => {
const fullPath = path.join(projectPath, mention.value);
for (const mention of mentions) {
const fullPath = path.join(projectPath, mention.value);
// Security: Skip paths that escape project directory
if (!isPathContained(fullPath, projectPath)) {
return [`@${mention.value}`, false] as const;
}
// Security: Skip paths that escape project directory
if (!isPathContained(fullPath, projectPath)) {
results.set(`@${mention.value}`, false);
continue;
}
try {
await fsp.access(fullPath);
return [`@${mention.value}`, true] as const;
} catch {
return [`@${mention.value}`, false] as const;
}
})
);
results.set(`@${mention.value}`, fs.existsSync(fullPath));
}
return Object.fromEntries(results);
return Object.fromEntries(entries);
}
/**

View file

@ -13,6 +13,7 @@ import { getHomeDir, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { validateRegexPattern } from '@main/utils/regexValidation';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager';
@ -352,21 +353,21 @@ export class ConfigManager {
/**
* Loads configuration from disk.
* Returns default config if file doesn't exist or is invalid.
* Uses a single readFileSync (no TOCTOU from existsSync + readFileSync).
*/
private loadConfig(): AppConfig {
try {
if (!fs.existsSync(this.configPath)) {
logger.info('No config file found, using defaults');
return this.deepClone(DEFAULT_CONFIG);
}
const content = fs.readFileSync(this.configPath, 'utf8');
const parsed = JSON.parse(content) as Partial<AppConfig>;
// Merge with defaults to ensure all fields exist
return this.mergeWithDefaults(parsed);
} catch (error) {
logger.error('Error loading config, using defaults:', error);
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info('No config file found, using defaults');
} else {
logger.error('Error loading config, using defaults:', error);
}
return this.deepClone(DEFAULT_CONFIG);
}
}
@ -384,16 +385,18 @@ export class ConfigManager {
}
/**
* Persists configuration to the canonical path.
* Persists configuration to the canonical path asynchronously.
* Uses async I/O to avoid blocking the main process event loop.
* mkdir({ recursive: true }) is idempotent no need for an existsSync guard.
*/
private persistConfig(config: AppConfig): void {
const configDir = path.dirname(this.configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const content = JSON.stringify(config, null, 2);
fs.writeFileSync(this.configPath, content, 'utf8');
fsp
.mkdir(path.dirname(this.configPath), { recursive: true })
.then(() => fsp.writeFile(this.configPath, content, 'utf8'))
.catch((error) => {
logger.error('Error persisting config:', error);
});
}
/**

View file

@ -13,7 +13,8 @@ import { type HttpServices, registerHttpRoutes } from '@main/http';
import { broadcastEvent } from '@main/http/events';
import { createLogger } from '@shared/utils/logger';
import Fastify, { type FastifyInstance } from 'fastify';
import { existsSync, readFileSync } from 'fs';
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { join } from 'path';
const logger = createLogger('Service:HttpServer');
@ -97,8 +98,8 @@ export class HttpServer {
if (rendererPath) {
logger.info(`Serving static files from: ${rendererPath}`);
// Cache index.html for SPA fallback
const indexHtml = readFileSync(join(rendererPath, 'index.html'), 'utf-8');
// Cache index.html for SPA fallback (async to avoid blocking main thread)
const indexHtml = await readFile(join(rendererPath, 'index.html'), 'utf-8');
await this.app.register(fastifyStatic, {
root: rendererPath,

View file

@ -16,7 +16,6 @@ import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { type BrowserWindow, Notification } from 'electron';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
@ -165,10 +164,11 @@ export class NotificationManager extends EventEmitter {
/**
* Loads notifications from disk (async to avoid blocking startup).
* Uses a single readFile instead of access() + readFile() to eliminate
* a redundant syscall and TOCTOU race condition.
*/
private async loadNotifications(): Promise<void> {
try {
await fsp.access(NOTIFICATIONS_PATH, fs.constants.F_OK);
const data = await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
const parsed = JSON.parse(data) as unknown;
@ -188,20 +188,20 @@ export class NotificationManager extends EventEmitter {
}
/**
* Saves notifications to disk.
* Saves notifications to disk asynchronously.
* Uses async I/O to avoid blocking the main process event loop,
* which is critical on Windows where sync writes can freeze the UI.
*/
private saveNotifications(): void {
try {
// Ensure directory exists
const dir = path.dirname(NOTIFICATIONS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const data = JSON.stringify(this.notifications, null, 2);
const dir = path.dirname(NOTIFICATIONS_PATH);
fs.writeFileSync(NOTIFICATIONS_PATH, JSON.stringify(this.notifications, null, 2), 'utf8');
} catch (error) {
logger.error('Error saving notifications:', error);
}
fsp
.mkdir(dir, { recursive: true })
.then(() => fsp.writeFile(NOTIFICATIONS_PATH, data, 'utf8'))
.catch((error) => {
logger.error('Error saving notifications:', error);
});
}
/**

View file

@ -84,24 +84,30 @@ async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
const pathParts = rawPath.split(path.delimiter);
const binaryNames =
process.platform === 'win32' ? expandWindowsBinaryNames(binaryName) : [binaryName];
for (const part of pathParts) {
if (!part) {
continue;
}
const cleanedPart = stripSurroundingQuotes(part);
if (!cleanedPart) {
continue;
}
// Check all PATH directories in parallel. Each directory checks all extension
// variants concurrently. This turns N_dirs × N_exts sequential stat() calls
// into a single parallel batch, dramatically reducing startup time on Windows.
const dirResults = await Promise.all(
pathParts.map(async (part) => {
if (!part) return null;
const cleanedPart = stripSurroundingQuotes(part);
if (!cleanedPart) return null;
for (const name of binaryNames) {
const candidate = path.join(cleanedPart, name);
if (await isExecutable(candidate)) {
return candidate;
}
}
}
return null;
const candidates = binaryNames.map((name) => path.join(cleanedPart, name));
const results = await Promise.all(
candidates.map(async (candidate) => ({
path: candidate,
ok: await isExecutable(candidate),
}))
);
// Return the first matching extension variant within this directory
return results.find((r) => r.ok)?.path ?? null;
})
);
// Return first non-null result, preserving PATH priority order
return dirResults.find((r) => r !== null) ?? null;
}
async function resolveFromExplicitPath(inputPath: string): Promise<string | null> {
@ -186,11 +192,20 @@ export class ClaudeBinaryResolver {
);
const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates();
for (const candidate of [...candidates, ...nvmCandidates]) {
if (await isExecutable(candidate)) {
cachedPath = candidate;
return cachedPath;
}
const allCandidates = [...candidates, ...nvmCandidates];
// Check all fallback candidates in parallel for speed
const results = await Promise.all(
allCandidates.map(async (candidate) => ({
path: candidate,
ok: await isExecutable(candidate),
}))
);
// Return first match, preserving candidate priority order
const found = results.find((r) => r.ok);
if (found) {
cachedPath = found.path;
return cachedPath;
}
// Don't cache null — CLI may be installed later without app restart

View file

@ -23,20 +23,12 @@ export const App = (): React.JSX.Element => {
}
}, []);
// Defer IPC-heavy initialization to after the first paint.
// On Windows, firing 6+ IPC calls simultaneously at startup saturates the
// UV thread pool (4 threads by default), causing the app to freeze.
// Context system init is skipped here — local context is ready by default,
// and SSH context is initialized lazily when SSH connects (see below).
// Initialize IPC listeners and start sequential data fetch chain.
// No delay needed: UV_THREADPOOL_SIZE=16 prevents thread pool saturation,
// and the init chain fetches data sequentially to avoid concurrent I/O spikes.
useEffect(() => {
let cleanup: (() => void) | undefined;
const timer = setTimeout(() => {
cleanup = initializeNotificationListeners();
}, 100);
return () => {
clearTimeout(timer);
cleanup?.();
};
const cleanup = initializeNotificationListeners();
return cleanup;
}, []);
// Initialize context system lazily when SSH connection state changes.

View file

@ -236,14 +236,26 @@ export const DateGroupedSessions = (): React.JSX.Element => {
const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false);
const worktreeDropdownRef = useRef<HTMLDivElement>(null);
// Fetch project data on mount
// Fetch project data on mount or when viewMode changes.
// Loading guards in the store actions prevent duplicate IPC calls
// when the centralized init chain has already started a fetch.
const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading);
const projectsLoading = useStore((s) => s.projectsLoading);
useEffect(() => {
if (viewMode === 'grouped' && repositoryGroups.length === 0) {
if (viewMode === 'grouped' && repositoryGroups.length === 0 && !repositoryGroupsLoading) {
void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0) {
} else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading) {
void fetchProjects();
}
}, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]);
}, [
viewMode,
repositoryGroups.length,
projects.length,
repositoryGroupsLoading,
projectsLoading,
fetchRepositoryGroups,
fetchProjects,
]);
// Project combobox options
const projectComboboxOptions = useMemo((): ComboboxOption[] => {

View file

@ -128,12 +128,14 @@ export const GlobalTaskList = ({
saveGroupingMode(mode);
};
// Fetch tasks on mount — loading guard in the store action prevents
// duplicate IPC calls when the centralized init chain is already fetching.
useEffect(() => {
if (!hasFetchedRef.current) {
if (!hasFetchedRef.current && !globalTasksLoading) {
hasFetchedRef.current = true;
void fetchAllTasks();
}
}, [fetchAllTasks]);
}, [fetchAllTasks, globalTasksLoading]);
// Build project combobox options from available projects/repos
const projectFilterOptions = useMemo((): ComboboxOption[] => {

View file

@ -39,12 +39,15 @@ export function useTheme(): {
return 'dark';
});
// Fetch config on mount if not loaded
// Fetch config on mount if not loaded.
// The centralized init chain also calls fetchConfig — configLoading guard
// in the store action prevents duplicate IPC calls.
const configLoading = useStore((s) => s.configLoading);
useEffect(() => {
if (!appConfig) {
if (!appConfig && !configLoading) {
void fetchConfig();
}
}, [appConfig, fetchConfig]);
}, [appConfig, configLoading, fetchConfig]);
// Get configured theme
const configuredTheme: Theme = appConfig?.general?.theme ?? 'dark';

View file

@ -73,18 +73,41 @@ export function initializeNotificationListeners(): () => void {
cleanupFns.push(() => {
useStore.getState().unsubscribeProvisioningProgress();
});
// Stagger IPC data fetches to avoid saturating the UV thread pool on Windows.
// Each fetch triggers file I/O in the main process; firing them all at once
// blocks all 4 default UV threads simultaneously, freezing the app.
void useStore
.getState()
.fetchTeams()
.finally(() => {
void useStore.getState().fetchNotifications();
if (api.cliInstaller) {
void useStore.getState().fetchCliStatus();
}
});
// Initial data fetches. Config loads first (needed for theme), then the rest
// run in parallel (no data dependencies between them). UV_THREADPOOL_SIZE=16
// prevents thread pool saturation even with concurrent I/O on Windows.
// Components also fire these from useEffect — loading guards in each action
// prevent duplicate IPC calls (whichever caller starts first wins).
void (async () => {
// Config: fast (in-memory read) — needed for theme before first paint.
await useStore.getState().fetchConfig();
// Remaining fetches have no data dependency on each other — run in parallel
// to avoid blocking teams/notifications behind a slow repository scan.
await Promise.all([
// Repository groups: heavy — full project directory scan for sidebar.
useStore.getState().fetchRepositoryGroups(),
// Global tasks: moderate — reads team task files for sidebar.
useStore.getState().fetchAllTasks(),
// Team summaries: moderate — reads team config files.
useStore.getState().fetchTeams(),
// Notification count: light — reads from in-memory store in main process.
useStore.getState().fetchNotifications(),
]);
})();
// CLI status check is non-critical for initial render (spawns child processes
// + iterates PATH directories with stat() calls — heavy on Windows).
// Defer until the app is fully interactive.
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
if (api.cliInstaller) {
cliStatusTimer = setTimeout(() => {
void useStore.getState().fetchCliStatus();
cliStatusTimer = null;
}, 5000);
}
cleanupFns.push(() => {
if (cliStatusTimer) clearTimeout(cliStatusTimer);
});
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamRefreshTimer: ReturnType<typeof setTimeout> | null = null;
@ -179,7 +202,7 @@ export function initializeNotificationListeners(): () => void {
}
}
// fetchNotifications() is called in the staggered init chain above (after fetchTeams).
// fetchNotifications() is called in the parallel init chain above.
/**
* Check if a session is visible in any pane (not just the focused pane's active tab).
@ -373,7 +396,7 @@ export function initializeNotificationListeners(): () => void {
}
}
// fetchCliStatus() is called in the staggered init chain above (after fetchTeams).
// fetchCliStatus() is deferred 5s after app start (heavy on Windows).
// Listen for CLI installer progress events from main process
let cliCompletedRevertTimer: ReturnType<typeof setTimeout> | null = null;

View file

@ -42,6 +42,8 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
// Fetch app configuration from main process
fetchConfig: async () => {
// Guard: prevent concurrent fetches (useTheme + centralized init chain)
if (get().configLoading) return;
set({ configLoading: true, configError: null });
try {
const config = await api.config.get();

View file

@ -39,6 +39,8 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
// Fetch all projects from main process
fetchProjects: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain)
if (get().projectsLoading) return;
set({ projectsLoading: true, projectsError: null });
try {
const projects = await api.getProjects();

View file

@ -51,6 +51,8 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
// Fetch all repository groups (projects grouped by git repo)
fetchRepositoryGroups: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain)
if (get().repositoryGroupsLoading) return;
set({ repositoryGroupsLoading: true, repositoryGroupsError: null });
try {
const groups = await api.getRepositoryGroups();

View file

@ -212,6 +212,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
deletedTasksLoading: false,
fetchTeams: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain).
// Only effective during initial load (when teamsLoading is set to true below).
// Refreshes are already serialized by the throttle timer in onTeamChange.
if (get().teamsLoading) return;
// Only show loading spinner on initial load — avoids flickering when refreshing
const isInitialLoad = get().teams.length === 0;
if (isInitialLoad) {
@ -236,6 +240,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
},
fetchAllTasks: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain)
if (get().globalTasksLoading) return;
const isInitialLoad = get().globalTasks.length === 0;
if (isInitialLoad) {
set({ globalTasksLoading: true, globalTasksError: null });

View file

@ -9,6 +9,13 @@ const hoisted = vi.hoisted(() => ({
vi.mock('@renderer/api', () => ({
api: {
config: {
get: vi.fn(async () => ({
general: { theme: 'dark' },
notifications: { enabled: true, triggers: [] },
})),
},
getRepositoryGroups: vi.fn(async () => []),
notifications: {
onNew: vi.fn(() => () => undefined),
onUpdated: vi.fn(() => () => undefined),
@ -39,6 +46,7 @@ vi.mock('@renderer/api', () => ({
}
),
getAllTasks: vi.fn(async () => []),
list: vi.fn(async () => []),
},
},
}));
@ -48,7 +56,7 @@ import { initializeNotificationListeners, useStore } from '../../../src/renderer
describe('team change throttling', () => {
let cleanup: (() => void) | null = null;
beforeEach(() => {
beforeEach(async () => {
vi.useFakeTimers();
const fetchTeams = vi.fn(async () => undefined);
const refreshTeamData = vi.fn(async () => undefined);
@ -70,6 +78,10 @@ describe('team change throttling', () => {
} as never);
cleanup = initializeNotificationListeners();
// Flush microtask queue so the sequential init chain completes
// before test assertions start (prevents init calls from leaking into spies).
await vi.advanceTimersByTimeAsync(0);
});
afterEach(() => {