feat: enhance project editor with autosave, improved file management, and performance optimizations

- Implemented draft autosave functionality to prevent data loss during crashes, with recovery options for unsaved changes.
- Updated file management services to support better path validation and conflict detection.
- Enhanced performance with optimized file watcher and caching strategies for project scanning.
- Improved user experience with confirmation dialogs for unsaved changes and refined keyboard shortcuts.
- Documented testing strategies and rollback plans for iterative development.
This commit is contained in:
iliya 2026-02-28 14:49:41 +02:00
parent d7fc71a5e6
commit a58d50c749
17 changed files with 400 additions and 169 deletions

View file

@ -12,7 +12,7 @@
- **State**: Zustand slice (`editorSlice.ts`)
- **Виртуализация**: `@tanstack/react-virtual` (уже в проекте)
- **Fuzzy search**: `cmdk` v1.0.4 (уже в зависимостях)
- **Новые npm-зависимости**: `@codemirror/search` (~15KB gzipped) — для встроенного Cmd+F поиска в файле. Остальное уже установлено
- **Новые npm-зависимости**: `@codemirror/search` (~15KB gzipped), `@radix-ui/react-context-menu` (итерация 3, контекстное меню) — для встроенного Cmd+F поиска в файле и CRUD-меню. Остальное уже установлено
## Ключевые архитектурные решения

View file

@ -51,12 +51,12 @@
```
src/renderer/components/team/editor/
├── ProjectEditorOverlay.tsx # Полноэкранный overlay (max 150 LOC)
├── EditorFileTree.tsx # Обёртка над generic FileTree (max 200 LOC)
├── EditorTabBar.tsx # Панель вкладок (max 100 LOC)
├── CodeMirrorEditor.tsx # CM6 wrapper: lifecycle + EditorState pooling (max 150 LOC)
├── EditorToolbar.tsx # Save, Undo, Redo, язык (max 100 LOC)
├── EditorStatusBar.tsx # Ln:Col, язык, отступы, кодировка (max 80 LOC)
├── ProjectEditorOverlay.tsx # Полноэкранный overlay (~150-200 LOC)
├── EditorFileTree.tsx # Обёртка над generic FileTree (~150-200 LOC)
├── EditorTabBar.tsx # Панель вкладок (~100-130 LOC)
├── CodeMirrorEditor.tsx # CM6 wrapper: lifecycle + EditorState pooling + editorBridge (~250-350 LOC)
├── EditorToolbar.tsx # Save, Undo, Redo, язык (~80-100 LOC)
├── EditorStatusBar.tsx # Ln:Col, язык, отступы, кодировка (~60-80 LOC)
├── EditorContextMenu.tsx # Context menu для дерева файлов (итерация 3)
├── NewFileDialog.tsx # Inline-input для имени нового файла (итерация 3)
├── QuickOpenDialog.tsx # Cmd+P fuzzy search (итерация 4)
@ -217,7 +217,7 @@ export interface EditorSlice {
editorFileTreeLoading: boolean;
editorFileTreeError: string | null;
editorExpandedDirs: Set<string>; // Сохраняется при re-open (H7)
editorExpandedDirs: Record<string, boolean>; // Сохраняется при re-open. Record — согласовано с editorModifiedFiles (Zustand не отслеживает мутации Set)
openEditor: (projectPath: string) => Promise<void>;
closeEditor: () => void;
@ -230,7 +230,7 @@ export interface EditorSlice {
// 2. stateCache.current.clear() — освободить все EditorState из Map
// 3. scrollTopCache.current.clear() — освободить scroll positions
// 4. viewRef.current?.destroy() — уничтожить активный EditorView
// 5. Сброс slice state: tabs=[], tree=null, modified=Set(), loading={}, errors={}
// 5. Сброс slice state: tabs=[], tree=null, modified={}, expandedDirs={}, loading={}, errors={}
// }
loadFileTree: (dirPath: string) => Promise<void>;
expandDirectory: (dirPath: string) => Promise<void>;
@ -306,14 +306,17 @@ export const editorBridge = {
},
/** Вызывается CodeMirrorEditor при unmount */
unregister() { stateCache = null; scrollTopCache = null; activeView = null; },
/** Проверка: зарегистрирован ли bridge (HMR guard) */
get isRegistered(): boolean { return stateCache !== null; },
/** Для saveFile() — контент из кешированного state */
getContent(filePath: string): string | null {
return stateCache?.get(filePath)?.doc.toString() ?? null;
},
/** Для saveAllFiles() — контент всех modified файлов */
getAllModifiedContent(modifiedFiles: Set<string>): Map<string, string> {
getAllModifiedContent(modifiedFiles: Record<string, boolean>): Map<string, string> {
const result = new Map<string, string>();
for (const fp of modifiedFiles) {
for (const fp of Object.keys(modifiedFiles)) {
if (!modifiedFiles[fp]) continue;
const content = stateCache?.get(fp)?.doc.toString();
if (content !== undefined) result.set(fp, content);
}
@ -333,6 +336,15 @@ export const editorBridge = {
Паттерн аналогичен `ConfirmDialog.tsx` (module-level `globalSetState`) и `changeReviewSlice.ts` (module-level state).
**HMR guard**: При HMR модуль перезагружается → refs обнуляются. Компонент CodeMirrorEditor в `useEffect` проверяет `editorBridge.isRegistered` и перерегистрируется при необходимости:
```typescript
useEffect(() => {
editorBridge.register(stateCache.current, scrollTopCache.current, viewRef.current!);
return () => editorBridge.unregister();
}, []); // single registration at mount
```
Store actions проверяют `editorBridge.isRegistered` перед обращением — при false логируют warning и graceful fallback (не крашат).
### EditorState pooling (Map в useRef)
Контент файлов живёт ТОЛЬКО в CodeMirror EditorState. Один активный EditorView на весь редактор.
@ -368,8 +380,12 @@ function switchTab(oldTabId: string, newTabId: string) {
// LRU eviction при > 30 states:
if (stateCache.current.size > 30) {
// Вытеснить oldest, сохранив { content: doc.toString(), cursorPos }
// При возврате -- восстановить через EditorState.create()
// LRU eviction: вытеснить наименее недавно использованный state (least recently used).
// Трекинг порядка: обновлять `accessOrder: string[]` при каждом switchTab (push tabId в конец,
// удалить предыдущее вхождение). Вытеснять accessOrder[0].
// При eviction:
// 1. Удалить dirty flag из editorModifiedFiles (если был) + очистить draft из localStorage
// 2. Сохранить { content: doc.toString(), cursorPos } для восстановления через EditorState.create()
}
```
@ -566,7 +582,7 @@ removeEditorHandlers(ipcMain);
## Компоненты
### ProjectEditorOverlay.tsx (max 150 LOC)
### ProjectEditorOverlay.tsx (~150-200 LOC)
**Ответственность**: Layout shell -- `fixed inset-0 z-50`, header с кнопкой закрытия, split layout (sidebar + main).
@ -578,7 +594,7 @@ removeEditorHandlers(ipcMain);
- Escape/X с unsaved changes: ConfirmDialog с тремя кнопками -- "Save All & Close" / "Discard & Close" / "Cancel"
- Кнопка `?` в header: открывает `EditorShortcutsHelp`
### EditorFileTree.tsx (max 200 LOC)
### EditorFileTree.tsx (~150-200 LOC)
**Ответственность**: Тонкая обёртка над generic `FileTree<FileTreeEntry>`.
@ -592,7 +608,7 @@ removeEditorHandlers(ipcMain);
- Длинные имена: `truncate` + `title` tooltip
- ARIA: `role="tree"`, `role="treeitem"`, `aria-expanded`, `role="group"`, keyboard navigation (arrow keys)
### Generic FileTree.tsx (common/, max 250 LOC)
### Generic FileTree.tsx (common/, ~200-250 LOC)
**Ответственность**: Переиспользуемый generic tree с render-props.
@ -604,7 +620,7 @@ interface FileTreeProps<T extends { name: string; path: string; type: 'file' | '
renderLeafNode?: (node: TreeNode<T>, isSelected: boolean, depth: number) => React.ReactNode;
renderFolderLabel?: (node: TreeNode<T>, isOpen: boolean, depth: number) => React.ReactNode;
renderNodeIcon?: (node: TreeNode<T>) => React.ReactNode;
collapsedFolders: Set<string>;
collapsedFolders: Record<string, boolean>;
onToggleFolder: (fullPath: string) => void;
}
@ -623,7 +639,7 @@ interface TreeNode<T> {
- `renderLeafNode` заменяет весь leaf-элемент (не просто "extra"), что покрывает сложные сценарии ReviewFileTree (11 пропсов из store)
- Виртуализация через `@tanstack/react-virtual` с итерации 4: `flattenTree(tree, expandedDirs) -> FlatNode[]` + `useVirtualizer({ count, estimateSize: () => 28 })`
### EditorTabBar.tsx (max 100 LOC)
### EditorTabBar.tsx (~100-130 LOC)
**Ответственность**: Панель вкладок с переключением, закрытием, dirty indicator.
@ -634,32 +650,34 @@ interface TreeNode<T> {
- Middle-click close, X button close
- ARIA: `role="tablist"`, `role="tab"`, `aria-selected`
### CodeMirrorEditor.tsx (max 150 LOC)
### CodeMirrorEditor.tsx (~250-350 LOC)
**Ответственность**: CM6 lifecycle -- EditorState pooling, extensions, keybindings.
**Ответственность**: CM6 lifecycle — EditorState pooling, extensions, keybindings, bridge registration, autosave.
- Один EditorView на весь редактор (активный файл)
- `Map<tabId, EditorState>` в useRef
- Extensions через `buildEditorExtensions(options)` -- фабрика, компонент не знает о конкретных CM plugins
- Extensions через `buildEditorExtensions(options)` фабрика, компонент не знает о конкретных CM plugins
- Dirty flag через debounced `EditorView.updateListener` (300ms)
- LRU eviction при > 30 states
- `editorBridge.register()` при mount, `editorBridge.unregister()` при unmount (R3)
- Draft autosave в localStorage (30 сек debounce) + recovery при reopen
- Паттерн lifecycle из `MembersJsonEditor.tsx` (строки 27-73)
### EditorStatusBar.tsx (max 80 LOC)
### EditorStatusBar.tsx (~60-80 LOC)
**Ответственность**: Нижняя полоска: `[Ln 42, Col 15] | [TypeScript] | [UTF-8] | [Spaces: 2] | [LF]`
- Данные из CM6 state (cursor position, language)
- CSS: `bg-surface-sidebar border-t border-border text-text-muted text-xs h-6`
### EditorBinaryState.tsx (max 60 LOC)
### EditorBinaryState.tsx (~50-60 LOC)
**Ответственность**: Заглушка вместо CM6 для бинарных файлов.
- Иконка файла, тип, размер
- Кнопки "Open in System Viewer" (`shell:openPath`) и "Close Tab"
### EditorErrorState.tsx (max 60 LOC)
### EditorErrorState.tsx (~50-60 LOC)
**Ответственность**: Заглушка при ошибке чтения.

View file

@ -10,7 +10,7 @@
| 4 | CM6 тормозит на файлах >2MB | Низкая | Средний | 1 | Hard limit 2MB + тиерная стратегия + external editor fallback |
| 5 | TOCTOU race condition при save | Высокая | Высокий | 2 | Atomic write (tmp + rename) + post-read verify |
| 6 | Race condition: агент и пользователь редактируют один файл | Высокая | Высокий | 5 | mtime check + conflict dialog (overwrite / cancel / diff) |
| 7 | Unsaved data loss при crash | Средняя | Средний | 2 | Возможен autosave в localStorage/IndexedDB (P2 фича) |
| 7 | Unsaved data loss при crash | Средняя | Средний | 2 | Draft autosave в localStorage (30 сек debounce, max 10 drafts x 500KB). Recovery banner при reopen |
| 8 | Device file DoS (/dev/zero) | Средняя | Высокий | 1 | `fs.lstat()` + `isFile()` + block /dev/ /proc/ /sys/ |
| 9 | Credential leakage (.env, .key) | Высокая | Высокий | 1 | `validateFilePath()` + визуальная пометка + блокировка чтения |
| 10 | ReDoS в searchInFiles | Средняя | Средний | 4 | Только literal search + timeout через AbortController |
@ -22,7 +22,28 @@
---
## Benchmarks
## Тест-стратегия
### Unit-тесты (Vitest)
~15 файлов, покрывают: сервисы (ProjectFileService, FileSearchService, GitStatusService), store slices, утилиты (fileTreeBuilder, tabLabelDisambiguation, codemirrorLanguages, atomicWrite), IPC wrapper. Запуск: `pnpm test`.
### Integration-тесты (Vitest + happy-dom)
Для компонентов использующих CM6 — happy-dom НЕ поддерживает `contenteditable` полностью. Стратегия:
- **CodeMirrorEditor**: тестировать через mock EditorView. Проверять lifecycle (mount → register bridge, unmount → unregister), tab switch (stateCache save/restore), dirty flag propagation
- **editorSlice + editorBridge**: интеграционный тест — store action вызывает bridge, bridge возвращает mock content
- **IPC handlers (editor.ts)**: тестировать с mock fs + mock ProjectFileService. Проверять security guards (path traversal, .git/ write block, device paths)
### Manual smoke-тесты (каждая итерация)
Обязательный чеклист перед мёрджем каждого PR:
- [ ] Открыть editor, навигировать по дереву, открыть файл — подсветка работает
- [ ] Редактировать файл, Cmd+S — сохранение без ошибок
- [ ] Unsaved changes при закрытии — confirmation dialog
- [ ] ChangeReviewDialog по-прежнему работает корректно (regression)
- [ ] Горячие клавиши НЕ конфликтуют с глобальными при закрытом editor
### Benchmarks (manual, один раз после iter-4)
Запускать вручную через DevTools Performance tab + React DevTools Profiler. Результаты фиксировать в PR description.
```
Benchmark 1: EditorView memory
@ -52,6 +73,23 @@ Benchmark 5: Keystroke re-renders
---
## Стратегия отката
Каждая итерация — отдельный PR. При проблемах — revert PR целиком.
| Итерация | Fallback при провале | Минимально жизнеспособный результат |
|----------|---------------------|-------------------------------------|
| PR 0 | Revert PR. Рефакторинги механические (извлечение функций), не трогают review logic. При проблеме — revert + дублировать код в editor | — |
| Iter 1 | Read-only browser без CM6 — просто дерево + `<pre>` с raw text | Кнопка "Open in Editor" → файловый браузер |
| Iter 2 | Оставить read-only из iter-1, открывать файлы в external editor (`shell:openPath`) | Read-only + external editor fallback |
| Iter 3 | Один таб (последний открытый файл). CRUD через terminal/external | Single-tab editor |
| Iter 4 | Без Quick Open, без search — ручная навигация по дереву. Без виртуализации (работает до ~2000 файлов) | Editor без search/shortcuts |
| Iter 5 | Без git badges, без file watcher — ручной F5 refresh. Без conflict detection — last-write-wins | Полный editor без live features |
**Критическая точка невозврата**: нет. Каждый PR изолирован. Даже если iter-5 провалится, iter-1-4 дают полноценный editor без git/watcher.
---
## Полный список файлов
### Новые файлы (~30)
@ -77,7 +115,7 @@ Benchmark 5: Keystroke re-renders
| 17 | `src/renderer/components/common/FileTree.tsx` | 1 | Generic FileTree с render-props |
| 18 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | 1 | Full-screen overlay |
| 19 | `src/renderer/components/team/editor/EditorFileTree.tsx` | 1 | Обёртка над FileTree |
| 20 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | 1 | CM6 wrapper |
| 20 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | 1 | CM6 wrapper (~250-350 LOC: pooling + LRU + bridge + dirty + autosave) |
| 21 | `src/renderer/components/team/editor/EditorTabBar.tsx` | 2 | Панель вкладок |
| 22 | `src/renderer/components/team/editor/EditorToolbar.tsx` | 2 | Toolbar |
| 23 | `src/renderer/components/team/editor/EditorStatusBar.tsx` | 2 | Status bar |

View file

@ -79,9 +79,26 @@ const wrapHandler = createIpcWrapper('IPC:editor');
- `review.ts` импортирует `createIpcWrapper` из `ipcWrapper.ts`
- `teams.ts` — миграция `wrapTeamHandler``createIpcWrapper` в отдельном follow-up PR (40+ замен, высокий blast radius)
## Уровень риска по рефакторингам
| R# | Что трогает | Уровень риска | Почему |
|----|-------------|--------------|--------|
| R1 | ReviewFileTree (дерево файлов) | Низкий | Извлечение чистой функции buildTree(). Diff-viewer, hunks, approve/reject — не затрагиваются |
| R2 | CodeMirrorDiffView (языки) | Около нуля | Чистые stateless функции, просто выносим в отдельный файл |
| R3 | CodeMirrorDiffView (тема) | Низкий | Разрезка CSS-объекта на base + diff-specific. Визуально проверить |
| R4 | review.ts (IPC wrapper) | Около нуля | 15 LOC try-catch factory, тривиальная замена |
**Что НЕ затрагивается**: ChangeReviewDialog logic, diff rendering, hunks, approve/reject, комментарии, changeReviewSlice state.
## Критерии готовности
- `pnpm typecheck` проходит
- Тесты `ReviewFileTree` и `CodeMirrorDiffView` проходят (zero behavior change)
- ChangeReviewDialog работает корректно (manual check)
- Новые unit-тесты для `fileTreeBuilder.ts` и `ipcWrapper.ts`
- [ ] `pnpm typecheck` проходит
- [ ] `pnpm test` проходит (zero behavior change)
- [ ] Новые unit-тесты для `fileTreeBuilder.ts` и `ipcWrapper.ts`
- [ ] Новые unit-тесты для `codemirrorLanguages.ts` — проверка маппинга расширений на языки
- [ ] Manual smoke-тест: открыть ChangeReviewDialog → файловое дерево рендерится корректно, diff подсвечивается
## Оценка
- **Надёжность решения: 9/10** — все 4 рефакторинга механические (извлечение функций без изменения поведения). R2/R4 — около нуля риска, R1/R3 — низкий.
- **Уверенность: 9/10** — код review workflow не затрагивается, трогается только утилитарная часть (дерево файлов, языки, тема, wrapper).

View file

@ -61,10 +61,11 @@
## Security-требования
1. `ProjectFileService.readDir()`: для каждого entry проверять containment через `isPathWithinAllowedDirectories()` (экспортирована из pathValidation.ts). Для symlinks -- `fs.realpath()` + повторная проверка containment. Молча пропускать entries за пределами projectRoot (SEC-2). **НЕ вызывать `validateFilePath()` целиком** — она блокирует sensitive файлы, а readDir должен их ПОКАЗЫВАТЬ с пометкой `isSensitive: true`. Для пометки использовать новую экспортируемую функцию `matchesSensitivePattern()` из pathValidation.ts (сейчас приватная — нужно экспортировать) (SEC-6)
2. `ProjectFileService.readFile()`: `fs.lstat()` -> `isFile()` ДО чтения. `stats.size <= 2MB`. Block device paths. Post-read realpath verify (SEC-3, SEC-4)
3. `activeProjectRoot` в module-level state, НЕ от renderer (SEC-5)
4. Sensitive файлы: показывать с замком в дереве, "Sensitive file, cannot open" при клике (SEC-6)
1. **SEC-15**: `editor:open` handler валидирует `projectPath` ДО установки `activeProjectRoot`: `path.isAbsolute()`, `fs.stat().isDirectory()`, `!== '/'`/`'C:\\'`, `!isPathWithinRoot(path, claudeDir)`. Без этого злонамеренный renderer может передать `"/"`, делая ВСЕ пути валидными
2. `ProjectFileService.readDir()`: для каждого entry проверять containment через `isPathWithinAllowedDirectories()` (экспортирована из pathValidation.ts). Для symlinks -- `fs.realpath()` + повторная проверка containment. Молча пропускать entries за пределами projectRoot (SEC-2). **НЕ вызывать `validateFilePath()` целиком** — она блокирует sensitive файлы, а readDir должен их ПОКАЗЫВАТЬ с пометкой `isSensitive: true`. Для пометки использовать новую экспортируемую функцию `matchesSensitivePattern()` из pathValidation.ts (сейчас приватная — нужно экспортировать) (SEC-6)
3. `ProjectFileService.readFile()`: `fs.lstat()` -> `isFile()` ДО чтения. `stats.size <= 2MB`. Block device paths. Post-read realpath verify (SEC-3, SEC-4)
4. `activeProjectRoot` в module-level state, НЕ от renderer (SEC-5)
5. Sensitive файлы: показывать с замком в дереве, "Sensitive file, cannot open" при клике (SEC-6)
## Performance-требования

View file

@ -48,7 +48,28 @@
- Dirty flag через debounced `EditorView.updateListener` (300ms)
- Гранулярные Zustand-селекторы: FileTreePanel не подписывается на tabs/content
- EditorState pooling: один EditorView, Map<tabId, EditorState> в useRef
- LRU eviction при > 30 states
- LRU eviction при > 30 states. При eviction: удалить dirty flag из `editorModifiedFiles` (предотвращает stale dirty indicator), очистить draft из localStorage
## Autosave (draft recovery)
Минимальный autosave для защиты от потери данных при crash/kill:
```typescript
// В CodeMirrorEditor.tsx — debounced 30 секунд после последнего изменения
const AUTOSAVE_DELAY = 30_000;
// Сохранять draft в localStorage:
// key: `editor-draft:${filePath}`
// value: JSON.stringify({ content: doc.toString(), timestamp: Date.now() })
// Очищать при успешном saveFile() или при closeTab()
// При openFile() — проверить наличие draft:
// if (draft exists && draft.timestamp > file.mtimeMs) → banner "Recovered unsaved changes. [Apply] [Discard]"
// Edge case: если draft.timestamp < file.mtimeMs файл был изменён извне ПОСЛЕ draft draft устарел, удалить молча
// Edge case: если file.mtimeMs === 0 или undefined (новый файл) — применять draft безусловно
```
Лимиты: max 10 drafts, max 500KB per draft. При превышении — вытеснять oldest. Не сохранять draft для read-only/preview файлов.
## UX-требования
@ -56,6 +77,8 @@
- Unsaved changes при закрытии overlay: три кнопки ("Save All & Close" / "Discard & Close" / "Cancel")
- Dirty indicator (точка) на вкладке ПЕРЕД текстом
- `hasUnsavedChanges()` в slice
- `closeTab()` с dirty state: показать confirm dialog ("Save / Discard / Cancel") перед закрытием. При "Save" — сохранить через IPC, при "Discard" — закрыть + удалить draft, при "Cancel" — отмена. Закрытие overlay с dirty tabs — аналогично через "Save All & Close" / "Discard & Close" / "Cancel"
- Draft recovery banner при обнаружении несохранённого draft
## Тестирование
@ -65,7 +88,8 @@
| 2 | `editorSlice` -- open/close файлы, dirty state, save | `test/renderer/store/editorSlice.test.ts` (расширение) |
| 3 | `atomicWrite` -- unit тесты | `test/main/utils/atomicWrite.test.ts` |
| 4 | EditorState pooling -- save/restore state при switch tab | — |
| 5 | Manual: открыть файл -> отредактировать -> Cmd+S -> dirty indicator сбрасывается | — |
| 5 | Draft autosave — сохранение в localStorage, recovery при reopen, cleanup при save/close | — (manual + unit для localStorage logic) |
| 6 | Manual: открыть файл -> отредактировать -> Cmd+S -> dirty indicator сбрасывается | — |
## Критерии готовности
@ -74,6 +98,7 @@
- [ ] Dirty indicator на вкладке
- [ ] Status bar показывает позицию курсора и язык
- [ ] При закрытии overlay с unsaved changes -- confirmation dialog
- [ ] Draft autosave: после 30 сек без сохранения — draft в localStorage, recovery при reopen
- [ ] Benchmark: 0 re-render FileTreePanel/TabBar при наборе текста
## Оценка

View file

@ -39,13 +39,13 @@
## Security-требования
1. `searchInFiles`: ТОЛЬКО literal string search, НЕ regex. Default case-insensitive (`line.toLowerCase().includes(query.toLowerCase())` — ReDoS-безопасно). Опция `caseSensitive?: boolean` в параметрах. Max 1000 файлов, max 1MB/файл. Каждый файл валидируется через `validateFilePath()`. AbortController timeout 5s (SEC-8)
1. `searchInFiles`: ТОЛЬКО literal string search, НЕ regex. Default case-insensitive (`line.toLowerCase().includes(query.toLowerCase())` — ReDoS-безопасно). Опция `caseSensitive?: boolean` в параметрах. Max 1000 файлов, max 1MB/файл. Каждый файл валидируется через `validateFilePath()`. AbortController timeout 5s (SEC-8). **Cancellation**: предыдущий поиск отменяется AbortController при новом запросе (debounce 300ms на renderer перед IPC вызовом)
## Performance-требования
- File tree виртуализация: `@tanstack/react-virtual` -- `flattenTree()` + `useVirtualizer({ estimateSize: () => 28 })`
- Quick Open: кешировать flat file list при открытии editor. Invalidate по file watcher event или F5
- Search in files: запускать с AbortController timeout
- Search in files: запускать с AbortController timeout. На renderer: debounce 300ms + отмена предыдущего IPC запроса при новом вводе (хранить `abortControllerRef` в SearchInFilesPanel)
## Keyboard Scope Isolation (R1)
@ -61,6 +61,8 @@ const editorOpen = useStore(s => s.editorProjectPath !== null);
Плюс в `useEditorKeyboardShortcuts.ts` — все CM6 keybindings с `stopPropagation: true` как safety net.
**Keyboard scope для диалогов внутри editor**: Escape в QuickOpenDialog/SearchInFilesPanel закрывает ДИАЛОГ, не overlay. Реализация: диалоги вызывают `e.stopPropagation()` на Escape, overlay слушает Escape только когда нет открытых диалогов (state-guard `quickOpenVisible || searchPanelVisible`).
## Изменения в существующих файлах (доп.)
| # | Файл | Изменение |

View file

@ -19,7 +19,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` парсинг, кеш 5 сек. Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` (~200-250 LOC) |
| 2 | `src/main/services/editor/GitStatusService.ts` | `git --no-optional-locks status --porcelain -z` парсинг (NUL-separated), кеш 5 сек. Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` (~200-250 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 бейджи в дереве |
@ -59,7 +59,12 @@ Git status в дереве файлов. Live refresh при изменения
- 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 вывод (безопасный парсинг путей с пробелами)
- `-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
- Graceful degradation:
@ -85,7 +90,7 @@ Git status в дереве файлов. Live refresh при изменения
## Критерии готовности
- [ ] Git status бейджи (M/U/A) в файловом дереве
- [ ] Git status бейджи (M/U/A/C) в файловом дереве (C = conflict для UU/AA/DD)
- [ ] Auto-refresh при изменениях на диске (при включённом watcher)
- [ ] Conflict detection при сохранении
- [ ] Line wrap toggle

View file

@ -214,6 +214,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// Wire file-change events to renderer and HTTP SSE
const fileChangeHandler = (event: unknown): void => {
// Invalidate the scan cache so the next IPC request sees fresh data
context.projectScanner.clearScanCache();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('file-change', event);
}
@ -406,9 +409,11 @@ function initializeServices(): void {
todosDir: localTodosDir,
});
// Register and start local context
// Register context and start cache cleanup only.
// FileWatcher is deferred to did-finish-load to avoid blocking window creation
// with fs.watch() setup (especially slow on Windows NTFS with recursive watchers).
contextRegistry.registerContext(localContext);
localContext.start();
localContext.startCacheOnly();
logger.info(`Projects directory: ${localContext.projectScanner.getProjectsDir()}`);
@ -654,6 +659,12 @@ function createWindow(): void {
mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, mainWindow.isFullScreen());
}
}, 0);
// Start file watchers now that the window is visible and responsive.
// 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();
setTimeout(() => updaterService.checkForUpdates(), 3000);
// Defer non-critical startup work to avoid thread pool contention.

View file

@ -78,6 +78,15 @@ export class ProjectScanner {
{ mtimeMs: number; size: number; preview: { text: string; timestamp: string } | null }
>();
// Short-lived scan cache to prevent duplicate scans within the same request cycle.
// Both getProjects() and getRepositoryGroups() call scan() — the cache deduplicates.
private scanCache: { projects: Project[]; timestamp: number } | null = null;
private static readonly SCAN_CACHE_TTL_MS = 2000;
// Platform-aware batch sizes to avoid UV thread pool saturation on Windows
private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 32 : 128;
private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 8 : 24;
// Delegated services
private readonly fsProvider: FileSystemProvider;
private readonly sessionContentFilter: typeof SessionContentFilter;
@ -108,6 +117,15 @@ export class ProjectScanner {
* @returns Promise resolving to projects sorted by most recent activity
*/
async scan(): Promise<Project[]> {
// Short-lived cache: prevents duplicate scans when getProjects() and
// getRepositoryGroups() fire in Promise.all() on startup/context switch.
if (
this.scanCache &&
Date.now() - this.scanCache.timestamp < ProjectScanner.SCAN_CACHE_TTL_MS
) {
return this.scanCache.projects;
}
const startedAt = Date.now();
try {
if (!(await this.fsProvider.exists(this.projectsDir))) {
@ -128,7 +146,7 @@ export class ProjectScanner {
// Process each project directory (may return multiple projects per dir)
const projectArrays = await this.collectFulfilledInBatches(
projectDirs,
this.fsProvider.type === 'ssh' ? 8 : 24,
this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH,
async (dir) => this.scanProject(dir.name)
);
@ -142,6 +160,7 @@ export class ProjectScanner {
);
}
this.scanCache = { projects: validProjects, timestamp: Date.now() };
return validProjects;
} catch (error) {
logger.error('Error scanning projects directory:', error);
@ -149,6 +168,14 @@ export class ProjectScanner {
}
}
/**
* Clears the scan cache so the next scan() call reads fresh data.
* Call this when a file change is detected by FileWatcher.
*/
clearScanCache(): void {
this.scanCache = null;
}
// ===========================================================================
// Repository Grouping (Worktree Support)
// ===========================================================================
@ -173,8 +200,33 @@ export class ProjectScanner {
return [];
}
// 2. Delegate to WorktreeGrouper
return this.worktreeGrouper.groupByRepository(projects);
// 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
// Git identity resolution is bypassed to avoid blocking I/O on startup.
// Each project becomes a single-worktree group.
const groups: RepositoryGroup[] = projects.map((project) => ({
id: project.id,
identity: null,
worktrees: [
{
id: project.id,
path: project.path,
name: project.name,
isMainWorktree: true,
source: 'unknown' as const,
sessions: project.sessions,
createdAt: project.createdAt,
mostRecentSession: project.mostRecentSession,
},
],
name: project.name,
mostRecentSession: project.mostRecentSession,
totalSessions: project.sessions.length,
}));
// Sort by most recent activity (same order as the full git-aware version)
groups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
return groups;
} catch (error) {
logger.error('Error scanning with worktree grouping:', error);
return [];
@ -226,7 +278,7 @@ export class ProjectScanner {
const shouldSplitByCwd = this.fsProvider.type !== 'ssh';
const sessionInfos = await this.collectFulfilledInBatches(
sessionFiles,
this.fsProvider.type === 'ssh' ? 32 : 128,
this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH,
async (file) => {
const filePath = path.join(projectPath, file.name);
const { mtimeMs, birthtimeMs } = await this.resolveFileDetails(file, filePath);
@ -736,11 +788,8 @@ export class ProjectScanner {
});
}
// Check for subagents and load task list data in parallel
const [hasSubagents, todoData] = await Promise.all([
this.subagentLocator.hasSubagents(projectId, sessionId),
this.loadTodoData(sessionId),
]);
// Check for subagents (todoData skipped here — loaded on-demand in detail view)
const hasSubagents = await this.subagentLocator.hasSubagents(projectId, sessionId);
const metadataLevel: SessionMetadataLevel = 'deep';
const firstMessageTimestampMs = this.parseTimestampMs(metadata.firstUserMessage?.timestamp);
const createdAt =
@ -752,7 +801,6 @@ export class ProjectScanner {
id: sessionId,
projectId,
projectPath,
todoData,
createdAt: Math.floor(createdAt),
firstMessage: metadata.firstUserMessage?.text,
messageTimestamp: metadata.firstUserMessage?.timestamp,
@ -920,15 +968,16 @@ export class ProjectScanner {
async loadTodoData(sessionId: string): Promise<unknown> {
try {
const todoPath = buildTodoPath(path.dirname(this.projectsDir), sessionId);
if (!(await this.fsProvider.exists(todoPath))) {
return undefined;
}
const content = await this.fsProvider.readFile(todoPath);
return JSON.parse(content) as unknown;
} catch (error) {
// Log but continue - task list data is non-critical
} catch (error: unknown) {
// ENOENT/EACCES = file missing or inaccessible — normal when no todos exist
if (error instanceof Error && 'code' in error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'EACCES') {
return undefined;
}
}
logger.debug(`Failed to load task list data for session ${sessionId}:`, error);
return undefined;
}

View file

@ -43,37 +43,21 @@ export class SubagentLocator {
async hasSubagents(projectId: string, sessionId: string): Promise<boolean> {
// Check NEW structure: {projectId}/{sessionId}/subagents/
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
if (await this.fsProvider.exists(newSubagentsPath)) {
try {
const entries = await this.fsProvider.readdir(newSubagentsPath);
const subagentFiles = entries.filter(
(entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
);
// Check if at least one subagent file has content (not empty)
for (const entry of subagentFiles) {
const filePath = path.join(newSubagentsPath, entry.name);
try {
const stats = await this.fsProvider.stat(filePath);
// File must have size > 0 and contain at least one line
if (stats.size > 0) {
const content = await this.fsProvider.readFile(filePath);
if (content.trim().length > 0) {
return true;
}
}
} catch (error) {
// Skip this file if we can't read it - log for debugging
logger.debug(`SubagentLocator: Could not read file ${filePath}:`, error);
continue;
}
}
} catch {
// Ignore errors
}
try {
const entries = await this.fsProvider.readdir(newSubagentsPath);
// A non-empty agent-*.jsonl file is sufficient proof of subagents.
// readdir() populates size from stat, so no extra I/O needed.
return entries.some(
(entry) =>
entry.name.startsWith('agent-') &&
entry.name.endsWith('.jsonl') &&
typeof entry.size === 'number' &&
entry.size > 0
);
} catch {
// Directory doesn't exist or is unreadable — no subagents
return false;
}
return false;
}
/**
@ -88,37 +72,22 @@ export class SubagentLocator {
hasSubagentsSync(projectId: string, sessionId: string): boolean {
// Check NEW structure: {projectId}/{sessionId}/subagents/
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
if (fs.existsSync(newSubagentsPath)) {
try {
const entries = fs.readdirSync(newSubagentsPath);
const subagentFiles = entries.filter(
(name) => name.startsWith('agent-') && name.endsWith('.jsonl')
);
// Check if at least one subagent file has content (not empty)
for (const fileName of subagentFiles) {
const filePath = path.join(newSubagentsPath, fileName);
try {
const stats = fs.statSync(filePath);
// File must have size > 0 and contain at least one line
if (stats.size > 0) {
const content = fs.readFileSync(filePath, 'utf8');
if (content.trim().length > 0) {
return true;
}
}
} catch (error) {
// Skip this file if we can't read it - log for debugging
logger.debug(`SubagentLocator: Could not read file ${filePath}:`, error);
continue;
}
try {
const entries = fs.readdirSync(newSubagentsPath);
// A non-empty agent-*.jsonl file is sufficient proof of subagents.
return entries.some((name) => {
if (!name.startsWith('agent-') || !name.endsWith('.jsonl')) return false;
try {
const stats = fs.statSync(path.join(newSubagentsPath, name));
return stats.size > 0;
} catch {
return false;
}
} catch {
// Ignore errors
}
});
} catch {
// Directory doesn't exist or is unreadable — no subagents
return false;
}
return false;
}
/**

View file

@ -21,6 +21,7 @@ import {
import { createLogger } from '@shared/utils/logger';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { projectPathResolver } from '../discovery/ProjectPathResolver';
@ -149,7 +150,9 @@ export class FileWatcher extends EventEmitter {
if (this.fsProvider.type === 'ssh') {
this.startPollingMode();
} else {
this.ensureWatchers();
// Fire-and-forget: ensureWatchers is now async to avoid blocking the event loop
// with synchronous fs.existsSync() calls during startup.
void this.ensureWatchers();
}
this.startCatchUpTimer();
}
@ -278,18 +281,21 @@ export class FileWatcher extends EventEmitter {
/**
* Starts the projects directory watcher.
*/
private startProjectsWatcher(): void {
private async startProjectsWatcher(): Promise<void> {
if (this.projectsWatcher) {
return;
}
try {
if (!fs.existsSync(this.projectsPath)) {
if (!(await this.pathExists(this.projectsPath))) {
logger.warn(`FileWatcher: Projects directory does not exist: ${this.projectsPath}`);
this.scheduleWatcherRetry();
return;
}
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
this.projectsWatcher = fs.watch(
this.projectsPath,
{ recursive: true },
@ -312,18 +318,21 @@ export class FileWatcher extends EventEmitter {
/**
* Starts the todos directory watcher.
*/
private startTodosWatcher(): void {
private async startTodosWatcher(): Promise<void> {
if (this.todosWatcher) {
return;
}
try {
if (!fs.existsSync(this.todosPath)) {
if (!(await this.pathExists(this.todosPath))) {
// Todos directory may not exist yet - that's OK
this.scheduleWatcherRetry();
return;
}
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
this.todosWatcher = fs.watch(this.todosPath, (eventType, filename) => {
if (filename) {
this.handleTodosChange(eventType, filename);
@ -342,17 +351,20 @@ export class FileWatcher extends EventEmitter {
/**
* Starts the teams directory watcher.
*/
private startTeamsWatcher(): void {
private async startTeamsWatcher(): Promise<void> {
if (this.teamsWatcher) {
return;
}
try {
if (!fs.existsSync(this.teamsPath)) {
if (!(await this.pathExists(this.teamsPath))) {
this.scheduleWatcherRetry();
return;
}
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
this.teamsWatcher = fs.watch(this.teamsPath, { recursive: true }, (eventType, filename) => {
if (filename) {
this.handleTeamsChange(eventType, filename);
@ -370,17 +382,20 @@ export class FileWatcher extends EventEmitter {
/**
* Starts the tasks directory watcher.
*/
private startTasksWatcher(): void {
private async startTasksWatcher(): Promise<void> {
if (this.tasksWatcher) {
return;
}
try {
if (!fs.existsSync(this.tasksPath)) {
if (!(await this.pathExists(this.tasksPath))) {
this.scheduleWatcherRetry();
return;
}
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
this.tasksWatcher = fs.watch(this.tasksPath, { recursive: true }, (eventType, filename) => {
if (filename) {
this.handleTasksChange(eventType, filename);
@ -395,15 +410,31 @@ export class FileWatcher extends EventEmitter {
}
}
private ensureWatchers(): void {
/**
* Async check for path existence. Replaces sync fs.existsSync()
* to avoid blocking the event loop during watcher initialization.
*/
private async pathExists(p: string): Promise<boolean> {
try {
await fsp.access(p, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
private async ensureWatchers(): Promise<void> {
if (!this.isWatching || this.fsProvider.type === 'ssh') {
return;
}
this.startProjectsWatcher();
this.startTodosWatcher();
this.startTeamsWatcher();
this.startTasksWatcher();
// 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();
@ -417,7 +448,7 @@ export class FileWatcher extends EventEmitter {
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.ensureWatchers();
void this.ensureWatchers();
}, WATCHER_RETRY_MS);
}

View file

@ -43,20 +43,27 @@ export class LocalFileSystemProvider implements FileSystemProvider {
async readdir(dirPath: string): Promise<FsDirent[]> {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
// Stat all entries concurrently to populate mtimeMs, used by SessionSearcher's
// mtime-based cache invalidation. Failures are silently ignored (mtimeMs stays undefined).
// Stat all entries concurrently to populate mtimeMs/birthtimeMs/size.
// Populating all three avoids a second stat() call in resolveFileDetails().
// Failures are silently ignored (fields stay undefined).
return Promise.all(
entries.map(async (entry) => {
let mtimeMs: number | undefined;
let birthtimeMs: number | undefined;
let size: number | undefined;
try {
const stat = await fs.promises.stat(`${dirPath}/${entry.name}`);
mtimeMs = stat.mtimeMs;
birthtimeMs = stat.birthtimeMs;
size = stat.size;
} catch {
// ignore
}
return {
name: entry.name,
mtimeMs,
birthtimeMs,
size,
isFile: () => entry.isFile(),
isDirectory: () => entry.isDirectory(),
};

View file

@ -17,6 +17,7 @@ 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';
import { type DetectedError } from '../error/ErrorMessageBuilder';
@ -90,6 +91,10 @@ export class NotificationManager extends EventEmitter {
private mainWindow: BrowserWindow | null = null;
private throttleMap = new Map<string, number>();
private isInitialized: boolean = false;
/** Promise that resolves when async initialization is complete.
* Used by addError() to wait for notifications to be loaded from disk
* before writing, preventing a race where save overwrites unloaded data. */
private initPromise: Promise<void> | null = null;
constructor(configManager?: ConfigManager) {
super();
@ -106,7 +111,9 @@ export class NotificationManager extends EventEmitter {
static getInstance(): NotificationManager {
if (!NotificationManager.instance) {
NotificationManager.instance = new NotificationManager();
NotificationManager.instance.initialize();
// Async init: loads notifications without blocking startup.
// addError() awaits initPromise to prevent save-before-load races.
NotificationManager.instance.initPromise = NotificationManager.instance.initialize();
}
return NotificationManager.instance;
}
@ -133,12 +140,12 @@ export class NotificationManager extends EventEmitter {
* Initializes the notification manager.
* Loads existing notifications and prunes if needed.
*/
initialize(): void {
async initialize(): Promise<void> {
if (this.isInitialized) {
return;
}
this.loadNotifications();
await this.loadNotifications();
this.pruneNotifications();
this.isInitialized = true;
@ -157,23 +164,25 @@ export class NotificationManager extends EventEmitter {
// ===========================================================================
/**
* Loads notifications from disk.
* Loads notifications from disk (async to avoid blocking startup).
*/
private loadNotifications(): void {
private async loadNotifications(): Promise<void> {
try {
if (fs.existsSync(NOTIFICATIONS_PATH)) {
const data = fs.readFileSync(NOTIFICATIONS_PATH, 'utf8');
const parsed = JSON.parse(data) as unknown;
await fsp.access(NOTIFICATIONS_PATH, fs.constants.F_OK);
const data = await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
const parsed = JSON.parse(data) as unknown;
if (Array.isArray(parsed)) {
this.notifications = parsed as StoredNotification[];
} else {
logger.warn('Invalid notifications file format, starting fresh');
this.notifications = [];
}
if (Array.isArray(parsed)) {
this.notifications = parsed as StoredNotification[];
} else {
logger.warn('Invalid notifications file format, starting fresh');
this.notifications = [];
}
} catch (error) {
logger.error('Error loading notifications:', error);
// ENOENT is expected on first run — no file to load
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Error loading notifications:', error);
}
this.notifications = [];
}
}
@ -451,6 +460,12 @@ export class NotificationManager extends EventEmitter {
* @returns The stored notification, or null if filtered/throttled
*/
async addError(error: DetectedError): Promise<StoredNotification | null> {
// Wait for async initialization to complete before modifying notifications.
// Prevents a race where saveNotifications() overwrites not-yet-loaded data.
if (this.initPromise) {
await this.initPromise;
}
// Deduplicate by toolUseId: the same tool call can appear in both the
// subagent JSONL file and the parent session JSONL (as a progress event).
// Keep the subagent-annotated version (with subagentId) when possible.

View file

@ -137,6 +137,21 @@ export class ServiceContext {
this.cleanupInterval = this.dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES);
}
/**
* Starts only cache cleanup, deferring FileWatcher to later.
* Use this at app startup so the window appears without waiting for fs.watch().
* Call startFileWatcher() separately after the window is visible.
*/
startCacheOnly(): void {
if (this.disposed) {
logger.error(`Cannot start disposed context: ${this.id}`);
return;
}
logger.info(`Starting ServiceContext (cache only): ${this.id}`);
this.cleanupInterval = this.dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES);
}
/**
* Stops the file watcher (for pausing on context switch).
* Does not dispose resources - can be resumed with startFileWatcher().

View file

@ -309,7 +309,7 @@ export class SshConnectionManager extends EventEmitter {
* Handles macOS GUI apps not inheriting SSH_AUTH_SOCK from shell.
*/
private async discoverAgentSocket(): Promise<string | null> {
// 1. Check SSH_AUTH_SOCK env var
// 1. Check SSH_AUTH_SOCK env var (all platforms)
if (process.env.SSH_AUTH_SOCK) {
try {
await fs.promises.access(process.env.SSH_AUTH_SOCK);
@ -319,7 +319,19 @@ export class SshConnectionManager extends EventEmitter {
}
}
// 2. macOS: ask launchctl for the socket (GUI apps don't inherit shell env)
// 2. Windows: use OpenSSH named pipe (no Unix sockets on Windows)
if (process.platform === 'win32') {
const pipe = '\\\\.\\pipe\\openssh-ssh-agent';
try {
await fs.promises.access(pipe);
return pipe;
} catch {
// OpenSSH agent not running
}
return null;
}
// 3. macOS: ask launchctl for the socket (GUI apps don't inherit shell env)
if (process.platform === 'darwin') {
try {
const sock = await new Promise<string | null>((resolve) => {
@ -344,7 +356,7 @@ export class SshConnectionManager extends EventEmitter {
}
}
// 3. Try known socket paths
// 4. Try known socket paths (macOS/Linux only)
const knownPaths = [
// 1Password SSH agent
path.join(

View file

@ -24,6 +24,16 @@ vi.mock('fs', async () => {
};
});
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
return {
...actual,
access: vi.fn(),
// Stash the real access for tests with real files
__realAccess: actual.access,
};
});
vi.mock('../../../../src/main/services/error/ErrorDetector', () => ({
errorDetector: {
detectErrors: vi.fn().mockResolvedValue([]),
@ -47,6 +57,7 @@ vi.mock('../../../../src/main/services/discovery/ProjectPathResolver', () => ({
}));
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import { errorDetector } from '../../../../src/main/services/error/ErrorDetector';
import { DataCache } from '../../../../src/main/services/infrastructure/DataCache';
@ -97,16 +108,16 @@ describe('FileWatcher', () => {
vi.restoreAllMocks();
});
it('retries and starts watchers when directories appear later', () => {
it('retries and starts watchers when directories appear later', async () => {
const dataCache = new DataCache(50, 10, false);
let dirsAvailable = false;
const existsSyncMock = vi.mocked(fs.existsSync);
existsSyncMock.mockImplementation((targetPath) => {
if (targetPath === '/tmp/projects' || targetPath === '/tmp/todos') {
return dirsAvailable;
const accessMock = vi.mocked(fsp.access);
accessMock.mockImplementation(async (targetPath) => {
if ((targetPath === '/tmp/projects' || targetPath === '/tmp/todos') && dirsAvailable) {
return;
}
return false;
throw new Error('ENOENT');
});
const watchMock = vi.mocked(fs.watch);
@ -114,25 +125,29 @@ describe('FileWatcher', () => {
const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');
watcher.start();
await vi.advanceTimersByTimeAsync(0);
expect(watchMock).toHaveBeenCalledTimes(0);
dirsAvailable = true;
vi.advanceTimersByTime(2000);
await vi.advanceTimersByTimeAsync(2000);
expect(watchMock).toHaveBeenCalledTimes(2);
watcher.stop();
});
it('recovers from watcher errors by re-registering affected watcher', () => {
it('recovers from watcher errors by re-registering affected watcher', async () => {
const dataCache = new DataCache(50, 10, false);
const projectWatcher = createFakeWatcher();
const todoWatcher = createFakeWatcher();
const replacementProjectWatcher = createFakeWatcher();
const existsSyncMock = vi.mocked(fs.existsSync);
existsSyncMock.mockImplementation((targetPath) => {
return targetPath === '/tmp/projects' || targetPath === '/tmp/todos';
const accessMock = vi.mocked(fsp.access);
accessMock.mockImplementation(async (targetPath) => {
if (targetPath === '/tmp/projects' || targetPath === '/tmp/todos') {
return;
}
throw new Error('ENOENT');
});
const watchMock = vi.mocked(fs.watch);
@ -143,10 +158,11 @@ describe('FileWatcher', () => {
const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');
watcher.start();
await vi.advanceTimersByTimeAsync(0);
expect(watchMock).toHaveBeenCalledTimes(2);
(projectWatcher as unknown as EventEmitter).emit('error', new Error('watch failed'));
vi.advanceTimersByTime(2000);
await vi.advanceTimersByTimeAsync(2000);
expect(watchMock).toHaveBeenCalledTimes(3);
watcher.stop();
@ -519,7 +535,7 @@ describe('FileWatcher', () => {
it('starts catch-up timer on start() and clears on stop()', () => {
const dataCache = new DataCache(50, 10, false);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fsp.access).mockResolvedValue();
vi.mocked(fs.watch).mockImplementation(() => createFakeWatcher());
const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');
@ -540,7 +556,7 @@ describe('FileWatcher', () => {
it('clears all tracking state on stop()', () => {
const dataCache = new DataCache(50, 10, false);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fsp.access).mockResolvedValue();
vi.mocked(fs.watch).mockImplementation(() => createFakeWatcher());
const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');