From 1a928a68de48c557a3980e3aee485e342c198fdb Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Feb 2026 14:59:53 +0200 Subject: [PATCH] feat: integrate simple-git for enhanced Git status tracking and improve file watcher performance - Replaced direct Git command usage with `simple-git` for more reliable status tracking, including support for renamed files and conflict detection. - Updated IPC channels to reflect changes in Git status retrieval method. - Enhanced file watcher initialization on Windows to prevent UV thread pool saturation by starting watchers sequentially. - Improved application startup by staggering context system initialization and notification listeners to optimize performance. --- docs/iterations/edit-project/README.md | 2 +- docs/iterations/edit-project/architecture.md | 13 ++++- docs/iterations/edit-project/file-list.md | 2 +- .../edit-project/iter-5-git-watching.md | 55 ++++++++++++++----- src/main/index.ts | 9 ++- .../services/infrastructure/FileWatcher.ts | 25 ++++++--- src/renderer/App.tsx | 22 +++++--- 7 files changed, 92 insertions(+), 36 deletions(-) diff --git a/docs/iterations/edit-project/README.md b/docs/iterations/edit-project/README.md index 9d1ad8d4..7c637ffe 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, контекстное меню) — для встроенного Cmd+F поиска в файле и CRUD-меню. Остальное уже установлено +- **Новые npm-зависимости**: `@codemirror/search` (~15KB gzipped), `@radix-ui/react-context-menu` (итерация 3, контекстное меню), `simple-git` v3.32+ (итерация 5, git status). Остальное уже установлено ## Ключевые архитектурные решения diff --git a/docs/iterations/edit-project/architecture.md b/docs/iterations/edit-project/architecture.md index 90cfbb69..2c0d81c3 100644 --- a/docs/iterations/edit-project/architecture.md +++ b/docs/iterations/edit-project/architecture.md @@ -431,7 +431,7 @@ const treeLoading = useStore(s => s.editorFileTreeLoading); // FileTreePanel | `editor:createDir` | 3 | renderer -> main | `(parentDir: string, name: string)` -> `IpcResult` | Создание директории | | `editor:deleteFile` | 3 | renderer -> main | `(filePath: string)` -> `IpcResult` | Удаление через shell.trashItem() | | `editor:searchInFiles` | 4 | renderer -> main | `(query: string, options?: { caseSensitive?: boolean })` -> `IpcResult` | Literal search, default case-insensitive (как SessionSearcher), max 100 results. Кнопка "Aa" в UI для toggle | -| `editor:gitStatus` | 5 | renderer -> main | `()` -> `IpcResult` | git status --porcelain, кеш 5 сек | +| `editor:gitStatus` | 5 | renderer -> main | `()` -> `IpcResult` | git status через `simple-git`, кеш 5 сек | | `editor:watchDir` | 5 | renderer -> main | `()` -> `IpcResult` | Запуск file watcher | | `editor:change` | 5 | main -> renderer | event: `EditorFileChangeEvent` | Файл изменился на диске | @@ -463,8 +463,15 @@ interface ReadFileResult { interface GitFileStatus { path: string; - status: 'modified' | 'untracked' | 'staged' | 'deleted' | 'conflict'; - // 'conflict' = merge conflicts (git porcelain codes UU, AA, DD) + status: 'modified' | 'untracked' | 'staged' | 'deleted' | 'renamed' | 'conflict'; + // Маппинг из simple-git StatusResult: + // status.modified → 'modified' + // status.not_added → 'untracked' + // status.staged → 'staged' + // status.deleted → 'deleted' + // status.renamed → 'renamed' (with from/to) + // status.conflicted → 'conflict' + renamedFrom?: string; // Только для 'renamed' } interface SearchResult { diff --git a/docs/iterations/edit-project/file-list.md b/docs/iterations/edit-project/file-list.md index 93ff5425..3fce21a6 100644 --- a/docs/iterations/edit-project/file-list.md +++ b/docs/iterations/edit-project/file-list.md @@ -100,7 +100,7 @@ Benchmark 5: Keystroke re-renders | 2 | `src/main/services/editor/ProjectFileService.ts` | 1 | Stateless файловый сервис | | 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 --porcelain | +| 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) | | 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 | diff --git a/docs/iterations/edit-project/iter-5-git-watching.md b/docs/iterations/edit-project/iter-5-git-watching.md index a04d29ed..c4cd4f4d 100644 --- a/docs/iterations/edit-project/iter-5-git-watching.md +++ b/docs/iterations/edit-project/iter-5-git-watching.md @@ -6,11 +6,15 @@ Git status в дереве файлов. Live refresh при изменениях на диске. Conflict detection при сохранении. Line wrap toggle. +## Новые npm-зависимости + +`simple-git` v3.32+ (`pnpm add simple-git`) — обёртка над git CLI с TypeScript типами, parsed StatusResult, встроенным timeout/abort. 7.9M downloads/нед, dual ESM/CJS, не native module. + ## IPC каналы | Канал | Описание | |-------|----------| -| `editor:gitStatus` | `git --no-optional-locks status --porcelain -z --untracked-files=normal --ignore-submodules`, кеш 5 сек | +| `editor:gitStatus` | Git status через `simple-git` (v3.32+), кеш 5 сек | | `editor:watchDir` | Запуск file watcher (opt-in, НЕ по умолчанию) | | `editor:change` | Event: файл изменился на диске (main -> renderer) | @@ -19,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 | -| 2 | `src/main/services/editor/GitStatusService.ts` | `git --no-optional-locks status --porcelain -z` парсинг (NUL-separated), кеш 5 сек. Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` (~200-250 LOC) | +| 2 | `src/main/services/editor/GitStatusService.ts` | Git status через `simple-git` с `StatusResult` маппингом, кеш 5 сек. Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` (~80-100 LOC) | | 3 | `src/main/services/editor/conflictDetection.ts` | Утилита mtime check: сравнение mtime до/после save, conflict resolution (~40 LOC) | | 4 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | M/U/A бейджи в дереве | @@ -57,16 +61,37 @@ Git status в дереве файлов. Live refresh при изменения - **macOS**: FSEvents reliable (надёжность 9/10) - **Linux**: inotify (надёжность 6/10). При `ENOSPC` → fallback на polling (5-10 сек). НЕ падать, деградировать - Git status кешировать на 5 секунд. Invalidate по file watcher event -- Git command: `git --no-optional-locks status --porcelain -z --untracked-files=normal --ignore-submodules` - - `--no-optional-locks` — предотвращает `.git/index.lock` конфликты (критично!) - - `-z` — NUL-separated вывод (безопасный парсинг путей с пробелами). **Важные особенности парсинга**: - - Записи разделены NUL (`\0`), НЕ newline - - Rename/copy (R/C коды): 2 пути через NUL — `R old\0new\0` (3 записи в сплите, считывать парами) - - Conflict коды: `UU` (both modified), `AA` (both added), `DD` (both deleted) → маппить на `status: 'conflict'` - - Untracked: `?? path\0` → `status: 'untracked'` - - Формат каждой записи: `XY path` где X=index status, Y=worktree status (2 символа + пробел + путь) - - `--ignore-submodules` — ускорение на монорепо -- Timeout: 10 секунд (паттерн из GitDiffFallback.ts), AbortSignal + +### simple-git конфигурация + +```typescript +// src/main/services/editor/GitStatusService.ts +import { simpleGit, StatusResult, SimpleGit } from 'simple-git'; + +// Создать инстанс с --no-optional-locks + timeout +const createGit = (projectRoot: string): SimpleGit => + simpleGit({ + baseDir: projectRoot, + timeout: { block: 10_000 }, // 10s (паттерн из GitDiffFallback.ts) + }).env('GIT_OPTIONAL_LOCKS', '0'); // эквивалент --no-optional-locks + +// Маппинг StatusResult → GitFileStatus[] +function mapStatus(result: StatusResult): GitFileStatus[] { + const files: GitFileStatus[] = []; + for (const p of result.modified) files.push({ path: p, status: 'modified' }); + for (const p of result.not_added) files.push({ path: p, status: 'untracked' }); + for (const p of result.staged) files.push({ path: p, status: 'staged' }); + for (const p of result.deleted) files.push({ path: p, status: 'deleted' }); + for (const p of result.conflicted) files.push({ path: p, status: 'conflict' }); + for (const r of result.renamed) files.push({ path: r.to, status: 'renamed', renamedFrom: r.from }); + return files; +} +``` + +- **`GIT_OPTIONAL_LOCKS=0`** — предотвращает `.git/index.lock` конфликты (критично для фоновых запросов!) +- **`timeout.block: 10_000`** — SIGINT после 10 сек без вывода +- **Парсинг не нужен** — `simple-git` делает полный парсинг porcelain вывода включая renamed, conflicts, ahead/behind +- Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` для проверки наличия `.git` - Graceful degradation: - Нет git → скрыть git бейджи, "Git not available" в status bar - Не git-repo → скрыть git бейджи @@ -83,7 +108,7 @@ Git status в дереве файлов. Live refresh при изменения | # | Что тестировать | Файл | |---|----------------|------| -| 1 | `GitStatusService` -- парсинг `git status --porcelain` вывода | `test/main/services/editor/GitStatusService.test.ts` | +| 1 | `GitStatusService` -- маппинг `simple-git` StatusResult → GitFileStatus[], кеш, graceful degradation | `test/main/services/editor/GitStatusService.test.ts` | | 2 | `EditorFileWatcher` -- debounce, event types | `test/main/services/editor/EditorFileWatcher.test.ts` | | 3 | `conflictDetection` -- mtime check логика | `test/main/services/editor/conflictDetection.test.ts` | | 4 | Manual: изменить файл в внешнем редакторе -> conflict banner | — | @@ -97,5 +122,5 @@ Git status в дереве файлов. Live refresh при изменения ## Оценка -- **Надёжность решения: 7/10** (было 6/10) -- R4/R5 ресёрч закрыл ключевые пробелы: `--no-optional-locks`, ENOSPC fallback, burst coalescing. Race conditions остаются, но митигированы. -- **Уверенность: 8/10** (было 7/10) -- паттерны FileWatcher + GitDiffFallback изучены, переиспользование кода подтверждено. +- **Надёжность решения: 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. Риск минимален. diff --git a/src/main/index.ts b/src/main/index.ts index 66a74fd7..1c7253f6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -663,7 +663,14 @@ function createWindow(): void { // Deferred from initializeServices() to avoid blocking window creation // with fs.watch() setup (especially slow on Windows with recursive watchers). const activeContext = contextRegistry.getActive(); - activeContext.startFileWatcher(); + if (process.platform === 'win32') { + // On Windows, delay FileWatcher startup to let the renderer complete + // its initial IPC calls without UV thread pool contention. Recursive + // fs.watch() on NTFS saturates all 4 default UV threads. + setTimeout(() => activeContext.startFileWatcher(), 1500); + } else { + activeContext.startFileWatcher(); + } setTimeout(() => updaterService.checkForUpdates(), 3000); diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index d843134d..65518930 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -428,13 +428,24 @@ export class FileWatcher extends EventEmitter { return; } - // Start all watchers in parallel to minimize total startup latency - await Promise.all([ - this.startProjectsWatcher(), - this.startTodosWatcher(), - this.startTeamsWatcher(), - this.startTasksWatcher(), - ]); + if (process.platform === 'win32') { + // On Windows, start watchers sequentially to avoid saturating the UV + // thread pool (4 threads by default). Recursive fs.watch() on NTFS is + // significantly slower than on macOS/Linux and can block all threads + // simultaneously when started in parallel, freezing the app. + await this.startProjectsWatcher(); + await this.startTodosWatcher(); + await this.startTeamsWatcher(); + await this.startTasksWatcher(); + } else { + // On macOS/Linux, start all watchers in parallel to minimize total startup latency + await Promise.all([ + this.startProjectsWatcher(), + this.startTodosWatcher(), + this.startTeamsWatcher(), + this.startTasksWatcher(), + ]); + } if (!this.projectsWatcher || !this.todosWatcher || !this.teamsWatcher || !this.tasksWatcher) { this.scheduleWatcherRetry(); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 764da802..34f45534 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -23,9 +23,21 @@ export const App = (): React.JSX.Element => { } }, []); - // Initialize context system (before notification listeners) + // Initialize context system first, then notification listeners. + // Staggered to avoid flooding the main process with 6+ simultaneous IPC + // calls at startup, which saturates the UV thread pool on Windows. useEffect(() => { - void useStore.getState().initializeContextSystem(); + let notificationCleanup: (() => void) | undefined; + + void useStore + .getState() + .initializeContextSystem() + .finally(() => { + // Start notification listeners after context system is ready + notificationCleanup = initializeNotificationListeners(); + }); + + return () => notificationCleanup?.(); }, []); // Refresh available contexts when SSH connection state changes @@ -37,12 +49,6 @@ export const App = (): React.JSX.Element => { return cleanup; }, []); - // Initialize IPC event listeners (notifications, file changes) - useEffect(() => { - const cleanup = initializeNotificationListeners(); - return cleanup; - }, []); - return (