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:
parent
736ec470d9
commit
0cb85d463c
25 changed files with 313 additions and 178 deletions
|
|
@ -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 итераций)
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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-требования
|
||||
|
|
|
|||
|
|
@ -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-требования
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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[] => {
|
||||
|
|
|
|||
|
|
@ -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[] => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue