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:
parent
d7fc71a5e6
commit
a58d50c749
17 changed files with 400 additions and 169 deletions
|
|
@ -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-меню. Остальное уже установлено
|
||||
|
||||
## Ключевые архитектурные решения
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
**Ответственность**: Заглушка при ошибке чтения.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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-требования
|
||||
|
||||
|
|
|
|||
|
|
@ -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 при наборе текста
|
||||
|
||||
## Оценка
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
## Изменения в существующих файлах (доп.)
|
||||
|
||||
| # | Файл | Изменение |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue