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.
This commit is contained in:
parent
a58d50c749
commit
1a928a68de
7 changed files with 92 additions and 36 deletions
|
|
@ -12,7 +12,7 @@
|
||||||
- **State**: Zustand slice (`editorSlice.ts`)
|
- **State**: Zustand slice (`editorSlice.ts`)
|
||||||
- **Виртуализация**: `@tanstack/react-virtual` (уже в проекте)
|
- **Виртуализация**: `@tanstack/react-virtual` (уже в проекте)
|
||||||
- **Fuzzy search**: `cmdk` v1.0.4 (уже в зависимостях)
|
- **Fuzzy search**: `cmdk` v1.0.4 (уже в зависимостях)
|
||||||
- **Новые npm-зависимости**: `@codemirror/search` (~15KB gzipped), `@radix-ui/react-context-menu` (итерация 3, контекстное меню) — для встроенного Cmd+F поиска в файле и CRUD-меню. Остальное уже установлено
|
- **Новые npm-зависимости**: `@codemirror/search` (~15KB gzipped), `@radix-ui/react-context-menu` (итерация 3, контекстное меню), `simple-git` v3.32+ (итерация 5, git status). Остальное уже установлено
|
||||||
|
|
||||||
## Ключевые архитектурные решения
|
## Ключевые архитектурные решения
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -431,7 +431,7 @@ const treeLoading = useStore(s => s.editorFileTreeLoading); // FileTreePanel
|
||||||
| `editor:createDir` | 3 | renderer -> main | `(parentDir: string, name: string)` -> `IpcResult<void>` | Создание директории |
|
| `editor:createDir` | 3 | renderer -> main | `(parentDir: string, name: string)` -> `IpcResult<void>` | Создание директории |
|
||||||
| `editor:deleteFile` | 3 | renderer -> main | `(filePath: string)` -> `IpcResult<void>` | Удаление через shell.trashItem() |
|
| `editor:deleteFile` | 3 | renderer -> main | `(filePath: string)` -> `IpcResult<void>` | Удаление через shell.trashItem() |
|
||||||
| `editor:searchInFiles` | 4 | renderer -> main | `(query: string, options?: { caseSensitive?: boolean })` -> `IpcResult<SearchResult[]>` | Literal search, default case-insensitive (как SessionSearcher), max 100 results. Кнопка "Aa" в UI для toggle |
|
| `editor:searchInFiles` | 4 | renderer -> main | `(query: string, options?: { caseSensitive?: boolean })` -> `IpcResult<SearchResult[]>` | Literal search, default case-insensitive (как SessionSearcher), max 100 results. Кнопка "Aa" в UI для toggle |
|
||||||
| `editor:gitStatus` | 5 | renderer -> main | `()` -> `IpcResult<GitFileStatus[]>` | git status --porcelain, кеш 5 сек |
|
| `editor:gitStatus` | 5 | renderer -> main | `()` -> `IpcResult<GitFileStatus[]>` | git status через `simple-git`, кеш 5 сек |
|
||||||
| `editor:watchDir` | 5 | renderer -> main | `()` -> `IpcResult<void>` | Запуск file watcher |
|
| `editor:watchDir` | 5 | renderer -> main | `()` -> `IpcResult<void>` | Запуск file watcher |
|
||||||
| `editor:change` | 5 | main -> renderer | event: `EditorFileChangeEvent` | Файл изменился на диске |
|
| `editor:change` | 5 | main -> renderer | event: `EditorFileChangeEvent` | Файл изменился на диске |
|
||||||
|
|
||||||
|
|
@ -463,8 +463,15 @@ interface ReadFileResult {
|
||||||
|
|
||||||
interface GitFileStatus {
|
interface GitFileStatus {
|
||||||
path: string;
|
path: string;
|
||||||
status: 'modified' | 'untracked' | 'staged' | 'deleted' | 'conflict';
|
status: 'modified' | 'untracked' | 'staged' | 'deleted' | 'renamed' | 'conflict';
|
||||||
// 'conflict' = merge conflicts (git porcelain codes UU, AA, DD)
|
// Маппинг из 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 {
|
interface SearchResult {
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ Benchmark 5: Keystroke re-renders
|
||||||
| 2 | `src/main/services/editor/ProjectFileService.ts` | 1 | Stateless файловый сервис |
|
| 2 | `src/main/services/editor/ProjectFileService.ts` | 1 | Stateless файловый сервис |
|
||||||
| 3 | `src/main/services/editor/index.ts` | 1 | Barrel export: `{ ProjectFileService }` (расширяется в итерациях 4-5) |
|
| 3 | `src/main/services/editor/index.ts` | 1 | Barrel export: `{ ProjectFileService }` (расширяется в итерациях 4-5) |
|
||||||
| 4 | `src/main/services/editor/FileSearchService.ts` | 4 | Search in files |
|
| 4 | `src/main/services/editor/FileSearchService.ts` | 4 | Search in files |
|
||||||
| 5 | `src/main/services/editor/GitStatusService.ts` | 5 | git status --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) |
|
| 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) |
|
| 7 | `src/main/services/editor/conflictDetection.ts` | 5 | Утилита mtime check: сравнение mtime до/после save, conflict resolution (~40 LOC) |
|
||||||
| 8 | `src/main/ipc/editor.ts` | 1 | IPC handlers |
|
| 8 | `src/main/ipc/editor.ts` | 1 | IPC handlers |
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,15 @@
|
||||||
|
|
||||||
Git status в дереве файлов. Live refresh при изменениях на диске. Conflict detection при сохранении. Line wrap toggle.
|
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 каналы
|
## 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:watchDir` | Запуск file watcher (opt-in, НЕ по умолчанию) |
|
||||||
| `editor:change` | Event: файл изменился на диске (main -> renderer) |
|
| `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 |
|
| 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) |
|
| 3 | `src/main/services/editor/conflictDetection.ts` | Утилита mtime check: сравнение mtime до/после save, conflict resolution (~40 LOC) |
|
||||||
| 4 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | M/U/A бейджи в дереве |
|
| 4 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | M/U/A бейджи в дереве |
|
||||||
|
|
||||||
|
|
@ -57,16 +61,37 @@ Git status в дереве файлов. Live refresh при изменения
|
||||||
- **macOS**: FSEvents reliable (надёжность 9/10)
|
- **macOS**: FSEvents reliable (надёжность 9/10)
|
||||||
- **Linux**: inotify (надёжность 6/10). При `ENOSPC` → fallback на polling (5-10 сек). НЕ падать, деградировать
|
- **Linux**: inotify (надёжность 6/10). При `ENOSPC` → fallback на polling (5-10 сек). НЕ падать, деградировать
|
||||||
- Git status кешировать на 5 секунд. Invalidate по file watcher event
|
- 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` конфликты (критично!)
|
### simple-git конфигурация
|
||||||
- `-z` — NUL-separated вывод (безопасный парсинг путей с пробелами). **Важные особенности парсинга**:
|
|
||||||
- Записи разделены NUL (`\0`), НЕ newline
|
```typescript
|
||||||
- Rename/copy (R/C коды): 2 пути через NUL — `R old\0new\0` (3 записи в сплите, считывать парами)
|
// src/main/services/editor/GitStatusService.ts
|
||||||
- Conflict коды: `UU` (both modified), `AA` (both added), `DD` (both deleted) → маппить на `status: 'conflict'`
|
import { simpleGit, StatusResult, SimpleGit } from 'simple-git';
|
||||||
- Untracked: `?? path\0` → `status: 'untracked'`
|
|
||||||
- Формат каждой записи: `XY path` где X=index status, Y=worktree status (2 символа + пробел + путь)
|
// Создать инстанс с --no-optional-locks + timeout
|
||||||
- `--ignore-submodules` — ускорение на монорепо
|
const createGit = (projectRoot: string): SimpleGit =>
|
||||||
- Timeout: 10 секунд (паттерн из GitDiffFallback.ts), AbortSignal
|
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:
|
- Graceful degradation:
|
||||||
- Нет git → скрыть git бейджи, "Git not available" в status bar
|
- Нет git → скрыть git бейджи, "Git not available" в status bar
|
||||||
- Не git-repo → скрыть git бейджи
|
- Не 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` |
|
| 2 | `EditorFileWatcher` -- debounce, event types | `test/main/services/editor/EditorFileWatcher.test.ts` |
|
||||||
| 3 | `conflictDetection` -- mtime check логика | `test/main/services/editor/conflictDetection.test.ts` |
|
| 3 | `conflictDetection` -- mtime check логика | `test/main/services/editor/conflictDetection.test.ts` |
|
||||||
| 4 | Manual: изменить файл в внешнем редакторе -> conflict banner | — |
|
| 4 | Manual: изменить файл в внешнем редакторе -> conflict banner | — |
|
||||||
|
|
@ -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) -- `simple-git` убирает ~120 LOC ручного парсинга, conflict/renamed detection из коробки. ENOSPC fallback и burst coalescing проработаны.
|
||||||
- **Уверенность: 8/10** (было 7/10) -- паттерны FileWatcher + GitDiffFallback изучены, переиспользование кода подтверждено.
|
- **Уверенность: 9/10** (было 8/10) -- simple-git 7.9M downloads/нед, TypeScript типы, встроенный timeout/abort. Риск минимален.
|
||||||
|
|
|
||||||
|
|
@ -663,7 +663,14 @@ function createWindow(): void {
|
||||||
// Deferred from initializeServices() to avoid blocking window creation
|
// Deferred from initializeServices() to avoid blocking window creation
|
||||||
// with fs.watch() setup (especially slow on Windows with recursive watchers).
|
// with fs.watch() setup (especially slow on Windows with recursive watchers).
|
||||||
const activeContext = contextRegistry.getActive();
|
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);
|
setTimeout(() => updaterService.checkForUpdates(), 3000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -428,13 +428,24 @@ export class FileWatcher extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start all watchers in parallel to minimize total startup latency
|
if (process.platform === 'win32') {
|
||||||
await Promise.all([
|
// On Windows, start watchers sequentially to avoid saturating the UV
|
||||||
this.startProjectsWatcher(),
|
// thread pool (4 threads by default). Recursive fs.watch() on NTFS is
|
||||||
this.startTodosWatcher(),
|
// significantly slower than on macOS/Linux and can block all threads
|
||||||
this.startTeamsWatcher(),
|
// simultaneously when started in parallel, freezing the app.
|
||||||
this.startTasksWatcher(),
|
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) {
|
if (!this.projectsWatcher || !this.todosWatcher || !this.teamsWatcher || !this.tasksWatcher) {
|
||||||
this.scheduleWatcherRetry();
|
this.scheduleWatcherRetry();
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
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
|
// Refresh available contexts when SSH connection state changes
|
||||||
|
|
@ -37,12 +49,6 @@ export const App = (): React.JSX.Element => {
|
||||||
return cleanup;
|
return cleanup;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Initialize IPC event listeners (notifications, file changes)
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = initializeNotificationListeners();
|
|
||||||
return cleanup;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue