diff --git a/docs/iterations/edit-project/README.md b/docs/iterations/edit-project/README.md index 7c637ffe..3623c2af 100644 --- a/docs/iterations/edit-project/README.md +++ b/docs/iterations/edit-project/README.md @@ -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 итераций) diff --git a/docs/iterations/edit-project/architecture.md b/docs/iterations/edit-project/architecture.md index 2c0d81c3..60711a60 100644 --- a/docs/iterations/edit-project/architecture.md +++ b/docs/iterations/edit-project/architecture.md @@ -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; + openFile: (filePath: string) => Promise; // 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 { - 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 { ```typescript // flattenTree преобразует иерархию в плоский массив для виртуализации -function flattenTree(tree: FileTreeEntry[], expandedDirs: Set): FlatNode[] { ... } +function flattenTree(tree: FileTreeEntry[], expandedDirs: Record): FlatNode[] { ... } // В компоненте: const flatNodes = useMemo(() => flattenTree(tree, expandedDirs), [tree, expandedDirs]); diff --git a/docs/iterations/edit-project/file-list.md b/docs/iterations/edit-project/file-list.md index 3fce21a6..9db766db 100644 --- a/docs/iterations/edit-project/file-list.md +++ b/docs/iterations/edit-project/file-list.md @@ -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) | diff --git a/docs/iterations/edit-project/iter-1-walking-skeleton.md b/docs/iterations/edit-project/iter-1-walking-skeleton.md index 750913eb..bfdf6b7f 100644 --- a/docs/iterations/edit-project/iter-1-walking-skeleton.md +++ b/docs/iterations/edit-project/iter-1-walking-skeleton.md @@ -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>` для readFile ## UX-требования diff --git a/docs/iterations/edit-project/iter-3-multi-tab-crud.md b/docs/iterations/edit-project/iter-3-multi-tab-crud.md index aaf544f4..46dd4f66 100644 --- a/docs/iterations/edit-project/iter-3-multi-tab-crud.md +++ b/docs/iterations/edit-project/iter-3-multi-tab-crud.md @@ -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-требования diff --git a/docs/iterations/edit-project/iter-5-git-watching.md b/docs/iterations/edit-project/iter-5-git-watching.md index c4cd4f4d..d33f661d 100644 --- a/docs/iterations/edit-project/iter-5-git-watching.md +++ b/docs/iterations/edit-project/iter-5-git-watching.md @@ -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. diff --git a/src/main/http/utility.ts b/src/main/http/utility.ts index efd6228a..b3aa982f 100644 --- a/src/main/http/utility.ts +++ b/src/main/http/utility.ts @@ -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) { diff --git a/src/main/http/validation.ts b/src/main/http/validation.ts index 12863622..c9d9ddcd 100644 --- a/src/main/http/validation.ts +++ b/src/main/http/validation.ts @@ -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(); - 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); } ); diff --git a/src/main/index.ts b/src/main/index.ts index 1c7253f6..db9e7d37 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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'; diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts index 2746fe35..2ff7642a 100644 --- a/src/main/ipc/utility.ts +++ b/src/main/ipc/utility.ts @@ -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 { + 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); diff --git a/src/main/ipc/validation.ts b/src/main/ipc/validation.ts index edf52760..852ddcb6 100644 --- a/src/main/ipc/validation.ts +++ b/src/main/ipc/validation.ts @@ -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> { - const results = new Map(); + // 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); } /** diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 64f1dca8..645213a9 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -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; // 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); + }); } /** diff --git a/src/main/services/infrastructure/HttpServer.ts b/src/main/services/infrastructure/HttpServer.ts index 597675f6..f4469432 100644 --- a/src/main/services/infrastructure/HttpServer.ts +++ b/src/main/services/infrastructure/HttpServer.ts @@ -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, diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index cacf3d6f..b1f8a2bc 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -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 { 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); + }); } /** diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index 90e345fd..af7d9f7b 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -84,24 +84,30 @@ async function resolveFromPathEnv(binaryName: string): Promise { 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 { @@ -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 diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1223d102..ece32b9b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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. diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 56014bee..c72701fc 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -236,14 +236,26 @@ export const DateGroupedSessions = (): React.JSX.Element => { const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false); const worktreeDropdownRef = useRef(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[] => { diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index c1248b55..36925236 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -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[] => { diff --git a/src/renderer/hooks/useTheme.ts b/src/renderer/hooks/useTheme.ts index c9d2f4c9..dc6bc9f3 100644 --- a/src/renderer/hooks/useTheme.ts +++ b/src/renderer/hooks/useTheme.ts @@ -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'; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index b5eab735..6178936e 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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 | null = null; + if (api.cliInstaller) { + cliStatusTimer = setTimeout(() => { + void useStore.getState().fetchCliStatus(); + cliStatusTimer = null; + }, 5000); + } + cleanupFns.push(() => { + if (cliStatusTimer) clearTimeout(cliStatusTimer); + }); const pendingSessionRefreshTimers = new Map>(); const pendingProjectRefreshTimers = new Map>(); let teamRefreshTimer: ReturnType | 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 | null = null; diff --git a/src/renderer/store/slices/configSlice.ts b/src/renderer/store/slices/configSlice.ts index 0cdc5320..66122ad5 100644 --- a/src/renderer/store/slices/configSlice.ts +++ b/src/renderer/store/slices/configSlice.ts @@ -42,6 +42,8 @@ export const createConfigSlice: StateCreator = (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(); diff --git a/src/renderer/store/slices/projectSlice.ts b/src/renderer/store/slices/projectSlice.ts index bbcdf440..e02426e1 100644 --- a/src/renderer/store/slices/projectSlice.ts +++ b/src/renderer/store/slices/projectSlice.ts @@ -39,6 +39,8 @@ export const createProjectSlice: StateCreator = // 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(); diff --git a/src/renderer/store/slices/repositorySlice.ts b/src/renderer/store/slices/repositorySlice.ts index f9b1b4da..d133928a 100644 --- a/src/renderer/store/slices/repositorySlice.ts +++ b/src/renderer/store/slices/repositorySlice.ts @@ -51,6 +51,8 @@ export const createRepositorySlice: StateCreator { + // Guard: prevent concurrent fetches (component mount + centralized init chain) + if (get().repositoryGroupsLoading) return; set({ repositoryGroupsLoading: true, repositoryGroupsError: null }); try { const groups = await api.getRepositoryGroups(); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 12cdc62f..7ec5a138 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -212,6 +212,10 @@ export const createTeamSlice: StateCreator = (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 = (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 }); diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index d02db025..b781c62e 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -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(() => {