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`) - **State**: Zustand slice (`editorSlice.ts`)
- **Виртуализация**: `@tanstack/react-virtual` (уже в проекте) - **Виртуализация**: `@tanstack/react-virtual` (уже в проекте)
- **Fuzzy search**: `cmdk` v1.0.4 (уже в зависимостях) - **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 существующих файлов - **Модификации**: ~18 существующих файлов
- **Тесты**: ~15 новых тестовых файлов - **Тесты**: ~15 новых тестовых файлов
- **Итерации**: 6 (PR 0 + 5 итераций) - **Итерации**: 6 (PR 0 + 5 итераций)

View file

@ -65,6 +65,7 @@ src/renderer/components/team/editor/
├── EditorEmptyState.tsx # Нет открытых файлов + shortcuts шпаргалка ├── EditorEmptyState.tsx # Нет открытых файлов + shortcuts шпаргалка
├── EditorBinaryState.tsx # Заглушка для бинарных файлов ├── EditorBinaryState.tsx # Заглушка для бинарных файлов
├── EditorErrorState.tsx # Заглушка для ошибок чтения (EACCES, ENOENT) ├── EditorErrorState.tsx # Заглушка для ошибок чтения (EACCES, ENOENT)
├── EditorErrorBoundary.tsx # React ErrorBoundary для CM6 crashes (аналог DiffErrorBoundary)
├── EditorShortcutsHelp.tsx # Модальное окно shortcuts (кнопка ?) ├── EditorShortcutsHelp.tsx # Модальное окно shortcuts (кнопка ?)
└── GitStatusBadge.tsx # M/U/A бейджи в дереве (итерация 5) └── GitStatusBadge.tsx # M/U/A бейджи в дереве (итерация 5)
@ -181,8 +182,8 @@ async function handleEditorReadFile(
// 4. Size check // 4. Size check
if (stats.size > MAX_FILE_SIZE) throw new Error('File too large'); if (stats.size > MAX_FILE_SIZE) throw new Error('File too large');
// 5. Binary check // 5. Binary check (isbinaryfile v5 — UTF-16, BOM, encoding hints)
const isBinary = await detectBinary(validation.normalizedPath!); const isBinary = await isBinaryFile(validation.normalizedPath!);
// 6. Read // 6. Read
const content = isBinary ? '' : await fs.readFile(validation.normalizedPath!, 'utf8'); const content = isBinary ? '' : await fs.readFile(validation.normalizedPath!, 'utf8');
@ -241,7 +242,7 @@ export interface EditorSlice {
editorOpenTabs: EditorFileTab[]; editorOpenTabs: EditorFileTab[];
editorActiveTabId: string | null; editorActiveTabId: string | null;
openFile: (filePath: string) => Promise<void>; openFile: (filePath: string) => Promise<void>; // Dedup: если filePath уже в editorOpenTabs → setActiveTab(existing), не создавать дубликат
closeTab: (tabId: string) => void; closeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void; setActiveTab: (tabId: string) => void;
@ -530,6 +531,7 @@ const MAX_DIR_DEPTH = 15;
const MAX_FILENAME_LENGTH = 255; const MAX_FILENAME_LENGTH = 255;
const MAX_PATH_LENGTH = 4096; 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_DIRS = ['.git', 'node_modules', '.next', 'dist', '__pycache__', '.cache', '.venv', '.tox', 'vendor'];
const IGNORED_FILES = ['.DS_Store', 'Thumbs.db']; const IGNORED_FILES = ['.DS_Store', 'Thumbs.db'];
const BLOCKED_PATHS = ['/dev/', '/proc/', '/sys/', '\\\\.\\']; 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` режиме. Для 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 ### Atomic write
@ -692,6 +694,15 @@ interface TreeNode<T> {
- ENOENT: "File was deleted. Create new? / Close tab" - ENOENT: "File was deleted. Create new? / Close tab"
- EACCES: "Permission denied" - 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 ## File Tree
@ -713,7 +724,7 @@ interface TreeNode<T> {
```typescript ```typescript
// flattenTree преобразует иерархию в плоский массив для виртуализации // 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]); 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) | | 3 | `src/main/services/editor/index.ts` | 1 | Barrel export: `{ ProjectFileService }` (расширяется в итерациях 4-5) |
| 4 | `src/main/services/editor/FileSearchService.ts` | 4 | Search in files | | 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) | | 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) | | 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 | | 8 | `src/main/ipc/editor.ts` | 1 | IPC handlers |
| 9 | `src/main/ipc/ipcWrapper.ts` | 1 | Общий `createIpcWrapper()` | | 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 | | 24 | `src/renderer/components/team/editor/EditorEmptyState.tsx` | 1 | Empty state |
| 25 | `src/renderer/components/team/editor/EditorBinaryState.tsx` | 1 | Binary файлы | | 25 | `src/renderer/components/team/editor/EditorBinaryState.tsx` | 1 | Binary файлы |
| 26 | `src/renderer/components/team/editor/EditorErrorState.tsx` | 1 | Ошибки чтения | | 26 | `src/renderer/components/team/editor/EditorErrorState.tsx` | 1 | Ошибки чтения |
| 27 | `src/renderer/components/team/editor/EditorContextMenu.tsx` | 3 | Context menu | | 27 | `src/renderer/components/team/editor/EditorErrorBoundary.tsx` | 1 | React ErrorBoundary для CM6 (аналог DiffErrorBoundary) |
| 28 | `src/renderer/components/team/editor/NewFileDialog.tsx` | 3 | Inline-input | | 29 | `src/renderer/components/team/editor/EditorContextMenu.tsx` | 3 | Context menu |
| 29 | `src/renderer/components/team/editor/QuickOpenDialog.tsx` | 4 | Cmd+P dialog | | 30 | `src/renderer/components/team/editor/NewFileDialog.tsx` | 3 | Inline-input |
| 30 | `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | 4 | Cmd+Shift+F | | 31 | `src/renderer/components/team/editor/QuickOpenDialog.tsx` | 4 | Cmd+P dialog |
| 31 | `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | 4 | Breadcrumb | | 32 | `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | 4 | Cmd+Shift+F |
| 32 | `src/renderer/components/team/editor/EditorShortcutsHelp.tsx` | 4 | Shortcuts modal | | 33 | `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | 4 | Breadcrumb |
| 33 | `src/renderer/components/team/editor/fileIcons.ts` | 4 | Иконки файлов | | 34 | `src/renderer/components/team/editor/EditorShortcutsHelp.tsx` | 4 | Shortcuts modal |
| 34 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | 5 | M/U/A/C(conflict) бейджи | | 35 | `src/renderer/components/team/editor/fileIcons.ts` | 4 | Иконки файлов |
| 35 | `src/renderer/utils/editorBridge.ts` | 2 | Module-level singleton: Store ↔ CM6 refs bridge (R3) | | 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") | | 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) | | 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) | | 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-переменные | | 16 | `src/renderer/index.css` | 2 | +editor CSS-переменные |
| 17 | `src/renderer/hooks/useKeyboardShortcuts.ts` | 4 | Guard `editorOpen` для 6 конфликтующих shortcuts (R1) | | 17 | `src/renderer/hooks/useKeyboardShortcuts.ts` | 4 | Guard `editorOpen` для 6 конфликтующих shortcuts (R1) |

View file

@ -8,7 +8,8 @@
## Новые npm-зависимости ## Новые 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 каналы ## IPC каналы
@ -39,6 +40,7 @@
| 14 | `src/renderer/components/team/editor/EditorEmptyState.tsx` | Нет открытых файлов | | 14 | `src/renderer/components/team/editor/EditorEmptyState.tsx` | Нет открытых файлов |
| 15 | `src/renderer/components/team/editor/EditorBinaryState.tsx` | Заглушка для бинарных файлов | | 15 | `src/renderer/components/team/editor/EditorBinaryState.tsx` | Заглушка для бинарных файлов |
| 16 | `src/renderer/components/team/editor/EditorErrorState.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 | | 7 | `src/renderer/components/team/TeamDetailView.tsx` | Кнопка "Open in Editor" + state для overlay |
| 8 | `src/renderer/components/team/review/ReviewFileTree.tsx` | Рефакторинг: использовать generic FileTree + fileTreeBuilder | | 8 | `src/renderer/components/team/review/ReviewFileTree.tsx` | Рефакторинг: использовать generic FileTree + fileTreeBuilder |
| 9 | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Рефакторинг: импорт из codemirrorLanguages/Theme | | 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 | | 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`) | | 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 | | 13 | `src/renderer/store/types.ts` | `EditorSlice` в AppState |
@ -71,7 +73,7 @@
- MAX_ENTRIES_PER_DIR = 500; при превышении -- "N more files..." - MAX_ENTRIES_PER_DIR = 500; при превышении -- "N more files..."
- readFile тиерная стратегия: <256KB мгновенно, 256KB-2MB progress, 2MB-5MB preview, >5MB external - 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 - Дедупликация IPC: `Map<string, Promise<ReadFileResult>>` для readFile
## UX-требования ## UX-требования

View file

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

View file

@ -8,7 +8,8 @@ Git status в дереве файлов. Live refresh при изменения
## Новые npm-зависимости ## Новые 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 каналы ## 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) | | 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) | | 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 бейджи в дереве | | 4 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | M/U/A бейджи в дереве |
@ -57,9 +58,36 @@ Git status в дереве файлов. Live refresh при изменения
## Performance-требования (R4/R5) ## Performance-требования (R4/R5)
- File watcher opt-in: по умолчанию ВЫКЛЮЧЕН. Toggle "Watch for external changes". По умолчанию ручной refresh (F5) - 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) ### chokidar конфигурация
- **Linux**: inotify (надёжность 6/10). При `ENOSPC` → fallback на polling (5-10 сек). НЕ падать, деградировать
```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 - Git status кешировать на 5 секунд. Invalidate по file watcher event
### simple-git конфигурация ### 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` | | 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` | | 3 | `conflictDetection` -- mtime check логика | `test/main/services/editor/conflictDetection.test.ts` |
| 4 | Manual: изменить файл в внешнем редакторе -> conflict banner | — | | 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` + `chokidar` убирают ~300 LOC ручного кода: парсинг porcelain, fs.watch нормализация, ENOSPC fallback. Всё покрыто проверенными пакетами.
- **Уверенность: 9/10** (было 8/10) -- simple-git 7.9M downloads/нед, TypeScript типы, встроенный timeout/abort. Риск минимален. - **Уверенность: 9/10** -- simple-git 7.9M + chokidar 117M downloads/нед. Оба с TypeScript, проверены в production.

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ import { getHomeDir, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { validateRegexPattern } from '@main/utils/regexValidation'; import { validateRegexPattern } from '@main/utils/regexValidation';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs'; import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager'; import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager';
@ -352,21 +353,21 @@ export class ConfigManager {
/** /**
* Loads configuration from disk. * Loads configuration from disk.
* Returns default config if file doesn't exist or is invalid. * Returns default config if file doesn't exist or is invalid.
* Uses a single readFileSync (no TOCTOU from existsSync + readFileSync).
*/ */
private loadConfig(): AppConfig { private loadConfig(): AppConfig {
try { 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 content = fs.readFileSync(this.configPath, 'utf8');
const parsed = JSON.parse(content) as Partial<AppConfig>; const parsed = JSON.parse(content) as Partial<AppConfig>;
// Merge with defaults to ensure all fields exist // Merge with defaults to ensure all fields exist
return this.mergeWithDefaults(parsed); return this.mergeWithDefaults(parsed);
} catch (error) { } 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); 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 { 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); 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 { broadcastEvent } from '@main/http/events';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
import { existsSync, readFileSync } from 'fs'; import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
const logger = createLogger('Service:HttpServer'); const logger = createLogger('Service:HttpServer');
@ -97,8 +98,8 @@ export class HttpServer {
if (rendererPath) { if (rendererPath) {
logger.info(`Serving static files from: ${rendererPath}`); logger.info(`Serving static files from: ${rendererPath}`);
// Cache index.html for SPA fallback // Cache index.html for SPA fallback (async to avoid blocking main thread)
const indexHtml = readFileSync(join(rendererPath, 'index.html'), 'utf-8'); const indexHtml = await readFile(join(rendererPath, 'index.html'), 'utf-8');
await this.app.register(fastifyStatic, { await this.app.register(fastifyStatic, {
root: rendererPath, root: rendererPath,

View file

@ -16,7 +16,6 @@ import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { type BrowserWindow, Notification } from 'electron'; import { type BrowserWindow, Notification } from 'electron';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as fsp from 'fs/promises'; import * as fsp from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
@ -165,10 +164,11 @@ export class NotificationManager extends EventEmitter {
/** /**
* Loads notifications from disk (async to avoid blocking startup). * 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> { private async loadNotifications(): Promise<void> {
try { try {
await fsp.access(NOTIFICATIONS_PATH, fs.constants.F_OK);
const data = await fsp.readFile(NOTIFICATIONS_PATH, 'utf8'); const data = await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
const parsed = JSON.parse(data) as unknown; 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 { private saveNotifications(): void {
try { const data = JSON.stringify(this.notifications, null, 2);
// Ensure directory exists const dir = path.dirname(NOTIFICATIONS_PATH);
const dir = path.dirname(NOTIFICATIONS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(NOTIFICATIONS_PATH, JSON.stringify(this.notifications, null, 2), 'utf8'); fsp
} catch (error) { .mkdir(dir, { recursive: true })
logger.error('Error saving notifications:', error); .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 pathParts = rawPath.split(path.delimiter);
const binaryNames = const binaryNames =
process.platform === 'win32' ? expandWindowsBinaryNames(binaryName) : [binaryName]; process.platform === 'win32' ? expandWindowsBinaryNames(binaryName) : [binaryName];
for (const part of pathParts) {
if (!part) {
continue;
}
const cleanedPart = stripSurroundingQuotes(part); // Check all PATH directories in parallel. Each directory checks all extension
if (!cleanedPart) { // variants concurrently. This turns N_dirs × N_exts sequential stat() calls
continue; // 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 candidates = binaryNames.map((name) => path.join(cleanedPart, name));
const candidate = path.join(cleanedPart, name); const results = await Promise.all(
if (await isExecutable(candidate)) { candidates.map(async (candidate) => ({
return candidate; path: candidate,
} ok: await isExecutable(candidate),
} }))
} );
return null; // 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> { async function resolveFromExplicitPath(inputPath: string): Promise<string | null> {
@ -186,11 +192,20 @@ export class ClaudeBinaryResolver {
); );
const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates(); const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates();
for (const candidate of [...candidates, ...nvmCandidates]) { const allCandidates = [...candidates, ...nvmCandidates];
if (await isExecutable(candidate)) {
cachedPath = candidate; // Check all fallback candidates in parallel for speed
return cachedPath; 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 // 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. // Initialize IPC listeners and start sequential data fetch chain.
// On Windows, firing 6+ IPC calls simultaneously at startup saturates the // No delay needed: UV_THREADPOOL_SIZE=16 prevents thread pool saturation,
// UV thread pool (4 threads by default), causing the app to freeze. // and the init chain fetches data sequentially to avoid concurrent I/O spikes.
// Context system init is skipped here — local context is ready by default,
// and SSH context is initialized lazily when SSH connects (see below).
useEffect(() => { useEffect(() => {
let cleanup: (() => void) | undefined; const cleanup = initializeNotificationListeners();
const timer = setTimeout(() => { return cleanup;
cleanup = initializeNotificationListeners();
}, 100);
return () => {
clearTimeout(timer);
cleanup?.();
};
}, []); }, []);
// Initialize context system lazily when SSH connection state changes. // 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 [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false);
const worktreeDropdownRef = useRef<HTMLDivElement>(null); 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(() => { useEffect(() => {
if (viewMode === 'grouped' && repositoryGroups.length === 0) { if (viewMode === 'grouped' && repositoryGroups.length === 0 && !repositoryGroupsLoading) {
void fetchRepositoryGroups(); void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0) { } else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading) {
void fetchProjects(); void fetchProjects();
} }
}, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]); }, [
viewMode,
repositoryGroups.length,
projects.length,
repositoryGroupsLoading,
projectsLoading,
fetchRepositoryGroups,
fetchProjects,
]);
// Project combobox options // Project combobox options
const projectComboboxOptions = useMemo((): ComboboxOption[] => { const projectComboboxOptions = useMemo((): ComboboxOption[] => {

View file

@ -128,12 +128,14 @@ export const GlobalTaskList = ({
saveGroupingMode(mode); 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(() => { useEffect(() => {
if (!hasFetchedRef.current) { if (!hasFetchedRef.current && !globalTasksLoading) {
hasFetchedRef.current = true; hasFetchedRef.current = true;
void fetchAllTasks(); void fetchAllTasks();
} }
}, [fetchAllTasks]); }, [fetchAllTasks, globalTasksLoading]);
// Build project combobox options from available projects/repos // Build project combobox options from available projects/repos
const projectFilterOptions = useMemo((): ComboboxOption[] => { const projectFilterOptions = useMemo((): ComboboxOption[] => {

View file

@ -39,12 +39,15 @@ export function useTheme(): {
return 'dark'; 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(() => { useEffect(() => {
if (!appConfig) { if (!appConfig && !configLoading) {
void fetchConfig(); void fetchConfig();
} }
}, [appConfig, fetchConfig]); }, [appConfig, configLoading, fetchConfig]);
// Get configured theme // Get configured theme
const configuredTheme: Theme = appConfig?.general?.theme ?? 'dark'; const configuredTheme: Theme = appConfig?.general?.theme ?? 'dark';

View file

@ -73,18 +73,41 @@ export function initializeNotificationListeners(): () => void {
cleanupFns.push(() => { cleanupFns.push(() => {
useStore.getState().unsubscribeProvisioningProgress(); useStore.getState().unsubscribeProvisioningProgress();
}); });
// Stagger IPC data fetches to avoid saturating the UV thread pool on Windows. // Initial data fetches. Config loads first (needed for theme), then the rest
// Each fetch triggers file I/O in the main process; firing them all at once // run in parallel (no data dependencies between them). UV_THREADPOOL_SIZE=16
// blocks all 4 default UV threads simultaneously, freezing the app. // prevents thread pool saturation even with concurrent I/O on Windows.
void useStore // Components also fire these from useEffect — loading guards in each action
.getState() // prevent duplicate IPC calls (whichever caller starts first wins).
.fetchTeams() void (async () => {
.finally(() => { // Config: fast (in-memory read) — needed for theme before first paint.
void useStore.getState().fetchNotifications(); await useStore.getState().fetchConfig();
if (api.cliInstaller) { // Remaining fetches have no data dependency on each other — run in parallel
void useStore.getState().fetchCliStatus(); // 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 pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>(); const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamRefreshTimer: ReturnType<typeof setTimeout> | null = null; 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). * 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 // Listen for CLI installer progress events from main process
let cliCompletedRevertTimer: ReturnType<typeof setTimeout> | null = null; 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 // Fetch app configuration from main process
fetchConfig: async () => { fetchConfig: async () => {
// Guard: prevent concurrent fetches (useTheme + centralized init chain)
if (get().configLoading) return;
set({ configLoading: true, configError: null }); set({ configLoading: true, configError: null });
try { try {
const config = await api.config.get(); const config = await api.config.get();

View file

@ -39,6 +39,8 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
// Fetch all projects from main process // Fetch all projects from main process
fetchProjects: async () => { fetchProjects: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain)
if (get().projectsLoading) return;
set({ projectsLoading: true, projectsError: null }); set({ projectsLoading: true, projectsError: null });
try { try {
const projects = await api.getProjects(); 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) // Fetch all repository groups (projects grouped by git repo)
fetchRepositoryGroups: async () => { fetchRepositoryGroups: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain)
if (get().repositoryGroupsLoading) return;
set({ repositoryGroupsLoading: true, repositoryGroupsError: null }); set({ repositoryGroupsLoading: true, repositoryGroupsError: null });
try { try {
const groups = await api.getRepositoryGroups(); const groups = await api.getRepositoryGroups();

View file

@ -212,6 +212,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
deletedTasksLoading: false, deletedTasksLoading: false,
fetchTeams: async () => { 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 // Only show loading spinner on initial load — avoids flickering when refreshing
const isInitialLoad = get().teams.length === 0; const isInitialLoad = get().teams.length === 0;
if (isInitialLoad) { if (isInitialLoad) {
@ -236,6 +240,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}, },
fetchAllTasks: async () => { fetchAllTasks: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain)
if (get().globalTasksLoading) return;
const isInitialLoad = get().globalTasks.length === 0; const isInitialLoad = get().globalTasks.length === 0;
if (isInitialLoad) { if (isInitialLoad) {
set({ globalTasksLoading: true, globalTasksError: null }); set({ globalTasksLoading: true, globalTasksError: null });

View file

@ -9,6 +9,13 @@ const hoisted = vi.hoisted(() => ({
vi.mock('@renderer/api', () => ({ vi.mock('@renderer/api', () => ({
api: { api: {
config: {
get: vi.fn(async () => ({
general: { theme: 'dark' },
notifications: { enabled: true, triggers: [] },
})),
},
getRepositoryGroups: vi.fn(async () => []),
notifications: { notifications: {
onNew: vi.fn(() => () => undefined), onNew: vi.fn(() => () => undefined),
onUpdated: vi.fn(() => () => undefined), onUpdated: vi.fn(() => () => undefined),
@ -39,6 +46,7 @@ vi.mock('@renderer/api', () => ({
} }
), ),
getAllTasks: vi.fn(async () => []), getAllTasks: vi.fn(async () => []),
list: vi.fn(async () => []),
}, },
}, },
})); }));
@ -48,7 +56,7 @@ import { initializeNotificationListeners, useStore } from '../../../src/renderer
describe('team change throttling', () => { describe('team change throttling', () => {
let cleanup: (() => void) | null = null; let cleanup: (() => void) | null = null;
beforeEach(() => { beforeEach(async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const fetchTeams = vi.fn(async () => undefined); const fetchTeams = vi.fn(async () => undefined);
const refreshTeamData = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined);
@ -70,6 +78,10 @@ describe('team change throttling', () => {
} as never); } as never);
cleanup = initializeNotificationListeners(); 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(() => { afterEach(() => {